Compare commits

..

98 Commits

Author SHA1 Message Date
a7ede3303d refactor(task): 移除重复的景区配置查询逻辑
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 删除了 DownloadNotificationTasker 中多次调用的 getScenicMpConfig 方法
- 简化了视频下载通知任务的执行流程- 提高代码可读性和维护性
- 避免不必要的数据库查询操作
2025-10-14 20:32:36 +08:00
aa7330000f fix(task): 避免重复发送下载和过期通知
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 在发送下载通知前检查用户是否已接收通知
- 在发送过期通知前检查用户是否已接收通知- 在发送额外下载通知前检查用户是否已接收通知
- 使用ConcurrentHashMap.newKeySet()确保线程安全- 添加调试日志以追踪重复通知的跳过情况- 优化通知逻辑以提升定时任务执行效率
2025-10-14 20:31:45 +08:00
29f4bbf2d8 feat(message): 添加ZT消息生产者空实现服务
- 创建 ZtMessageProducerNoOpService 类作为 Kafka 禁用时的替代实现- 实现 ConditionalOnProperty 注解,当 kafka.enabled=false 时激活该服务- 覆写 send 方法,仅记录日志而不实际发送消息
- 添加构造函数以满足父类依赖要求
- 提供详细注释说明服务用途和实现逻辑
2025-10-14 20:28:00 +08:00
ad42254ea0 refactor(task): 移除通知模块依赖
- 删除了对通知模块的包引用
- 移除了通知模块相关的类导入- 清理了与通知功能相关的代码依赖
-优化了任务服务实现类的依赖结构
- 简化了下载通知任务器的代码引用
- 解除了通知工厂类的直接依赖关系
2025-10-14 19:38:47 +08:00
0ceecf0488 fix(message): 将消息相关接口的日志级别从 info 调整为 debug
- 修改消息列表查询接口的日志级别- 修改获取消息通道列表接口的日志级别- 统一调整日志输出方式以减少生产环境日志量
2025-10-14 19:20:41 +08:00
311008cbf2 feat(message): 集成ZT消息服务发送通知
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 在TaskTaskServiceImpl中引入ZtMessageProducerService依赖
- 替换原有微信通知逻辑,使用ZT消息服务发送视频生成通知- 在DownloadNotificationTasker中引入ZtMessageProducerService依赖
- 修改视频下载通知发送逻辑,使用ZT消息服务
- 修改视频过期提醒通知逻辑,使用ZT消息服务
- 调整额外通知时间配置获取方式,从scenicConfigManager获取
- 统一构建通知消息参数格式,包含data和page信息
- 添加详细的日志记录,便于追踪消息发送过程
2025-10-14 19:06:30 +08:00
f54d40d026 feat(message):为消息添加唯一标识符支持
- 在 ZtMessage DTO 中新增 messageId 字段
- 发送消息前自动生成 UUID 作为默认 messageId
- 更新 Kafka 生产者日志,包含 messageId 以便追踪
- 增强错误日志记录,附带 messageId 提升调试效率
2025-10-14 18:27:15 +08:00
3cb12c13c2 feat(printer):优化用户照片添加逻辑并返回结果ID
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 修改 addUserPhoto 方法参数,使用 MemberPrintEntity 实体传参- 在 PrinterMapper.xml 中配置 insert 语句返回主键 ID- 更新 addUserPhotoFromSource 方法返回值为 List<Integer>
- 添加异常处理和日志记录
- 调整 AppPrinterController 接口返回照片 ID 列表
2025-10-14 11:45:46 +08:00
feac2e8d93 refactor(config): 移除ScenicConfigManager中的冗余代码
- 删除了未使用的configMap字段- 移除了基于Map的构造函数- 清理了所有与configMap相关的getter方法
- 移除了hasKey和hasNonNullValue方法
- 删除了获取所有配置键和配置数量的方法
- 移除了配置子集和扁平化配置相关功能
- 简化了toString方法的实现
2025-10-12 01:09:54 +08:00
be375067ce feat(message): 移除ZT消息生产者示例代码- 删除ZtMessageProducerExample类及相关依赖
- 移除示例消息发送逻辑
- 清理无用的HashMap和日志记录代码
- 移除条件注解@ConditionalOnProperty配置
- 删除消息构建及发送示例实现
2025-10-11 20:34:00 +08:00
7dec2e614c feat(watchdog): 增强任务监控告警机制
- 引入ZtMessageProducerService实现消息通知
- 添加任务积压、失败任务和长时间运行任务的分类监控
- 实现异常通知计数器,限制重复告警次数
-优化告警逻辑,支持异常恢复后计数器重置
- 移除旧的通知工厂依赖,统一使用消息队列发送
- 增加长时任务监控的清理机制,避免无效计数累积
2025-10-11 20:33:49 +08:00
51d0716606 Merge branch 'message-microservice'
# Conflicts:
#	src/main/java/com/ycwl/basic/integration/CLAUDE.md
2025-10-11 15:07:52 +08:00
765998bd97 docs(integration): 移除示例代码并更新配置说明- 删除设备集成测试中的默认配置启用示例
- 移除了消息集成组件中的示例引用
- 更新ZT-Message集成概述,去除对旧文档的引用
- 简化目录结构展示,移除example模块
- 清理冗余的配置键值说明- 统一删除各模块下的example目录引用
- 优化文档结构,提高可读性
2025-10-11 11:24:42 +08:00
5f4f89112b refactor(scenic): 移除ScenicV2WithConfigDTO并简化实体转换逻辑
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 删除ScenicV2WithConfigDTO类定义
- 更新ScenicV2Controller中的导入依赖- 更新ScenicV2Client中的导入依赖
- 更新ScenicIntegrationService中的导入依赖
- 更新ScenicRepository中的导入依赖
- 简化convertToScenicEntity方法参数类型
- 移除手动组合ScenicV2WithConfigDTO的代码逻辑
2025-10-11 00:11:42 +08:00
d68b062951 refactor(repository):重构景区配置管理逻辑- 引入 ScenicConfigManager 管理配置信息
- 移除手动构建 configMap 的逻辑
- 修改 convertToScenicEntity 方法签名,支持传入配置管理器
- 使用 configManager 替代直接从 DTO 获取配置值的方式
- 统一配置项获取方式,增强代码可维护性与扩展性
2025-10-11 00:10:25 +08:00
99857db006 feat(examples): 移除设备和问卷集成示例代码
- 删除默认配置集成服务使用示例类- 移除设备配置筛选功能使用示例
- 清理设备集成基础操作示例代码
- 移除设备集成降级机制示例
- 删除Kafka集成使用示例
- 清理问卷集成服务示例代码
2025-10-11 00:09:33 +08:00
e8c645a3c0 refactor(device): 移除设备与景区的冗余配置接口
Some checks failed
ZhenTu-BE/pipeline/head There was a failure building this commit
- 删除 DeviceV2Controller 中的设备配置相关接口
- 删除 ScenicV2Controller 中的景区配置相关接口
- 移除 DeviceConfigV2Client 中的扁平化配置接口
- 移除 DeviceV2Client 中的设备详情配置接口
- 更新 DeviceIntegrationExample 示例代码
- 移除 DeviceIntegrationFallbackExample 中的配置缓存示例
- 删除 DeviceConfigIntegrationService 中的配置获取方法
- 删除 DeviceIntegrationService 中的设备配置服务方法- 移除 RenderWorkerV2Client 中的工作器配置接口- 删除 RenderWorkerConfigIntegrationService 中的配置键名- 移除 RenderWorkerIntegrationService 中的工作器配置方法
- 删除 ScenicConfigV2Client 中的扁平化配置接口
- 移除 ScenicV2Client 中的景区配置接口
- 更新 ScenicIntegrationExample 示例代码
- 删除 ScenicConfigIntegrationService 中的配置获取方法
- 删除 ScenicIntegrationService 中的景区配置服务方法
- 修改 ScenicRepository 中景区实体获取逻辑
2025-10-10 23:55:17 +08:00
fe8068b3d9 refactor(scenic): 重构景区配置响应结构
- 移除了过时的配置字段,如预约流程、强制完成时间等
- 调整了字段顺序并添加分类注释(基础配置、功能开关、提示文案)
-保留并优化核心配置项,如水印URL、防录屏类型等
- 清理了未使用的导入包和冗余代码
- 统一了优惠券开关字段,移除重复定义
2025-10-10 13:46:59 +08:00
c689496130 feat(scenic): 添加分享功能配置项
- 在ScenicConfigResp中新增shareEnable字段
- 在AppScenicController中设置shareEnable默认值为true
- 支持景区配置是否开启分享功能
- 保持与shareBeforeBuy配置项的一致性处理
2025-10-10 10:38:47 +08:00
7e16ad35e7 feat(app): 新增分享前购买配置项
- 在AppScenicController中增加shareBeforeBuy配置项- 默认值设置为true以启用该功能- 更新响应对象以支持新的配置选项
2025-10-10 09:26:42 +08:00
1727619b29 refactor(kafka): 将人脸识别处理改为异步执行- 引入CompletableFuture实现异步处理
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 修改processFaceRecognition方法为异步版本
- 移除原同步处理中的try-catch块
- 更新方法返回类型从boolean改为void-保留处理成功和失败的状态更新逻辑- 添加异步处理成功后的日志记录
2025-10-04 10:12:37 +08:00
3099e68a97 refactor(logging): 调整人脸处理服务中的日志级别
- 将接收到人脸消息的日志级别从 info 调整为 debug
- 移除了部分冗余的 info 级别日志输出
- 统一异常处理中的日志记录方式
-优化日志内容,减少不必要的信息输出
- 确保关键操作仍然保留适当日志记录- 提升系统在高并发下的日志可读性与性能
2025-10-03 13:46:22 +08:00
db86c82bc8 refactor(task):优化视频片段获取逻辑并增强日志记录
- 移除任务执行前的空列表检查,统一通过VideoPieceGetter.addTask处理
- 增强Placeholder初始化阶段的日志输出,区分有无templateId情况- 细化计数器递减过程中的日志信息,记录设备关联及剩余数量
- 完善进度检查时的日志内容,增加已完成与未完成的统计显示- 补充Callback调用条件判断,避免重复触发并记录调用状态
- 添加兜底逻辑中对Callback是否已触发的判断和相应日志提示
2025-10-01 22:01:34 +08:00
f33ce8e7a7 feat(video):优化视频切片任务处理逻辑
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 添加对配对设备的处理,确保主设备也能正确执行切片任务
- 调整计数器逻辑,使主设备和配对设备的未完成占位符计数一致
- 增强日志记录,明确标识设备占位符满足情况
- 改进进度计算方式,更准确地反映任务完成状态- 在所有占位符满足时提前调用回调函数,提升任务执行效率
2025-10-01 21:22:19 +08:00
de65fa1dd8 feat(scenic): 添加水印URL配置支持
- 在ScenicConfigResp中新增watermarkUrl字段
- 在AppScenicController中设置水印URL配置项
- 支持从scenicConfig中获取watermark_url配置值
2025-10-01 17:00:44 +08:00
132a539bb6 fix(kafka): 调整人脸识别消息处理逻辑,确保消息始终被消费- 修改消息处理失败时的确认机制,避免消息堆积
- 即使人脸样本保存或识别处理失败,也消费消息防止重复处理
- 异常情况下同样确认消息消费,记录错误日志而非阻塞流程- 优化日志记录,明确区分处理结果与消息确认状态
2025-09-28 11:26:01 +08:00
9f66544a29 feat(source): 处理ZT-Source消息时支持设备裁剪配置
- 新增DeviceRepository依赖注入
- 获取设备配置管理器并检查裁剪配置
- 根据裁剪配置设置缩略图URL
-优化sourceEntity数据处理逻辑
2025-09-27 23:28:50 +08:00
f4a16b5b09 feat(dto): 添加位置信息字段支持
- 在 ZTSourceMessage DTO 中新增 posJson 字段
- 更新数据库插入语句以支持 posJson 字段存储
- 调整日志输出内容,突出关键业务标识
- 在数据服务层增加对 posJson 的处理逻辑
2025-09-27 23:09:44 +08:00
9bc34fcfdb feat(kafka): 新增ZT-Source Kafka消息处理功能
- 新增ZTSourceMessage实体类用于接收Kafka消息
- 新增ZTSourceConsumerService监听zt-source主题
- 新增ZTSourceDataService处理消息并保存至数据库- 扩展SourceMapper支持从ZT-Source消息新增素材
- 实现照片类型素材的解析、校验与存储逻辑
- 添加Kafka手动ACK确认机制确保消息可靠处理
2025-09-27 22:16:47 +08:00
4b01e4cf82 feat(task):优化人脸识别任务中的样本排序逻辑
- 引入HashMap以支持按ID顺序排序人脸样本列表
- 在筛选前对搜索结果按分数降序排序
- 简化设备照片限制逻辑,去除冗余的时间排序步骤
- 提升匹配准确性和处理效率
2025-09-27 13:45:05 +08:00
f885f734ad perf(viid):优化线程池配置与图片裁剪内存管理
- 调整线程池核心线程数为8,最大线程数为32,空闲时间10秒- 队列大小从1024降至100,提升响应速度
- 添加CallerRunsPolicy策略,防止任务丢失
- 图片裁剪方法增加try-finally块确保资源释放- 显式调用image.flush()和System.gc()优化内存使用
- ByteArrayOutputStream关闭操作添加异常捕获
-修复潜在的内存泄漏问题
2025-09-27 13:17:48 +08:00
ddbc2a0edb fix(biz):修复用户购买检查逻辑
- 修改PriceBiz中checkUserBuyItem方法的模板ID参数为-1
- 在FaceServiceImpl中增加对模板ID的购买检查逻辑- 确保用户购买状态判断的准确性
2025-09-27 01:50:26 +08:00
da89067c48 refactor(task):优化视频片段获取任务的设备计数逻辑
- 将 currentUnFinPlaceholder从 List 类型改为 Map<String, AtomicInteger>- 使用 AtomicInteger 跟踪每个设备的未完成任务数量
- 在设备任务完成时正确减少计数并清理已完成的设备
- 更新进度日志以反映去重后的设备总数
2025-09-27 01:07:52 +08:00
2836326518 fix(face):修复vlog渲染状态显示错误问题
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 调整step3状态逻辑,确保渲染中状态正确显示
- 修改状态文本提示,优化用户体验
-修复渲染完成状态判断逻辑错误
2025-09-26 17:34:12 +08:00
6091d41df9 feat(face):优化视频切分任务筛选逻辑
- 按设备ID分组并按创建时间倒序排序
- 根据设备配置限制视频数量
- 修复日志中原始
2025-09-26 16:43:20 +08:00
d4f9f1fe0d feat(face):优化视频重切任务的样本选择逻辑
- 根据设备配置限制视频样本数量
- 实现按设备分组并应用数量限制- 更新视频重切任务中的样本ID列表
- 保留原有照片与视频数量比较逻辑
2025-09-26 16:20:31 +08:00
d860996f6d feat(face):优化视频重切任务的样本选择逻辑
- 根据设备配置限制视频样本数量
- 实现按设备分组并应用数量限制- 更新视频重切任务中的样本ID列表
- 保留原有照片与视频数量比较逻辑
2025-09-26 16:15:34 +08:00
1b2793215f fix(video): 解决并发环境下视频片段处理的文件名冲突问题
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 为输出文件名添加时间戳和线程ID后缀,确保唯一性
-为临时文件名添加时间戳和线程ID后缀,防止并发冲突
- 避免因文件名重复导致的视频处理错误
2025-09-26 14:26:09 +08:00
4f1443a3ca fix(video): 处理空imgSource情况- 添加空值检查以避免保存空source记录
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 记录警告日志当imgSource为空时- 返回false以跳过无效处理流程
2025-09-26 12:39:22 +08:00
aba9fb0a15 feat(printer): 添加用户购买项设置的Redis缓存控制
- 引入RedisTemplate依赖用于缓存控制
- 新增60秒的缓存键避免重复处理用户购买项
- 在setUserIsBuyItem方法中实现缓存检查逻辑- 添加TimeUnit依赖支持缓存过期时间设置
- 定义USER_PHOTO_LIST_TO_PRINTER缓存键前缀
2025-09-26 12:39:17 +08:00
ab3208c9df feat(kafka): 添加手动提交模式支持以增强消息处理可靠性
- 在 KafkaConfig 中新增 manualCommitKafkaListenerContainerFactory 配置
- 启用手动提交模式并设置 AckMode 为 MANUAL_IMMEDIATE
- 修改 FaceProcessingKafkaService 使用新的容器工厂- 添加 Acknowledgment 参数以控制消息提交时机
-仅在人脸样本保存和识别全部成功后才手动确认消息
- 处理失败时不再调用 ack.acknowledge()使消息可重新消费
- 更新 processFaceRecognition 方法返回处理结果状态
- 增强异常处理逻辑,确保失败情况下不提交消息
2025-09-25 18:46:15 +08:00
09e376e089 refactor(kafka): 统一时人脸消息时间类型为Date
- 将FaceProcessingMessage中的LocalDateTime替换为Date类型- 更新消息创建工厂方法以使用Date参数
- 调整Kafka服务中时间转换逻辑以匹配新类型
- 移除LocalDateTime相关的导入和引用- 更新字段注释以反映新的时间类型
2025-09-25 18:09:17 +08:00
dad9ddc17c docs 2025-09-25 16:18:03 +08:00
4a05773860 fix(device): 添加空值检查避免空指针异常- 在设置设备在线状态时添加对 lastActiveTime 和 clientIP 的空值检查
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 在判断设备是否在线时,增加对 keepaliveAt 时间的空值判断
- 防止因空值导致的 NullPointerException 异常- 提高代码健壮性和稳定性
2025-09-25 15:52:16 +08:00
3c700a42f9 feat(device): 添加设备在线状态查询功能- 在DeviceV2Controller中新增getDeviceOnlineStatus接口,用于根据设备ID查询设备在线状态
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 引入DeviceStatusDTO和DeviceStatusIntegrationService以支持设备状态查询- 修改DeviceStatusDTO中的时间字段类型为Date,并调整JSON序列化格式- 在DeviceRepository中增加convertToEntityWithStatus方法,用于合并设备信息与状态信息
- 优化DeviceRepository中的getOnlineStatus方法,增加异常处理和降级机制- 完善设备在线状态查询的日志记录和错误处理逻辑
2025-09-25 15:32:09 +08:00
47c6b2ca67 feat(device): 新增设备状态管理集成服务
- 添加设备状态客户端接口,支持设备在线状态查询与设置
- 创建设备状态相关 DTO,包括设备状态、在线状态和状态动作枚举
- 实现设备状态集成服务,封装设备状态操作与异常处理逻辑
- 支持单个及批量设备在线状态检查与设置功能
- 提供
2025-09-25 14:18:06 +08:00
59baf8811b feat(pricing): 添加商品一口价优惠支持检查
- 在 PriceProductConfig 实体中新增 canUseOnePrice 字段
- 更新数据库插入和更新语句,支持 canUseOnePrice 字段持久化- 在 OnePricePurchaseDiscountProvider 中实现商品一口价优惠支持检查逻辑
- 新增 areAllProductsSupportOnePrice 方法,验证购物车商品是否支持一口价优惠
- 支持查询具体商品配置和默认配置的一口价优惠设置
- 添加日志记录和异常处理,确保检查过程不影响主流程
2025-09-25 10:40:10 +08:00
019b9ffca6 refactor(video):优化视频关联关系处理逻辑
- 调整source记录插入时机,确保关联关系处理前数据已存在
- 移除冗余的source存在性检查逻辑- 统一关联关系处理流程,避免重复代码
- 添加日志记录以便追踪处理过程- 优化代码结构,提高可读性和维护性
2025-09-24 18:04:47 +08:00
30805f3e30 refactor(mapper):优化查询逻辑并处理空列表情况
- 将 filterExistingRelations 查询中的 if 判断替换为 choose-when 结构
- 在 otherwise 分支中添加空结果集查询,避免空列表时 SQL 异常- 统一 filterValidSourceRelations 查询结构,增强代码一致性
-修正 foreach 标签中 UNION ALL 前后的空格问题,确保 SQL 语法正确- 提升 XML 映射文件的可读性和健壮性
2025-09-24 17:50:53 +08:00
94d6b2f443 feat(source): 增强source关联关系的数据一致性校验
- 在SourceMapper中新增sourceExists方法,用于校验source是否存在
- 新增filterValidSourceRelations方法,过滤无效的source引用
- 在FaceServiceImpl中增强关联关系创建逻辑,防止重复和无效数据
- 在VideoPieceGetter任务中增加source存在性校验,避免创建孤立关联- 添加详细的日志记录,便于追踪关联关系创建过程
-优化XML映射文件,支持新的校验和过滤查询逻辑
2025-09-24 17:39:05 +08:00
b34f994298 feat(source): 添加过滤已存在关联关系功能
- 在SourceMapper中新增filterExistingRelations方法
- 修改FaceServiceImpl中的关联关系保存逻辑
- 修改TaskFaceServiceImpl中的关联关系保存逻辑
- 修改VideoPieceGetter中的关联关系检查逻辑
- 在SourceMapper.xml中添加filterExistingRelations的SQL实现
2025-09-24 17:16:12 +08:00
7728f4424f status 2025-09-24 13:45:48 +08:00
becbe5f6ab 允许重复
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
2025-09-24 05:03:47 +08:00
dc3a46362b Merge branch 'kafka_face_sample' 2025-09-24 05:03:04 +08:00
a361b59d74 配置
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
2025-09-23 20:57:01 +08:00
f779b0e040 计算 2025-09-23 20:53:22 +08:00
78c4548d02 文字 2025-09-23 17:54:49 +08:00
842310f73c ignore 2025-09-23 14:34:32 +08:00
cf235d38bb feat(模板): 为模板查找方法添加scanSource参数
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
在findFirstAvailableTemplate方法中新增scanSource参数,用于控制模板生成时的来源检查逻辑。调用方TaskTaskServiceImpl在强制创建vlog时传入false以跳过来源检查。
2025-09-23 13:50:26 +08:00
8903818cb0 订单详情 2025-09-23 12:21:34 +08:00
ae0cf56216 content返回url 2025-09-23 10:40:04 +08:00
90b6f53986 兜底1个 2025-09-23 10:38:23 +08:00
80b4508211 docs 2025-09-23 10:07:14 +08:00
57b8d90d5e 名称 2025-09-23 10:04:05 +08:00
59b481989c 避免文件过小损坏
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
2025-09-22 09:44:26 +08:00
61cf9383d0 修改 2025-09-22 09:33:17 +08:00
878dec2c55 npe 2025-09-21 21:18:43 +08:00
48bd9d2b0c 通知地址 2025-09-21 02:40:40 +08:00
b365d86796 显示逻辑 2025-09-21 02:38:30 +08:00
18cb459320 一口价查询 2025-09-21 00:16:45 +08:00
b7d3e20c46 C a c h e 2025-09-20 18:07:55 +08:00
d55c7a7769 Reapply "feat(PriceBiz): 新增商品类型字段并完善商品列表逻辑"
This reverts commit 638da8cd3d.
2025-09-20 16:53:55 +08:00
0432b99524 feat(OrderMapper): 查询订单时按支付时间倒序排列在queryTypeOrder查询中添加order by pay_at desc子句,
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
确保返回的订单记录按支付时间从新到旧排序,便于业务逻辑处理和展示最新订单信息。
2025-09-20 15:51:33 +08:00
da71e6d16f debug 2025-09-20 15:51:22 +08:00
f29217ac1f fix(FaceServiceImpl):修复视频模板封面URL设置问题当视频模板封面URL为空时,使用图片列表第一项的URL进行设置,避免空值导致的问题。 2025-09-20 15:51:19 +08:00
638da8cd3d Revert "feat(PriceBiz): 新增商品类型字段并完善商品列表逻辑"
This reverts commit 7ca59a1b0b.
2025-09-20 15:51:10 +08:00
7ca59a1b0b feat(PriceBiz): 新增商品类型字段并完善商品列表逻辑
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
新增 GoodsListRespVO 中的 goodsType 字段,用于区分不同商品类型。补充商品列表中“录像集”与“照片集”的类型标识。
在 PriceBiz 中注入 FaceService 并延迟加载,优化依赖关系。
根据内容购买状态判断是否整体购买,增强一口价商品的处理逻辑。
2025-09-20 04:51:51 +08:00
f10ede0d2c refactor(biz): 优化模板参数过滤逻辑
- 统计每个占位符在模板中出现的次数
- 根据占位符出现次数和实际可用源数量,选择合适的源进行过滤
- 优化日志输出,增加占位符统计信息
2025-09-19 18:50:01 +08:00
9226dfff1d feat(questionnaire): 添加下载问卷小程序二维码功能
- 新增 downloadQrCode 方法,用于生成和下载问卷小程序二维码
- 集成微信小程序配置和二维码生成工具
- 实现问卷二维码的生成、上传和访问控制
2025-09-19 18:50:01 +08:00
67f5c274f7 feat(ScenicConfigResp): 添加等待时显示照片的字段
在 ScenicConfigResp 类中添加了 showPhotoWhenWaiting 字段,用于控制在等待时是否显示照片。
2025-09-19 17:06:17 +08:00
ff8fe33eb0 Merge branch 'print-price' 2025-09-19 15:08:55 +08:00
292157885a refactor(questionnaire): 移除问卷模块中的健康检查接口
- 删除了 QuestionnaireClient 中的 health 方法
- 删除了 QuestionnaireIntegrationService 中的 health 方法
-移除了与健康检查相关的代码和注释
2025-09-19 13:33:20 +08:00
ad33b1abef feat(pc): 添加打印机管理功能- 新增 PrinterManageController 类实现打印机管理接口
- 添加打印机列表查询、详情、创建、更新、删除等功能
- 新增打印机状态、首选尺寸、当前使用设备更新接口
- 在 PrinterMapper.xml 中添加相关 SQL 语句
2025-09-19 12:32:58 +08:00
524627ea73 Merge branch 'refs/heads/questionnare-microservice'
# Conflicts:
#	src/main/java/com/ycwl/basic/controller/pc/DefaultConfigController.java
2025-09-19 12:29:54 +08:00
7a35551a7b feat(video): 添加视频查看权限控制功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 新增视频查看权限相关数据结构和接口
- 实现用户视频查看记录的创建和更新逻辑
- 添加视频查看权限的检查和记录功能
-优化分布式环境下的并发控制
2025-09-18 18:42:53 +08:00
7820a282d9 refactor(pc): 优化人脸服务中的模式匹配逻辑
- 在获取 re_match_mode 配置时,添加默认值 0,避免潜在的 NullPointerException
- 优化了 switch 表达式中的条件判断,提高代码可读性
2025-09-18 18:31:30 +08:00
1220348bae refactor(PrinterServiceImpl):重构价格计算逻辑以使用统一的价格计算服务
在 `PrinterServiceImpl` 类中,移除了对 `PriceRepository` 的依赖,并引入了 `IPriceCalculationService` 接口来处理价格计算。主要改动包括:- 添加了新的导入语句,如 `PriceCalculationRequest`, `PriceCalculationResult`, `ProductItem`, `ProductType` 和 `IPriceCalculationService`。
- 在 `queryPrice` 方法中,通过 `IPriceCalculationService` 计算照片打印的总价格,替代了原有的直接从 `PriceRepository` 获取价格配置的方式。
- 更新了 `createOrder` 方法中的价格计算逻辑,同样采用 `IPriceCalculationService` 来确定订单的价格信息。
- 对于没有可打印照片的情况,现在会返回一个零价格的对象或抛出异常,具体取决于调用上下文。

这些更改旨在提高代码的模块化程度和可维护性,同时确保价格计算的一致性和准确性。
2025-09-18 13:36:25 +08:00
e9102e8e58 feat(pricing): 新增打包购买优惠功能
- 添加打包购买优惠信息类 BundleDiscountInfo
- 实现打包购买优惠提供者 BundleDiscountProvider
- 添加打包购买优惠服务接口 IBundleDiscountService 及其实现类 BundleDiscountServiceImpl
- 在 DiscountInfo 中添加 bundleDiscountInfo 字段以支持打包优惠
- 更新 CLAUDE.md 文档,详细说明打包购买优惠系统的设计和实现
2025-09-18 11:37:23 +08:00
e86dc85afe refactor(pricing): 调整优惠优先级顺序
-
2025-09-18 10:28:35 +08:00
b14754ec0a feat(integration): 添加消息服务相关接口和功能
- 新增 MessageController 类,实现消息列表查询和消息通道列表获取功能
- 新增 MessageClient 接口,用于调用消息服务的 Feign客户端
- 新增 ChannelsResponse、MessageListData 和 MessageRecordDTO 数据传输对象
- 新增 MessageIntegrationService 服务类,处理消息服务相关业务逻辑
2025-09-17 21:53:41 +08:00
a888ed3fe2 feat(integration): 添加 ZT-Message Kafka 生产者集成
- 新增 ZtMessage DTO 类用于消息体
- 实现 ZtMessageProducerService 生产者服务
- 添加示例演示如何发送消息
- 更新配置文件和文档以支持新功能
2025-09-17 21:38:26 +08:00
dc2154c020 feat(integration): 添加 Kafka 消息系统集成
- 新增 Kafka 配置和连接测试功能- 实现人脸处理消息的消费逻辑
- 添加消息发送预留接口
- 优化人脸样本保存和处理流程
2025-09-12 06:38:44 +08:00
24f692b69a refactor(questionnaire): 调整问卷相关请求对象结构
- 移除 SubmitAnswerRequest 中的 @NotNull 注解
- 在 CreateQuestionRequest 中添加 id 字段
2025-09-06 21:46:06 +08:00
b9c65cf030 feat(mobile): 添加移动端问卷接口
- 新增 AppQuestionnaireController 控制器,提供移动端问卷相关接口
- 实现问卷详情获取和问卷答案提交两个主要功能
- 集成 QuestionnaireIntegrationService 服务进行问卷数据处理
- 使用 ApiResponse 统一接口返回格式
- 添加日志记录和异常处理,提高系统稳定性
2025-09-06 15:36:37 +08:00
58488d2cde refactor(integration): 重构设备和服务配置
- 修改 DefaultConfigIntegrationService 类名以更准确地反映其功能
- 移除不必要的导入和注解
- 统一命名规范
2025-09-06 15:35:42 +08:00
32f7660dc0 feat(questionnaire): 新增问卷管理 V2 版本接口
- 添加了新的 QuestionnaireV2Controller 类,实现了问卷管理的 CRUD操作
- 新增了问卷答案查看和统计功能相关接口
- 重构了 ResponseDetailResponse 类,将 AnswerDetailResponse 类独立出来- 简化了 CreateQuestionOptionRequest 类的结构
2025-09-06 01:09:17 +08:00
180f89042c refactor(questionnaire): 重构问卷列表和回答记录列表的响应结构
- 将 QuestionnaireListResponse 和 ResponseListResponse 类移除
- 使用泛型化的 PageResponse 类作为列表响应的基类
- 更新相关接口和方法的返回类型
- 调整示例代码和测试用例
2025-09-06 01:04:15 +08:00
a49450b795 feat(integration): 添加问卷服务集成模块
- 新增问卷服务配置和客户端接口
- 实现问卷创建、查询、提交答案和统计分析等功能
- 添加问卷集成示例,演示各项功能的使用- 设计并实现问卷服务的 fallback 缓存管理策略
2025-09-06 00:19:48 +08:00
145 changed files with 6290 additions and 2920 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ target/
.serena .serena
.claude .claude
.vscode .vscode
*.jpg

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# Repository Guidelines
## Project Structure & Module Organization
- Application code: `src/main/java/com/ycwl/basic/**` (controllers, services, mapper/repository, dto/model, config, util).
- Resources: `src/main/resources/**` (Spring configs, `mapper/*.xml`, static assets, logging).
- Tests: `src/test/java/**` mirrors main packages.
- Build output: `target/` (never commit).
## Build, Test, and Development Commands
- Build artifact: `mvn clean package` (tests are skipped by default via `pom.xml`).
- Run locally (dev): `mvn spring-boot:run -Dspring-boot.run.profiles=dev`.
- Run jar: `java -jar target/basic21-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev`.
- Execute tests: `mvn -DskipTests=false test` (note: `pom.xml` excludes `**/*Test.java` from test-compile; temporarily remove/override that config if you need to compile and run tests).
## Coding Style & Naming Conventions
- Java 21. Use 4-space indentation; UTF-8; no wildcard imports.
- Packages: `com.ycwl.basic.*`; classes PascalCase; methods/fields camelCase; constants UPPER_SNAKE_CASE.
- Controllers in `controller`, business logic in `service`, persistence in `mapper` + `resources/mapper/*.xml`.
- Prefer Lombok for boilerplate and constructor injection where applicable.
## Testing Guidelines
- Framework: Spring Boot testing + JUnit (see `spring-boot-starter-test`).
- Test names end with `Test` or `Tests` and mirror package structure.
- Aim to cover service/util layers and critical controllers. No enforced coverage target.
- To enable tests locally, remove/override the `maven-compiler-plugin` `testExcludes` in `pom.xml` and run `mvn -DskipTests=false test`.
## Commit & Pull Request Guidelines
- Follow Conventional Commits: `feat(scope): summary`, `fix(scope): summary`, `refactor: ...`.
- Reference issues (e.g., `#123`) and include brief rationale and screenshots for UI-facing changes.
- Keep PRs focused; include run/build instructions and any config changes.
## Security & Configuration Tips
- Profiles: `application.yml` and `bootstrap.yml` with `-dev`/`-prod` variants. Select via `--spring.profiles.active`.
- Do not commit secrets. Provide Nacos, Redis, MySQL, OSS/S3, and 3rd‑party keys via environment or secure config.
- Review `logback-spring*.xml` before raising log levels in production.
## Agent-Specific Notes
- Keep changes minimal and within existing package boundaries.
- Do not reorganize MyBatis XML names or mapper interfaces without updating both sides.
- If altering APIs, update affected tests and documentation in the same PR.

567
CLAUDE.md
View File

@@ -1,527 +1,40 @@
# CLAUDE.md # Repository Guidelines
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Structure & Module Organization
- Application code: `src/main/java/com/ycwl/basic/**` (controllers, services, mapper/repository, dto/model, config, util).
## 构建和开发命令 - Resources: `src/main/resources/**` (Spring configs, `mapper/*.xml`, static assets, logging).
- Tests: `src/test/java/**` mirrors main packages.
### 构建应用程序 - Build output: `target/` (never commit).
```bash
# 清理构建(默认跳过测试) ## Build, Test, and Development Commands
mvn clean package - Build artifact: `mvn clean package` (tests are skipped by default via `pom.xml`).
- Run locally (dev): `mvn spring-boot:run -Dspring-boot.run.profiles=dev`.
# 清理构建并执行测试 - Run jar: `java -jar target/basic21-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev`.
mvn clean package -DskipTests=false - Execute tests: `mvn -DskipTests=false test` (note: `pom.xml` excludes `**/*Test.java` from test-compile; temporarily remove/override that config if you need to compile and run tests).
# 运行应用程序 ## Coding Style & Naming Conventions
mvn spring-boot:run - Java 21. Use 4-space indentation; UTF-8; no wildcard imports.
``` - Packages: `com.ycwl.basic.*`; classes PascalCase; methods/fields camelCase; constants UPPER_SNAKE_CASE.
- Controllers in `controller`, business logic in `service`, persistence in `mapper` + `resources/mapper/*.xml`.
### 测试命令 - Prefer Lombok for boilerplate and constructor injection where applicable.
```bash
# 运行特定测试类 ## Testing Guidelines
mvn test -Dtest=FaceCleanerTest - Framework: Spring Boot testing + JUnit (see `spring-boot-starter-test`).
- Test names end with `Test` or `Tests` and mirror package structure.
# 运行特定测试方法 - Aim to cover service/util layers and critical controllers. No enforced coverage target.
mvn test -Dtest=FaceCleanerTest#testSpecificMethod - To enable tests locally, remove/override the `maven-compiler-plugin` `testExcludes` in `pom.xml` and run `mvn -DskipTests=false test`.
# 运行特定包的测试 ## Commit & Pull Request Guidelines
mvn test -Dtest="com.ycwl.basic.storage.adapters.*Test" - Follow Conventional Commits: `feat(scope): summary`, `fix(scope): summary`, `refactor: ...`.
- Reference issues (e.g., `#123`) and include brief rationale and screenshots for UI-facing changes.
# 运行pricing模块测试 - Keep PRs focused; include run/build instructions and any config changes.
mvn test -Dtest="com.ycwl.basic.pricing.*Test"
## Security & Configuration Tips
# 运行所有测试 - Profiles: `application.yml` and `bootstrap.yml` with `-dev`/`-prod` variants. Select via `--spring.profiles.active`.
mvn test -DskipTests=false - Do not commit secrets. Provide Nacos, Redis, MySQL, OSS/S3, and 3rd‑party keys via environment or secure config.
- Review `logback-spring*.xml` before raising log levels in production.
# 运行测试并生成详细报告
mvn test -DskipTests=false -Dsurefire.printSummary=true ## Agent-Specific Notes
``` - Keep changes minimal and within existing package boundaries.
- Do not reorganize MyBatis XML names or mapper interfaces without updating both sides.
### 开发环境配置 - If altering APIs, update affected tests and documentation in the same PR.
应用程序使用 Spring 配置文件:
- 默认激活配置文件:`dev`
- 生产环境配置文件:`prod`(启用定时任务)
- 配置文件:`application-dev.yml``application-prod.yml`
## 架构概览
这是一个 Spring Boot 3.3.5 应用程序(Java 21),采用多租户架构,通过不同的 API 端点为不同的客户端类型提供服务。
### 控制器架构
- **移动端 APIs** (`/api/mobile/`):面向移动应用的客户端端点
- **PC 端 APIs** (`/api/`):Web 仪表板/管理面板端点
- **任务 APIs** (`/task/`):后台工作和渲染任务端点
- **外部 APIs**:专用集成(打印机、代理、viid、vpt、wvp)
### 核心业务模块
#### 工厂模式实现
三个主要工厂类管理第三方集成:
1. **StorageFactory** (`com.ycwl.basic.storage.StorageFactory`)
- 管理:本地存储、AWS S3、阿里云 OSS 存储适配器
- 配置节:`storage.configs[]`
2. **PayFactory** (`com.ycwl.basic.pay.PayFactory`)
- 管理:微信支付、聪明支付适配器
- 配置节:`pay.configs[]`
3. **FaceBodyFactory** (`com.ycwl.basic.facebody.FaceBodyFactory`)
- 管理:阿里云、百度人脸识别适配器
- 配置节:`facebody.configs[]`
#### 适配器模式
每个工厂使用标准化接口:
- `IStorageAdapter`:文件操作(上传/下载/删除/ACL)
- `IPayAdapter`:支付生命周期(创建/回调/退款)
- `IFaceBodyAdapter`:人脸识别操作
#### 定时任务系统
`com.ycwl.basic.task` 包中的后台任务(仅生产环境):
- `VideoTaskGenerator`:人脸识别和视频处理
- `FaceCleaner`:人脸和存储清理任务
- `DynamicTaskGenerator`:带延迟队列的动态任务创建
- `ScenicStatsTask`:统计数据聚合
### 数据库和持久化
- **MyBatis Plus**:具有自动 CRUD 操作的 ORM
- **MapperScan**:扫描 `com.ycwl.basic.mapper` 及子包
- **数据库**:MySQL 配合 HikariCP 连接池
- **Redis**:会话管理和缓存
### 主要库和依赖
- Spring Boot 3.3.5 启用 Java 21 虚拟线程
- MyBatis Plus 3.5.5 用于数据库操作
- JWT (jjwt 0.9.0) 用于身份验证
- 微信支付 SDK 用于支付处理
- 阿里云 OSS 和 AWS S3 用于文件存储
- 阿里云和百度 SDK 用于人脸识别
- OpenTelemetry 用于可观测性(开发环境中禁用)
### 业务逻辑组织
- **Service 层**:`service` 包中的业务逻辑实现
- **Biz 层**:`biz` 包中的高级业务编排
- **Repository 模式**:`repository` 包中的数据访问抽象
- **自定义异常**:特定领域的异常处理
### 配置管理
每个模块使用 Spring Boot 自动配置启动器:
- 支持多供应商的命名配置
- 通过配置进行默认供应商选择
- 针对不同环境的特定配置文件
## 常见开发模式
### 添加新的存储/支付/人脸识别供应商
1. 实现相应接口(`IStorageAdapter``IPayAdapter``IFaceBodyAdapter`
2. 在相应的类型枚举中添加枚举值
3. 更新工厂的 switch 表达式
4. 如需要,添加配置类
5. 在 application.yml 中更新新供应商配置
### 身份验证上下文
在整个应用程序中使用 `BaseContextHandler.getUserId()` 获取当前已认证用户 ID。
### API 响应模式
所有 API 都返回 `ApiResponse<T>` 包装器,通过 `CustomExceptionHandle` 进行一致的错误处理。
### 添加新的定时任务
1.`com.ycwl.basic.task` 包中创建类
2. 添加 `@Component``@Profile("prod")` 注解
3. 使用 `@Scheduled` 进行基于 cron 的执行
4. 遵循现有的错误处理和日志记录模式
### 多端API架构
应用程序通过路径前缀区分不同的客户端:
- **移动端**: `/api/mobile/*` - 针对移动应用优化的接口
- **PC管理端**: `/api/*` - Web管理面板接口
- **任务处理**: `/task/*` - 后台任务和渲染服务接口
- **外部集成**: 专用集成接口(打印机、代理、viid、vpt、wvp等)
每个端点都有对应的Controller包结构,确保API的职责分离和维护性。
## 价格查询系统 (Pricing Module)
### 核心架构
价格查询系统是一个独立的业务模块,位于 `com.ycwl.basic.pricing` 包中,提供商品定价、优惠券管理、券码管理和统一优惠检测功能。
#### 关键组件
- **PriceCalculationController** (`/api/pricing/calculate`):统一价格计算API,支持自动优惠组合
- **CouponManagementController** (`/api/pricing/admin/coupons/`):优惠券配置和统计管理
- **VoucherManagementController** (`/api/pricing/voucher/`):券码批次和券码管理
- **VoucherUsageController** (`/api/pricing/voucher/usage/`):券码使用记录和统计
- **PricingConfigController** (`/api/pricing/config/`):商品价格配置管理
- **OnePricePurchaseController** (`/api/pricing/admin/one-price/`):一口价配置管理
#### 商品类型支持
```java
ProductType枚举定义了支持的商品类型
- VLOG_VIDEO: Vlog视频
- RECORDING_SET: 录像集
- PHOTO_SET: 照相集
- PHOTO_PRINT: 照片打印
- MACHINE_PRINT: 一体机打印
```
#### 价格计算流程(统一优惠检测)
1. 接收PriceCalculationRequest(包含商品列表、用户ID、券码等)
2. 查找商品基础配置和分层定价
3. 处理套餐商品(BundleProductItem)
4. **统一优惠检测**:通过IDiscountDetectionService自动检测所有可用优惠
- 券码优惠(VoucherDiscountProvider,优先级100)
- 优惠券优惠(CouponDiscountProvider,优先级80)
- 一口价优惠(OnePricePurchaseDiscountProvider,优先级60)
5. **智能优惠组合**:按优先级和叠加规则应用最优优惠组合
6. 返回PriceCalculationResult(包含原价、最终价格、使用的优惠详情、可用优惠列表)
#### 优惠券系统
- **CouponType**: PERCENTAGE(百分比)、FIXED_AMOUNT(固定金额)
- **CouponStatus**: CLAIMED(已领取)、USED(已使用)、EXPIRED(已过期)
- 支持商品类型限制 (`applicableProducts` JSON字段)
- 最小消费金额和最大折扣限制
- 时间有效期控制
#### 分页查询功能
所有管理接口都支持分页查询:
- **优惠券系统**:使用PageHelper实现
- 优惠券配置分页:支持按状态、名称筛选
- 领取记录分页:支持按用户、优惠券、状态、时间范围筛选
- **券码系统**:使用MyBatis-Plus Page实现
- 券码批次分页:支持按景区、批次名称、状态筛选
- 券码列表分页:支持按批次、状态、用户筛选
- 使用记录分页:支持按券码、用户、时间范围筛选
#### 统计功能
- **优惠券统计**:基础统计(领取数、使用数、可用数)、详细统计(使用率、平均使用天数)
- **券码统计**:支持可重复使用的统计(使用率、重复使用率、平均使用次数)
- 时间范围统计:指定时间段的整体数据分析
## 券码管理系统 (Voucher System)
### 核心特性
券码系统支持**可重复使用**的优惠券管理,与传统优惠券系统并行工作。
#### 关键优势
- **可重复使用**:支持单个券码多次使用,通过`maxUseCount`配置最大使用次数
- **用户使用限制**:支持单个用户对券码的使用次数限制(`maxUsePerUser`)
- **使用间隔控制**:支持设置使用时间间隔(`useIntervalHours`)
- **时间范围控制**:支持设置券码的有效期开始和结束时间
#### 券码优惠类型
```java
public enum VoucherDiscountType {
FREE_ALL(0, "全场免费"), // 优先级最高,且不可叠加
REDUCE_PRICE(1, "商品降价"), // 每个商品减免固定金额
DISCOUNT(2, "商品打折"); // 每个商品按百分比打折
}
```
#### 数据库表结构
- **price_voucher_batch_config**:券码批次配置表,支持按景区、推客创建券码批次
- **price_voucher_code**:券码表,每个券码全局唯一,支持同一用户在同一景区只能领取一次
- **price_voucher_usage_record**:券码使用记录表,记录每次使用的完整信息
- **voucher_print_record**:券码打印记录表,用于移动端打印功能
## 统一优惠检测系统 (Unified Discount Detection)
### 设计模式
采用**策略模式**的可扩展优惠检测系统,统一管理并自动组合多种优惠类型。
#### 核心接口
```java
// 优惠提供者接口
public interface IDiscountProvider {
String getProviderType(); // 提供者类型
int getPriority(); // 优先级(数字越大越高)
List<DiscountInfo> detectAvailableDiscounts(); // 检测可用优惠
DiscountResult applyDiscount(); // 应用优惠
}
// 优惠检测服务接口
public interface IDiscountDetectionService {
DiscountCombinationResult calculateOptimalCombination(); // 计算最优组合
DiscountCombinationResult previewOptimalCombination(); // 预览优惠组合
}
```
#### 优惠提供者实现(按优先级排序)
1. **VoucherDiscountProvider** (优先级: 100)
- 处理券码优惠逻辑
- 支持用户主动输入券码或自动选择最优券码
- 全场免费券码不可与其他优惠叠加
2. **CouponDiscountProvider** (优先级: 80)
- 处理优惠券优惠逻辑
- 自动选择最优优惠券
- 可与券码叠加使用(除全场免费券码外)
3. **OnePricePurchaseDiscountProvider** (优先级: 60)
- 处理一口价优惠逻辑(景区级统一价格)
- 仅当一口价小于当前金额时产生优惠
- 叠加性由配置`canUseCoupon/canUseVoucher`控制
#### 优惠应用策略
```java
原价 券码 优惠券 一口价 最终价格
特殊情况
- 全场免费券码直接最终价=0停止后续优惠
- 一口价可叠加性由配置 canUseCoupon / canUseVoucher 控制
```
### 开发模式
#### 添加新商品类型
1. 在ProductType枚举中添加新类型
2. 在PriceProductConfig表中配置default配置
3. 根据需要添加分层定价(PriceTierConfig)
4. 更新前端产品类型映射
#### 添加新优惠券类型
1. 在CouponType枚举中添加类型
2. 在CouponServiceImpl中实现计算逻辑
3. 更新applicableProducts验证规则
#### 添加新优惠提供者(策略扩展)
```java
@Component
public class FlashSaleDiscountProvider implements IDiscountProvider {
@Override
public String getProviderType() { return "FLASH_SALE"; }
@Override
public int getPriority() { return 90; } // 介于券码和优惠券之间
@Override
public List<DiscountInfo> detectAvailableDiscounts(DiscountDetectionContext context) {
// 实现限时抢购优惠检测逻辑
return discountInfoList;
}
@Override
public DiscountResult applyDiscount(DiscountDetectionContext context, DiscountInfo discount) {
// 实现优惠应用逻辑
return discountResult;
}
}
```
#### 创建可重复使用券码批次
```java
VoucherBatchCreateReqV2 request = new VoucherBatchCreateReqV2();
request.setBatchName("限时活动券码");
request.setMaxUseCount(3); // 每个券码最多使用三次
request.setMaxUsePerUser(2); // 每个用户最多使用两次
request.setUseIntervalHours(12); // 使用间隔12小时
request.setValidStartTime(startTime); // 有效期开始时间
request.setValidEndTime(endTime); // 有效期结束时间
```
#### 自定义TypeHandler使用
项目使用自定义TypeHandler处理复杂JSON字段:
- `BundleProductListTypeHandler`:处理套餐商品列表JSON序列化
### 测试策略
针对pricing模块的全面测试策略:
#### 单元测试类型
- **服务层测试**:每个服务类都有对应测试类
- `PriceBundleServiceTest` - 套餐价格计算测试
- `ReusableVoucherServiceTest` - 可重复使用券码测试
- `VoucherTimeRangeTest` - 券码时间范围功能测试
- `VoucherPrintServiceCodeGenerationTest` - 券码生成测试
- **实体映射测试**:验证数据库映射和JSON序列化
- `PriceBundleConfigStructureTest` - 实体结构测试
- `PriceBundleConfigJsonTest` - JSON序列化测试
- `CouponSwitchFieldsMappingTest` - 字段映射测试
- **类型处理器测试**:验证自定义TypeHandler
- `BundleProductListTypeHandlerTest` - 套餐商品列表序列化测试
- **配置验证测试**:验证系统配置完整性
- `DefaultConfigValidationTest` - 验证所有ProductType的default配置
- `CodeGenerationStandaloneTest` - 独立代码生成测试
#### 测试执行命令
```bash
# 运行单个测试类
mvn test -Dtest=VoucherTimeRangeTest
mvn test -Dtest=ReusableVoucherServiceTest
mvn test -Dtest=BundleProductListTypeHandlerTest
# 运行整个pricing模块测试
mvn test -Dtest="com.ycwl.basic.pricing.*Test"
# 运行特定分类的测试
mvn test -Dtest="com.ycwl.basic.pricing.service.*Test" # 服务层测试
mvn test -Dtest="com.ycwl.basic.pricing.handler.*Test" # TypeHandler测试
mvn test -Dtest="com.ycwl.basic.pricing.entity.*Test" # 实体测试
mvn test -Dtest="com.ycwl.basic.pricing.mapper.*Test" # Mapper测试
# 运行带详细报告的测试
mvn test -Dtest="com.ycwl.basic.pricing.*Test" -Dsurefire.printSummary=true
```
#### 重点测试场景
- **价格计算核心流程**:验证统一优惠检测和组合逻辑
- **可重复使用券码**:验证多次使用、时间间隔、用户限制逻辑
- **时间范围控制**:验证券码有效期开始和结束时间
- **优惠叠加规则**:验证券码、优惠券、一口价的叠加逻辑
- **JSON序列化**:验证复杂对象在数据库中的存储和读取
- **分页功能**:验证PageHelper和MyBatis-Plus分页集成
- **异常处理**:验证业务异常和全局异常处理器
## 关键架构模式
### Repository 层模式
项目使用Repository层抽象数据访问逻辑:
- Repository接口定义数据访问契约
- Mapper接口处理MyBatis Plus的数据库映射
- Service层通过Repository访问数据,避免直接依赖Mapper
### 异常处理架构
- **全局异常处理**: `CustomExceptionHandle` 提供统一的异常处理和响应格式
- **业务异常**: 自定义异常类继承RuntimeException,携带业务错误码
- **集成异常**: `IntegrationException` 专门处理外部服务调用异常
### 配置驱动的扩展性
通过配置文件驱动的多供应商支持:
- 存储:本地、AWS S3、阿里云 OSS
- 支付:微信支付、聪明支付
- 人脸识别:阿里云、百度
每个供应商通过统一接口访问,配置切换无需代码修改。
### 业务层架构
- **Service层**: 核心业务逻辑实现
- **Biz层**: 高级业务流程编排,组合多个Service
- **Controller层**: HTTP请求处理和响应转换
- **Repository层**: 数据访问抽象
### 认证和会话管理
- **JWT**: 使用jjwt库进行身份验证
- **Redis**: 存储会话信息和缓存
- **BaseContextHandler**: 提供当前用户上下文访问
## 微服务集成架构 (Integration Package)
### 核心架构
位于 `com.ycwl.basic.integration` 包,使用 Spring Cloud OpenFeign 和 Nacos 实现外部微服务集成。
#### 通用基础设施
- **IntegrationProperties**: 所有集成的集中配置管理
- **FeignErrorDecoder**: 自定义错误解码器,统一错误处理
- **IntegrationException**: 标准化集成异常
- **CommonResponse/PageResponse**: 外部服务响应包装器
#### 已实现的服务集成
- **Scenic Integration** (`integration.scenic`): ZT-Scenic 微服务集成
- **Device Integration** (`integration.device`): ZT-Device 微服务集成
#### 集成模式
每个外部服务按以下结构组织:
```
service/
├── client/ # Feign 客户端
├── config/ # 服务特定配置
├── dto/ # 数据传输对象
├── service/ # 业务逻辑层
└── example/ # 使用示例
```
### 配置管理
```yaml
integration:
scenic:
enabled: true
serviceName: zt-scenic
connectTimeout: 5000
readTimeout: 10000
device:
enabled: true
serviceName: zt-device
connectTimeout: 5000
readTimeout: 10000
```
### 使用模式
所有集成服务使用统一的 `handleResponse` 模式进行错误处理,确保一致的异常包装和日志记录。
### 测试集成服务
```bash
# 运行特定集成测试
mvn test -Dtest=ScenicIntegrationServiceTest
mvn test -Dtest=DeviceIntegrationServiceTest
# 运行所有集成测试
mvn test -Dtest="com.ycwl.basic.integration.*Test"
```
### 调试集成问题
启用 Feign 客户端日志:
```yaml
logging:
level:
com.ycwl.basic.integration: DEBUG
```
## 开发环境和调试
### 端口配置
- **开发环境端口**: 8030
- **应用名称**: zt
### 日志配置
开发环境默认启用详细的集成服务日志,便于调试外部服务调用问题。
### CI/CD 配置
项目使用 Jenkins 进行持续集成:
- **JDK 版本**: OpenJDK 21
- **构建命令**: `mvn clean package -DskipTests=true`
- **构建产物**: 自动归档和发布 JAR 文件
## 重要开发约定
### 测试文件组织
测试按功能模块组织,包括:
- **适配器测试**: `*AdapterTest.java` 测试第三方集成
- **实体测试**: 验证数据库映射和JSON序列化
- **Mapper测试**: 验证数据访问层逻辑
- **Handler测试**: 测试自定义TypeHandler
### 模块化架构
每个业务模块(如 `pricing``integration``order`)都有完整的分层结构:
```
module/
├── controller/ # REST API控制器
├── service/ # 业务逻辑层
├── repository/ # 数据访问抽象
├── mapper/ # MyBatis数据映射
├── entity/ # JPA/MyBatis实体
├── dto/ # 数据传输对象
├── enums/ # 枚举定义
└── exception/ # 模块特定异常
```
### 外部服务集成
集成服务统一使用以下模式:
- **Feign客户端**: 声明式HTTP客户端调用
- **错误处理**: 统一的`handleResponse`模式
- **配置管理**: 通过`IntegrationProperties`集中配置
- **超时配置**: 连接超时5秒,读取超时10秒
## Windows 开发环境注意事项
### 路径处理
- 项目在Windows系统上运行,注意路径分隔符使用反斜杠 `\`
- 配置文件中的资源路径已适配Windows环境
- 日志文件和临时文件路径会自动适配系统环境
### 开发工具兼容性
- 确保使用Java 21兼容的IDE
- Maven命令在Windows Command Prompt和PowerShell中均可使用
- 建议使用UTF-8编码避免中文字符问题
### 端口占用检查
开发时如遇端口冲突,使用以下命令检查:
```cmd
netstat -ano | findstr :8030
taskkill /f /pid <PID>
```

View File

View File

@@ -266,6 +266,12 @@
<artifactId>mts20140618</artifactId> <artifactId>mts20140618</artifactId>
<version>5.0.0</version> <version>5.0.0</version>
</dependency> </dependency>
<!-- Spring Kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -360,4 +360,12 @@ public class OrderBiz {
orderRepository.clearOrderCache(orderId); // 更新完了,清理下 orderRepository.clearOrderCache(orderId); // 更新完了,清理下
profitSharingBiz.revokeProfitSharing(order.getScenicId(), orderId, "订单已退款"); profitSharingBiz.revokeProfitSharing(order.getScenicId(), orderId, "订单已退款");
} }
/**
* 检查用户是否购买了指定商品
* 提供给PriceBiz使用,避免循环调用
*/
public boolean checkUserBuyItem(Long userId, int goodsType, Long goodsId) {
return orderRepository.checkUserBuyItem(userId, goodsType, goodsId);
}
} }

View File

@@ -1,6 +1,8 @@
package com.ycwl.basic.biz; package com.ycwl.basic.biz;
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO; import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity; import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp; import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; import com.ycwl.basic.model.pc.face.entity.FaceEntity;
@@ -9,20 +11,25 @@ import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO; import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity; import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO; import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
import com.ycwl.basic.pricing.entity.PriceOnePriceConfig; import com.ycwl.basic.pricing.entity.PriceOnePriceConfig;
import com.ycwl.basic.pricing.service.IOnePricePurchaseService; import com.ycwl.basic.pricing.service.IOnePricePurchaseService;
import com.ycwl.basic.repository.FaceRepository; import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.PriceRepository; import com.ycwl.basic.repository.PriceRepository;
import com.ycwl.basic.repository.ScenicRepository; import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.repository.TemplateRepository; import com.ycwl.basic.repository.TemplateRepository;
import com.ycwl.basic.service.pc.FaceService;
import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.Strings;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Component @Component
@@ -38,7 +45,12 @@ public class PriceBiz {
@Autowired @Autowired
private FaceRepository faceRepository; private FaceRepository faceRepository;
@Autowired @Autowired
@Lazy
private FaceService faceService;
@Autowired
private CouponBiz couponBiz; private CouponBiz couponBiz;
@Autowired
private MemberRelationRepository memberRelationRepository;
public List<GoodsListRespVO> listGoodsByScenic(Long scenicId) { public List<GoodsListRespVO> listGoodsByScenic(Long scenicId) {
List<GoodsListRespVO> goodsList = new ArrayList<>(); List<GoodsListRespVO> goodsList = new ArrayList<>();
@@ -48,15 +60,16 @@ public class PriceBiz {
GoodsListRespVO goods = new GoodsListRespVO(); GoodsListRespVO goods = new GoodsListRespVO();
goods.setGoodsId(template.getId()); goods.setGoodsId(template.getId());
goods.setGoodsName(template.getName()); goods.setGoodsName(template.getName());
goods.setGoodsType(0);
return goods; return goods;
}).forEach(goodsList::add); }).forEach(goodsList::add);
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(scenicId); ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(scenicId);
if (scenicConfig != null) { if (scenicConfig != null) {
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceVideo())) { if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceVideo())) {
goodsList.add(new GoodsListRespVO(1L, "录像集")); goodsList.add(new GoodsListRespVO(1L, "录像集", 1));
} }
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceImage())) { if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceImage())) {
goodsList.add(new GoodsListRespVO(2L, "照片集")); goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
} }
} }
return goodsList; return goodsList;
@@ -140,6 +153,52 @@ public class PriceBiz {
respVO.setBuy(Integer.valueOf(1).equals(orderEntity.getStatus())); respVO.setBuy(Integer.valueOf(1).equals(orderEntity.getStatus()));
} }
} }
if (type == -1 && !respVO.isBuy()) {
// 直接查询用户购买状态,避免调用faceContentList造成循环调用
boolean allContentsPurchased = true;
// 检查视频模板购买状态
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
for (TemplateRespVO template : templateList) {
// 使用OrderRepository直接检查是否购买了该模板下的内容
List<MemberVideoEntity> videoEntities = memberRelationRepository.listRelationByFaceAndTemplate(faceId, template.getId());
if (videoEntities == null || videoEntities.isEmpty()) {
allContentsPurchased = false;
break;
}
boolean hasPurchasedTemplate = orderBiz.checkUserBuyItem(userId, -1, videoEntities.getFirst().getVideoId());
if (!hasPurchasedTemplate) {
allContentsPurchased = false;
break;
}
}
// 检查源文件购买状态(录像集和照片集)
if (allContentsPurchased) {
if (scenicConfig != null) {
// 检查录像集
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceVideo())) {
boolean hasPurchasedRecording = orderBiz.checkUserBuyItem(userId, 1, faceId);
if (!hasPurchasedRecording) {
allContentsPurchased = false;
}
}
// 检查照片集
if (allContentsPurchased && !Boolean.TRUE.equals(scenicConfig.getDisableSourceImage())) {
boolean hasPurchasedPhoto = orderBiz.checkUserBuyItem(userId, 2, faceId);
if (!hasPurchasedPhoto) {
allContentsPurchased = false;
}
}
}
}
// 如果所有内容都已购买,则认为已购买套餐
if (allContentsPurchased) {
respVO.setBuy(true);
}
}
respVO.setShare(false); respVO.setShare(false);
if (face == null || !face.getMemberId().equals(userId)) { if (face == null || !face.getMemberId().equals(userId)) {
respVO.setShare(true); respVO.setShare(true);

View File

@@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -31,8 +32,6 @@ public class TemplateBiz {
private FaceRepository faceRepository; private FaceRepository faceRepository;
@Autowired @Autowired
private SourceMapper sourceMapper; private SourceMapper sourceMapper;
@Autowired
private SourceRepository sourceRepository;
public boolean determineTemplateCanGenerate(Long templateId, Long faceId) { public boolean determineTemplateCanGenerate(Long templateId, Long faceId) {
return determineTemplateCanGenerate(templateId, faceId, true); return determineTemplateCanGenerate(templateId, faceId, true);
@@ -133,29 +132,87 @@ public class TemplateBiz {
log.info("filterTaskParams: templateId:{} has no placeholders", templateId); log.info("filterTaskParams: templateId:{} has no placeholders", templateId);
return Map.of(); return Map.of();
} }
TemplateConfigEntity templateConfig = templateRepository.getTemplateConfig(templateId);
// 统计每个 placeholder 在模板中出现的次数
Map<String, Long> placeholderCounts = templatePlaceholders.stream()
.collect(Collectors.groupingBy(
placeholder -> placeholder,
Collectors.counting()
));
Map<String, List<SourceEntity>> filteredParams = new HashMap<>(); Map<String, List<SourceEntity>> filteredParams = new HashMap<>();
for (String placeholder : templatePlaceholders) { // 判断是否允许片段重复
boolean allowDuplicate = templateConfig != null && Integer.valueOf(1).equals(templateConfig.getDuplicateEnable());
for (Map.Entry<String, Long> entry : placeholderCounts.entrySet()) {
String placeholder = entry.getKey();
Long requiredCount = entry.getValue();
if (placeholder.startsWith("P")) { if (placeholder.startsWith("P")) {
// 图片源:占位符格式为 "P{deviceId}" // 图片源:占位符格式为 "P{deviceId}"
String imageKey = placeholder; String imageKey = placeholder;
if (allTaskParams.containsKey(imageKey)) { if (allTaskParams.containsKey(imageKey)) {
filteredParams.put(imageKey, allTaskParams.get(imageKey)); List<SourceEntity> allSources = allTaskParams.get(imageKey);
List<SourceEntity> selectedSources = selectSources(allSources, requiredCount.intValue(), allowDuplicate);
if (!selectedSources.isEmpty()) {
filteredParams.put(imageKey, selectedSources);
}
} }
} else { } else {
// 视频源:占位符直接对应设备ID // 视频源:占位符直接对应设备ID
String videoKey = placeholder; String videoKey = placeholder;
if (allTaskParams.containsKey(videoKey)) { if (allTaskParams.containsKey(videoKey)) {
filteredParams.put(videoKey, allTaskParams.get(videoKey)); List<SourceEntity> allSources = allTaskParams.get(videoKey);
List<SourceEntity> selectedSources = selectSources(allSources, requiredCount.intValue(), allowDuplicate);
if (!selectedSources.isEmpty()) {
filteredParams.put(videoKey, selectedSources);
}
} }
} }
} }
log.info("filterTaskParams: templateId:{}, original keys:{}, filtered keys:{}", log.debug("filterTaskParams: templateId:{}, original keys:{}, filtered keys:{}, placeholder counts:{}, allowDuplicate:{}",
templateId, allTaskParams.keySet().size(), filteredParams.keySet().size()); templateId, allTaskParams.keySet().size(), filteredParams.keySet().size(), placeholderCounts, allowDuplicate);
return filteredParams; return filteredParams;
} }
private List<SourceEntity> selectSources(List<SourceEntity> allSources, int requiredCount, boolean allowDuplicate) {
if (allSources == null || allSources.isEmpty()) {
return new ArrayList<>();
}
if (!allowDuplicate) {
// 不允许重复,使用原有逻辑
int actualCount = Math.min(requiredCount, allSources.size());
return new ArrayList<>(allSources.subList(0, actualCount));
}
// 允许重复,循环填充到所需数量
List<SourceEntity> selectedSources = new ArrayList<>();
int sourceIndex = 0;
for (int i = 0; i < requiredCount; i++) {
selectedSources.add(allSources.get(sourceIndex));
sourceIndex = (sourceIndex + 1) % allSources.size();
}
return selectedSources;
}
public Long findFirstAvailableTemplate(List<Long> templateIds, Long faceId, boolean scanSource) {
if (templateIds == null || templateIds.isEmpty() || faceId == null) {
return null;
}
for (Long templateId : templateIds) {
if (determineTemplateCanGenerate(templateId, faceId, scanSource)) {
return templateId;
}
}
return null;
}
} }

View File

@@ -0,0 +1,109 @@
package com.ycwl.basic.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory;
import org.springframework.kafka.core.*;
import org.springframework.kafka.listener.ContainerProperties;
import java.util.HashMap;
import java.util.Map;
@Configuration
@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true", matchIfMissing = false)
public class KafkaConfig {
@Value("${kafka.bootstrap-servers:100.64.0.12:39092}")
private String bootstrapServers;
@Value("${kafka.consumer.group-id:liuying-microservice}")
private String consumerGroupId;
@Value("${kafka.consumer.auto-offset-reset:earliest}")
private String autoOffsetReset;
@Value("${kafka.producer.acks:all}")
private String acks;
@Value("${kafka.producer.retries:3}")
private Integer retries;
@Value("${kafka.producer.batch-size:16384}")
private Integer batchSize;
@Value("${kafka.producer.linger-ms:1}")
private Integer lingerMs;
@Value("${kafka.producer.buffer-memory:33554432}")
private Integer bufferMemory;
@Value("${kafka.producer.enable-idempotence:true}")
private boolean enableIdempotence;
@Value("${kafka.producer.compression-type:snappy}")
private String compressionType;
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.ACKS_CONFIG, acks);
configProps.put(ProducerConfig.RETRIES_CONFIG, retries);
configProps.put(ProducerConfig.BATCH_SIZE_CONFIG, batchSize);
configProps.put(ProducerConfig.LINGER_MS_CONFIG, lingerMs);
configProps.put(ProducerConfig.BUFFER_MEMORY_CONFIG, bufferMemory);
configProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, enableIdempotence);
configProps.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, compressionType);
return new DefaultKafkaProducerFactory<>(configProps);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
@Bean
public ConsumerFactory<String, String> consumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset);
return new DefaultKafkaConsumerFactory<>(props);
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
return factory;
}
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> manualCommitKafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>();
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
props.put(ConsumerConfig.GROUP_ID_CONFIG, consumerGroupId);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset);
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(props));
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
return factory;
}
}

View File

@@ -1,18 +0,0 @@
package com.ycwl.basic.constant;
public class ShareParkingSpaceRedisKeyConstant {
// 更改数量时候的锁
public final static String UPDATE_NUMBER_LOCK_KEY="ShareParking:updateNumberLockKey";
// 地上车位
public final static String GROUND_PARKING_SPACE_NUMBER="ShareParking:groundParkingSpaceNumber";
// 地下车位数
public final static String UNDERGROUND_PARKING_SPACE_NUMBER="ShareParking:undergroundParkingSpaceNumber";
// 每日开放预约时间
public final static String OPEN_TIME="ShareParking:openTime";
// 预约后当日车辆最晚停留时间
public final static String RESIDENCE_TIME="ShareParking:residenceTime";
//取消时间
public final static String CANCEL_TIME="ShareParking:cancelTime";
//支付时间
public final static String PAY_TIME="ShareParking:payTime";
}

View File

@@ -196,7 +196,7 @@ public class AppOrderV2Controller {
if (cachedResult.getFinalAmount().compareTo(request.getExpectedFinalAmount()) != 0) { if (cachedResult.getFinalAmount().compareTo(request.getExpectedFinalAmount()) != 0) {
log.warn("移动端下单:价格不匹配, cached={}, expected={}, userId={}, scenicId={}", log.warn("移动端下单:价格不匹配, cached={}, expected={}, userId={}, scenicId={}",
cachedResult.getFinalAmount(), request.getExpectedFinalAmount(), currentUserId, scenicId); cachedResult.getFinalAmount(), request.getExpectedFinalAmount(), currentUserId, scenicId);
return ApiResponse.fail("请重新下单"); return ApiResponse.fail("价格信息变化,请退出后重新查询价格");
} }
// 验证原价是否匹配(可选) // 验证原价是否匹配(可选)
@@ -215,6 +215,7 @@ public class AppOrderV2Controller {
Long orderId = oldOrderService.createOrderCompact(currentUserId, request, cachedResult); Long orderId = oldOrderService.createOrderCompact(currentUserId, request, cachedResult);
return ApiResponse.success(String.valueOf(orderId)); return ApiResponse.success(String.valueOf(orderId));
} catch (Exception e) { } catch (Exception e) {
log.warn("移动端下单:订单创建失败, userId={}, scenicId={}, error={}", currentUserId, scenicId, e.getMessage(), e);
return ApiResponse.fail("订单创建失败,请稍后重试"); return ApiResponse.fail("订单创建失败,请稍后重试");
} }

View File

@@ -75,8 +75,8 @@ public class AppPrinterController {
} }
@PostMapping("/uploadTo/{scenicId}/formSource") @PostMapping("/uploadTo/{scenicId}/formSource")
public ApiResponse<?> uploadFromSource(@PathVariable("scenicId") Long scenicId, @RequestBody FromSourceReq req) throws IOException { public ApiResponse<?> uploadFromSource(@PathVariable("scenicId") Long scenicId, @RequestBody FromSourceReq req) throws IOException {
printerService.addUserPhotoFromSource(JwtTokenUtil.getWorker().getUserId(), scenicId, req); List<Integer> list = printerService.addUserPhotoFromSource(JwtTokenUtil.getWorker().getUserId(), scenicId, req);
return ApiResponse.success(null); return ApiResponse.success(list);
} }
@PostMapping("/setQuantity/{scenicId}/{id}") @PostMapping("/setQuantity/{scenicId}/{id}")

View File

@@ -0,0 +1,76 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.integration.questionnaire.dto.answer.ResponseDetailResponse;
import com.ycwl.basic.integration.questionnaire.dto.answer.SubmitAnswerRequest;
import com.ycwl.basic.integration.questionnaire.dto.questionnaire.QuestionnaireResponse;
import com.ycwl.basic.integration.questionnaire.service.QuestionnaireIntegrationService;
import com.ycwl.basic.utils.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 移动端问卷接口控制器
*
* @author Claude Code
* @date 2025-09-05
*/
@Slf4j
@RestController
@RequestMapping("/api/mobile/questionnaire/v1")
@RequiredArgsConstructor
public class AppQuestionnaireController {
private final QuestionnaireIntegrationService questionnaireIntegrationService;
/**
* 获取问卷详情
* 包含问卷基本信息和所有题目
*/
@IgnoreToken
@GetMapping("/{id}")
public ApiResponse<QuestionnaireResponse> getQuestionnaire(@PathVariable Long id) {
log.info("移动端获取问卷详情, id: {}", id);
try {
QuestionnaireResponse questionnaire = questionnaireIntegrationService.getQuestionnaire(id);
// 检查问卷状态,只有已发布的问卷才能被移动端访问
if (questionnaire.getStatus() != 2) {
return ApiResponse.fail("问卷未发布或已停止");
}
return ApiResponse.success(questionnaire);
} catch (Exception e) {
log.error("移动端获取问卷详情失败, id: {}", id, e);
return ApiResponse.fail("获取问卷详情失败: " + e.getMessage());
}
}
/**
* 提交问卷答案
*/
@PostMapping("/{id}/submit")
public ApiResponse<ResponseDetailResponse> submitAnswer(
@PathVariable Long id,
@Valid @RequestBody SubmitAnswerRequest request) {
String userId = BaseContextHandler.getUserId();
log.info("移动端提交问卷答案, questionnaireId: {}, userId: {}, answers count: {}",
id, userId, request.getAnswers() != null ? request.getAnswers().size() : 0);
try {
// 设置问卷ID和用户ID
request.setQuestionnaireId(id);
request.setUserId(userId);
ResponseDetailResponse response = questionnaireIntegrationService.submitAnswer(request);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("移动端提交问卷答案失败, questionnaireId: {}, userId: {}", id, userId, e);
return ApiResponse.fail("提交问卷答案失败: " + e.getMessage());
}
}
}

View File

@@ -72,25 +72,14 @@ public class AppScenicController {
public ApiResponse<ScenicConfigResp> getConfig(@PathVariable Long id){ public ApiResponse<ScenicConfigResp> getConfig(@PathVariable Long id){
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(id); ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(id);
ScenicConfigResp resp = new ScenicConfigResp(); ScenicConfigResp resp = new ScenicConfigResp();
resp.setBookRoutine(scenicConfig.getInteger("book_routine")); resp.setWatermarkUrl(scenicConfig.getString("watermark_url"));
resp.setForceFinishTime(scenicConfig.getInteger("force_finish_time"));
resp.setTourTime(scenicConfig.getInteger("tour_time"));
resp.setSampleStoreDay(scenicConfig.getInteger("sample_store_day"));
resp.setFaceStoreDay(scenicConfig.getInteger("face_store_day"));
resp.setVideoStoreDay(scenicConfig.getInteger("video_store_day")); resp.setVideoStoreDay(scenicConfig.getInteger("video_store_day"));
resp.setAllFree(scenicConfig.getBoolean("all_free"));
resp.setDisableSourceVideo(scenicConfig.getBoolean("disable_source_video"));
resp.setDisableSourceImage(scenicConfig.getBoolean("disable_source_image"));
resp.setAntiScreenRecordType(scenicConfig.getInteger("anti_screen_record_type")); resp.setAntiScreenRecordType(scenicConfig.getInteger("anti_screen_record_type"));
resp.setVideoSourceStoreDay(scenicConfig.getInteger("video_source_store_day")); resp.setGroupingEnable(scenicConfig.getBoolean("grouping_enable", false));
resp.setImageSourceStoreDay(scenicConfig.getInteger("image_source_store_day")); resp.setVoucherEnable(scenicConfig.getBoolean("voucher_enable", false));
resp.setUserSourceExpireDay(scenicConfig.getInteger("user_source_expire_day")); resp.setShowPhotoWhenWaiting(scenicConfig.getBoolean("show_photo_when_waiting", false));
resp.setBrokerDirectRate(scenicConfig.getBigDecimal("broker_direct_rate"));
resp.setVideoSourcePackHint(scenicConfig.getString("video_source_pack_hint"));
resp.setImageSourcePackHint(scenicConfig.getString("image_source_pack_hint")); resp.setImageSourcePackHint(scenicConfig.getString("image_source_pack_hint"));
resp.setVoucherEnable(scenicConfig.getBoolean("voucher_enable")); resp.setVideoSourcePackHint(scenicConfig.getString("video_source_pack_hint"));
resp.setEnableVoucher(scenicConfig.getBoolean("voucher_enable")); // compactible
resp.setGroupingEnable(scenicConfig.getBoolean("grouping_enable"));
return ApiResponse.success(resp); return ApiResponse.success(resp);
} }

View File

@@ -55,7 +55,7 @@ public class AppTaskController {
@PostMapping("/submit") @PostMapping("/submit")
public ApiResponse<String> submitVideoTask(@RequestBody VideoTaskReq videoTaskReq) { public ApiResponse<String> submitVideoTask(@RequestBody VideoTaskReq videoTaskReq) {
taskService.createTaskByFaceIdAndTempalteId(videoTaskReq.getFaceId(),videoTaskReq.getTemplateId(),0); taskService.createTaskByFaceIdAndTemplateId(videoTaskReq.getFaceId(),videoTaskReq.getTemplateId(),0);
return ApiResponse.success("成功"); return ApiResponse.success("成功");
} }
} }

View File

@@ -1,23 +1,93 @@
package com.ycwl.basic.controller.mobile; package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.model.mobile.video.dto.VideoViewPermissionDTO;
import com.ycwl.basic.model.task.req.VideoInfoReq; import com.ycwl.basic.model.task.req.VideoInfoReq;
import com.ycwl.basic.repository.VideoRepository; import com.ycwl.basic.repository.VideoRepository;
import com.ycwl.basic.service.mobile.VideoViewPermissionService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Deprecated @Slf4j
@RestController @RestController
@RequestMapping("/api/mobile/video/v1") @RequestMapping("/api/mobile/video/v1")
public class AppVideoController { public class AppVideoController {
@Autowired @Autowired
private VideoRepository videoRepository; private VideoRepository videoRepository;
@Autowired
private VideoViewPermissionService videoViewPermissionService;
@PostMapping("/{videoId}/updateMeta") @PostMapping("/{videoId}/updateMeta")
public void updateMeta(@PathVariable("videoId") Long videoId, @RequestBody VideoInfoReq req) { public void updateMeta(@PathVariable("videoId") Long videoId, @RequestBody VideoInfoReq req) {
videoRepository.updateMeta(videoId, req); videoRepository.updateMeta(videoId, req);
} }
/**
* 记录用户查看视频并返回权限信息
*
* @param videoId 视频ID
* @return 查看权限信息
*/
@PostMapping("/{videoId}/recordView")
public ApiResponse<VideoViewPermissionDTO> recordView(@PathVariable("videoId") Long videoId) {
try {
String userIdStr = BaseContextHandler.getUserId();
if (userIdStr == null || userIdStr.isEmpty()) {
log.warn("用户未登录,无法记录查看: videoId={}", videoId);
return ApiResponse.fail("用户未登录");
}
Long userId = Long.valueOf(userIdStr);
log.debug("记录用户查看视频: userId={}, videoId={}", userId, videoId);
VideoViewPermissionDTO permission = videoViewPermissionService.checkAndRecordView(userId, videoId);
return ApiResponse.success(permission);
} catch (NumberFormatException e) {
log.error("用户ID格式错误: userId={}, videoId={}", BaseContextHandler.getUserId(), videoId, e);
return ApiResponse.fail("用户信息无效");
} catch (Exception e) {
log.error("记录用户查看视频失败: videoId={}", videoId, e);
return ApiResponse.fail("记录查看失败,请稍后重试");
}
}
/**
* 检查用户查看权限(不记录查看次数)
*
* @param videoId 视频ID
* @return 查看权限信息
*/
@GetMapping("/{videoId}/checkPermission")
public ApiResponse<VideoViewPermissionDTO> checkPermission(@PathVariable("videoId") Long videoId) {
try {
String userIdStr = BaseContextHandler.getUserId();
if (userIdStr == null || userIdStr.isEmpty()) {
log.warn("用户未登录,无法查看权限: videoId={}", videoId);
return ApiResponse.fail("用户未登录");
}
Long userId = Long.valueOf(userIdStr);
log.debug("检查用户查看权限: userId={}, videoId={}", userId, videoId);
VideoViewPermissionDTO permission = videoViewPermissionService.checkViewPermission(userId, videoId);
return ApiResponse.success(permission);
} catch (NumberFormatException e) {
log.error("用户ID格式错误: userId={}, videoId={}", BaseContextHandler.getUserId(), videoId, e);
return ApiResponse.fail("用户信息无效");
} catch (Exception e) {
log.error("检查用户查看权限失败: videoId={}", videoId, e);
return ApiResponse.fail("权限检查失败,请稍后重试");
}
}
} }

View File

@@ -25,7 +25,7 @@ public class CouponController {
@GetMapping("/{scenicId}/goodsList") @GetMapping("/{scenicId}/goodsList")
public ApiResponse<List<GoodsListRespVO>> scenicGoodsList(@PathVariable Long scenicId) { public ApiResponse<List<GoodsListRespVO>> scenicGoodsList(@PathVariable Long scenicId) {
List<GoodsListRespVO> data = priceBiz.listGoodsByScenic(scenicId); List<GoodsListRespVO> data = priceBiz.listGoodsByScenic(scenicId);
data.add(new GoodsListRespVO(-1L, "一口价")); data.add(new GoodsListRespVO(-1L, "一口价", -1));
return ApiResponse.success(data); return ApiResponse.success(data);
} }

View File

@@ -3,8 +3,10 @@ package com.ycwl.basic.controller.pc;
import com.ycwl.basic.integration.device.dto.config.*; import com.ycwl.basic.integration.device.dto.config.*;
import com.ycwl.basic.integration.common.response.PageResponse; import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.device.dto.device.*; import com.ycwl.basic.integration.device.dto.device.*;
import com.ycwl.basic.integration.device.dto.status.DeviceStatusDTO;
import com.ycwl.basic.integration.device.service.DeviceConfigIntegrationService; import com.ycwl.basic.integration.device.service.DeviceConfigIntegrationService;
import com.ycwl.basic.integration.device.service.DeviceIntegrationService; import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
import com.ycwl.basic.integration.device.service.DeviceStatusIntegrationService;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -29,6 +31,7 @@ public class DeviceV2Controller {
private final DeviceIntegrationService deviceIntegrationService; private final DeviceIntegrationService deviceIntegrationService;
private final DeviceConfigIntegrationService deviceConfigIntegrationService; private final DeviceConfigIntegrationService deviceConfigIntegrationService;
private final DeviceStatusIntegrationService deviceStatusIntegrationService;
// ========== 设备基础 CRUD 操作 ========== // ========== 设备基础 CRUD 操作 ==========
@@ -60,34 +63,6 @@ public class DeviceV2Controller {
} }
} }
/**
* 设备V2带配置信息分页列表
*/
@GetMapping("/with-config")
public ApiResponse<PageResponse<DeviceV2WithConfigDTO>> listDevicesWithConfig(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String name,
@RequestParam(required = false) String no,
@RequestParam(required = false) String type,
@RequestParam(required = false) Integer isActive,
@RequestParam(required = false) Long scenicId) {
log.info("分页查询设备带配置信息列表, page: {}, pageSize: {}, name: {}, no: {}, type: {}, isActive: {}, scenicId: {}",
page, pageSize, name, no, type, isActive, scenicId);
// 参数验证:限制pageSize最大值为100
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<DeviceV2WithConfigDTO> response = deviceIntegrationService.listDevicesWithConfig(page, pageSize, name, no, type, isActive, scenicId);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("分页查询设备带配置信息列表失败", e);
return ApiResponse.fail("分页查询设备带配置信息列表失败: " + e.getMessage());
}
}
/** /**
* 根据ID获取设备信息 * 根据ID获取设备信息
*/ */
@@ -102,20 +77,6 @@ public class DeviceV2Controller {
} }
} }
/**
* 根据ID获取设备带配置信息
*/
@GetMapping("/{id}/with-config")
public ApiResponse<DeviceV2WithConfigDTO> getDeviceWithConfig(@PathVariable Long id) {
try {
DeviceV2WithConfigDTO device = deviceIntegrationService.getDeviceWithConfig(id);
return ApiResponse.success(device);
} catch (Exception e) {
log.error("获取设备配置信息失败, id: {}", id, e);
return ApiResponse.fail("获取设备配置信息失败: " + e.getMessage());
}
}
/** /**
* 根据设备编号获取设备信息 * 根据设备编号获取设备信息
*/ */
@@ -131,16 +92,24 @@ public class DeviceV2Controller {
} }
/** /**
* 根据设备编号获取设备带配置信息 * 根据设备ID获取设备在线状态
*/ */
@GetMapping("/no/{no}/with-config") @GetMapping("/{id}/status")
public ApiResponse<DeviceV2WithConfigDTO> getDeviceWithConfigByNo(@PathVariable String no) { public ApiResponse<DeviceStatusDTO> getDeviceOnlineStatus(@PathVariable Long id) {
log.info("获取设备在线状态, deviceId: {}", id);
try { try {
DeviceV2WithConfigDTO device = deviceIntegrationService.getDeviceWithConfigByNo(no); // 首先获取设备信息以获得设备编号
return ApiResponse.success(device); DeviceV2DTO device = deviceIntegrationService.getDevice(id);
if (device == null) {
return ApiResponse.fail("设备不存在");
}
// 使用设备编号查询在线状态
DeviceStatusDTO onlineStatus = deviceStatusIntegrationService.getDeviceStatus(device.getNo());
return ApiResponse.success(onlineStatus);
} catch (Exception e) { } catch (Exception e) {
log.error("根据设备编号获取设备配置信息失败, no: {}", no, e); log.error("获取设备在线状态失败, deviceId: {}", id, e);
return ApiResponse.fail("根据设备编号获取设备配置信息失败: " + e.getMessage()); return ApiResponse.fail("获取设备在线状态失败: " + e.getMessage());
} }
} }
@@ -302,20 +271,6 @@ public class DeviceV2Controller {
} }
} }
/**
* 获取设备扁平化配置
*/
@GetMapping("/{id}/flat-config")
public ApiResponse<Map<String, Object>> getDeviceFlatConfig(@PathVariable Long id) {
try {
Map<String, Object> config = deviceConfigIntegrationService.getDeviceFlatConfig(id);
return ApiResponse.success(config);
} catch (Exception e) {
log.error("获取设备扁平化配置失败, deviceId: {}", id, e);
return ApiResponse.fail("获取设备扁平化配置失败: " + e.getMessage());
}
}
/** /**
* 根据配置键获取配置 * 根据配置键获取配置
*/ */
@@ -346,21 +301,6 @@ public class DeviceV2Controller {
} }
} }
/**
* 根据设备编号获取扁平化配置
*/
@GetMapping("/no/{no}/flat-config")
public ApiResponse<Map<String, Object>> getDeviceFlatConfigByNo(@PathVariable String no) {
log.info("根据设备编号获取扁平化配置, deviceNo: {}", no);
try {
Map<String, Object> config = deviceConfigIntegrationService.getDeviceFlatConfigByNo(no);
return ApiResponse.success(config);
} catch (Exception e) {
log.error("根据设备编号获取扁平化配置失败, deviceNo: {}", no, e);
return ApiResponse.fail("根据设备编号获取扁平化配置失败: " + e.getMessage());
}
}
/** /**
* 创建设备配置 * 创建设备配置
*/ */

View File

@@ -0,0 +1,60 @@
package com.ycwl.basic.controller.pc;
import com.ycwl.basic.integration.message.dto.ChannelsResponse;
import com.ycwl.basic.integration.message.dto.MessageListData;
import com.ycwl.basic.integration.message.service.MessageIntegrationService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/api/message/v1")
@RequiredArgsConstructor
public class MessageController {
private final MessageIntegrationService messageService;
@GetMapping("/messages")
public ApiResponse<MessageListData> listMessages(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize,
@RequestParam(required = false) String channelId,
@RequestParam(required = false) String title,
@RequestParam(required = false) String content,
@RequestParam(required = false) String sendBiz,
@RequestParam(required = false) String sentAtStart,
@RequestParam(required = false) String sentAtEnd,
@RequestParam(required = false) String createdAtStart,
@RequestParam(required = false) String createdAtEnd
) {
log.debug("PC|消息列表查询 page={}, pageSize={}, channelId={}, title={}, sendBiz={}", page, pageSize, channelId, title, sendBiz);
if (pageSize > 100) {
pageSize = 100;
}
try {
MessageListData data = messageService.listMessages(page, pageSize, channelId, title, content, sendBiz,
sentAtStart, sentAtEnd, createdAtStart, createdAtEnd);
return ApiResponse.success(data);
} catch (Exception e) {
log.error("PC|消息列表查询失败", e);
return ApiResponse.fail("消息列表查询失败: " + e.getMessage());
}
}
@GetMapping("/channels")
public ApiResponse<ChannelsResponse> listChannels() {
log.debug("PC|获取消息通道列表");
try {
ChannelsResponse data = messageService.listChannels();
return ApiResponse.success(data);
} catch (Exception e) {
log.error("PC|获取消息通道列表失败", e);
return ApiResponse.fail("获取消息通道列表失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,131 @@
package com.ycwl.basic.controller.pc;
import com.ycwl.basic.model.pc.printer.entity.PrinterEntity;
import com.ycwl.basic.model.pc.printer.req.PrinterPreferredSizeUpdateReq;
import com.ycwl.basic.model.pc.printer.req.PrinterStatusUpdateReq;
import com.ycwl.basic.model.pc.printer.req.PrinterUsePrinterUpdateReq;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.utils.ApiConst;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 打印机管理接口
*/
@RestController
@RequestMapping("/api/pc/printers/v1")
@RequiredArgsConstructor
public class PrinterManageController {
private final PrinterService printerService;
/**
* 打印机列表查询
*/
@GetMapping
public ApiResponse<List<PrinterEntity>> list(@RequestParam(value = "scenicId", required = false) Long scenicId,
@RequestParam(value = "status", required = false) Integer status,
@RequestParam(value = "name", required = false) String name) {
PrinterEntity condition = new PrinterEntity();
condition.setScenicId(scenicId);
condition.setStatus(status);
condition.setName(name);
return printerService.list(condition);
}
/**
* 打印机详情
*/
@GetMapping("/{id}")
public ApiResponse<PrinterEntity> detail(@PathVariable("id") Integer id) {
ApiResponse<PrinterEntity> response = printerService.get(id);
if (response.getData() == null) {
return ApiResponse.buildResponse(ApiConst.Code.CODE_NOT_EXIST, "打印机不存在");
}
return response;
}
/**
* 新增打印机
*/
@PostMapping
public ApiResponse<Integer> create(@RequestBody PrinterEntity request) {
request.setId(null);
return printerService.add(request);
}
/**
* 更新打印机信息
*/
@PutMapping("/{id}")
public ApiResponse<Integer> update(@PathVariable("id") Integer id, @RequestBody PrinterEntity request) {
request.setId(id);
return printerService.update(request);
}
/**
* 更新打印机状态
*/
@PatchMapping("/{id}/status")
public ApiResponse<Integer> updateStatus(@PathVariable("id") Integer id,
@RequestBody PrinterStatusUpdateReq req) {
if (req == null || req.getStatus() == null) {
return ApiResponse.buildResponse(ApiConst.Code.CODE_PARAM_ERROR, "状态不能为空");
}
PrinterEntity entity = new PrinterEntity();
entity.setId(id);
entity.setStatus(req.getStatus());
return printerService.update(entity);
}
/**
* 更新打印机首选尺寸
*/
@PatchMapping("/{id}/preferred-size")
public ApiResponse<Integer> updatePreferredSize(@PathVariable("id") Integer id,
@RequestBody PrinterPreferredSizeUpdateReq req) {
if (req == null || (req.getPreferW() == null && req.getPreferH() == null)) {
return ApiResponse.buildResponse(ApiConst.Code.CODE_PARAM_ERROR, "首选尺寸不能为空");
}
PrinterEntity entity = new PrinterEntity();
entity.setId(id);
entity.setPreferW(req.getPreferW());
entity.setPreferH(req.getPreferH());
return printerService.update(entity);
}
/**
* 更新当前使用的打印机
*/
@PatchMapping("/{id}/use-printer")
public ApiResponse<Integer> updateUsePrinter(@PathVariable("id") Integer id,
@RequestBody PrinterUsePrinterUpdateReq req) {
if (req == null) {
return ApiResponse.buildResponse(ApiConst.Code.CODE_PARAM_ERROR, "请求参数不能为空");
}
PrinterEntity entity = new PrinterEntity();
entity.setId(id);
entity.setUsePrinter(req.getUsePrinter());
return printerService.update(entity);
}
/**
* 删除打印机
*/
@DeleteMapping("/{id}")
public ApiResponse<Integer> delete(@PathVariable("id") Integer id) {
return printerService.delete(id);
}
}

View File

@@ -0,0 +1,313 @@
package com.ycwl.basic.controller.pc;
import com.ycwl.basic.integration.questionnaire.dto.answer.ResponseDetailResponse;
import com.ycwl.basic.integration.questionnaire.dto.questionnaire.CreateQuestionnaireRequest;
import com.ycwl.basic.integration.questionnaire.dto.questionnaire.QuestionnaireResponse;
import com.ycwl.basic.integration.questionnaire.dto.statistics.QuestionnaireStatistics;
import com.ycwl.basic.integration.questionnaire.service.QuestionnaireIntegrationService;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.enums.StorageAcl;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.WxMpUtil;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.io.File;
import java.util.Map;
/**
* 问卷管理 V2 版本控制器 - 基于 zt-questionnaire 集成服务
*
* @author Claude Code
* @date 2025-09-05
*/
@Slf4j
@RestController
@RequestMapping("/api/questionnaire/v2")
@RequiredArgsConstructor
public class QuestionnaireV2Controller {
private final QuestionnaireIntegrationService questionnaireIntegrationService;
private final ScenicRepository scenicRepository;
// ========== 问卷管理 CRUD 操作 ==========
/**
* 分页查询问卷列表
*/
@GetMapping("/")
public ApiResponse<PageResponse<QuestionnaireResponse>> listQuestionnaires(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String name) {
log.info("分页查询问卷列表, page: {}, pageSize: {}, status: {}, name: {}",
page, pageSize, status, name);
// 参数验证:限制pageSize最大值为100
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<QuestionnaireResponse> response =
questionnaireIntegrationService.getQuestionnaireList(page, pageSize, name, status, null);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("分页查询问卷列表失败", e);
return ApiResponse.fail("分页查询问卷列表失败: " + e.getMessage());
}
}
/**
* 获取问卷详情
*/
@GetMapping("/{id}")
public ApiResponse<QuestionnaireResponse> getQuestionnaire(@PathVariable Long id) {
log.info("获取问卷详情, id: {}", id);
try {
QuestionnaireResponse questionnaire = questionnaireIntegrationService.getQuestionnaire(id);
return ApiResponse.success(questionnaire);
} catch (Exception e) {
log.error("获取问卷详情失败, id: {}", id, e);
return ApiResponse.fail("获取问卷详情失败: " + e.getMessage());
}
}
/**
* 创建问卷
*/
@PostMapping("/")
public ApiResponse<QuestionnaireResponse> createQuestionnaire(@Valid @RequestBody CreateQuestionnaireRequest request) {
log.info("创建问卷, name: {}, questions count: {}",
request.getName(), request.getQuestions() != null ? request.getQuestions().size() : 0);
try {
QuestionnaireResponse questionnaire = questionnaireIntegrationService.createQuestionnaire(request, "admin");
return ApiResponse.success(questionnaire);
} catch (Exception e) {
log.error("创建问卷失败", e);
return ApiResponse.fail("创建问卷失败: " + e.getMessage());
}
}
/**
* 更新问卷
*/
@PutMapping("/{id}")
public ApiResponse<QuestionnaireResponse> updateQuestionnaire(
@PathVariable Long id,
@Valid @RequestBody CreateQuestionnaireRequest request) {
log.info("更新问卷, id: {}", id);
try {
QuestionnaireResponse questionnaire = questionnaireIntegrationService.updateQuestionnaire(id, request, "admin");
return ApiResponse.success(questionnaire);
} catch (Exception e) {
log.error("更新问卷失败, id: {}", id, e);
return ApiResponse.fail("更新问卷失败: " + e.getMessage());
}
}
/**
* 更新问卷状态
*/
@PutMapping("/{id}/status")
public ApiResponse<String> updateQuestionnaireStatus(@PathVariable Long id, @RequestBody Map<String, Integer> request) {
Integer status = request.get("status");
log.info("更新问卷状态, id: {}, status: {}", id, status);
try {
// 根据状态调用不同的方法
if (status == 2) {
questionnaireIntegrationService.publishQuestionnaire(id, "admin");
} else if (status == 3) {
questionnaireIntegrationService.stopQuestionnaire(id, "admin");
}
return ApiResponse.success("问卷状态更新成功");
} catch (Exception e) {
log.error("更新问卷状态失败, id: {}, status: {}", id, status, e);
return ApiResponse.fail("更新问卷状态失败: " + e.getMessage());
}
}
/**
* 发布问卷
*/
@PutMapping("/{id}/publish")
public ApiResponse<String> publishQuestionnaire(@PathVariable Long id) {
log.info("发布问卷, id: {}", id);
try {
questionnaireIntegrationService.publishQuestionnaire(id, "admin");
return ApiResponse.success("问卷发布成功");
} catch (Exception e) {
log.error("发布问卷失败, id: {}", id, e);
return ApiResponse.fail("发布问卷失败: " + e.getMessage());
}
}
/**
* 停止问卷
*/
@PutMapping("/{id}/stop")
public ApiResponse<String> stopQuestionnaire(@PathVariable Long id) {
log.info("停止问卷, id: {}", id);
try {
questionnaireIntegrationService.stopQuestionnaire(id, "admin");
return ApiResponse.success("问卷停止成功");
} catch (Exception e) {
log.error("停止问卷失败, id: {}", id, e);
return ApiResponse.fail("停止问卷失败: " + e.getMessage());
}
}
/**
* 删除问卷
*/
@DeleteMapping("/{id}")
public ApiResponse<String> deleteQuestionnaire(@PathVariable Long id) {
log.info("删除问卷, id: {}", id);
try {
questionnaireIntegrationService.deleteQuestionnaire(id, "admin");
return ApiResponse.success("问卷删除成功");
} catch (Exception e) {
log.error("删除问卷失败, id: {}", id, e);
return ApiResponse.fail("删除问卷失败: " + e.getMessage());
}
}
// ========== 问卷答案查看操作 ==========
/**
* 分页查询问卷答案
*/
@GetMapping("/{id}/answers")
public ApiResponse<PageResponse<ResponseDetailResponse>> getQuestionnaireAnswers(
@PathVariable Long id,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime) {
log.info("分页查询问卷答案, questionnaireId: {}, page: {}, pageSize: {}, userId: {}",
id, page, pageSize, userId);
// 参数验证:限制pageSize最大值为100
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<ResponseDetailResponse> response =
questionnaireIntegrationService.getResponseList(page, pageSize, id, userId, startTime, endTime);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("分页查询问卷答案失败, questionnaireId: {}", id, e);
return ApiResponse.fail("分页查询问卷答案失败: " + e.getMessage());
}
}
/**
* 获取特定答案详情
*/
@GetMapping("/{id}/answers/{answerId}")
public ApiResponse<ResponseDetailResponse> getQuestionnaireAnswer(@PathVariable Long id, @PathVariable Long answerId) {
log.info("获取问卷答案详情, questionnaireId: {}, answerId: {}", id, answerId);
try {
ResponseDetailResponse answer = questionnaireIntegrationService.getResponseDetail(answerId);
return ApiResponse.success(answer);
} catch (Exception e) {
log.error("获取问卷答案详情失败, questionnaireId: {}, answerId: {}", id, answerId, e);
return ApiResponse.fail("获取问卷答案详情失败: " + e.getMessage());
}
}
/**
* 查询用户答题记录
*/
@GetMapping("/answers/user/{userId}")
public ApiResponse<PageResponse<ResponseDetailResponse>> getUserAnswers(
@PathVariable String userId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Long questionnaireId) {
log.info("查询用户答题记录, userId: {}, page: {}, pageSize: {}, questionnaireId: {}",
userId, page, pageSize, questionnaireId);
// 参数验证:限制pageSize最大值为100
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<ResponseDetailResponse> response =
questionnaireIntegrationService.getResponseList(page, pageSize, questionnaireId, userId, null, null);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("查询用户答题记录失败, userId: {}", userId, e);
return ApiResponse.fail("查询用户答题记录失败: " + e.getMessage());
}
}
// ========== 统计功能 ==========
/**
* 获取问卷统计信息
*/
@GetMapping("/{id}/statistics")
public ApiResponse<QuestionnaireStatistics> getQuestionnaireStatistics(@PathVariable Long id) {
log.info("获取问卷统计信息, id: {}", id);
try {
QuestionnaireStatistics statistics = questionnaireIntegrationService.getStatistics(id);
return ApiResponse.success(statistics);
} catch (Exception e) {
log.error("获取问卷统计信息失败, id: {}", id, e);
return ApiResponse.fail("获取问卷统计信息失败: " + e.getMessage());
}
}
/**
* 下载问卷小程序二维码
*/
@GetMapping("/{id}/QRCode")
public ApiResponse<String> downloadQrCode(@PathVariable Long id) {
log.info("下载问卷小程序二维码, id: {}", id);
try {
// 获取问卷详情
QuestionnaireResponse questionnaire = questionnaireIntegrationService.getQuestionnaire(id);
if (questionnaire == null) {
return ApiResponse.fail("问卷不存在");
}
MpConfigEntity mpConfig = scenicRepository.getScenicMpConfig(3930324797233434624L);
if (mpConfig == null) {
return ApiResponse.fail("小程序配置不存在");
}
String appId = mpConfig.getAppId();
String appSecret = mpConfig.getAppSecret();
String appState = mpConfig.getState();
String path = "pages/questionnaire/index?id=" + id;
String filePath = "qr_code_questionnaire_" + id + ".jpg";
IStorageAdapter adapter = StorageFactory.use();
if (adapter.isExists(filePath)) {
return ApiResponse.success(adapter.getUrl(filePath));
}
WxMpUtil.generateWXAQRCode(appId, appSecret, appState, path, filePath);
File file = new File(filePath);
String s = adapter.uploadFile(null, file, filePath);
file.delete();
adapter.setAcl(StorageAcl.PUBLIC_READ, filePath);
return ApiResponse.success(s);
} catch (Exception e) {
log.error("生成问卷二维码失败, id: {}", id, e);
return ApiResponse.fail("生成二维码失败: " + e.getMessage());
}
}
}

View File

@@ -10,7 +10,6 @@ import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterRequest;
import com.ycwl.basic.integration.scenic.dto.scenic.CreateScenicRequest; import com.ycwl.basic.integration.scenic.dto.scenic.CreateScenicRequest;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO; import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.integration.common.response.PageResponse; import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2WithConfigDTO;
import com.ycwl.basic.integration.scenic.dto.scenic.UpdateScenicRequest; import com.ycwl.basic.integration.scenic.dto.scenic.UpdateScenicRequest;
import com.ycwl.basic.integration.scenic.service.ScenicConfigIntegrationService; import com.ycwl.basic.integration.scenic.service.ScenicConfigIntegrationService;
import com.ycwl.basic.integration.scenic.service.ScenicIntegrationService; import com.ycwl.basic.integration.scenic.service.ScenicIntegrationService;
@@ -71,30 +70,6 @@ public class ScenicV2Controller {
} }
} }
/**
* 景区V2带配置信息分页列表
*/
@GetMapping("/with-config")
public ApiResponse<PageResponse<ScenicV2WithConfigDTO>> listScenicsWithConfig(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String name) {
log.info("分页查询景区带配置信息列表, page: {}, pageSize: {}, status: {}, name: {}", page, pageSize, status, name);
// 参数验证:限制pageSize最大值为100
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<ScenicV2WithConfigDTO> response = scenicIntegrationService.listScenicsWithConfig(page, pageSize, status, name);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("分页查询景区带配置信息列表失败", e);
return ApiResponse.fail("分页查询景区带配置信息列表失败: " + e.getMessage());
}
}
/** /**
* 查询单个景区详情 * 查询单个景区详情
*/ */
@@ -192,36 +167,6 @@ public class ScenicV2Controller {
// ========== 景区配置管理 ========== // ========== 景区配置管理 ==========
/**
* 获取景区及其配置信息
*/
@GetMapping("/{scenicId}/with-config")
public ApiResponse<ScenicV2WithConfigDTO> getScenicWithConfig(@PathVariable Long scenicId) {
log.info("获取景区配置信息, scenicId: {}", scenicId);
try {
ScenicV2WithConfigDTO scenic = scenicIntegrationService.getScenicWithConfig(scenicId);
return ApiResponse.success(scenic);
} catch (Exception e) {
log.error("获取景区配置信息失败, scenicId: {}", scenicId, e);
return ApiResponse.fail("获取景区配置信息失败: " + e.getMessage());
}
}
/**
* 获取景区扁平化配置
*/
@GetMapping("/{scenicId}/flat-config")
public ApiResponse<Map<String, Object>> getScenicFlatConfig(@PathVariable Long scenicId) {
log.info("获取景区扁平化配置, scenicId: {}", scenicId);
try {
Map<String, Object> config = scenicIntegrationService.getScenicFlatConfig(scenicId);
return ApiResponse.success(config);
} catch (Exception e) {
log.error("获取景区扁平化配置失败, scenicId: {}", scenicId, e);
return ApiResponse.fail("获取景区扁平化配置失败: " + e.getMessage());
}
}
/** /**
* 获取景区配置列表 * 获取景区配置列表
*/ */
@@ -316,20 +261,4 @@ public class ScenicV2Controller {
return ApiResponse.fail("批量更新配置失败: " + e.getMessage()); return ApiResponse.fail("批量更新配置失败: " + e.getMessage());
} }
} }
/**
* 扁平化批量更新景区配置
*/
@PutMapping("/{scenicId}/flat-config")
public ApiResponse<BatchUpdateResponse> batchFlatUpdateConfigs(@PathVariable Long scenicId,
@RequestBody Map<String, Object> configs) {
log.info("扁平化批量更新景区配置, scenicId: {}, configs count: {}", scenicId, configs.size());
try {
BatchUpdateResponse response = scenicConfigIntegrationService.batchFlatUpdateConfigs(scenicId, configs);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("扁平化批量更新景区配置失败, scenicId: {}", scenicId, e);
return ApiResponse.fail("扁平化批量更新配置失败: " + e.getMessage());
}
}
} }

View File

@@ -101,9 +101,11 @@ public class ViidController {
.setNamePrefix("VIID-" + scenicId + "-t") .setNamePrefix("VIID-" + scenicId + "-t")
.build(); .build();
return new ThreadPoolExecutor( return new ThreadPoolExecutor(
4, 1024, 0L, TimeUnit.MILLISECONDS, 8, 32, 10L, TimeUnit.SECONDS, // 核心2个线程,最大20个线程,空闲60秒回收
new ArrayBlockingQueue<>(1024), new ArrayBlockingQueue<>(1024), // 队列大小从1024降至100
threadFactory); threadFactory,
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程执行,提供背压控制
);
}); });
} }

View File

@@ -0,0 +1,65 @@
package com.ycwl.basic.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
/**
* ZT-Source Kafka消息实体
* 用于接收素材数据(照片和视频片段)
*
* @author system
* @date 2024/12/27
*/
@Data
public class ZTSourceMessage {
@JsonProperty("sourceId")
private Long sourceId;
@JsonProperty("sourceType")
private Integer sourceType;
@JsonProperty("scenicId")
private Long scenicId;
@JsonProperty("deviceId")
private Long deviceId;
@JsonProperty("shootTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date shootTime;
@JsonProperty("thumbnailUrl")
private String thumbnailUrl;
@JsonProperty("sourceUrl")
private String sourceUrl;
@JsonProperty("resolution")
private String resolution;
@JsonProperty("faceSampleId")
private Long faceSampleId;
@JsonProperty("posJson")
private String posJson;
/**
* 判断是否为视频片段
*/
public boolean isVideo() {
return sourceType != null && sourceType == 1;
}
/**
* 判断是否为照片
*/
public boolean isPhoto() {
return sourceType != null && sourceType == 2;
}
}

View File

@@ -24,6 +24,8 @@ Currently implemented:
- **Scenic Integration** (`com.ycwl.basic.integration.scenic`): ZT-Scenic microservice integration - **Scenic Integration** (`com.ycwl.basic.integration.scenic`): ZT-Scenic microservice integration
- **Device Integration** (`com.ycwl.basic.integration.device`): ZT-Device microservice integration - **Device Integration** (`com.ycwl.basic.integration.device`): ZT-Device microservice integration
- **Render Worker Integration** (`com.ycwl.basic.integration.render`): ZT-Render-Worker microservice integration - **Render Worker Integration** (`com.ycwl.basic.integration.render`): ZT-Render-Worker microservice integration
- **Questionnaire Integration** (`com.ycwl.basic.integration.questionnaire`): ZT-Questionnaire microservice integration
- **Message Integration** (`com.ycwl.basic.integration.message`): ZT-Message Kafka producer integration
### Integration Pattern ### Integration Pattern
@@ -33,8 +35,7 @@ service/
├── client/ # Feign clients for HTTP calls ├── client/ # Feign clients for HTTP calls
├── config/ # Service-specific configuration ├── config/ # Service-specific configuration
├── dto/ # Data transfer objects ├── dto/ # Data transfer objects
── service/ # Service layer with business logic ── service/ # Service layer with business logic
└── example/ # Usage examples
``` ```
## Integration Fallback Mechanism ## Integration Fallback Mechanism
@@ -791,13 +792,6 @@ mvn test -Dtest=DefaultConfigIntegrationServiceTest
# Run all device integration tests (including default configs) # Run all device integration tests (including default configs)
mvn test -Dtest="com.ycwl.basic.integration.device.*Test" mvn test -Dtest="com.ycwl.basic.integration.device.*Test"
# Enable example runner in application-dev.yml
integration:
device:
example:
default-config:
enabled: true
``` ```
### Common Configuration Keys ### Common Configuration Keys
@@ -819,8 +813,7 @@ com.ycwl.basic.integration.{service-name}/
├── client/ ├── client/
├── config/ ├── config/
├── dto/ ├── dto/
── service/ ── service/
└── example/
``` ```
### 2. Add Configuration Properties ### 2. Add Configuration Properties
@@ -1167,6 +1160,57 @@ fallbackService.clearAllFallbackCache("zt-render-worker");
- **Active (isActive=1)**: Worker is available for tasks - **Active (isActive=1)**: Worker is available for tasks
- **Inactive (isActive=0)**: Worker is disabled - **Inactive (isActive=0)**: Worker is disabled
## ZT-Message Integration (Kafka Producer)
### Overview
The zt-message microservice accepts messages via Kafka on topic `zt-message`. This integration provides a simple producer service to publish notification messages.
- Topic: `zt-message`
- Key: Use `channelId` for partitioning stability
- Value: UTF-8 JSON with fields: `channelId` (required), `title` (required), `content` (required), `target` (required), `extra` (object, optional), `sendReason` (optional), `sendBiz` (optional)
### Components
- `com.ycwl.basic.integration.message.dto.ZtMessage`: DTO for message body
- `com.ycwl.basic.integration.message.service.ZtMessageProducerService`: Producer service using Spring Kafka
### Configuration
```yaml
kafka:
enabled: true # enable Kafka integration
bootstrap-servers: 127.0.0.1:9092 # adjust per environment
zt-message-topic: zt-message # topic name (default already zt-message)
producer:
acks: all
enable-idempotence: true
retries: 5
linger-ms: 10
batch-size: 32768
compression-type: snappy
```
### Usage
```java
@Autowired
private ZtMessageProducerService producer;
public void sendWelcome() {
ZtMessage msg = ZtMessage.of("dummy", "欢迎", "注册成功", "user-001");
Map<String, Object> extra = new HashMap<>();
extra.put("k", "v");
msg.setExtra(extra);
msg.setSendReason("REGISTER");
msg.setSendBiz("USER");
producer.send(msg); // key uses channelId, value is JSON
}
```
### Notes
- Required fields must be non-empty: `channelId`, `title`, `content`, `target`
- Keep message body small (< 100 KB)
- Use string for 64-bit integers in `extra` to avoid JS precision loss
- Service logs the partition/offset upon success, errors on failure
## Common Development Tasks ## Common Development Tasks
### Running Integration Tests ### Running Integration Tests
@@ -1244,3 +1288,430 @@ logging:
- **Service-specific management**: Separate cache management per service - **Service-specific management**: Separate cache management per service
- **Debugging support**: Use cache statistics for troubleshooting - **Debugging support**: Use cache statistics for troubleshooting
- **Configuration validation**: Ensure fallback configuration matches service requirements - **Configuration validation**: Ensure fallback configuration matches service requirements
## Questionnaire Integration (ZT-Questionnaire Microservice)
### Key Components
#### Feign Client
- **QuestionnaireClient**: Comprehensive questionnaire operations (CRUD, answer submission, statistics)
#### Service
- **QuestionnaireIntegrationService**: High-level questionnaire operations (with automatic fallback for queries)
#### Configuration
```yaml
integration:
questionnaire:
enabled: true
serviceName: zt-questionnaire
connectTimeout: 5000
readTimeout: 10000
retryEnabled: false
maxRetries: 3
fallback:
questionnaire:
enabled: true
ttlDays: 7
```
### Usage Examples
#### Basic Questionnaire Operations (with Automatic Fallback)
```java
@Autowired
private QuestionnaireIntegrationService questionnaireService;
// Get questionnaire details (automatically falls back to cache on failure)
QuestionnaireResponse questionnaire = questionnaireService.getQuestionnaire(questionnaireId);
// Get questionnaire list with filters (automatically falls back to cache on failure)
QuestionnaireListResponse list = questionnaireService.getQuestionnaireList(1, 10, "客户调查", 2, null);
// Get questionnaire statistics (automatically falls back to cache on failure)
QuestionnaireStatistics stats = questionnaireService.getStatistics(questionnaireId);
// Get response records (automatically falls back to cache on failure)
ResponseListResponse responses = questionnaireService.getResponseList(1, 10, questionnaireId, null, null, null);
// Get response details (automatically falls back to cache on failure)
ResponseDetailResponse responseDetail = questionnaireService.getResponseDetail(responseId);
```
#### Questionnaire Management Operations (Direct Operations)
```java
// Create questionnaire (direct operation, fails immediately on error)
CreateQuestionnaireRequest request = new CreateQuestionnaireRequest();
request.setName("客户满意度调查");
request.setDescription("收集客户对服务的满意度反馈");
request.setIsAnonymous(true);
request.setMaxAnswers(1000);
// Add single-choice question
CreateQuestionRequest question1 = new CreateQuestionRequest();
question1.setTitle("您对我们的服务满意吗?");
question1.setType(1); // 单选题
question1.setIsRequired(true);
question1.setSort(1);
List<CreateQuestionOptionRequest> options1 = new ArrayList<>();
options1.add(new CreateQuestionOptionRequest("非常满意", "5", 1));
options1.add(new CreateQuestionOptionRequest("满意", "4", 2));
options1.add(new CreateQuestionOptionRequest("一般", "3", 3));
options1.add(new CreateQuestionOptionRequest("不满意", "2", 4));
options1.add(new CreateQuestionOptionRequest("非常不满意", "1", 5));
question1.setOptions(options1);
// Add multiple-choice question
CreateQuestionRequest question2 = new CreateQuestionRequest();
question2.setTitle("您感兴趣的服务有哪些?");
question2.setType(2); // 多选题
question2.setIsRequired(false);
question2.setSort(2);
List<CreateQuestionOptionRequest> options2 = new ArrayList<>();
options2.add(new CreateQuestionOptionRequest("技术支持", "tech_support", 1));
options2.add(new CreateQuestionOptionRequest("产品培训", "training", 2));
options2.add(new CreateQuestionOptionRequest("定制开发", "custom_dev", 3));
options2.add(new CreateQuestionOptionRequest("其他", "others", 4));
question2.setOptions(options2);
// Add text area question
CreateQuestionRequest question3 = new CreateQuestionRequest();
question3.setTitle("您还有什么建议吗?");
question3.setType(4); // 文本域题
question3.setIsRequired(false);
question3.setSort(3);
question3.setOptions(null); // 文本域题不需要选项
request.setQuestions(Arrays.asList(question1, question2, question3));
QuestionnaireResponse created = questionnaireService.createQuestionnaire(request, "admin");
// Update questionnaire (direct operation, fails immediately on error)
CreateQuestionnaireRequest updateRequest = new CreateQuestionnaireRequest();
updateRequest.setName("更新后的客户满意度调查");
QuestionnaireResponse updated = questionnaireService.updateQuestionnaire(questionnaireId, updateRequest, "admin");
// Publish questionnaire (direct operation, fails immediately on error)
QuestionnaireResponse published = questionnaireService.publishQuestionnaire(questionnaireId, "admin");
// Stop questionnaire (direct operation, fails immediately on error)
QuestionnaireResponse stopped = questionnaireService.stopQuestionnaire(questionnaireId, "admin");
// Delete questionnaire (direct operation, fails immediately on error)
questionnaireService.deleteQuestionnaire(questionnaireId, "admin");
```
#### Answer Submission
```java
// Submit questionnaire answers (direct operation, no fallback)
SubmitAnswerRequest answerRequest = new SubmitAnswerRequest();
answerRequest.setQuestionnaireId(questionnaireId);
answerRequest.setUserId("user123");
List<AnswerRequest> answers = new ArrayList<>();
// Single-choice answer
answers.add(new AnswerRequest(123L, "4")); // 满意
// Multiple-choice answer
answers.add(new AnswerRequest(124L, "tech_support,training")); // 技术支持和产品培训
// Text area answer
answers.add(new AnswerRequest(125L, "服务很好,希望能增加更多实用功能"));
answerRequest.setAnswers(answers);
ResponseDetailResponse response = questionnaireService.submitAnswer(answerRequest);
log.info("答案提交成功,回答ID: {}", response.getId());
```
#### Question Types and Answer Formats
##### 1. Single Choice (Type 1)
```java
// Creating single-choice question
CreateQuestionRequest singleChoice = new CreateQuestionRequest();
singleChoice.setTitle("您的性别是?");
singleChoice.setType(1);
singleChoice.setIsRequired(true);
singleChoice.setSort(1);
List<CreateQuestionOptionRequest> options = new ArrayList<>();
options.add(new CreateQuestionOptionRequest("男", "male", 1));
options.add(new CreateQuestionOptionRequest("女", "female", 2));
options.add(new CreateQuestionOptionRequest("不愿透露", "prefer_not_to_say", 3));
singleChoice.setOptions(options);
// Submitting single-choice answer
AnswerRequest singleChoiceAnswer = new AnswerRequest(123L, "male");
```
##### 2. Multiple Choice (Type 2)
```java
// Creating multiple-choice question
CreateQuestionRequest multipleChoice = new CreateQuestionRequest();
multipleChoice.setTitle("您感兴趣的编程语言有哪些?");
multipleChoice.setType(2);
multipleChoice.setIsRequired(false);
multipleChoice.setSort(2);
List<CreateQuestionOptionRequest> options = new ArrayList<>();
options.add(new CreateQuestionOptionRequest("Java", "java", 1));
options.add(new CreateQuestionOptionRequest("Python", "python", 2));
options.add(new CreateQuestionOptionRequest("Go", "go", 3));
options.add(new CreateQuestionOptionRequest("JavaScript", "javascript", 4));
multipleChoice.setOptions(options);
// Submitting multiple-choice answer (comma-separated values)
AnswerRequest multipleChoiceAnswer = new AnswerRequest(124L, "java,python,go");
```
##### 3. Fill in Blank (Type 3)
```java
// Creating fill-in-blank question
CreateQuestionRequest fillInBlank = new CreateQuestionRequest();
fillInBlank.setTitle("请输入您的姓名");
fillInBlank.setType(3);
fillInBlank.setIsRequired(true);
fillInBlank.setSort(3);
fillInBlank.setOptions(null); // No options needed
// Submitting fill-in-blank answer
AnswerRequest fillAnswer = new AnswerRequest(125L, "张三");
```
##### 4. Text Area (Type 4)
```java
// Creating text area question
CreateQuestionRequest textArea = new CreateQuestionRequest();
textArea.setTitle("请详细描述您对我们产品的建议");
textArea.setType(4);
textArea.setIsRequired(false);
textArea.setSort(4);
textArea.setOptions(null); // No options needed
// Submitting text area answer
AnswerRequest textAnswer = new AnswerRequest(126L, "建议增加更多功能,提升用户体验...");
```
##### 5. Rating (Type 5)
```java
// Creating rating question
CreateQuestionRequest rating = new CreateQuestionRequest();
rating.setTitle("请对我们的服务进行评分(1-10分)");
rating.setType(5);
rating.setIsRequired(true);
rating.setSort(5);
rating.setOptions(null); // No options needed, range controlled by frontend
// Submitting rating answer
AnswerRequest ratingAnswer = new AnswerRequest(127L, "8");
```
#### Complete Questionnaire Workflow
```java
// 1. Create questionnaire
CreateQuestionnaireRequest createRequest = buildSampleQuestionnaire();
QuestionnaireResponse questionnaire = questionnaireService.createQuestionnaire(createRequest, "admin");
// 2. Publish questionnaire
QuestionnaireResponse published = questionnaireService.publishQuestionnaire(questionnaire.getId(), "admin");
// 3. Users submit answers
SubmitAnswerRequest answerRequest = buildSampleAnswers(questionnaire.getId());
ResponseDetailResponse answerResponse = questionnaireService.submitAnswer(answerRequest);
// 4. View statistics
QuestionnaireStatistics statistics = questionnaireService.getStatistics(questionnaire.getId());
log.info("Statistics - Total responses: {}, Completion rate: {}%",
statistics.getTotalResponses(), statistics.getCompletionRate() * 100);
// 5. Stop questionnaire when done
QuestionnaireResponse stopped = questionnaireService.stopQuestionnaire(questionnaire.getId(), "admin");
```
#### Fallback Cache Management for Questionnaires
```java
@Autowired
private IntegrationFallbackService fallbackService;
// Check fallback cache status
boolean hasQuestionnaireCache = fallbackService.hasFallbackCache("zt-questionnaire", "questionnaire:1001");
boolean hasListCache = fallbackService.hasFallbackCache("zt-questionnaire", "questionnaire:list:1:10:null:null:null");
boolean hasStatsCache = fallbackService.hasFallbackCache("zt-questionnaire", "questionnaire:statistics:1001");
// Get cache statistics
IntegrationFallbackService.FallbackCacheStats stats = fallbackService.getFallbackCacheStats("zt-questionnaire");
log.info("Questionnaire fallback cache: {} items, TTL: {} days",
stats.getTotalCacheCount(), stats.getFallbackTtlDays());
// Clear specific cache
fallbackService.clearFallbackCache("zt-questionnaire", "questionnaire:1001");
// Clear all questionnaire caches
fallbackService.clearAllFallbackCache("zt-questionnaire");
```
### Question Types and Validation Rules
| Question Type | Type Value | Description | Options Required | Answer Format |
|---------------|------------|-------------|------------------|---------------|
| Single Choice | 1 | User can select one answer | Yes (2+ options) | Single option value |
| Multiple Choice | 2 | User can select multiple answers | Yes (2+ options) | Comma-separated option values |
| Fill in Blank | 3 | User inputs short text | No | Text content (1-200 chars) |
| Text Area | 4 | User inputs long text | No | Text content (1-2000 chars) |
| Rating | 5 | User provides numerical rating | No | Number as string (e.g., "1", "10") |
### Answer Validation Rules
| Question Type | Validation Rules | Example |
|---------------|------------------|---------|
| Single Choice | Must be existing option value | "male", "female" |
| Multiple Choice | Comma-separated existing option values | "java,python", "option1,option2,option3" |
| Fill in Blank | Non-empty string, 1-200 characters | "张三", "北京市" |
| Text Area | String, 1-2000 characters | "这是一段较长的文本内容..." |
| Rating | Numeric string, typically 1-10 range | "1", "5", "10" |
### Questionnaire Status
- **1**: Draft - Questionnaire is being edited
- **2**: Published - Questionnaire is live and accepting responses
- **3**: Stopped - Questionnaire is no longer accepting responses
- **4**: Deleted - Questionnaire has been deleted
### Common Use Cases
#### Customer Satisfaction Survey
```java
// Create customer satisfaction questionnaire with rating and feedback
CreateQuestionnaireRequest customerSurvey = new CreateQuestionnaireRequest();
customerSurvey.setName("客户满意度调查");
customerSurvey.setDescription("收集客户对服务的满意度反馈");
customerSurvey.setIsAnonymous(true);
// Add rating question
CreateQuestionRequest ratingQ = new CreateQuestionRequest();
ratingQ.setTitle("整体满意度评分(1-10分)");
ratingQ.setType(5);
ratingQ.setIsRequired(true);
// Add feedback question
CreateQuestionRequest feedbackQ = new CreateQuestionRequest();
feedbackQ.setTitle("请提供具体的改进建议");
feedbackQ.setType(4);
feedbackQ.setIsRequired(false);
customerSurvey.setQuestions(Arrays.asList(ratingQ, feedbackQ));
```
#### Product Feature Feedback
```java
// Create product feature questionnaire with multiple choice and priorities
CreateQuestionnaireRequest featureSurvey = new CreateQuestionnaireRequest();
featureSurvey.setName("产品功能需求调研");
featureSurvey.setIsAnonymous(false);
// Priority features question
CreateQuestionRequest featuresQ = new CreateQuestionRequest();
featuresQ.setTitle("您最希望我们优先开发哪些功能?");
featuresQ.setType(2); // Multiple choice
featuresQ.setIsRequired(true);
List<CreateQuestionOptionRequest> featureOptions = new ArrayList<>();
featureOptions.add(new CreateQuestionOptionRequest("移动端适配", "mobile_support", 1));
featureOptions.add(new CreateQuestionOptionRequest("数据导出", "data_export", 2));
featureOptions.add(new CreateQuestionOptionRequest("API集成", "api_integration", 3));
featureOptions.add(new CreateQuestionOptionRequest("高级分析", "advanced_analytics", 4));
featuresQ.setOptions(featureOptions);
featureSurvey.setQuestions(Arrays.asList(featuresQ));
```
### Error Handling and HTTP Status Codes
#### HTTP Status Codes
- **200 OK**: Operation successful
- **201 Created**: Questionnaire/response created successfully
- **400 Bad Request**: Invalid request parameters or validation errors
- **404 Not Found**: Questionnaire or response not found
- **500 Internal Server Error**: Server error occurred
#### Common Error Scenarios
```java
try {
QuestionnaireResponse questionnaire = questionnaireService.getQuestionnaire(invalidId);
} catch (IntegrationException e) {
switch (e.getCode()) {
case 404:
log.warn("问卷不存在: {}", invalidId);
break;
case 400:
log.warn("请求参数错误: {}", e.getMessage());
break;
case 500:
log.error("服务器内部错误: {}", e.getMessage());
break;
default:
log.error("未知错误: {}", e.getMessage());
}
}
```
### Testing Questionnaire Integration
```bash
# Run questionnaire integration tests
mvn test -Dtest=QuestionnaireIntegrationServiceTest
# Run all integration tests
mvn test -Dtest="com.ycwl.basic.integration.*Test"
# Enable example runner in application-dev.yml
integration:
questionnaire:
example:
enabled: true
```
### Configuration Properties
```yaml
integration:
questionnaire:
enabled: true # Enable questionnaire integration
serviceName: zt-questionnaire # Service name for Nacos discovery
connectTimeout: 5000 # Connection timeout in ms
readTimeout: 10000 # Read timeout in ms
retryEnabled: false # Enable retry mechanism
maxRetries: 3 # Maximum retry attempts
fallback:
questionnaire:
enabled: true # Enable fallback for questionnaire service
ttlDays: 7 # Cache TTL in days
cachePrefix: "questionnaire:fallback:" # Optional custom prefix
```
### Best Practices for Questionnaire Integration
#### Query vs Mutation Operations
- **Query operations (GET)**: Use fallback - questionnaire details, lists, statistics, responses
- **Mutation operations (POST/PUT/DELETE)**: No fallback - create, update, delete, publish, stop, submit
#### Cache Key Design
- `questionnaire:{id}` - Individual questionnaire cache
- `questionnaire:list:{page}:{size}:{name}:{status}:{createdBy}` - List cache
- `questionnaire:statistics:{id}` - Statistics cache
- `response:{id}` - Individual response cache
- `responses:list:{page}:{size}:{questionnaireId}:{userId}` - Response list cache
#### Answer Submission Best Practices
- Validate question types before submission
- Handle validation errors gracefully
- Provide clear error messages for users
- Log submission attempts for audit purposes
#### Performance Considerations
- Use appropriate page sizes for questionnaire lists
- Cache frequently accessed questionnaires
- Monitor response submission patterns
- Implement rate limiting for public questionnaires

View File

@@ -31,6 +31,11 @@ public class IntegrationProperties {
*/ */
private RenderWorkerConfig render = new RenderWorkerConfig(); private RenderWorkerConfig render = new RenderWorkerConfig();
/**
* 问卷服务配置
*/
private QuestionnaireConfig questionnaire = new QuestionnaireConfig();
@Data @Data
public static class ScenicConfig { public static class ScenicConfig {
/** /**
@@ -104,6 +109,7 @@ public class IntegrationProperties {
private ServiceFallbackConfig scenic = new ServiceFallbackConfig(); private ServiceFallbackConfig scenic = new ServiceFallbackConfig();
private ServiceFallbackConfig device = new ServiceFallbackConfig(); private ServiceFallbackConfig device = new ServiceFallbackConfig();
private ServiceFallbackConfig render = new ServiceFallbackConfig(); private ServiceFallbackConfig render = new ServiceFallbackConfig();
private ServiceFallbackConfig questionnaire = new ServiceFallbackConfig();
} }
@Data @Data
@@ -131,6 +137,31 @@ public class IntegrationProperties {
private int maxRetries = 3; private int maxRetries = 3;
} }
@Data
public static class QuestionnaireConfig {
/**
* 是否启用问卷服务集成
*/
private boolean enabled = true;
/**
* 服务名称
*/
private String serviceName = "zt-questionnaire";
/**
* 超时配置(毫秒)
*/
private int connectTimeout = 5000;
private int readTimeout = 10000;
/**
* 重试配置
*/
private boolean retryEnabled = false;
private int maxRetries = 3;
}
@Data @Data
public static class ServiceFallbackConfig { public static class ServiceFallbackConfig {
/** /**

View File

@@ -16,8 +16,6 @@ import java.util.stream.Collectors;
*/ */
public class ScenicConfigManager extends ConfigManager<ScenicConfigV2DTO> { public class ScenicConfigManager extends ConfigManager<ScenicConfigV2DTO> {
private final Map<String, Object> configMap;
/** /**
* 从配置列表构造管理器 * 从配置列表构造管理器
* *
@@ -25,26 +23,7 @@ public class ScenicConfigManager extends ConfigManager<ScenicConfigV2DTO> {
*/ */
public ScenicConfigManager(List<ScenicConfigV2DTO> configList) { public ScenicConfigManager(List<ScenicConfigV2DTO> configList) {
super(configList); super(configList);
this.configMap = new HashMap<>();
if (configList != null) {
for (ScenicConfigV2DTO config : configList) {
if (config.getConfigKey() != null && config.getConfigValue() != null) {
this.configMap.put(config.getConfigKey(), config.getConfigValue());
}
}
}
} }
/**
* 从配置Map构造管理器
*
* @param configMap 配置Map
*/
public ScenicConfigManager(Map<String, Object> configMap) {
super(null); // 使用Map构造时,父类configs为null
this.configMap = configMap != null ? new HashMap<>(configMap) : new HashMap<>();
}
@Override @Override
protected String getConfigKey(ScenicConfigV2DTO config) { protected String getConfigKey(ScenicConfigV2DTO config) {
return config != null ? config.getConfigKey() : null; return config != null ? config.getConfigKey() : null;
@@ -55,276 +34,4 @@ public class ScenicConfigManager extends ConfigManager<ScenicConfigV2DTO> {
return config != null ? config.getConfigValue() : null; return config != null ? config.getConfigValue() : null;
} }
/**
* 获取长整数值
*
* @param key 配置键
* @return Long值,如果键不存在或转换失败返回null
*/
public Long getLong(String key) {
return ConfigValueUtil.getLongValue(configMap, key);
}
/**
* 获取长整数值,如果为null则返回默认值
*
* @param key 配置键
* @param defaultValue 默认值
* @return Long值或默认值
*/
public Long getLong(String key, Long defaultValue) {
Long value = ConfigValueUtil.getLongValue(configMap, key);
return value != null ? value : defaultValue;
}
/**
* 获取浮点数值
*
* @param key 配置键
* @return Float值,如果键不存在或转换失败返回null
*/
public Float getFloat(String key) {
return ConfigValueUtil.getFloatValue(configMap, key);
}
/**
* 获取浮点数值,如果为null则返回默认值
*
* @param key 配置键
* @param defaultValue 默认值
* @return Float值或默认值
*/
public Float getFloat(String key, Float defaultValue) {
Float value = ConfigValueUtil.getFloatValue(configMap, key);
return value != null ? value : defaultValue;
}
/**
* 获取双精度浮点数值
*
* @param key 配置键
* @return Double值,如果键不存在或转换失败返回null
*/
public Double getDouble(String key) {
return ConfigValueUtil.getDoubleValue(configMap, key);
}
/**
* 获取双精度浮点数值,如果为null则返回默认值
*
* @param key 配置键
* @param defaultValue 默认值
* @return Double值或默认值
*/
public Double getDouble(String key, Double defaultValue) {
Double value = ConfigValueUtil.getDoubleValue(configMap, key);
return value != null ? value : defaultValue;
}
/**
* 获取高精度小数值
*
* @param key 配置键
* @return BigDecimal值,如果键不存在或转换失败返回null
*/
public BigDecimal getBigDecimal(String key) {
return ConfigValueUtil.getBigDecimalValue(configMap, key);
}
/**
* 获取高精度小数值,如果为null则返回默认值
*
* @param key 配置键
* @param defaultValue 默认值
* @return BigDecimal值或默认值
*/
public BigDecimal getBigDecimal(String key, BigDecimal defaultValue) {
BigDecimal value = ConfigValueUtil.getBigDecimalValue(configMap, key);
return value != null ? value : defaultValue;
}
/**
* 获取布尔值
*
* @param key 配置键
* @return Boolean值,如果键不存在或转换失败返回null
*/
public Boolean getBoolean(String key) {
return ConfigValueUtil.getBooleanValue(configMap, key);
}
/**
* 获取布尔值,如果为null则返回默认值
*
* @param key 配置键
* @param defaultValue 默认值
* @return Boolean值或默认值
*/
public Boolean getBoolean(String key, Boolean defaultValue) {
return ConfigValueUtil.getBooleanValue(configMap, key, defaultValue);
}
/**
* 检查配置键是否存在
*
* @param key 配置键
* @return true如果键存在,false如果不存在
*/
public boolean hasKey(String key) {
return ConfigValueUtil.hasKey(configMap, key);
}
/**
* 检查配置键是否存在且值不为null
*
* @param key 配置键
* @return true如果键存在且值不为null
*/
public boolean hasNonNullValue(String key) {
return ConfigValueUtil.hasNonNullValue(configMap, key);
}
/**
* 获取所有配置键
*
* @return 配置键集合
*/
public Set<String> getAllKeys() {
return new HashSet<>(configMap.keySet());
}
/**
* 获取配置项数量
*
* @return 配置项数量
*/
@Override
public int size() {
return configMap.size();
}
/**
* 检查配置是否为空
*
* @return true如果没有配置项
*/
public boolean isEmpty() {
return configMap.isEmpty();
}
/**
* 获取所有配置的拷贝
*
* @return 配置Map的拷贝
*/
public Map<String, Object> getAllConfigsAsMap() {
return new HashMap<>(configMap);
}
/**
* 根据键前缀过滤配置
*
* @param prefix 键前缀
* @return 匹配前缀的配置Map
*/
public Map<String, Object> getConfigsByPrefix(String prefix) {
if (prefix == null) {
return new HashMap<>();
}
return configMap.entrySet().stream()
.filter(entry -> entry.getKey() != null && entry.getKey().startsWith(prefix))
.collect(Collectors.toMap(
Map.Entry::getKey,
Map.Entry::getValue
));
}
/**
* 创建新的ScenicConfigManager,包含当前配置的子集
*
* @param keys 要包含的配置键
* @return 包含指定键配置的新管理器
*/
public ScenicConfigManager subset(Set<String> keys) {
Map<String, Object> subsetMap = new HashMap<>();
if (keys != null) {
for (String key : keys) {
if (configMap.containsKey(key)) {
subsetMap.put(key, configMap.get(key));
}
}
}
return new ScenicConfigManager(subsetMap);
}
/**
* 将配置转换为扁平化的Map,键名转换为驼峰形式
*
* @return 扁平化的配置Map,键为驼峰形式
*/
public Map<String, Object> toFlatConfig() {
Map<String, Object> flatConfig = new HashMap<>();
for (Map.Entry<String, Object> entry : configMap.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
if (key != null) {
String camelCaseKey = toCamelCase(key);
flatConfig.put(camelCaseKey, value);
}
}
return flatConfig;
}
/**
* 将字符串转换为驼峰形式
* 支持下划线、短横线、点号分隔的字符串转换
*
* @param str 原始字符串
* @return 驼峰形式的字符串
*/
private String toCamelCase(String str) {
if (str == null || str.isEmpty()) {
return str;
}
// 支持下划线、短横线、点号作为分隔符
String[] parts = str.split("[_\\-.]");
if (parts.length <= 1) {
return str;
}
StringBuilder camelCase = new StringBuilder();
// 第一部分保持原样(全小写)
camelCase.append(parts[0].toLowerCase());
// 后续部分首字母大写
for (int i = 1; i < parts.length; i++) {
String part = parts[i];
if (!part.isEmpty()) {
camelCase.append(Character.toUpperCase(part.charAt(0)));
if (part.length() > 1) {
camelCase.append(part.substring(1).toLowerCase());
}
}
}
return camelCase.toString();
}
@Override
public String toString() {
return "ScenicConfigManager{" +
"configCount=" + configMap.size() +
", keys=" + configMap.keySet() +
'}';
}
} }

View File

@@ -30,18 +30,6 @@ public interface DeviceConfigV2Client {
CommonResponse<DeviceConfigV2DTO> getDeviceConfigByKey(@PathVariable("deviceId") Long deviceId, CommonResponse<DeviceConfigV2DTO> getDeviceConfigByKey(@PathVariable("deviceId") Long deviceId,
@PathVariable("configKey") String configKey); @PathVariable("configKey") String configKey);
/**
* 获取设备扁平化配置
*/
@GetMapping("/{deviceId}/flat")
CommonResponse<Map<String, Object>> getDeviceFlatConfig(@PathVariable("deviceId") Long deviceId);
/**
* 根据设备编号获取设备扁平化配置
*/
@GetMapping("/no/{no}/flat")
CommonResponse<Map<String, Object>> getDeviceFlatConfigByNo(@PathVariable("no") String no);
/** /**
* 创建设备配置 * 创建设备配置
*/ */

View File

@@ -0,0 +1,51 @@
package com.ycwl.basic.integration.device.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.device.dto.status.DeviceStatusDTO;
import com.ycwl.basic.integration.device.dto.status.OnlineStatusResponseDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.List;
@FeignClient(name = "zt-device", contextId = "deviceStatusClient", path = "/api/device/status")
public interface DeviceStatusClient {
/**
* 获取设备状态
*/
@GetMapping("/{deviceNo}")
CommonResponse<DeviceStatusDTO> getDeviceStatus(@PathVariable("deviceNo") String deviceNo);
/**
* 检查设备是否在线
*/
@GetMapping("/{deviceNo}/online")
CommonResponse<OnlineStatusResponseDTO> isDeviceOnline(@PathVariable("deviceNo") String deviceNo);
/**
* 获取所有在线设备
*/
@GetMapping("/online")
CommonResponse<List<DeviceStatusDTO>> getAllOnlineDevices();
/**
* 设置设备离线
*/
@PostMapping("/{deviceNo}/offline")
CommonResponse<String> setDeviceOffline(@PathVariable("deviceNo") String deviceNo);
/**
* 设置设备在线
*/
@PostMapping("/{deviceNo}/online")
CommonResponse<String> setDeviceOnline(@PathVariable("deviceNo") String deviceNo);
/**
* 清理过期设备状态
*/
@PostMapping("/clean")
CommonResponse<String> cleanExpiredDevices();
}

View File

@@ -21,18 +21,6 @@ public interface DeviceV2Client {
@GetMapping("/no/{no}") @GetMapping("/no/{no}")
CommonResponse<DeviceV2DTO> getDeviceByNo(@PathVariable("no") String no); CommonResponse<DeviceV2DTO> getDeviceByNo(@PathVariable("no") String no);
/**
* 获取设备详细信息(含配置)
*/
@GetMapping("/{id}/with-config")
CommonResponse<DeviceV2WithConfigDTO> getDeviceWithConfig(@PathVariable("id") Long id);
/**
* 根据设备编号获取设备详细信息(含配置)
*/
@GetMapping("/no/{no}/with-config")
CommonResponse<DeviceV2WithConfigDTO> getDeviceByNoWithConfig(@PathVariable("no") String no);
/** /**
* 创建设备 * 创建设备
*/ */
@@ -65,19 +53,6 @@ public interface DeviceV2Client {
@RequestParam(value = "isActive", required = false) Integer isActive, @RequestParam(value = "isActive", required = false) Integer isActive,
@RequestParam(value = "scenicId", required = false) Long scenicId); @RequestParam(value = "scenicId", required = false) Long scenicId);
/**
* 分页获取设备列表(含配置)
*/
@GetMapping("/with-config")
CommonResponse<PageResponse<DeviceV2WithConfigDTO>> listDevicesWithConfig(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "pageSize", defaultValue = "10") Integer pageSize,
@RequestParam(value = "name", required = false) String name,
@RequestParam(value = "no", required = false) String no,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "isActive", required = false) Integer isActive,
@RequestParam(value = "scenicId", required = false) Long scenicId);
/** /**
* 根据配置条件筛选设备 * 根据配置条件筛选设备
*/ */

View File

@@ -0,0 +1,41 @@
package com.ycwl.basic.integration.device.dto.status;
/**
* 设备状态动作枚举
*/
public enum DeviceStatusActionEnum {
/**
* 设备注册
*/
REGISTER("register"),
/**
* 设备保活
*/
KEEPALIVE("keepalive"),
/**
* 设备注销
*/
UNREGISTER("unregister");
private final String action;
DeviceStatusActionEnum(String action) {
this.action = action;
}
public String getAction() {
return action;
}
public static DeviceStatusActionEnum fromString(String action) {
for (DeviceStatusActionEnum statusAction : values()) {
if (statusAction.action.equalsIgnoreCase(action)) {
return statusAction;
}
}
throw new IllegalArgumentException("Unknown device status action: " + action);
}
}

View File

@@ -0,0 +1,50 @@
package com.ycwl.basic.integration.device.dto.status;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Date;
/**
* 设备状态信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class DeviceStatusDTO {
/**
* 设备编号
*/
private String deviceNo;
/**
* 是否在线
*/
private Boolean isOnline;
/**
* 最后活动时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date lastActiveTime;
/**
* 最后动作(register/keepalive/unregister)
*/
private String lastAction;
/**
* 客户端IP
*/
private String clientIP;
/**
* 状态更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}

View File

@@ -0,0 +1,24 @@
package com.ycwl.basic.integration.device.dto.status;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 在线状态响应
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OnlineStatusResponseDTO {
/**
* 设备编号
*/
private String deviceNo;
/**
* 是否在线
*/
private Boolean isOnline;
}

View File

@@ -1,275 +0,0 @@
package com.ycwl.basic.integration.device.example;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.device.dto.defaults.*;
import com.ycwl.basic.integration.device.service.DeviceDefaultConfigIntegrationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* 默认配置集成服务使用示例
*
* 通过在 application.yml 中设置 integration.device.example.default-config.enabled=true 来启用
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "integration.device.example.default-config.enabled", havingValue = "true")
public class DefaultConfigIntegrationExample implements CommandLineRunner {
private final DeviceDefaultConfigIntegrationService defaultConfigService;
@Override
public void run(String... args) throws Exception {
log.info("=== 默认配置集成服务使用示例 ===");
try {
// 1. 基础查询操作示例(支持自动 Fallback)
basicQueryExamples();
// 2. 配置管理操作示例(直接操作)
configManagementExamples();
// 3. 批量操作示例
batchOperationExamples();
// 4. 高级使用模式示例
advancedUsageExamples();
} catch (Exception e) {
log.error("默认配置集成示例执行失败", e);
}
}
/**
* 基础查询操作示例(支持自动 Fallback)
*/
private void basicQueryExamples() {
log.info("--- 基础查询操作示例(支持自动 Fallback)---");
try {
// 获取默认配置列表(自动缓存,服务不可用时返回缓存数据)
PageResponse<DefaultConfigResponse> configList = defaultConfigService.listDefaultConfigs(1, 10);
log.info("默认配置列表: 总数={}, 当前页配置数={}",
configList.getTotal(), configList.getList().size());
// 显示配置详情
for (DefaultConfigResponse config : configList.getList()) {
log.info("配置详情: key={}, value={}, type={}, description={}",
config.getConfigKey(), config.getConfigValue(),
config.getConfigType(), config.getDescription());
// 获取单个配置详情(自动缓存,服务不可用时返回缓存数据)
DefaultConfigResponse detailConfig = defaultConfigService.getDefaultConfig(config.getConfigKey());
if (detailConfig != null) {
log.info("配置详情获取成功: usageCount={}, isActive={}",
detailConfig.getUsageCount(), detailConfig.getIsActive());
}
break; // 只展示第一个
}
} catch (Exception e) {
log.error("基础查询操作失败", e);
}
}
/**
* 配置管理操作示例(直接操作)
*/
private void configManagementExamples() {
log.info("--- 配置管理操作示例(直接操作)---");
String testConfigKey = "example_test_config";
try {
// 1. 创建默认配置(直接操作,失败时立即报错)
DefaultConfigRequest createRequest = new DefaultConfigRequest();
createRequest.setConfigKey(testConfigKey);
createRequest.setConfigValue("1920x1080");
createRequest.setConfigType("string");
createRequest.setDescription("示例测试配置 - 默认分辨率");
boolean createResult = defaultConfigService.createDefaultConfig(createRequest);
log.info("创建默认配置结果: {}", createResult ? "成功" : "失败");
// 2. 更新默认配置(直接操作,可能返回冲突信息)
Map<String, Object> updates = new HashMap<>();
updates.put("configValue", "3840x2160");
updates.put("description", "更新后的默认分辨率 - 4K");
DefaultConfigConflict conflict = defaultConfigService.updateDefaultConfig(testConfigKey, updates);
if (conflict != null) {
log.warn("更新配置存在冲突: configKey={}, conflictType={}, deviceCount={}",
conflict.getConfigKey(), conflict.getConflictType(), conflict.getDeviceCount());
} else {
log.info("配置更新成功,无冲突");
}
// 3. 验证更新结果
DefaultConfigResponse updatedConfig = defaultConfigService.getDefaultConfig(testConfigKey);
if (updatedConfig != null) {
log.info("更新后配置值: {}", updatedConfig.getConfigValue());
}
// 4. 删除测试配置(直接操作)
boolean deleteResult = defaultConfigService.deleteDefaultConfig(testConfigKey);
log.info("删除默认配置结果: {}", deleteResult ? "成功" : "失败");
} catch (Exception e) {
log.error("配置管理操作失败", e);
}
}
/**
* 批量操作示例
*/
private void batchOperationExamples() {
log.info("--- 批量操作示例 ---");
try {
// 1. 使用构建器模式创建批量配置
BatchDefaultConfigRequest batchRequest = defaultConfigService.createBatchConfigBuilder()
.addVideoConfig("1920x1080", 30, "H264") // 添加视频配置组
.addNetworkConfig("192.168.1.100", 554, "RTSP") // 添加网络配置组
.addConfig("recording_enabled", "true", "bool", "是否启用录制")
.addConfig("storage_path", "/data/recordings", "string", "录制存储路径")
.addConfig("max_file_size", "1024", "int", "最大文件大小(MB)")
.build();
log.info("准备批量创建 {} 个默认配置", batchRequest.getConfigs().size());
// 2. 执行批量更新(直接操作)
BatchDefaultConfigResponse batchResult = defaultConfigService.batchUpdateDefaultConfigs(batchRequest);
// 3. 处理批量结果
log.info("批量操作结果: 成功={}, 失败={}", batchResult.getSuccess(), batchResult.getFailed());
if (batchResult.getConflicts() != null && !batchResult.getConflicts().isEmpty()) {
log.warn("发现 {} 个配置冲突:", batchResult.getConflicts().size());
for (DefaultConfigConflict conflict : batchResult.getConflicts()) {
log.warn("冲突配置: key={}, type={}, deviceCount={}",
conflict.getConfigKey(), conflict.getConflictType(), conflict.getDeviceCount());
}
}
if (batchResult.getProcessedItems() != null) {
log.info("处理详情:");
batchResult.getProcessedItems().forEach(item ->
log.info(" 配置 {}: status={}, action={}, finalType={}",
item.getConfigKey(), item.getStatus(), item.getAction(), item.getFinalType())
);
}
if (batchResult.getErrors() != null && !batchResult.getErrors().isEmpty()) {
log.error("批量操作错误:");
batchResult.getErrors().forEach(error -> log.error(" {}", error));
}
} catch (Exception e) {
log.error("批量操作失败", e);
}
}
/**
* 高级使用模式示例
*/
private void advancedUsageExamples() {
log.info("--- 高级使用模式示例 ---");
try {
// 1. 设备类型特定的默认配置模式
createDeviceTypeSpecificConfigs();
// 2. 配置验证和完整性检查模式
validateConfigCompleteness();
// 3. 配置迁移和批量更新模式
configMigrationPattern();
} catch (Exception e) {
log.error("高级使用模式示例失败", e);
}
}
/**
* 创建设备类型特定的默认配置
*/
private void createDeviceTypeSpecificConfigs() {
log.info("创建设备类型特定的默认配置...");
// IPC摄像头默认配置
BatchDefaultConfigRequest ipcDefaults = defaultConfigService.createBatchConfigBuilder()
.addVideoConfig("1920x1080", 25, "H264")
.addConfig("night_vision", "true", "bool", "夜视功能")
.addConfig("motion_detection", "true", "bool", "移动检测")
.addConfig("stream_profile", "main", "string", "码流类型")
.build();
// NVR设备默认配置
BatchDefaultConfigRequest nvrDefaults = defaultConfigService.createBatchConfigBuilder()
.addConfig("max_channels", "16", "int", "最大通道数")
.addConfig("storage_mode", "continuous", "string", "存储模式")
.addConfig("backup_enabled", "true", "bool", "备份启用")
.build();
log.info("IPC默认配置项数: {}, NVR默认配置项数: {}",
ipcDefaults.getConfigs().size(), nvrDefaults.getConfigs().size());
}
/**
* 配置验证和完整性检查
*/
private void validateConfigCompleteness() {
log.info("验证配置完整性...");
// 获取所有默认配置
PageResponse<DefaultConfigResponse> allConfigs = defaultConfigService.listDefaultConfigs(1, 100);
// 检查必需的基础配置是否存在
String[] requiredConfigs = {"resolution", "frameRate", "codec", "protocol"};
for (String requiredConfig : requiredConfigs) {
boolean exists = allConfigs.getList().stream()
.anyMatch(config -> requiredConfig.equals(config.getConfigKey()));
log.info("必需配置 {} 存在: {}", requiredConfig, exists ? "" : "");
}
// 统计配置类型分布
Map<String, Long> typeDistribution = new HashMap<>();
allConfigs.getList().forEach(config ->
typeDistribution.merge(config.getConfigType(), 1L, Long::sum)
);
log.info("配置类型分布: {}", typeDistribution);
}
/**
* 配置迁移和批量更新模式
*/
private void configMigrationPattern() {
log.info("配置迁移模式示例...");
try {
// 1. 获取需要升级的配置
PageResponse<DefaultConfigResponse> oldConfigs = defaultConfigService.listDefaultConfigs(1, 50);
// 2. 创建升级配置批次
BatchDefaultConfigRequest upgradeRequest = defaultConfigService.createBatchConfigBuilder()
.addConfig("api_version", "v2", "string", "API版本")
.addConfig("security_mode", "enhanced", "string", "安全模式")
.build();
// 3. 执行批量升级
BatchDefaultConfigResponse upgradeResult = defaultConfigService.batchUpdateDefaultConfigs(upgradeRequest);
log.info("配置升级结果: 成功={}, 失败={}", upgradeResult.getSuccess(), upgradeResult.getFailed());
} catch (Exception e) {
log.warn("配置迁移示例执行失败(这是正常的,因为是示例代码)", e);
}
}
}

View File

@@ -1,190 +0,0 @@
package com.ycwl.basic.integration.device.example;
import com.ycwl.basic.integration.device.dto.device.*;
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 设备配置筛选功能使用示例
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceFilterExample {
private final DeviceIntegrationService deviceIntegrationService;
/**
* 示例1: 查找高质量户外IPC设备
* 条件: 分辨率1920x1080,帧率>=30,位置包含"outdoor"
*/
public void findHighQualityOutdoorDevices() {
log.info("=== 查找高质量户外IPC设备 ===");
List<ConfigFilter> configFilters = Arrays.asList(
new ConfigFilter("resolution", "1920x1080", "eq"),
new ConfigFilter("framerate", 30, "gte"),
new ConfigFilter("location", "outdoor", "like")
);
Map<String, Object> deviceFilters = new HashMap<>();
deviceFilters.put("type", "IPC");
deviceFilters.put("isActive", 1);
FilterDevicesByConfigsRequest request = new FilterDevicesByConfigsRequest();
request.setPage(1);
request.setPageSize(20);
request.setConfigFilters(configFilters);
request.setFilterLogic("AND");
request.setDeviceFilters(deviceFilters);
try {
FilterDevicesByConfigsResponse response = deviceIntegrationService.filterDevicesByConfigs(request);
log.info("找到 {} 个高质量户外IPC设备", response.getTotal());
response.getList().forEach(device -> {
log.info("设备: {} ({}), 配置: {}", device.getName(), device.getNo(), device.getConfig());
});
} catch (Exception e) {
log.error("查找高质量户外IPC设备失败", e);
}
}
/**
* 示例2: 查找缺少关键配置的设备
* 条件: 缺少备份服务器或夜视功能配置
*/
public void findDevicesWithMissingConfigs() {
log.info("=== 查找缺少关键配置的设备 ===");
List<ConfigFilter> configFilters = Arrays.asList(
new ConfigFilter("backup_server", null, "is_null"),
new ConfigFilter("night_vision", null, "is_null")
);
FilterDevicesByConfigsRequest request = new FilterDevicesByConfigsRequest();
request.setConfigFilters(configFilters);
request.setFilterLogic("OR"); // 缺少任一配置即返回
request.setPageSize(50);
try {
FilterDevicesByConfigsResponse response = deviceIntegrationService.filterDevicesByConfigs(request);
log.info("找到 {} 个缺少关键配置的设备", response.getTotal());
response.getList().forEach(device -> {
Map<String, Object> config = device.getConfig();
boolean missingBackup = !config.containsKey("backup_server") || config.get("backup_server") == null;
boolean missingNightVision = !config.containsKey("night_vision") || config.get("night_vision") == null;
log.info("设备: {} ({}), 缺少配置: {}{}",
device.getName(), device.getNo(),
missingBackup ? "备份服务器 " : "",
missingNightVision ? "夜视功能 " : "");
});
} catch (Exception e) {
log.error("查找缺少关键配置的设备失败", e);
}
}
/**
* 示例3: 根据多个位置查找设备
* 条件: 位置在指定列表中
*/
public void findDevicesByMultipleLocations() {
log.info("=== 根据多个位置查找设备 ===");
List<String> locations = Arrays.asList("outdoor", "indoor", "parking");
List<ConfigFilter> configFilters = Arrays.asList(
new ConfigFilter("location", locations, "in")
);
FilterDevicesByConfigsRequest request = new FilterDevicesByConfigsRequest();
request.setConfigFilters(configFilters);
request.setPageSize(100);
try {
FilterDevicesByConfigsResponse response = deviceIntegrationService.filterDevicesByConfigs(request);
log.info("找到 {} 个设备在指定位置", response.getTotal());
response.getList().forEach(device -> {
Object location = device.getConfig().get("location");
log.info("设备: {} ({}), 位置: {}", device.getName(), device.getNo(), location);
});
} catch (Exception e) {
log.error("根据多个位置查找设备失败", e);
}
}
/**
* 示例4: 使用便捷方法查找设备
*/
public void useConvenienceMethods() {
log.info("=== 使用便捷方法查找设备 ===");
try {
// 查找缺少配置的设备
FilterDevicesByConfigsResponse response4 = deviceIntegrationService
.findDevicesWithMissingConfig("firmware_version", 1, 10);
log.info("缺少固件版本配置的设备数: {}", response4.getTotal());
} catch (Exception e) {
log.error("使用便捷方法查找设备失败", e);
}
}
/**
* 示例5: 性能监控查询
* 条件: CPU使用率>80% 或 内存使用率>85%
*/
public void findHighLoadDevices() {
log.info("=== 查找高负载设备 ===");
List<ConfigFilter> configFilters = Arrays.asList(
new ConfigFilter("cpu_usage", 80, "gt"),
new ConfigFilter("memory_usage", 85, "gt")
);
FilterDevicesByConfigsRequest request = new FilterDevicesByConfigsRequest();
request.setConfigFilters(configFilters);
request.setFilterLogic("OR");
request.setPageSize(50);
try {
FilterDevicesByConfigsResponse response = deviceIntegrationService.filterDevicesByConfigs(request);
log.info("找到 {} 个高负载设备", response.getTotal());
response.getList().forEach(device -> {
Map<String, Object> config = device.getConfig();
Object cpuUsage = config.get("cpu_usage");
Object memoryUsage = config.get("memory_usage");
log.info("高负载设备: {} ({}), CPU: {}%, 内存: {}%",
device.getName(), device.getNo(), cpuUsage, memoryUsage);
});
} catch (Exception e) {
log.error("查找高负载设备失败", e);
}
}
/**
* 运行所有示例
*/
public void runAllExamples() {
log.info("开始运行设备配置筛选示例...");
findHighQualityOutdoorDevices();
findDevicesWithMissingConfigs();
findDevicesByMultipleLocations();
useConvenienceMethods();
findHighLoadDevices();
log.info("所有示例运行完成");
}
}

View File

@@ -1,185 +0,0 @@
package com.ycwl.basic.integration.device.example;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.device.dto.device.*;
import com.ycwl.basic.integration.device.dto.config.*;
import com.ycwl.basic.integration.device.service.DeviceConfigIntegrationService;
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* Device Integration 使用示例
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceIntegrationExample {
private final DeviceIntegrationService deviceService;
private final DeviceConfigIntegrationService deviceConfigService;
/**
* 基本设备操作
*/
public void basicDeviceOperations() {
log.info("=== 基本设备操作 ===");
// 创建IPC摄像头设备(默认排序)
DeviceV2DTO ipcDevice = deviceService.createIpcDevice(
"前门摄像头", "CAM001", 1001L);
log.info("创建IPC设备: {}, 排序值: {}", ipcDevice.getName(), ipcDevice.getSort());
// 根据ID获取设备信息
DeviceV2DTO device = deviceService.getDevice(ipcDevice.getId());
log.info("获取设备信息: {}, 排序值: {}", device.getName(), device.getSort());
// 根据设备编号获取设备信息
DeviceV2DTO deviceByNo = deviceService.getDeviceByNo("CAM001");
log.info("根据编号获取设备: {}", deviceByNo.getName());
// 获取设备详细信息(含配置)
DeviceV2WithConfigDTO deviceWithConfig = deviceService.getDeviceWithConfig(ipcDevice.getId());
log.info("获取设备配置: {}", deviceWithConfig.getName());
// 分页查询景区设备列表
PageResponse<DeviceV2DTO> deviceList = deviceService.getScenicIpcDevices(1001L, 1, 10);
log.info("景区设备列表: 总数={}", deviceList.getTotal());
// 启用设备
deviceService.enableDevice(ipcDevice.getId());
log.info("设备已启用");
}
/**
* 设备排序功能演示
*/
public void deviceSortingOperations() {
log.info("=== 设备排序功能演示 ===");
Long scenicId = 1001L;
// 创建带排序的设备
DeviceV2DTO camera1 = deviceService.createIpcDeviceWithSort(
"大门摄像头", "CAM_GATE", scenicId, 10);
log.info("创建摄像头1: {}, 排序: {}", camera1.getName(), camera1.getSort());
DeviceV2DTO camera2 = deviceService.createIpcDeviceWithSort(
"后门摄像头", "CAM_BACK", scenicId, 20);
log.info("创建摄像头2: {}, 排序: {}", camera2.getName(), camera2.getSort());
DeviceV2DTO sensor1 = deviceService.createCustomDeviceWithSort(
"温度传感器", "TEMP_01", scenicId, 5);
log.info("创建传感器: {}, 排序: {}", sensor1.getName(), sensor1.getSort());
// 更新设备排序
deviceService.updateDeviceSort(camera1.getId(), 1);
log.info("更新摄像头1排序为1(置顶)");
// 获取排序后的设备列表
PageResponse<DeviceV2DTO> sortedList = deviceService.listDevices(1, 10, null, null, null, 1, scenicId);
log.info("排序后的设备列表:");
for (DeviceV2DTO device : sortedList.getList()) {
log.info(" - {}: 排序={}, 类型={}", device.getName(), device.getSort(), device.getType());
}
// 批量调整排序演示
log.info("--- 批量调整排序演示 ---");
deviceService.updateDeviceSort(sensor1.getId(), 15); // 传感器排到中间
deviceService.updateDeviceSort(camera2.getId(), 30); // 后门摄像头排到最后
log.info("批量排序调整完成");
}
/**
* 设备配置管理
*/
public void deviceConfigurationOperations() {
log.info("=== 设备配置管理 ===");
Long deviceId = 1L;
// 获取设备所有配置
List<DeviceConfigV2DTO> configs = deviceConfigService.getDeviceConfigs(deviceId);
log.info("设备配置数量: {}", configs.size());
// 获取扁平化配置
Map<String, Object> flatConfig = deviceConfigService.getDeviceFlatConfig(deviceId);
log.info("扁平化配置项数: {}", flatConfig.size());
// 使用批量配置API
BatchDeviceConfigRequest builderRequest = deviceConfigService.createBatchConfigBuilder()
.build();
BatchUpdateResponse result = deviceConfigService.batchUpdateDeviceConfig(deviceId, builderRequest);
log.info("批量配置更新结果: 成功={}, 失败={}", result.getSuccess(), result.getFailed());
}
/**
* 排序最佳实践演示
*/
public void sortingBestPractices() {
log.info("=== 排序最佳实践演示 ===");
Long scenicId = 1001L;
// 推荐使用10的倍数作为排序值
DeviceV2DTO device1 = deviceService.createIpcDeviceWithSort(
"重要摄像头", "CAM_IMPORTANT", scenicId, 10);
DeviceV2DTO device2 = deviceService.createIpcDeviceWithSort(
"普通摄像头", "CAM_NORMAL", scenicId, 20);
DeviceV2DTO device3 = deviceService.createIpcDeviceWithSort(
"备用摄像头", "CAM_BACKUP", scenicId, 30);
log.info("使用10的倍数创建设备排序: 10, 20, 30");
// 在中间插入新设备
DeviceV2DTO insertDevice = deviceService.createIpcDeviceWithSort(
"中间摄像头", "CAM_MIDDLE", scenicId, 25);
log.info("在20和30之间插入设备,排序值: 25");
// 置顶操作
deviceService.updateDeviceSort(device2.getId(), 1);
log.info("将普通摄像头置顶(排序值: 1)");
// 查看最终排序结果
PageResponse<DeviceV2DTO> finalList = deviceService.listDevices(1, 10, null, null, null, 1, scenicId);
log.info("最终排序结果:");
for (DeviceV2DTO device : finalList.getList()) {
log.info(" - {}: 排序={}", device.getName(), device.getSort());
}
}
/**
* 运行所有示例
*/
public void runAllExamples() {
try {
basicDeviceOperations();
deviceSortingOperations();
sortingBestPractices();
deviceConfigurationOperations();
log.info("=== 所有示例执行完成 ===");
} catch (Exception e) {
log.error("示例执行过程中发生错误", e);
}
}
/**
* 运行基础示例(简化版)
*/
public void runBasicExamples() {
try {
basicDeviceOperations();
deviceConfigurationOperations();
log.info("=== 基础示例执行完成 ===");
} catch (Exception e) {
log.error("示例执行过程中发生错误", e);
}
}
}

View File

@@ -1,124 +0,0 @@
package com.ycwl.basic.integration.device.example;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
import com.ycwl.basic.integration.device.service.DeviceConfigIntegrationService;
import com.ycwl.basic.integration.device.dto.device.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 设备集成示例(包含降级机制)
* 演示设备集成和失败降级策略的使用
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DeviceIntegrationFallbackExample {
private final DeviceIntegrationService deviceService;
private final DeviceConfigIntegrationService configService;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-device";
/**
* 演示设备信息获取的降级机制
*/
public void deviceInfoFallbackExample() {
log.info("=== 设备信息获取降级示例 ===");
Long deviceId = 1001L;
String deviceNo = "CAM001";
try {
// 获取设备信息 - 自动降级
DeviceV2DTO device = deviceService.getDevice(deviceId);
log.info("获取设备成功: {}", device.getName());
// 根据设备号获取设备 - 自动降级
DeviceV2DTO deviceByNo = deviceService.getDeviceByNo(deviceNo);
log.info("根据设备号获取设备成功: {}", deviceByNo.getName());
// 获取设备配置 - 自动降级
DeviceV2WithConfigDTO deviceWithConfig = deviceService.getDeviceWithConfig(deviceId);
log.info("获取设备配置成功,配置数量: {}", deviceWithConfig.getConfig().size());
} catch (Exception e) {
log.error("所有降级策略失败", e);
}
}
/**
* 演示设备操作(无降级机制)
*/
public void deviceOperationExample() {
log.info("=== 设备操作示例 ===");
Long deviceId = 1001L;
try {
// 设备更新操作 - 直接操作,失败时抛出异常
UpdateDeviceRequest updateRequest = new UpdateDeviceRequest();
updateRequest.setName("更新后的摄像头");
deviceService.updateDevice(deviceId, updateRequest);
log.info("设备更新操作完成");
// 设备排序更新 - 直接操作,失败时抛出异常
deviceService.updateDeviceSort(deviceId, 5);
log.info("设备排序更新完成");
} catch (Exception e) {
log.error("设备操作失败", e);
}
}
/**
* 演示降级缓存管理
*/
public void fallbackCacheManagementExample() {
log.info("=== 降级缓存管理示例 ===");
String deviceCacheKey = "device:1001";
String configCacheKey = "device:flat:config:1001";
// 检查降级缓存状态
boolean hasDeviceCache = fallbackService.hasFallbackCache(SERVICE_NAME, deviceCacheKey);
boolean hasConfigCache = fallbackService.hasFallbackCache(SERVICE_NAME, configCacheKey);
log.info("设备降级缓存存在: {}", hasDeviceCache);
log.info("配置降级缓存存在: {}", hasConfigCache);
// 清理特定的降级缓存
if (hasDeviceCache) {
fallbackService.clearFallbackCache(SERVICE_NAME, deviceCacheKey);
log.info("已清理设备降级缓存");
}
// 获取降级缓存统计信息
IntegrationFallbackService.FallbackCacheStats stats = fallbackService.getFallbackCacheStats(SERVICE_NAME);
log.info("设备服务降级缓存统计: {}", stats);
// 批量清理所有设备降级缓存
if (stats.getTotalCacheCount() > 10) {
fallbackService.clearAllFallbackCache(SERVICE_NAME);
log.info("已批量清理所有设备降级缓存");
}
}
/**
* 运行所有示例
*/
public void runAllExamples() {
log.info("开始运行设备集成示例...");
deviceInfoFallbackExample();
deviceOperationExample();
fallbackCacheManagementExample();
log.info("设备集成示例运行完成");
}
}

View File

@@ -39,31 +39,6 @@ public class DeviceConfigIntegrationService {
return handleResponse(response, "根据键获取设备配置失败"); return handleResponse(response, "根据键获取设备配置失败");
} }
public Map<String, Object> getDeviceFlatConfig(Long deviceId) {
return fallbackService.executeWithFallback(
SERVICE_NAME,
"device:flat:config:" + deviceId,
() -> {
CommonResponse<Map<String, Object>> response = deviceConfigV2Client.getDeviceFlatConfig(deviceId);
return handleResponse(response, "获取设备扁平化配置失败");
},
Map.class
);
}
public Map<String, Object> getDeviceFlatConfigByNo(String deviceNo) {
log.debug("根据设备编号获取扁平化配置, deviceNo: {}", deviceNo);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"device:flat:config:no:" + deviceNo,
() -> {
CommonResponse<Map<String, Object>> response = deviceConfigV2Client.getDeviceFlatConfigByNo(deviceNo);
return handleResponse(response, "根据设备编号获取扁平化配置失败");
},
Map.class
);
}
public DeviceConfigV2DTO createDeviceConfig(Long deviceId, CreateDeviceConfigRequest request) { public DeviceConfigV2DTO createDeviceConfig(Long deviceId, CreateDeviceConfigRequest request) {
log.debug("创建设备配置, deviceId: {}, configKey: {}", deviceId, request.getConfigKey()); log.debug("创建设备配置, deviceId: {}, configKey: {}", deviceId, request.getConfigKey());
CommonResponse<DeviceConfigV2DTO> response = deviceConfigV2Client.createDeviceConfig(deviceId, request); CommonResponse<DeviceConfigV2DTO> response = deviceConfigV2Client.createDeviceConfig(deviceId, request);

View File

@@ -48,32 +48,6 @@ public class DeviceIntegrationService {
); );
} }
public DeviceV2WithConfigDTO getDeviceWithConfig(Long deviceId) {
log.debug("获取设备配置信息, deviceId: {}", deviceId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"device:config:" + deviceId,
() -> {
CommonResponse<DeviceV2WithConfigDTO> response = deviceV2Client.getDeviceWithConfig(deviceId);
return handleResponse(response, "获取设备配置信息失败");
},
DeviceV2WithConfigDTO.class
);
}
public DeviceV2WithConfigDTO getDeviceWithConfigByNo(String deviceNo) {
log.debug("根据设备编号获取设备配置信息, deviceNo: {}", deviceNo);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"device:config:no:" + deviceNo,
() -> {
CommonResponse<DeviceV2WithConfigDTO> response = deviceV2Client.getDeviceByNoWithConfig(deviceNo);
return handleResponse(response, "根据设备编号获取设备配置信息失败");
},
DeviceV2WithConfigDTO.class
);
}
public DeviceV2DTO createDevice(CreateDeviceRequest request) { public DeviceV2DTO createDevice(CreateDeviceRequest request) {
log.debug("创建设备, name: {}, no: {}, type: {}", request.getName(), request.getNo(), request.getType()); log.debug("创建设备, name: {}, no: {}, type: {}", request.getName(), request.getNo(), request.getType());
CommonResponse<DeviceV2DTO> response = deviceV2Client.createDevice(request); CommonResponse<DeviceV2DTO> response = deviceV2Client.createDevice(request);
@@ -101,15 +75,6 @@ public class DeviceIntegrationService {
return handleResponse(response, "分页查询设备列表失败"); return handleResponse(response, "分页查询设备列表失败");
} }
public PageResponse<DeviceV2WithConfigDTO> listDevicesWithConfig(Integer page, Integer pageSize, String name, String no,
String type, Integer isActive, Long scenicId) {
log.debug("分页查询设备带配置列表, page: {}, pageSize: {}, name: {}, no: {}, type: {}, isActive: {}, scenicId: {}",
page, pageSize, name, no, type, isActive, scenicId);
CommonResponse<PageResponse<DeviceV2WithConfigDTO>> response = deviceV2Client.listDevicesWithConfig(
page, pageSize, name, no, type, isActive, scenicId);
return handleResponse(response, "分页查询设备带配置列表失败");
}
/** /**
* 创建IPC摄像头设备 * 创建IPC摄像头设备
*/ */

View File

@@ -0,0 +1,211 @@
package com.ycwl.basic.integration.device.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.device.client.DeviceStatusClient;
import com.ycwl.basic.integration.device.dto.status.DeviceStatusDTO;
import com.ycwl.basic.integration.device.dto.status.OnlineStatusResponseDTO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class DeviceStatusIntegrationService {
private final DeviceStatusClient deviceStatusClient;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-device";
public DeviceStatusDTO getDeviceStatus(String deviceNo) {
log.debug("获取设备状态, deviceNo: {}", deviceNo);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"device:status:" + deviceNo,
() -> {
CommonResponse<DeviceStatusDTO> response = deviceStatusClient.getDeviceStatus(deviceNo);
return handleResponse(response, "获取设备状态失败");
},
DeviceStatusDTO.class
);
}
public OnlineStatusResponseDTO isDeviceOnline(String deviceNo) {
log.debug("检查设备是否在线, deviceNo: {}", deviceNo);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"device:online:" + deviceNo,
() -> {
CommonResponse<OnlineStatusResponseDTO> response = deviceStatusClient.isDeviceOnline(deviceNo);
return handleResponse(response, "检查设备在线状态失败");
},
OnlineStatusResponseDTO.class
);
}
public List<DeviceStatusDTO> getAllOnlineDevices() {
log.debug("获取所有在线设备");
return fallbackService.executeWithFallback(
SERVICE_NAME,
"devices:online:all",
() -> {
CommonResponse<List<DeviceStatusDTO>> response = deviceStatusClient.getAllOnlineDevices();
return handleResponse(response, "获取所有在线设备失败");
},
List.class
);
}
public void setDeviceOffline(String deviceNo) {
log.debug("设置设备离线, deviceNo: {}", deviceNo);
CommonResponse<String> response = deviceStatusClient.setDeviceOffline(deviceNo);
handleResponse(response, "设置设备离线失败");
}
public void setDeviceOnline(String deviceNo) {
log.debug("设置设备在线, deviceNo: {}", deviceNo);
CommonResponse<String> response = deviceStatusClient.setDeviceOnline(deviceNo);
handleResponse(response, "设置设备在线失败");
}
public void cleanExpiredDevices() {
log.debug("清理过期设备状态");
CommonResponse<String> response = deviceStatusClient.cleanExpiredDevices();
handleResponse(response, "清理过期设备状态失败");
}
/**
* 安全地获取设备状态
*/
public Optional<DeviceStatusDTO> getDeviceStatusSafely(String deviceNo) {
try {
DeviceStatusDTO status = getDeviceStatus(deviceNo);
return Optional.ofNullable(status);
} catch (Exception e) {
log.warn("获取设备状态异常: deviceNo={}, error={}", deviceNo, e.getMessage());
return Optional.empty();
}
}
/**
* 安全地检查设备是否在线
*/
public boolean isDeviceOnlineSafely(String deviceNo) {
try {
OnlineStatusResponseDTO response = isDeviceOnline(deviceNo);
return response != null && Boolean.TRUE.equals(response.getIsOnline());
} catch (Exception e) {
log.warn("检查设备在线状态异常: deviceNo={}, error={}", deviceNo, e.getMessage());
return false;
}
}
/**
* 批量检查设备是否在线
*/
public boolean areAllDevicesOnline(List<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return true;
}
log.debug("批量检查设备在线状态, deviceNos: {}", deviceNos);
for (String deviceNo : deviceNos) {
if (!isDeviceOnlineSafely(deviceNo)) {
return false;
}
}
return true;
}
/**
* 批量设置设备离线
*/
public void setDevicesOffline(List<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return;
}
log.debug("批量设置设备离线, deviceNos: {}", deviceNos);
for (String deviceNo : deviceNos) {
try {
setDeviceOffline(deviceNo);
} catch (Exception e) {
log.error("设置设备离线失败: deviceNo={}, error={}", deviceNo, e.getMessage());
}
}
}
/**
* 批量设置设备在线
*/
public void setDevicesOnline(List<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return;
}
log.debug("批量设置设备在线, deviceNos: {}", deviceNos);
for (String deviceNo : deviceNos) {
try {
setDeviceOnline(deviceNo);
} catch (Exception e) {
log.error("设置设备在线失败: deviceNo={}, error={}", deviceNo, e.getMessage());
}
}
}
/**
* 获取在线设备数量
*/
public int getOnlineDeviceCount() {
try {
List<DeviceStatusDTO> onlineDevices = getAllOnlineDevices();
return onlineDevices != null ? onlineDevices.size() : 0;
} catch (Exception e) {
log.warn("获取在线设备数量失败: {}", e.getMessage());
return 0;
}
}
/**
* 获取指定设备编号列表中的在线设备
*/
public List<String> getOnlineDeviceNos(List<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return List.of();
}
return deviceNos.stream()
.filter(this::isDeviceOnlineSafely)
.toList();
}
/**
* 获取指定设备编号列表中的离线设备
*/
public List<String> getOfflineDeviceNos(List<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return List.of();
}
return deviceNos.stream()
.filter(deviceNo -> !isDeviceOnlineSafely(deviceNo))
.toList();
}
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
if (response == null || !response.isSuccess()) {
String msg = response != null && response.getMessage() != null
? response.getMessage()
: errorMessage;
Integer code = response != null ? response.getCode() : 5000;
throw new IntegrationException(code, msg, SERVICE_NAME);
}
return response.getData();
}
}

View File

@@ -0,0 +1,49 @@
package com.ycwl.basic.integration.kafka.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* 人脸识别异步处理线程池配置
*/
@Slf4j
@Configuration
@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true")
public class FaceRecognitionThreadPoolConfig {
/**
* 创建人脸识别专用线程池
* - 核心线程数:32
* - 最大线程数:128
* - 队列容量:1000(避免无限制增长)
* - 拒绝策略:CallerRunsPolicy(调用者线程执行)
*/
@Bean(name = "faceRecognitionExecutor", destroyMethod = "shutdown")
public ThreadPoolExecutor faceRecognitionExecutor() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
32, // 核心线程数
128, // 最大线程数
60L, // 空闲线程存活时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(1000), // 任务队列
r -> {
Thread thread = new Thread(r);
thread.setName("face-recognition-" + thread.getId());
thread.setDaemon(false);
return thread;
},
new ThreadPoolExecutor.CallerRunsPolicy() // 超过容量时由调用者线程执行
);
log.info("人脸识别线程池初始化完成 - 核心线程数: {}, 最大线程数: {}, 队列容量: 1000",
executor.getCorePoolSize(), executor.getMaximumPoolSize());
return executor;
}
}

View File

@@ -0,0 +1,36 @@
package com.ycwl.basic.integration.kafka.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "kafka")
public class KafkaIntegrationProperties {
private boolean enabled = false;
private String bootstrapServers = "100.64.0.12:39092";
private String ztMessageTopic = "zt-message"; // topic for zt-message microservice
private Consumer consumer = new Consumer();
private Producer producer = new Producer();
@Data
public static class Consumer {
private String groupId = "liuying-microservice";
private String autoOffsetReset = "earliest";
private String keyDeserializer = "org.apache.kafka.common.serialization.StringDeserializer";
private String valueDeserializer = "org.apache.kafka.common.serialization.StringDeserializer";
}
@Data
public static class Producer {
private String acks = "all";
private Integer retries = 3;
private Integer batchSize = 16384;
private Integer lingerMs = 1;
private Integer bufferMemory = 33554432;
private String keySerializer = "org.apache.kafka.common.serialization.StringSerializer";
private String valueSerializer = "org.apache.kafka.common.serialization.StringSerializer";
}
}

View File

@@ -0,0 +1,83 @@
package com.ycwl.basic.integration.kafka.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
/**
* zt-face topic消息结构
* 用于人脸处理任务的异步消息传递
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FaceProcessingMessage {
/**
* 人脸样本ID(外部系统传入)
*/
private Long faceSampleId;
/**
* 景区ID
*/
private Long scenicId;
/**
* 设备ID
*/
private Long deviceId;
/**
* 人脸图片URL
*/
private String faceUrl;
// 不再需要faceUniqueId,直接使用faceSampleId作为唯一标识
/**
* 拍摄时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date shotTime;
// status字段已移除,由系统内部管理状态
// deviceConfig字段已移除,由系统内部通过deviceRepository查询
/**
* 消息创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/**
* 消息来源
*/
private String source;
// retryCount字段已移除,重试机制由系统内部管理
// DeviceConfig内部类已移除,不再需要在消息中传递设备配置
/**
* 创建人脸处理消息的工厂方法(使用外部传入的faceId)
*/
public static FaceProcessingMessage create(Long externalFaceId, Long scenicId, Long deviceId,
String faceUrl, Date shotTime) {
return FaceProcessingMessage.builder()
.faceSampleId(externalFaceId) // 使用外部传入的ID作为唯一标识
.scenicId(scenicId)
.deviceId(deviceId)
.faceUrl(faceUrl)
.shotTime(shotTime)
.createTime(new Date())
.source("external-system")
.build();
}
}

View File

@@ -0,0 +1,38 @@
package com.ycwl.basic.integration.kafka.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KafkaMessage<T> {
private String messageId;
private String topic;
private String eventType;
private T payload;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timestamp;
private String source;
private String version;
public static <T> KafkaMessage<T> of(String topic, String eventType, T payload) {
return KafkaMessage.<T>builder()
.topic(topic)
.eventType(eventType)
.payload(payload)
.timestamp(LocalDateTime.now())
.source("liuying-microservice")
.version("1.0")
.build();
}
}

View File

@@ -0,0 +1,194 @@
package com.ycwl.basic.integration.kafka.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.facebody.entity.AddFaceResp;
import com.ycwl.basic.integration.kafka.dto.FaceProcessingMessage;
import com.ycwl.basic.mapper.FaceSampleMapper;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.service.task.TaskFaceService;
import com.ycwl.basic.task.DynamicTaskGenerator;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
// 不再需要SnowFlakeUtil,使用外部传入的ID
import com.ycwl.basic.utils.JacksonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.stereotype.Service;
import java.time.ZoneId;
import java.util.Date;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 人脸处理Kafka消费服务
* 消费外部系统发送到zt-face topic的消息
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true")
public class FaceProcessingKafkaService {
private static final String ZT_FACE_TOPIC = "zt-face";
private final FaceSampleMapper faceSampleMapper;
private final TaskFaceService taskFaceService;
private final ScenicService scenicService;
private final DeviceRepository deviceRepository;
private final ThreadPoolExecutor faceRecognitionExecutor;
/**
* 消费外部系统发送的人脸处理消息
* 先保存人脸样本数据,再进行异步人脸识别处理
*/
@KafkaListener(topics = ZT_FACE_TOPIC, containerFactory = "manualCommitKafkaListenerContainerFactory")
public void processFaceMessage(String message, Acknowledgment ack) {
try {
FaceProcessingMessage faceMessage = JacksonUtil.parseObject(message, FaceProcessingMessage.class);
log.debug("接收到外部人脸处理消息, scenicId: {}, deviceId: {}, faceUrl: {}",
faceMessage.getScenicId(), faceMessage.getDeviceId(), faceMessage.getFaceUrl());
// 使用外部传入的faceSampleId
Long externalFaceId = faceMessage.getFaceSampleId();
if (externalFaceId == null) {
log.error("外部消息中未包含faceSampleId");
// 即使消息格式错误,也消费消息避免重复处理
ack.acknowledge();
return;
}
// 先保存人脸样本数据
boolean saved = saveFaceSample(faceMessage, externalFaceId);
// 然后异步进行人脸识别处理(使用专用线程池)
if (saved) {
faceRecognitionExecutor.execute(() -> processFaceRecognitionAsync(faceMessage));
log.debug("人脸识别任务已提交至线程池, faceSampleId: {}, 活跃线程: {}, 队列大小: {}",
externalFaceId, faceRecognitionExecutor.getActiveCount(),
faceRecognitionExecutor.getQueue().size());
} else {
log.warn("人脸样本保存失败,但消息仍将被消费, faceSampleId: {}", externalFaceId);
}
// 无论处理是否成功,都消费消息
ack.acknowledge();
} catch (Exception e) {
log.error("处理外部人脸消息失败: {}", e.getMessage(), e);
// 即使发生异常也消费消息,避免消息堆积
ack.acknowledge();
}
}
/**
* 保存人脸样本数据到数据库
* @param faceMessage 人脸处理消息
* @param externalFaceId 外部传入的人脸ID
* @return 是否保存成功
*/
private boolean saveFaceSample(FaceProcessingMessage faceMessage, Long externalFaceId) {
try {
FaceSampleEntity faceSample = new FaceSampleEntity();
faceSample.setId(externalFaceId); // 使用外部传入的ID
faceSample.setScenicId(faceMessage.getScenicId());
faceSample.setDeviceId(faceMessage.getDeviceId());
faceSample.setStatus(0); // 初始状态
faceSample.setFaceUrl(faceMessage.getFaceUrl());
// 转换时间格式
if (faceMessage.getShotTime() != null) {
Date shotTime = faceMessage.getShotTime();
faceSample.setCreateAt(shotTime);
} else {
faceSample.setCreateAt(new Date());
}
// 保存到数据库
faceSampleMapper.add(faceSample);
return true;
} catch (Exception e) {
log.error("保存人脸样本数据失败, 外部faceId: {}, scenicId: {}, deviceId: {}",
externalFaceId, faceMessage.getScenicId(), faceMessage.getDeviceId(), e);
return false;
}
}
/**
* 异步执行人脸识别处理逻辑
* 对已保存的人脸样本进行识别处理
*/
private void processFaceRecognitionAsync(FaceProcessingMessage message) {
Long faceSampleId = message.getFaceSampleId();
Long scenicId = message.getScenicId();
String faceUrl = message.getFaceUrl();
// 直接使用faceSampleId作为唯一标识
String faceUniqueId = faceSampleId.toString();
// 获取人脸识别适配器
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(scenicId);
if (faceBodyAdapter == null) {
log.error("人脸识别适配器不存在, scenicId: {}", scenicId);
updateFaceSampleStatus(faceSampleId, -1);
return;
}
try {
// 更新状态为处理中
updateFaceSampleStatus(faceSampleId, 1);
// 确保人脸数据库存在
taskFaceService.assureFaceDb(faceBodyAdapter, scenicId.toString());
// 添加人脸到识别服务(使用faceSampleId作为唯一标识)
AddFaceResp addFaceResp = faceBodyAdapter.addFace(
scenicId.toString(),
faceSampleId.toString(),
faceUrl,
faceUniqueId // 即faceSampleId.toString()
);
if (addFaceResp != null) {
// 更新人脸样本得分和状态
faceSampleMapper.updateScore(faceSampleId, addFaceResp.getScore());
updateFaceSampleStatus(faceSampleId, 2);
log.info("人脸识别处理成功, faceSampleId: {}", faceSampleId);
// 查询设备配置,判断是否启用预订功能
Long deviceId = message.getDeviceId();
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
if (deviceConfig != null &&
Integer.valueOf(1).equals(deviceConfig.getInteger("enable_pre_book"))) {
DynamicTaskGenerator.addTask(faceSampleId);
}
} else {
log.warn("人脸添加返回空结果, faceSampleId: {}", faceSampleId);
updateFaceSampleStatus(faceSampleId, -1);
}
} catch (Exception e) {
log.error("人脸识别处理失败, faceSampleId: {}, error: {}",
faceSampleId, e.getMessage(), e);
// 标记人脸样本为处理失败状态
updateFaceSampleStatus(faceSampleId, -1);
}
}
/**
* 更新人脸样本状态
*/
private void updateFaceSampleStatus(Long faceSampleId, Integer status) {
try {
faceSampleMapper.updateStatus(faceSampleId, status);
} catch (Exception e) {
log.error("更新人脸样本状态失败, faceSampleId: {}", faceSampleId, e);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.ycwl.basic.integration.kafka.service;
import com.ycwl.basic.integration.kafka.config.KafkaIntegrationProperties;
import com.ycwl.basic.integration.kafka.dto.KafkaMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true")
public class KafkaIntegrationService {
private final KafkaTemplate<String, String> kafkaTemplate;
private final KafkaIntegrationProperties kafkaProperties;
/**
* 测试Kafka连接
*/
public boolean testConnection() {
try {
log.info("Testing Kafka connection to: {}", kafkaProperties.getBootstrapServers());
// 尝试获取元数据以测试连接
var metadata = kafkaTemplate.getProducerFactory().createProducer().partitionsFor("test-topic");
log.info("Kafka connection test successful");
return true;
} catch (Exception e) {
log.error("Kafka connection test failed", e);
return false;
}
}
/**
* 发送消息(预留接口,暂不实现具体逻辑)
*/
public void sendMessage(String topic, String key, KafkaMessage<?> message) {
log.info("Kafka message sending is not implemented yet. Topic: {}, Key: {}", topic, key);
// TODO: 后续实现具体的消息发送逻辑
}
/**
* 获取Kafka配置信息
*/
public KafkaIntegrationProperties getKafkaProperties() {
return kafkaProperties;
}
}

View File

@@ -0,0 +1,29 @@
package com.ycwl.basic.integration.message.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.message.dto.ChannelsResponse;
import com.ycwl.basic.integration.message.dto.MessageListData;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "zt-message", contextId = "zt-message", path = "")
public interface MessageClient {
@GetMapping("/messages")
CommonResponse<MessageListData> listMessages(
@RequestParam(name = "page", defaultValue = "1") Integer page,
@RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize,
@RequestParam(name = "channelId", required = false) String channelId,
@RequestParam(name = "title", required = false) String title,
@RequestParam(name = "content", required = false) String content,
@RequestParam(name = "sendBiz", required = false) String sendBiz,
@RequestParam(name = "sentAtStart", required = false) String sentAtStart,
@RequestParam(name = "sentAtEnd", required = false) String sentAtEnd,
@RequestParam(name = "createdAtStart", required = false) String createdAtStart,
@RequestParam(name = "createdAtEnd", required = false) String createdAtEnd
);
@GetMapping("/channels")
CommonResponse<ChannelsResponse> listChannels();
}

View File

@@ -0,0 +1,10 @@
package com.ycwl.basic.integration.message.dto;
import lombok.Data;
import java.util.List;
@Data
public class ChannelsResponse {
private List<String> channels;
}

View File

@@ -0,0 +1,13 @@
package com.ycwl.basic.integration.message.dto;
import lombok.Data;
import java.util.List;
@Data
public class MessageListData {
private List<MessageRecordDTO> list;
private String total; // string to avoid JS precision
private Integer page;
private Integer pageSize;
}

View File

@@ -0,0 +1,25 @@
package com.ycwl.basic.integration.message.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.util.Map;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class MessageRecordDTO {
private String id; // string to avoid JS precision
private String channelId;
private String title;
private String content;
private String target;
private Map<String, Object> extraJson;
private String sendReason;
private String sendBiz;
private String status;
private String errorMsg;
private Integer attempts;
private String sentAt; // RFC3339 or yyyy-MM-dd HH:mm:ss (pass-through)
private String createdAt;
private String updatedAt;
}

View File

@@ -0,0 +1,36 @@
package com.ycwl.basic.integration.message.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ZtMessage {
private String messageId; // unique message identifier
private String channelId; // required
private String title; // required
private String content; // required
private String target; // required
private Map<String, Object> extra; // optional
private String sendReason; // optional
private String sendBiz; // optional
public static ZtMessage of(String channelId, String title, String content, String target) {
return ZtMessage.builder()
.channelId(channelId)
.title(title)
.content(content)
.target(target)
.extra(new HashMap<>())
.build();
}
}

View File

@@ -0,0 +1,57 @@
package com.ycwl.basic.integration.message.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.message.client.MessageClient;
import com.ycwl.basic.integration.message.dto.ChannelsResponse;
import com.ycwl.basic.integration.message.dto.MessageListData;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class MessageIntegrationService {
private final MessageClient client;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-message";
public MessageListData listMessages(Integer page, Integer pageSize,
String channelId, String title, String content, String sendBiz,
String sentAtStart, String sentAtEnd,
String createdAtStart, String createdAtEnd) {
log.debug("查询消息列表 page={}, pageSize={}, channelId={}, title={}, sendBiz={}", page, pageSize, channelId, title, sendBiz);
CommonResponse<MessageListData> resp = client.listMessages(page, pageSize, channelId, title, content, sendBiz,
sentAtStart, sentAtEnd, createdAtStart, createdAtEnd);
return handleResponse(resp, "查询消息列表失败");
}
public ChannelsResponse listChannels() {
log.debug("查询消息通道列表");
// 相对稳定的数据,使用fallback缓存
return fallbackService.executeWithFallback(
SERVICE_NAME,
"channels",
() -> {
CommonResponse<ChannelsResponse> resp = client.listChannels();
return handleResponse(resp, "查询通道列表失败");
},
ChannelsResponse.class
);
}
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
if (response == null || !response.isSuccess()) {
String msg = response != null && response.getMessage() != null
? response.getMessage()
: errorMessage;
Integer code = response != null ? response.getCode() : 5000;
throw new IntegrationException(code, msg, SERVICE_NAME);
}
return response.getData();
}
}

View File

@@ -0,0 +1,52 @@
package com.ycwl.basic.integration.message.service;
import com.ycwl.basic.integration.message.dto.ZtMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
/**
* ZT消息生产者空实现服务
* <p>
* 当 kafka.enabled=false 时,该服务会被激活,作为 ZtMessageProducerService 的替代。
* 所有消息发送操作都会被忽略,只记录日志。
* </p>
*
* @see ZtMessageProducerService
*/
@Slf4j
@Service
@ConditionalOnProperty(name = "kafka.enabled", havingValue = "false", matchIfMissing = true)
public class ZtMessageProducerNoOpService extends ZtMessageProducerService {
/**
* 空构造函数
* 由于父类需要依赖项,但在此实现中不会使用,因此传入 null
*/
public ZtMessageProducerNoOpService() {
super(null, null, null);
}
/**
* 消息发送的空操作实现
* <p>
* 当 Kafka 未启用时,此方法会被调用。
* 它不会实际发送消息,只会记录一条 debug 日志。
* </p>
*
* @param msg 待发送的消息(会被验证基本字段)
*/
@Override
public void send(ZtMessage msg) {
if (msg == null) {
log.debug("[ZT-MESSAGE] Kafka未启用,跳过消息发送(消息为null)");
return;
}
log.debug("[ZT-MESSAGE] Kafka未启用,跳过消息发送: channelId={}, title={}, target={}, messageId={}",
msg.getChannelId(),
msg.getTitle(),
msg.getTarget(),
msg.getMessageId());
}
}

View File

@@ -0,0 +1,70 @@
package com.ycwl.basic.integration.message.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.integration.kafka.config.KafkaIntegrationProperties;
import com.ycwl.basic.integration.message.dto.ZtMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true")
public class ZtMessageProducerService {
public static final String DEFAULT_TOPIC = "zt-message";
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
private final KafkaIntegrationProperties kafkaProps;
public void send(ZtMessage msg) {
validate(msg);
// Generate messageId if not present
if (StringUtils.isBlank(msg.getMessageId())) {
msg.setMessageId(java.util.UUID.randomUUID().toString());
}
String topic = kafkaProps != null && StringUtils.isNotBlank(kafkaProps.getZtMessageTopic())
? kafkaProps.getZtMessageTopic()
: DEFAULT_TOPIC;
String key = msg.getChannelId();
String payload = toJson(msg);
log.info("[ZT-MESSAGE] producing to topic={}, key={}, messageId={}, title={}", topic, key, msg.getMessageId(), msg.getTitle());
kafkaTemplate.send(topic, key, payload).whenComplete((metadata, ex) -> {
if (ex != null) {
log.error("[ZT-MESSAGE] produce failed: messageId={}, error={}", msg.getMessageId(), ex.getMessage(), ex);
} else if (metadata != null) {
log.info("[ZT-MESSAGE] produced: messageId={}, partition={}, offset={}", msg.getMessageId(), metadata.getRecordMetadata().partition(), metadata.getRecordMetadata().offset());
}
});
}
private void validate(ZtMessage msg) {
if (msg == null) throw new IllegalArgumentException("message is null");
if (StringUtils.isBlank(msg.getChannelId())) throw new IllegalArgumentException("channelId is required");
if (StringUtils.isBlank(msg.getTitle())) throw new IllegalArgumentException("title is required");
if (StringUtils.isBlank(msg.getContent())) throw new IllegalArgumentException("content is required");
if (StringUtils.isBlank(msg.getTarget())) throw new IllegalArgumentException("target is required");
if (msg.getExtra() != null && !(msg.getExtra() instanceof Map)) {
throw new IllegalArgumentException("extra must be a Map");
}
}
private String toJson(ZtMessage msg) {
try {
return objectMapper.writeValueAsString(msg);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("failed to serialize message", e);
}
}
}

View File

@@ -0,0 +1,118 @@
package com.ycwl.basic.integration.questionnaire.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.questionnaire.dto.answer.ResponseDetailResponse;
import com.ycwl.basic.integration.questionnaire.dto.answer.SubmitAnswerRequest;
import com.ycwl.basic.integration.questionnaire.dto.questionnaire.CreateQuestionnaireRequest;
import com.ycwl.basic.integration.questionnaire.dto.questionnaire.QuestionnaireResponse;
import com.ycwl.basic.integration.questionnaire.dto.statistics.QuestionnaireStatistics;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name = "zt-questionnaire", contextId = "questionnaire", path = "/api")
public interface QuestionnaireClient {
// ==================== 问卷管理接口 ====================
/**
* 创建问卷
*/
@PostMapping("/questionnaires")
CommonResponse<QuestionnaireResponse> createQuestionnaire(
@RequestBody CreateQuestionnaireRequest request,
@RequestHeader("X-User-ID") String userId
);
/**
* 获取问卷详情
*/
@GetMapping("/questionnaires/{id}")
CommonResponse<QuestionnaireResponse> getQuestionnaire(@PathVariable("id") Long id);
/**
* 获取问卷列表
*/
@GetMapping("/questionnaires")
CommonResponse<PageResponse<QuestionnaireResponse>> getQuestionnaireList(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String name,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String createdBy
);
/**
* 更新问卷
*/
@PutMapping("/questionnaires/{id}")
CommonResponse<QuestionnaireResponse> updateQuestionnaire(
@PathVariable("id") Long id,
@RequestBody CreateQuestionnaireRequest request,
@RequestHeader("X-User-ID") String userId
);
/**
* 删除问卷
*/
@DeleteMapping("/questionnaires/{id}")
CommonResponse<Void> deleteQuestionnaire(
@PathVariable("id") Long id,
@RequestHeader("X-User-ID") String userId
);
/**
* 发布问卷
*/
@PostMapping("/questionnaires/{id}/publish")
CommonResponse<QuestionnaireResponse> publishQuestionnaire(
@PathVariable("id") Long id,
@RequestHeader("X-User-ID") String userId
);
/**
* 停止问卷
*/
@PostMapping("/questionnaires/{id}/stop")
CommonResponse<QuestionnaireResponse> stopQuestionnaire(
@PathVariable("id") Long id,
@RequestHeader("X-User-ID") String userId
);
// ==================== 答案提交接口 ====================
/**
* 提交问卷答案
*/
@PostMapping("/questionnaires/submit")
CommonResponse<ResponseDetailResponse> submitAnswer(@RequestBody SubmitAnswerRequest request);
// ==================== 统计分析接口 ====================
/**
* 获取问卷统计
*/
@GetMapping("/questionnaires/{id}/statistics")
CommonResponse<QuestionnaireStatistics> getStatistics(@PathVariable("id") Long id);
/**
* 获取回答记录列表
*/
@GetMapping("/responses")
CommonResponse<PageResponse<ResponseDetailResponse>> getResponseList(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Long questionnaireId,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime
);
/**
* 获取回答详情
*/
@GetMapping("/responses/{id}")
CommonResponse<ResponseDetailResponse> getResponseDetail(@PathVariable("id") Long id);
}

View File

@@ -0,0 +1,14 @@
package com.ycwl.basic.integration.questionnaire.config;
import com.ycwl.basic.integration.common.config.IntegrationProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
@Configuration
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "integration.questionnaire", name = "enabled", havingValue = "true", matchIfMissing = true)
public class QuestionnaireIntegrationConfig {
private final IntegrationProperties integrationProperties;
}

View File

@@ -0,0 +1,20 @@
package com.ycwl.basic.integration.questionnaire.dto.answer;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class AnswerDetailResponse {
@JsonProperty("questionId")
private Long questionId;
@JsonProperty("questionTitle")
private String questionTitle;
@JsonProperty("questionType")
private Integer questionType;
@JsonProperty("answer")
private String answer;
}

View File

@@ -0,0 +1,23 @@
package com.ycwl.basic.integration.questionnaire.dto.answer;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AnswerRequest {
@NotNull(message = "问题ID不能为空")
@JsonProperty("questionId")
private Long questionId;
@NotBlank(message = "答案内容不能为空")
@JsonProperty("answer")
private String answer;
}

View File

@@ -0,0 +1,28 @@
package com.ycwl.basic.integration.questionnaire.dto.answer;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
public class ResponseDetailResponse {
@JsonProperty("id")
private Long id;
@JsonProperty("questionnaireId")
private Long questionnaireId;
@JsonProperty("userId")
private String userId;
@JsonProperty("ipAddress")
private String ipAddress;
@JsonProperty("submittedAt")
private String submittedAt;
@JsonProperty("answers")
private List<AnswerDetailResponse> answers;
}

View File

@@ -0,0 +1,24 @@
package com.ycwl.basic.integration.questionnaire.dto.answer;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
@Data
public class SubmitAnswerRequest {
@JsonProperty("questionnaireId")
private Long questionnaireId;
@JsonProperty("userId")
private String userId; // 可选,用于非匿名问卷
@NotEmpty(message = "答案不能为空")
@Valid
@JsonProperty("answers")
private List<AnswerRequest> answers;
}

View File

@@ -0,0 +1,28 @@
package com.ycwl.basic.integration.questionnaire.dto.question;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateQuestionOptionRequest {
@NotBlank(message = "选项文本不能为空")
@Size(max = 500, message = "选项文本长度不能超过500字符")
@JsonProperty("text")
private String text;
@NotBlank(message = "选项值不能为空")
@Size(max = 100, message = "选项值长度不能超过100字符")
@JsonProperty("value")
private String value;
@JsonProperty("sort")
private Integer sort = 0;
}

View File

@@ -0,0 +1,39 @@
package com.ycwl.basic.integration.questionnaire.dto.question;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
@Data
public class CreateQuestionRequest {
@JsonProperty("id")
private Long id;
@NotBlank(message = "问题标题不能为空")
@Size(max = 500, message = "问题标题长度不能超过500字符")
@JsonProperty("title")
private String title;
@NotNull(message = "问题类型不能为空")
@Min(value = 1, message = "问题类型无效")
@Max(value = 5, message = "问题类型无效")
@JsonProperty("type")
private Integer type; // 1:单选 2:多选 3:填空 4:文本域 5:评分
@JsonProperty("isRequired")
private Boolean isRequired = false;
@JsonProperty("sort")
private Integer sort = 0;
@Valid
@JsonProperty("options")
private List<CreateQuestionOptionRequest> options;
}

View File

@@ -0,0 +1,26 @@
package com.ycwl.basic.integration.questionnaire.dto.question;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class QuestionOptionResponse {
@JsonProperty("id")
private Long id;
@JsonProperty("text")
private String text;
@JsonProperty("value")
private String value;
@JsonProperty("sort")
private Integer sort;
@JsonProperty("createdAt")
private String createdAt;
@JsonProperty("updatedAt")
private String updatedAt;
}

View File

@@ -0,0 +1,37 @@
package com.ycwl.basic.integration.questionnaire.dto.question;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
@Data
public class QuestionResponse {
@JsonProperty("id")
private Long id;
@JsonProperty("title")
private String title;
@JsonProperty("type")
private Integer type;
@JsonProperty("typeText")
private String typeText;
@JsonProperty("isRequired")
private Boolean isRequired;
@JsonProperty("sort")
private Integer sort;
@JsonProperty("createdAt")
private String createdAt;
@JsonProperty("updatedAt")
private String updatedAt;
@JsonProperty("options")
private List<QuestionOptionResponse> options;
}

View File

@@ -0,0 +1,38 @@
package com.ycwl.basic.integration.questionnaire.dto.questionnaire;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ycwl.basic.integration.questionnaire.dto.question.CreateQuestionRequest;
import lombok.Data;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
@Data
public class CreateQuestionnaireRequest {
@NotBlank(message = "问卷名称不能为空")
@Size(max = 255, message = "问卷名称长度不能超过255字符")
@JsonProperty("name")
private String name;
@JsonProperty("description")
private String description;
@JsonProperty("startTime")
private String startTime; // 格式: "2024-01-01 00:00:00"
@JsonProperty("endTime")
private String endTime; // 格式: "2024-12-31 23:59:59"
@JsonProperty("isAnonymous")
private Boolean isAnonymous = true;
@JsonProperty("maxAnswers")
private Integer maxAnswers = 0;
@Valid
@JsonProperty("questions")
private List<CreateQuestionRequest> questions;
}

View File

@@ -0,0 +1,54 @@
package com.ycwl.basic.integration.questionnaire.dto.questionnaire;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ycwl.basic.integration.questionnaire.dto.question.QuestionResponse;
import com.ycwl.basic.integration.questionnaire.dto.statistics.QuestionnaireStatistics;
import lombok.Data;
import java.util.List;
@Data
public class QuestionnaireResponse {
@JsonProperty("id")
private Long id;
@JsonProperty("name")
private String name;
@JsonProperty("description")
private String description;
@JsonProperty("status")
private Integer status;
@JsonProperty("statusText")
private String statusText;
@JsonProperty("createdBy")
private String createdBy;
@JsonProperty("startTime")
private String startTime;
@JsonProperty("endTime")
private String endTime;
@JsonProperty("isAnonymous")
private Boolean isAnonymous;
@JsonProperty("maxAnswers")
private Integer maxAnswers;
@JsonProperty("createdAt")
private String createdAt;
@JsonProperty("updatedAt")
private String updatedAt;
@JsonProperty("questions")
private List<QuestionResponse> questions;
@JsonProperty("statistics")
private QuestionnaireStatistics statistics;
}

View File

@@ -0,0 +1,73 @@
package com.ycwl.basic.integration.questionnaire.dto.statistics;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
public class QuestionnaireStatistics {
@JsonProperty("totalResponses")
private Integer totalResponses;
@JsonProperty("completionRate")
private Double completionRate;
@JsonProperty("averageTime")
private Integer averageTime; // 平均答题时间(秒)
@JsonProperty("questionStats")
private List<QuestionStatistics> questionStats;
@JsonProperty("responsesByDate")
private Map<String, Integer> responsesByDate;
@JsonProperty("createdAt")
private String createdAt;
@JsonProperty("updatedAt")
private String updatedAt;
}
@Data
class QuestionStatistics {
@JsonProperty("questionId")
private Long questionId;
@JsonProperty("questionTitle")
private String questionTitle;
@JsonProperty("questionType")
private Integer questionType;
@JsonProperty("totalAnswers")
private Integer totalAnswers;
@JsonProperty("optionStats")
private List<OptionStatistics> optionStats;
@JsonProperty("textAnswers")
private List<String> textAnswers; // 用于填空题和文本域题
}
@Data
class OptionStatistics {
@JsonProperty("optionId")
private Long optionId;
@JsonProperty("optionText")
private String optionText;
@JsonProperty("optionValue")
private String optionValue;
@JsonProperty("count")
private Integer count;
@JsonProperty("percentage")
private Double percentage;
}

View File

@@ -0,0 +1,138 @@
package com.ycwl.basic.integration.questionnaire.service;
import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.questionnaire.client.QuestionnaireClient;
import com.ycwl.basic.integration.questionnaire.dto.answer.ResponseDetailResponse;
import com.ycwl.basic.integration.questionnaire.dto.answer.SubmitAnswerRequest;
import com.ycwl.basic.integration.questionnaire.dto.questionnaire.CreateQuestionnaireRequest;
import com.ycwl.basic.integration.questionnaire.dto.questionnaire.QuestionnaireResponse;
import com.ycwl.basic.integration.questionnaire.dto.statistics.QuestionnaireStatistics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class QuestionnaireIntegrationService {
private final QuestionnaireClient questionnaireClient;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-questionnaire";
// ==================== 问卷查询接口(支持 fallback) ====================
public QuestionnaireResponse getQuestionnaire(Long id) {
log.info("获取问卷详情, id: {}", id);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"questionnaire:" + id,
() -> {
CommonResponse<QuestionnaireResponse> response = questionnaireClient.getQuestionnaire(id);
return handleResponse(response, "获取问卷详情失败");
},
QuestionnaireResponse.class
);
}
public PageResponse<QuestionnaireResponse> getQuestionnaireList(Integer page, Integer pageSize,
String name, Integer status, String createdBy) {
log.info("获取问卷列表, page: {}, pageSize: {}, name: {}, status: {}, createdBy: {}",
page, pageSize, name, status, createdBy);
CommonResponse<PageResponse<QuestionnaireResponse>> response =
questionnaireClient.getQuestionnaireList(page, pageSize, name, status, createdBy);
return handleResponse(response, "获取问卷列表失败");
}
public QuestionnaireStatistics getStatistics(Long id) {
log.info("获取问卷统计, id: {}", id);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"questionnaire:statistics:" + id,
() -> {
CommonResponse<QuestionnaireStatistics> response = questionnaireClient.getStatistics(id);
return handleResponse(response, "获取问卷统计失败");
},
QuestionnaireStatistics.class
);
}
public PageResponse<ResponseDetailResponse> getResponseList(Integer page, Integer pageSize, Long questionnaireId,
String userId, String startTime, String endTime) {
log.info("获取回答记录列表, page: {}, pageSize: {}, questionnaireId: {}, userId: {}",
page, pageSize, questionnaireId, userId);
CommonResponse<PageResponse<ResponseDetailResponse>> response =
questionnaireClient.getResponseList(page, pageSize, questionnaireId, userId, startTime, endTime);
return handleResponse(response, "获取回答记录列表失败");
}
public ResponseDetailResponse getResponseDetail(Long id) {
log.info("获取回答详情, id: {}", id);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"response:" + id,
() -> {
CommonResponse<ResponseDetailResponse> response = questionnaireClient.getResponseDetail(id);
return handleResponse(response, "获取回答详情失败");
},
ResponseDetailResponse.class
);
}
// ==================== 问卷管理接口(直接执行,不支持 fallback) ====================
public QuestionnaireResponse createQuestionnaire(CreateQuestionnaireRequest request, String userId) {
log.info("创建问卷, name: {}, userId: {}", request.getName(), userId);
CommonResponse<QuestionnaireResponse> response = questionnaireClient.createQuestionnaire(request, userId);
return handleResponse(response, "创建问卷失败");
}
public QuestionnaireResponse updateQuestionnaire(Long id, CreateQuestionnaireRequest request, String userId) {
log.info("更新问卷, id: {}, userId: {}", id, userId);
CommonResponse<QuestionnaireResponse> response = questionnaireClient.updateQuestionnaire(id, request, userId);
return handleResponse(response, "更新问卷失败");
}
public void deleteQuestionnaire(Long id, String userId) {
log.info("删除问卷, id: {}, userId: {}", id, userId);
CommonResponse<Void> response = questionnaireClient.deleteQuestionnaire(id, userId);
handleResponse(response, "删除问卷失败");
}
public QuestionnaireResponse publishQuestionnaire(Long id, String userId) {
log.info("发布问卷, id: {}, userId: {}", id, userId);
CommonResponse<QuestionnaireResponse> response = questionnaireClient.publishQuestionnaire(id, userId);
return handleResponse(response, "发布问卷失败");
}
public QuestionnaireResponse stopQuestionnaire(Long id, String userId) {
log.info("停止问卷, id: {}, userId: {}", id, userId);
CommonResponse<QuestionnaireResponse> response = questionnaireClient.stopQuestionnaire(id, userId);
return handleResponse(response, "停止问卷失败");
}
public ResponseDetailResponse submitAnswer(SubmitAnswerRequest request) {
log.info("提交问卷答案, questionnaireId: {}, userId: {}", request.getQuestionnaireId(), request.getUserId());
CommonResponse<ResponseDetailResponse> response = questionnaireClient.submitAnswer(request);
return handleResponse(response, "提交问卷答案失败");
}
// ==================== 工具方法 ====================
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
if (response == null || !response.isSuccess()) {
String msg = response != null && response.getMessage() != null
? response.getMessage()
: errorMessage;
Integer code = response != null ? response.getCode() : 5000;
throw new IntegrationException(code, msg, SERVICE_NAME);
}
return response.getData();
}
}

View File

@@ -18,12 +18,6 @@ public interface RenderWorkerV2Client {
@GetMapping("/{id}") @GetMapping("/{id}")
CommonResponse<RenderWorkerV2DTO> getWorker(@PathVariable("id") Long id); CommonResponse<RenderWorkerV2DTO> getWorker(@PathVariable("id") Long id);
/**
* 获取工作器含配置信息
*/
@GetMapping("/{id}/with-config")
CommonResponse<RenderWorkerV2WithConfigDTO> getWorkerWithConfig(@PathVariable("id") Long id);
/** /**
* 创建工作器 * 创建工作器
*/ */
@@ -52,25 +46,9 @@ public interface RenderWorkerV2Client {
@RequestParam(required = false) Integer isEnabled, @RequestParam(required = false) Integer isEnabled,
@RequestParam(required = false) String name); @RequestParam(required = false) String name);
/**
* 分页查询工作器列表(含配置信息)
*/
@GetMapping("/with-config")
CommonResponse<PageResponse<RenderWorkerV2WithConfigDTO>> listWorkersWithConfig(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer isEnabled,
@RequestParam(required = false) String name);
/** /**
* 根据key获取工作器核心信息 * 根据key获取工作器核心信息
*/ */
@GetMapping("/key/{key}") @GetMapping("/key/{key}")
CommonResponse<RenderWorkerV2DTO> getWorkerByKey(@PathVariable("key") String key); CommonResponse<RenderWorkerV2DTO> getWorkerByKey(@PathVariable("key") String key);
/**
* 根据key获取工作器完整信息(含配置)
*/
@GetMapping("/key/{key}/with-config")
CommonResponse<RenderWorkerV2WithConfigDTO> getWorkerWithConfigByKey(@PathVariable("key") String key);
} }

View File

@@ -70,7 +70,7 @@ public class RenderWorkerConfigIntegrationService {
log.debug("获取渲染工作器平铺配置, workerId: {}", workerId); log.debug("获取渲染工作器平铺配置, workerId: {}", workerId);
return fallbackService.executeWithFallback( return fallbackService.executeWithFallback(
SERVICE_NAME, SERVICE_NAME,
"worker:flat:config:" + workerId, "worker:config:" + workerId,
() -> { () -> {
List<RenderWorkerConfigV2DTO> configs = getWorkerConfigsInternal(workerId); List<RenderWorkerConfigV2DTO> configs = getWorkerConfigsInternal(workerId);
return flattenConfigs(configs); return flattenConfigs(configs);

View File

@@ -42,22 +42,6 @@ public class RenderWorkerIntegrationService {
); );
} }
/**
* 获取工作器详细信息(含配置)(带降级)
*/
public RenderWorkerV2WithConfigDTO getWorkerWithConfig(Long id) {
log.debug("获取渲染工作器详细信息, id: {}", id);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"worker:config:" + id,
() -> {
CommonResponse<RenderWorkerV2WithConfigDTO> response = renderWorkerV2Client.getWorkerWithConfig(id);
return handleResponse(response, "获取渲染工作器详细信息失败");
},
RenderWorkerV2WithConfigDTO.class
);
}
/** /**
* 创建工作器(直接调用,不降级) * 创建工作器(直接调用,不降级)
*/ */
@@ -96,18 +80,6 @@ public class RenderWorkerIntegrationService {
return handleResponse(response, "查询渲染工作器列表失败"); return handleResponse(response, "查询渲染工作器列表失败");
} }
/**
* 分页查询工作器列表(含配置信息)(不降级)
*/
public PageResponse<RenderWorkerV2WithConfigDTO> listWorkersWithConfig(Integer page, Integer pageSize,
Integer isEnabled, String name) {
log.debug("分页查询渲染工作器列表(含配置), page: {}, pageSize: {}, isEnabled: {}, name: {}",
page, pageSize, isEnabled, name);
CommonResponse<PageResponse<RenderWorkerV2WithConfigDTO>> response =
renderWorkerV2Client.listWorkersWithConfig(page, pageSize, isEnabled, name);
return handleResponse(response, "查询渲染工作器列表(含配置)失败");
}
/** /**
* 根据key获取工作器核心信息(带降级) * 根据key获取工作器核心信息(带降级)
*/ */
@@ -124,22 +96,6 @@ public class RenderWorkerIntegrationService {
); );
} }
/**
* 根据key获取工作器详细信息(含配置)(带降级)
*/
public RenderWorkerV2WithConfigDTO getWorkerWithConfigByKey(String key) {
log.debug("根据key获取渲染工作器详细信息, key: {}", key);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"worker:key:config:" + key,
() -> {
CommonResponse<RenderWorkerV2WithConfigDTO> response = renderWorkerV2Client.getWorkerWithConfigByKey(key);
return handleResponse(response, "根据key获取渲染工作器详细信息失败");
},
RenderWorkerV2WithConfigDTO.class
);
}
/** /**
* 处理通用响应 * 处理通用响应
*/ */

View File

@@ -18,9 +18,6 @@ public interface ScenicConfigV2Client {
CommonResponse<ScenicConfigV2DTO> getConfigByKey(@PathVariable("scenicId") Long scenicId, CommonResponse<ScenicConfigV2DTO> getConfigByKey(@PathVariable("scenicId") Long scenicId,
@PathVariable("configKey") String configKey); @PathVariable("configKey") String configKey);
@GetMapping("/{scenicId}/keys")
CommonResponse<Map<String, Object>> getFlatConfigs(@PathVariable("scenicId") Long scenicId);
@PostMapping("/{scenicId}") @PostMapping("/{scenicId}")
CommonResponse<ScenicConfigV2DTO> createConfig(@PathVariable("scenicId") Long scenicId, CommonResponse<ScenicConfigV2DTO> createConfig(@PathVariable("scenicId") Long scenicId,
@RequestBody CreateConfigRequest request); @RequestBody CreateConfigRequest request);
@@ -37,8 +34,4 @@ public interface ScenicConfigV2Client {
@PostMapping("/{scenicId}/batch") @PostMapping("/{scenicId}/batch")
CommonResponse<BatchUpdateResponse> batchUpdateConfigs(@PathVariable("scenicId") Long scenicId, CommonResponse<BatchUpdateResponse> batchUpdateConfigs(@PathVariable("scenicId") Long scenicId,
@RequestBody BatchConfigRequest request); @RequestBody BatchConfigRequest request);
@PostMapping("/{scenicId}/batchFlatUpdate")
CommonResponse<BatchUpdateResponse> batchFlatUpdateConfigs(@PathVariable("scenicId") Long scenicId,
@RequestBody Map<String, Object> configs);
} }

View File

@@ -5,7 +5,6 @@ import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterPageResponse;
import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterRequest; import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterRequest;
import com.ycwl.basic.integration.scenic.dto.scenic.CreateScenicRequest; import com.ycwl.basic.integration.scenic.dto.scenic.CreateScenicRequest;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO; import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2WithConfigDTO;
import com.ycwl.basic.integration.common.response.PageResponse; import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.scenic.dto.scenic.UpdateScenicRequest; import com.ycwl.basic.integration.scenic.dto.scenic.UpdateScenicRequest;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
@@ -19,10 +18,6 @@ public interface ScenicV2Client {
@GetMapping("/{scenicId}") @GetMapping("/{scenicId}")
CommonResponse<ScenicV2DTO> getScenic(@PathVariable("scenicId") Long scenicId); CommonResponse<ScenicV2DTO> getScenic(@PathVariable("scenicId") Long scenicId);
@GetMapping("/{scenicId}/with-config")
CommonResponse<ScenicV2WithConfigDTO> getScenicWithConfig(@PathVariable("scenicId") Long scenicId);
@PostMapping("/") @PostMapping("/")
CommonResponse<ScenicV2DTO> createScenic(@RequestBody CreateScenicRequest request); CommonResponse<ScenicV2DTO> createScenic(@RequestBody CreateScenicRequest request);
@@ -41,10 +36,4 @@ public interface ScenicV2Client {
@RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer status, @RequestParam(required = false) Integer status,
@RequestParam(required = false) String name); @RequestParam(required = false) String name);
@GetMapping("/with-config")
CommonResponse<PageResponse<ScenicV2WithConfigDTO>> listScenicsWithConfig(@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) Integer status,
@RequestParam(required = false) String name);
} }

View File

@@ -1,14 +0,0 @@
package com.ycwl.basic.integration.scenic.dto.scenic;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Map;
@Data
@EqualsAndHashCode(callSuper = true)
public class ScenicV2WithConfigDTO extends ScenicV2DTO {
@JsonProperty("config")
private Map<String, Object> config;
}

View File

@@ -1,184 +0,0 @@
package com.ycwl.basic.integration.scenic.example;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
import com.ycwl.basic.integration.scenic.dto.config.*;
import com.ycwl.basic.integration.scenic.dto.filter.*;
import com.ycwl.basic.integration.scenic.dto.scenic.*;
import com.ycwl.basic.integration.scenic.service.ScenicConfigIntegrationService;
import com.ycwl.basic.integration.scenic.service.ScenicIntegrationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* 景区集成示例(包含降级机制)
* 演示景区集成和失败降级策略的使用
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ScenicIntegrationExample {
private final ScenicIntegrationService scenicIntegrationService;
private final ScenicConfigIntegrationService scenicConfigIntegrationService;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-scenic";
/**
* 示例:创建景区并设置配置
*/
public void createScenicWithConfig() {
try {
// 1. 创建景区
CreateScenicRequest createRequest = new CreateScenicRequest();
createRequest.setName("测试景区");
createRequest.setMpId(1001);
var scenic = scenicIntegrationService.createScenic(createRequest);
log.info("创建景区成功: {}", scenic.getName());
// 2. 为景区添加配置
CreateConfigRequest configRequest = new CreateConfigRequest();
configRequest.setConfigKey("tour_time");
configRequest.setConfigValue("120");
configRequest.setConfigType("int");
configRequest.setDescription("游览时长");
var config = scenicConfigIntegrationService.createConfig(
Long.valueOf(scenic.getId()), configRequest);
log.info("创建配置成功: {} = {}", config.getConfigKey(), config.getConfigValue());
} catch (Exception e) {
log.error("创建景区和配置失败", e);
}
}
/**
* 示例:筛选景区
*/
public void filterScenics() {
try {
FilterCondition condition = new FilterCondition();
condition.setConfigKey("tour_time");
condition.setConfigValue("120");
condition.setOperator("gte");
ScenicFilterRequest filterRequest = new ScenicFilterRequest();
filterRequest.setFilters(Collections.singletonList(condition));
filterRequest.setPage(1);
filterRequest.setPageSize(10);
var result = scenicIntegrationService.filterScenics(filterRequest);
log.info("筛选到 {} 个景区", result.getTotal());
} catch (Exception e) {
log.error("筛选景区失败", e);
}
}
/**
* 演示基础景区操作的降级机制
*/
public void basicScenicOperationsExample() {
log.info("=== 基础景区操作示例(含降级机制) ===");
Long scenicId = 2001L;
try {
// 获取景区信息 - 自动降级
ScenicV2DTO scenic = scenicIntegrationService.getScenic(scenicId);
log.info("获取景区成功: {}", scenic.getName());
// 获取景区配置信息 - 自动降级
ScenicV2WithConfigDTO scenicWithConfig = scenicIntegrationService.getScenicWithConfig(scenicId);
log.info("获取景区配置成功,配置数量: {}", scenicWithConfig.getConfig().size());
// 获取扁平化配置 - 自动降级
Map<String, Object> flatConfig = scenicIntegrationService.getScenicFlatConfig(scenicId);
log.info("获取扁平化配置成功,配置项数量: {}", flatConfig.size());
} catch (Exception e) {
log.error("景区操作降级失败", e);
}
}
/**
* 演示景区配置管理的降级机制
*/
public void scenicConfigManagementFallbackExample() {
log.info("=== 景区配置管理示例(含降级机制) ===");
Long scenicId = 2001L;
try {
// 获取扁平化配置 - 自动降级
Map<String, Object> flatConfigs = scenicConfigIntegrationService.getFlatConfigs(scenicId);
log.info("获取扁平化配置成功,配置项数量: {}", flatConfigs.size());
// 批量更新配置 - 直接操作,失败时抛出异常
Map<String, Object> updates = new HashMap<>();
updates.put("max_visitors", "5000");
updates.put("opening_hours", "08:00-18:00");
BatchUpdateResponse result = scenicConfigIntegrationService.batchFlatUpdateConfigs(scenicId, updates);
log.info("批量更新配置完成: 成功 {}, 失败 {}", result.getSuccess(), result.getFailed());
} catch (Exception e) {
log.error("景区配置管理操作失败", e);
}
}
/**
* 演示降级缓存管理
*/
public void fallbackCacheManagementExample() {
log.info("=== 景区降级缓存管理示例 ===");
String scenicCacheKey = "scenic:2001";
String configCacheKey = "scenic:flat:configs:2001";
// 检查降级缓存状态
boolean hasScenicCache = fallbackService.hasFallbackCache(SERVICE_NAME, scenicCacheKey);
boolean hasConfigCache = fallbackService.hasFallbackCache(SERVICE_NAME, configCacheKey);
log.info("景区降级缓存存在: {}", hasScenicCache);
log.info("配置降级缓存存在: {}", hasConfigCache);
// 获取降级缓存统计信息
IntegrationFallbackService.FallbackCacheStats stats = fallbackService.getFallbackCacheStats(SERVICE_NAME);
log.info("景区服务降级缓存统计: 缓存数量={}, TTL={}天",
stats.getTotalCacheCount(), stats.getFallbackTtlDays());
// 清理特定的降级缓存
if (hasScenicCache) {
fallbackService.clearFallbackCache(SERVICE_NAME, scenicCacheKey);
log.info("已清理景区降级缓存");
}
// 如果缓存过多,批量清理
if (stats.getTotalCacheCount() > 50) {
fallbackService.clearAllFallbackCache(SERVICE_NAME);
log.info("已批量清理所有景区降级缓存");
}
}
/**
* 运行所有示例
*/
public void runAllExamples() {
log.info("开始运行景区集成示例(包含降级机制)...");
createScenicWithConfig();
filterScenics();
basicScenicOperationsExample();
scenicConfigManagementFallbackExample();
fallbackCacheManagementExample();
log.info("景区集成示例运行完成");
}
}

View File

@@ -48,19 +48,6 @@ public class ScenicConfigIntegrationService {
); );
} }
public Map<String, Object> getFlatConfigs(Long scenicId) {
log.debug("获取景区扁平化配置, scenicId: {}", scenicId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"scenic:flat:configs:" + scenicId,
() -> {
CommonResponse<Map<String, Object>> response = scenicConfigV2Client.getFlatConfigs(scenicId);
return handleResponse(response, "获取景区扁平化配置失败");
},
Map.class
);
}
public ScenicConfigV2DTO createConfig(Long scenicId, CreateConfigRequest request) { public ScenicConfigV2DTO createConfig(Long scenicId, CreateConfigRequest request) {
log.debug("创建景区配置, scenicId: {}, configKey: {}", scenicId, request.getConfigKey()); log.debug("创建景区配置, scenicId: {}, configKey: {}", scenicId, request.getConfigKey());
CommonResponse<ScenicConfigV2DTO> response = scenicConfigV2Client.createConfig(scenicId, request); CommonResponse<ScenicConfigV2DTO> response = scenicConfigV2Client.createConfig(scenicId, request);
@@ -85,12 +72,6 @@ public class ScenicConfigIntegrationService {
return handleResponse(response, "批量更新景区配置失败"); return handleResponse(response, "批量更新景区配置失败");
} }
public BatchUpdateResponse batchFlatUpdateConfigs(Long scenicId, Map<String, Object> configs) {
log.debug("扁平化批量更新景区配置, scenicId: {}, configs count: {}", scenicId, configs.size());
CommonResponse<BatchUpdateResponse> response = scenicConfigV2Client.batchFlatUpdateConfigs(scenicId, configs);
return handleResponse(response, "扁平化批量更新景区配置失败");
}
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) { private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
if (response == null || !response.isSuccess()) { if (response == null || !response.isSuccess()) {

View File

@@ -9,7 +9,6 @@ import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterPageResponse;
import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterRequest; import com.ycwl.basic.integration.scenic.dto.filter.ScenicFilterRequest;
import com.ycwl.basic.integration.scenic.dto.scenic.CreateScenicRequest; import com.ycwl.basic.integration.scenic.dto.scenic.CreateScenicRequest;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO; import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2WithConfigDTO;
import com.ycwl.basic.integration.common.response.PageResponse; import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.scenic.dto.scenic.UpdateScenicRequest; import com.ycwl.basic.integration.scenic.dto.scenic.UpdateScenicRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -42,32 +41,6 @@ public class ScenicIntegrationService {
); );
} }
public ScenicV2WithConfigDTO getScenicWithConfig(Long scenicId) {
log.debug("获取景区配置信息, scenicId: {}", scenicId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"scenic:config:" + scenicId,
() -> {
CommonResponse<ScenicV2WithConfigDTO> response = scenicV2Client.getScenicWithConfig(scenicId);
return handleResponse(response, "获取景区配置信息失败");
},
ScenicV2WithConfigDTO.class
);
}
public Map<String, Object> getScenicFlatConfig(Long scenicId) {
log.debug("获取景区扁平化配置, scenicId: {}", scenicId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"scenic:flat:config:" + scenicId,
() -> {
CommonResponse<Map<String, Object>> response = scenicConfigV2Client.getFlatConfigs(scenicId);
return handleResponse(response, "获取景区扁平化配置失败");
},
Map.class
);
}
public ScenicV2DTO createScenic(CreateScenicRequest request) { public ScenicV2DTO createScenic(CreateScenicRequest request) {
log.debug("创建景区, name: {}", request.getName()); log.debug("创建景区, name: {}", request.getName());
CommonResponse<ScenicV2DTO> response = scenicV2Client.createScenic(request); CommonResponse<ScenicV2DTO> response = scenicV2Client.createScenic(request);
@@ -98,12 +71,6 @@ public class ScenicIntegrationService {
return handleResponse(response, "分页查询景区列表失败"); return handleResponse(response, "分页查询景区列表失败");
} }
public PageResponse<ScenicV2WithConfigDTO> listScenicsWithConfig(Integer page, Integer pageSize, Integer status, String name) {
log.debug("分页查询景区带配置列表, page: {}, pageSize: {}, status: {}, name: {}", page, pageSize, status, name);
CommonResponse<PageResponse<ScenicV2WithConfigDTO>> response = scenicV2Client.listScenicsWithConfig(page, pageSize, status, name);
return handleResponse(response, "分页查询景区带配置列表失败");
}
private <T> T handleResponse(CommonResponse<T> response, String errorMessage) { private <T> T handleResponse(CommonResponse<T> response, String errorMessage) {
if (response == null || !response.isSuccess()) { if (response == null || !response.isSuccess()) {
String msg = response != null && response.getMessage() != null String msg = response != null && response.getMessage() != null

View File

@@ -29,4 +29,6 @@ public interface FaceSampleMapper {
List<FaceSampleEntity> listEntityBeforeDate(Long scenicId, Date endDate); List<FaceSampleEntity> listEntityBeforeDate(Long scenicId, Date endDate);
void updateScore(Long id, Float score); void updateScore(Long id, Float score);
void updateStatus(Long id, Integer status);
} }

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.mapper; package com.ycwl.basic.mapper;
import com.ycwl.basic.model.pc.printer.entity.MemberPrintEntity;
import com.ycwl.basic.model.pc.printer.entity.PrintTaskEntity; import com.ycwl.basic.model.pc.printer.entity.PrintTaskEntity;
import com.ycwl.basic.model.pc.printer.entity.PrinterEntity; import com.ycwl.basic.model.pc.printer.entity.PrinterEntity;
import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp; import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp;
@@ -35,7 +36,7 @@ public interface PrinterMapper {
int deleteUserPhoto(Long memberId, Long scenicId, Long relationId); int deleteUserPhoto(Long memberId, Long scenicId, Long relationId);
int addUserPhoto(Long memberId, Long scenicId, String url); int addUserPhoto(MemberPrintEntity entity);
MemberPrintResp getUserPhoto(Long memberId, Long scenicId, Long id); MemberPrintResp getUserPhoto(Long memberId, Long scenicId, Long id);

View File

@@ -56,6 +56,12 @@ public interface SourceMapper {
int addRelations(List<MemberSourceEntity> list); int addRelations(List<MemberSourceEntity> list);
List<MemberSourceEntity> filterExistingRelations(List<MemberSourceEntity> list);
boolean sourceExists(Long sourceId);
List<MemberSourceEntity> filterValidSourceRelations(List<MemberSourceEntity> list);
int updateRelation(MemberSourceEntity memberSourceEntity); int updateRelation(MemberSourceEntity memberSourceEntity);
int freeRelations(List<Long> ids, int type); int freeRelations(List<Long> ids, int type);
@@ -69,7 +75,7 @@ public interface SourceMapper {
List<SourceEntity> listVideoByFaceRelation(Long memberId, Long faceId); List<SourceEntity> listVideoByFaceRelation(Long memberId, Long faceId);
List<SourceEntity> listImageByFaceRelation(Long memberId, Long faceId); List<SourceEntity> listImageByFaceRelation(Long memberId, Long faceId);
List<MemberSourceEntity> listByFaceRelation(Long memberId, Long faceId, Integer type); List<MemberSourceEntity> listByFaceRelation(Long faceId, Integer type);
SourceEntity getEntity(Long id); SourceEntity getEntity(Long id);
@@ -92,4 +98,11 @@ public interface SourceMapper {
* @return type=2的source列表 * @return type=2的source列表
*/ */
List<SourceEntity> listImageSourcesByFaceId(Long faceId); List<SourceEntity> listImageSourcesByFaceId(Long faceId);
/**
* 从ZT-Source消息添加素材
* @param source 素材实体
* @return 影响行数
*/
int addFromZTSource(SourceEntity source);
} }

View File

@@ -38,7 +38,7 @@ public interface VideoMapper {
MemberVideoEntity queryRelationByMemberTask(Long userId, Long taskId); MemberVideoEntity queryRelationByMemberTask(Long userId, Long taskId);
List<MemberVideoEntity> listRelationByTask(Long taskId); List<MemberVideoEntity> listRelationByTask(Long taskId);
List<MemberVideoEntity> listRelationByFace(Long userId, Long faceId); List<MemberVideoEntity> listRelationByFace(Long faceId);
List<MemberVideoEntity> listRelationByFaceAndTemplate(Long faceId, Long templateId); List<MemberVideoEntity> listRelationByFaceAndTemplate(Long faceId, Long templateId);
List<TaskEntity> listTaskByScenicRelation(Long userId, Long scenicId); List<TaskEntity> listTaskByScenicRelation(Long userId, Long scenicId);

View File

@@ -25,6 +25,7 @@ public class ContentPageVO {
private int lockType; private int lockType;
// 内容id contentType为0或1时才有值 // 内容id contentType为0或1时才有值
private Long contentId; private Long contentId;
private String videoUrl;
// 模版id // 模版id
private Long templateId; private Long templateId;
private String templateCoverUrl; private String templateCoverUrl;

View File

@@ -51,18 +51,18 @@ public class AppStatisticsFunnelVO {
return "-"; // TODO: REAL return "-"; // TODO: REAL
} }
// 扫码访问人数_上传头像人数_转化率 // 扫码访问人数_推送订阅人数_转化率
@JsonProperty("scaom_ufom") @JsonProperty("scaom_ufom")
public String getScaom_ufom() { public String getScaom_ufom() {
if (uploadFaceOfMemberNum == 0 || scanCodeVisitorOfMemberNum == 0) { if (scanCodeVisitorOfMemberNum == 0 || pushOfMemberNum == 0) {
return "0.00"; return "0.00";
} }
return new BigDecimal(uploadFaceOfMemberNum) return new BigDecimal(pushOfMemberNum)
.multiply(new BigDecimal(100)) .multiply(new BigDecimal(100))
.divide(new BigDecimal(scanCodeVisitorOfMemberNum), 2, RoundingMode.HALF_UP) .divide(new BigDecimal(scanCodeVisitorOfMemberNum), 2, RoundingMode.HALF_UP)
.toString(); .toString();
} }
// 上传头像人数_推送订阅人数_转化率 // 推送订阅人数_上传头像人数_转化率
@JsonProperty("ufom_pom") @JsonProperty("ufom_pom")
public String getUfom_pom() { public String getUfom_pom() {
if (pushOfMemberNum == 0 || uploadFaceOfMemberNum == 0) { if (pushOfMemberNum == 0 || uploadFaceOfMemberNum == 0) {
@@ -76,12 +76,12 @@ public class AppStatisticsFunnelVO {
// 上传头像人数_生成视频人数_转化率 // 上传头像人数_生成视频人数_转化率
@JsonProperty("pom_cvom") @JsonProperty("pom_cvom")
public String getPom_cvom() { public String getPom_cvom() {
if (uploadFaceOfMemberNum == 0 || pushOfMemberNum == 0) { if (uploadFaceOfMemberNum == 0 || completeVideoOfMemberNum == 0) {
return "0.00"; return "0.00";
} }
return new BigDecimal(completeVideoOfMemberNum) return new BigDecimal(completeVideoOfMemberNum)
.multiply(new BigDecimal(100)) .multiply(new BigDecimal(100))
.divide(new BigDecimal(pushOfMemberNum), 2, RoundingMode.HALF_UP) .divide(new BigDecimal(uploadFaceOfMemberNum), 2, RoundingMode.HALF_UP)
.toString(); .toString();
} }
// 生成视频人数_预览视频人数_转化率 // 生成视频人数_预览视频人数_转化率

View File

@@ -0,0 +1,119 @@
package com.ycwl.basic.model.mobile.video.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 视频查看权限响应DTO
* 包含用户查看视频的权限信息和限制详情
*/
@Data
public class VideoViewPermissionDTO {
/**
* 是否可以查看
*/
@JsonProperty("canView")
private Boolean canView;
/**
* 是否无限制查看
*/
private Boolean isUnlimited;
/**
* 当前查看次数
*/
private Integer currentViewCount;
/**
* 最大查看次数(0表示无限制)
*/
private Integer maxViewCount;
/**
* 时间限制(秒,0表示无限制)
*/
private Integer timeLimit;
/**
* 剩余完整查看次数
*/
private Integer remainingViews;
/**
* 权限信息描述
*/
private String message;
/**
* 是否为限时查看模式
*/
private Boolean isTimeLimitMode;
public VideoViewPermissionDTO() {
this.canView = true;
this.isUnlimited = false;
this.currentViewCount = 0;
this.maxViewCount = 0;
this.timeLimit = 0;
this.remainingViews = 0;
this.isTimeLimitMode = false;
}
/**
* 创建无限制查看的权限对象
*/
public static VideoViewPermissionDTO createUnlimitedPermission(Integer currentViewCount) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(true);
dto.setIsUnlimited(true);
dto.setCurrentViewCount(currentViewCount);
dto.setMessage("无限制查看");
return dto;
}
/**
* 创建完整查看权限对象
*/
public static VideoViewPermissionDTO createFullViewPermission(Integer currentViewCount, Integer maxViewCount) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(true);
dto.setIsUnlimited(false);
dto.setCurrentViewCount(currentViewCount);
dto.setMaxViewCount(maxViewCount);
dto.setRemainingViews(Math.max(0, maxViewCount - currentViewCount));
dto.setMessage("完整查看模式,剩余" + dto.getRemainingViews() + "");
return dto;
}
/**
* 创建限时查看权限对象
*/
public static VideoViewPermissionDTO createTimeLimitPermission(Integer currentViewCount, Integer maxViewCount, Integer timeLimit) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(true);
dto.setIsUnlimited(false);
dto.setIsTimeLimitMode(true);
dto.setCurrentViewCount(currentViewCount);
dto.setMaxViewCount(maxViewCount);
dto.setTimeLimit(timeLimit);
dto.setRemainingViews(0);
dto.setMessage("限时查看模式,每次可查看" + timeLimit + "");
return dto;
}
/**
* 创建禁止查看权限对象
*/
public static VideoViewPermissionDTO createNoPermission(Integer currentViewCount, Integer maxViewCount) {
VideoViewPermissionDTO dto = new VideoViewPermissionDTO();
dto.setCanView(false);
dto.setIsUnlimited(false);
dto.setCurrentViewCount(currentViewCount);
dto.setMaxViewCount(maxViewCount);
dto.setRemainingViews(0);
dto.setMessage("已达到查看次数限制,无法继续观看");
return dto;
}
}

View File

@@ -10,4 +10,5 @@ import lombok.NoArgsConstructor;
public class GoodsListRespVO { public class GoodsListRespVO {
private Long goodsId; private Long goodsId;
private String goodsName; private String goodsName;
private Integer goodsType;
} }

View File

@@ -0,0 +1,19 @@
package com.ycwl.basic.model.pc.printer.req;
import lombok.Data;
/**
* 打印机首选尺寸更新请求
*/
@Data
public class PrinterPreferredSizeUpdateReq {
/**
* 首选宽度
*/
private Integer preferW;
/**
* 首选高度
*/
private Integer preferH;
}

View File

@@ -0,0 +1,14 @@
package com.ycwl.basic.model.pc.printer.req;
import lombok.Data;
/**
* 打印机状态更新请求
*/
@Data
public class PrinterStatusUpdateReq {
/**
* 打印机状态:0-停用,1-启用
*/
private Integer status;
}

View File

@@ -0,0 +1,14 @@
package com.ycwl.basic.model.pc.printer.req;
import lombok.Data;
/**
* 打印机当前使用设备更新请求
*/
@Data
public class PrinterUsePrinterUpdateReq {
/**
* 当前使用打印机标识
*/
private String usePrinter;
}

View File

@@ -1,16 +1,7 @@
package com.ycwl.basic.model.pc.scenic.resp; package com.ycwl.basic.model.pc.scenic.resp;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ycwl.basic.facebody.enums.FaceBodyAdapterType;
import com.ycwl.basic.pay.enums.PayAdapterType;
import com.ycwl.basic.storage.enums.StorageType;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/** /**
* @Author:longbinbin * @Author:longbinbin
* @Date:2024/12/2 10:53 * @Date:2024/12/2 10:53
@@ -19,33 +10,49 @@ import java.util.Date;
@Data @Data
public class ScenicConfigResp { public class ScenicConfigResp {
// ========== 基础配置 ==========
/** /**
* 预约流程,1-预约,2-在线,3-全部 * 水印URL
*/ */
private Integer bookRoutine; private String watermarkUrl;
private Integer forceFinishTime;
private Integer tourTime;
/** /**
* 样本保存时间 * 视频存储天数
*/
private Integer sampleStoreDay;
private Integer faceStoreDay;
/**
* 视频保存时间
*/ */
private Integer videoStoreDay; private Integer videoStoreDay;
private Boolean allFree;
private Boolean disableSourceVideo;
private Boolean disableSourceImage;
private Integer antiScreenRecordType;
private Integer videoSourceStoreDay;
private Integer imageSourceStoreDay;
private Integer userSourceExpireDay;
private BigDecimal brokerDirectRate;
private String imageSourcePackHint = ""; /**
private String videoSourcePackHint = ""; * 防录屏类型配置
private Boolean voucherEnable; */
private Boolean enableVoucher; private Integer antiScreenRecordType;
// ========== 功能开关 ==========
/**
* 分组功能开关
*/
private Boolean groupingEnable; private Boolean groupingEnable;
/**
* 优惠券功能开关
*/
private Boolean voucherEnable;
/**
* 等待时显示照片开关
*/
private Boolean showPhotoWhenWaiting;
// ========== 提示文案 ==========
/**
* 图片素材包提示文案
*/
private String imageSourcePackHint = "";
/**
* 视频素材包提示文案
*/
private String videoSourcePackHint = "";
} }

View File

@@ -14,4 +14,5 @@ public class TemplateConfigEntity {
private Date createDate; private Date createDate;
private Integer minimalPlaceholderFill; private Integer minimalPlaceholderFill;
private Integer automaticPlaceholderFill; private Integer automaticPlaceholderFill;
private Integer duplicateEnable;
} }

View File

@@ -25,6 +25,7 @@ public class TemplateRespVO {
*/ */
// 模版名称 // 模版名称
private String name; private String name;
private String group;
/** /**
* 父模版ID * 父模版ID
*/ */

View File

@@ -0,0 +1,57 @@
package com.ycwl.basic.model.pc.video.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 用户视频查看记录实体
* 记录用户查看vlog视频的次数和时间
*/
@Data
@TableName("user_video_view_record")
public class UserVideoViewEntity {
/**
* 主键ID
*/
@TableId
private Long id;
/**
* 用户ID
*/
private Long userId;
/**
* 视频ID
*/
private Long videoId;
/**
* 景区ID
*/
private Long scenicId;
/**
* 完整查看次数
*/
private Integer viewCount;
/**
* 最后查看时间
*/
private Date lastViewTime;
/**
* 创建时间
*/
private Date createTime;
/**
* 更新时间
*/
private Date updateTime;
}

View File

@@ -1,51 +0,0 @@
package com.ycwl.basic.notify;
import com.ycwl.basic.notify.adapters.INotifyAdapter;
import com.ycwl.basic.notify.adapters.ServerChanNotifyAdapter;
import com.ycwl.basic.notify.adapters.WxMpSrvNotifyAdapter;
import com.ycwl.basic.notify.enums.NotifyType;
import java.util.HashMap;
import java.util.Map;
public class NotifyFactory {
public static INotifyAdapter get(NotifyType type) {
return switch (type) {
case SERVER_CHAN -> new ServerChanNotifyAdapter();
case WX_MP_SRV -> new WxMpSrvNotifyAdapter();
default -> throw new RuntimeException("不支持的通知类型");
};
}
public static INotifyAdapter get(NotifyType type, Map<String, String> config) {
INotifyAdapter adapter = get(type);
adapter.loadConfig(config);
return adapter;
}
protected static Map<String, INotifyAdapter> namedNotifier = new HashMap<>();
protected static INotifyAdapter defaultNotifier = null;
public static void register(String name, INotifyAdapter adapter) {
namedNotifier.put(name, adapter);
}
public static INotifyAdapter via(String name) {
INotifyAdapter adapter = namedNotifier.get(name);
if (adapter == null) {
throw new RuntimeException("未定义的通知方式:"+name);
}
return adapter;
}
public static INotifyAdapter via() {
if (defaultNotifier == null) {
throw new RuntimeException("未定义默认通知方式");
}
return defaultNotifier;
}
public static void setDefault(String defaultStorage) {
NotifyFactory.defaultNotifier = via(defaultStorage);
}
}

View File

@@ -1,11 +0,0 @@
package com.ycwl.basic.notify.adapters;
import com.ycwl.basic.notify.entity.NotifyContent;
import java.util.Map;
public interface INotifyAdapter {
void loadConfig(Map<String, String> _config);
void sendTo(NotifyContent notifyContent, String to);
}

View File

@@ -1,54 +0,0 @@
package com.ycwl.basic.notify.adapters;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.notify.entity.NotifyContent;
import com.ycwl.basic.notify.entity.ServerChanConfig;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class ServerChanNotifyAdapter implements INotifyAdapter {
ServerChanConfig config;
@Override
public void loadConfig(Map<String, String> _config) {
ServerChanConfig config = new ServerChanConfig();
config.setKey(_config.get("key"));
config.checkEverythingOK();
this.config = config;
}
@Override
public void sendTo(NotifyContent notifyContent, String to) {
scSend(notifyContent.getTitle(), notifyContent.getContent(), config.getKey());
}
public static String scSend(String title, String content, String key) {
try {
String api;
// 判断 sendkey 是否以 "sctp" 开头,并提取数字部分拼接 URL
if (key.startsWith("sctp")) {
Pattern pattern = Pattern.compile("sctp(\\d+)t");
Matcher matcher = pattern.matcher(key);
if (matcher.find()) {
String num = matcher.group(1);
api = "https://" + num + ".push.ft07.com/send/" + key +".send";
} else {
throw new IllegalArgumentException("Invalid sendkey format for sctp");
}
} else {
api = "https://sctapi.ftqq.com/" + key + ".send";
}
Map<String, Object> body = new HashMap<>();
body.put("title", title);
body.put("desp", content);
return HttpUtil.post(api, body);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}

View File

@@ -1,60 +0,0 @@
package com.ycwl.basic.notify.adapters;
import cn.hutool.http.HttpUtil;
import com.ycwl.basic.utils.JacksonUtil;
import com.ycwl.basic.notify.entity.NotifyContent;
import com.ycwl.basic.notify.entity.WxMpSrvConfig;
import java.util.Date;
import java.util.Map;
public class WxMpSrvNotifyAdapter implements INotifyAdapter{
private WxMpSrvConfig config;
@Override
public void loadConfig(Map<String, String> _config) {
WxMpSrvConfig config = new WxMpSrvConfig();
config.setAppId(_config.get("appId"));
config.setAppSecret(_config.get("appSecret"));
if (_config.containsKey("state")) {
config.setState(_config.get("state"));
}
config.checkEverythingOK();
this.config = config;
}
@Override
public void sendTo(NotifyContent notifyContent, String openId) {
Map<String, Object> params = notifyContent.getParams();
params.put("touser", openId);
params.put("miniprogram_state", config.getState());
sendServiceNotification(params);
}
private static final String SEND_TEMPLATE_MESSAGE_URL = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=%s";
private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
private String ACCESS_TOKEN = "";
private Date expireTime = new Date();
private String getAccessToken() {
if (ACCESS_TOKEN != null && !ACCESS_TOKEN.isEmpty()) {
if (expireTime.getTime() > System.currentTimeMillis()) {
return ACCESS_TOKEN;
}
}
String url = String.format(ACCESS_TOKEN_URL, config.getAppId(), config.getAppSecret());
String response = HttpUtil.get(url);
Map<String, Object> jsonObject = JacksonUtil.parseObject(response, Map.class);
ACCESS_TOKEN = (String) jsonObject.get("access_token");
Integer expiresIn = (Integer) jsonObject.get("expires_in");
expireTime = new Date(System.currentTimeMillis() + (expiresIn != null ? expiresIn : 7200) * 1000);
return ACCESS_TOKEN;
}
public void sendServiceNotification(Map<String, Object> params) {
String url = String.format(SEND_TEMPLATE_MESSAGE_URL, getAccessToken());
String response = HttpUtil.post(url, JacksonUtil.toJSONString(params));
System.out.println(response);
}
}

View File

@@ -1,22 +0,0 @@
package com.ycwl.basic.notify.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.HashMap;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class NotifyContent {
private String title;
private String content;
private Map<String, Object> params = new HashMap<>();
public NotifyContent(String title, String content) {
this.title = title;
this.content = content;
}
}

View File

@@ -1,11 +0,0 @@
package com.ycwl.basic.notify.entity;
import lombok.Data;
@Data
public class ServerChanConfig {
private String key;
public void checkEverythingOK() {
}
}

View File

@@ -1,15 +0,0 @@
package com.ycwl.basic.notify.entity;
import lombok.Data;
@Data
public class WxMpSrvConfig {
private String appId;
private String appSecret;
private String state = "formal";
private String templateId;
public void checkEverythingOK() {
}
}

View File

@@ -1,15 +0,0 @@
package com.ycwl.basic.notify.enums;
import lombok.Getter;
@Getter
public enum NotifyType {
WX_MP_SRV("WX_MP_SRV"),
SERVER_CHAN("SERVER_CHAN");
private final String type;
NotifyType(String type) {
this.type = type;
}
}

Some files were not shown because too many files have changed in this diff Show More