Compare commits

...

58 Commits

Author SHA1 Message Date
2a3b4ca19f fix(video): 修复设备视频连续性检查缓存覆盖问题
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 为VideoContinuityReportReq的gaps字段添加空列表默认值
- 在设备不支持连续性检查时检查Redis中是否已存在外部上报的缓存记录
- 避免已有的外部上报缓存被内部检查结果覆盖
- 保持已有缓存记录的完整性,仅在无缓存时进行存储
2025-12-30 10:49:58 +08:00
85599aa84a feat(pc): 添加外部工具上报视频连续性检查结果接口
- 添加 @IgnoreToken 注解支持无需认证的上报功能
- 新增 reportContinuityResult 方法处理外部工具上报请求
- 实现根据设备编号查询设备ID的逻辑
- 添加视频连续性检查结果的缓存存储功能
- 支持间隙信息的转换和存储到Redis
- 设置24小时缓存TTL策略
- 完善日志记录和异常处理机制
2025-12-30 10:45:04 +08:00
fbd4cfa83c refactor(integration): 将降级缓存从Redis迁移到Caffeine内存缓存
- 移除RedisTemplate依赖,改用Caffeine作为缓存实现
- 添加缓存互斥锁机制,避免并发请求打崩下游服务
- 统一缓存策略:有缓存直接返回,无缓存调用远程并缓存结果
- 调整缓存TTL配置,从天单位改为分钟单位
- 更新缓存统计信息结构,TTL字段从天改为分钟
- 优化批量清除缓存逻辑,使用流式过滤处理
- 简化缓存操作API,移除无返回值的执行方法
2025-12-29 17:32:13 +08:00
4596a61ba8 feat(ExtraDevice): 添加外部设备管理功能
- 创建了 ExtraDeviceController 提供分页查询外部设备列表的API接口
- 新增 ExtraDeviceService 和 ExtraDeviceServiceImpl 实现设备查询逻辑
- 添加 ExtraDevicePageQueryReq 和 ExtraDeviceRespVO 请求响应数据模型
- 扩展 ExtraDeviceMapper 支持分页查询外部设备列表
- 实现景区名称填充和设备在线状态判断功能
- 集成 Redis 获取设备心跳时间判断在线状态
- 添加了完整的参数校验和异常处理机制
2025-12-29 16:06:32 +08:00
d6780ccb7a feat(face): 添加打印机大屏人脸识别试点功能
- 在PrinterTvController中添加tv场景参数,用于触发打印机大屏识别试点逻辑
- 在FaceMatchingScene枚举中新增PRINTER_TV_RECOGNIZE场景,支持仅执行识别补救落库建关系
- 在FaceMatchingPipelineFactory中创建打印机大屏识别试点Pipeline,包含识别补救落库建关系等阶段
- 在FaceServiceImpl中添加打印机大屏人脸识别的专门处理方法matchFaceIdForPrinterTv
- 通过scene参数区分普通人脸识别和打印机大屏识别试点流程
2025-12-29 11:34:10 +08:00
58e8189b13 perf(face): 提高人脸匹配严格度阈值
- 将人脸匹配的严格度分数从0.6调整为0.75
- 增强人脸识别的准确性要求
2025-12-28 17:07:07 +08:00
84cb5ad8f9 refactor(face): 优化人脸识别服务中的购买验证逻辑
- 移除多余的空格格式
- 删除重复的购买验证逻辑
- 移除临时解决方案代码
- 简化条件判断流程
2025-12-28 13:58:04 +08:00
71bfa00c25 refactor(member): 移除会员响应对象中的冗余字段
- 移除了推客优惠码、推客id和用户协议同意状态字段
- 移除了创建时间、更新时间和订单数量等不必要字段
- 移除了服务通知开关设置
- 清理了会员服务实现中相关的字段赋值逻辑
2025-12-26 20:03:50 +08:00
1916dd96a2 feat(video): 添加移动端HLS视频流播放列表生成功能
- 实现AppVideoStreamController提供HLS播放列表生成接口
- 添加HlsStreamRequest和HlsStreamResponse数据传输对象
- 实现HlsStreamService服务类处理视频流逻辑
- 支持生成JSON格式和m3u8文件格式的播放列表
- 提供视频片段查询和设备视频HLS播放列表生成功能
- 支持EVENT和VOD两种播放列表类型
- 集成设备存储操作器获取视频文件列表
- 实现播放列表内容构建和视频片段时长计算功能
2025-12-26 15:37:22 +08:00
c583d4b007 feat(video): 添加通过faceId查询最新视频记录功能
- 在AppVideoController中新增getLatestByFaceId接口
- 添加VideoRespVO响应对象导入
- 实现通过faceId和可选templateId查询最新视频记录的功能
- 在VideoMapper中定义queryLatestByFaceIdAndTemplateId方法
- 在VideoRepository中实现查询逻辑
- 在VideoMapper.xml中添加对应的SQL查询语句
- 支持根据faceId和templateId条件查询最新视频记录
- 添加相应的日志记录和异常处理机制
2025-12-26 15:35:27 +08:00
50ee14cf8f feat(mobile): 添加移动端模板接口
- 实现了 AppTemplateController 控制器
- 添加了根据模板ID获取封面URL的接口
- 集成了 TemplateRepository 数据访问层
- 实现了模板ID参数校验逻辑
- 添加了模板不存在的错误处理
- 实现了封面URL为空的验证机制
2025-12-24 10:31:13 +08:00
3f4d3cb7ac refactor(scenic): 替换ScenicConfigEntity为ScenicConfigManager
- 将所有使用ScenicConfigEntity的地方替换为ScenicConfigManager
- 更新获取景区配置的方法调用
- 修改属性访问方式为通过manager的getter方法
- 移除已废弃的ScenicConfigEntity类及相关方法
- 统一配置读取接口,提高代码一致性与可维护性
2025-12-19 19:02:37 +08:00
f2ba5ed65b refactor(task): 移除视频重新上传功能及相关依赖
- 删除 VideoReUploader 类及其相关注入依赖
- 移除 VideoReUploader 的调用逻辑
- 清理无用的导入语句及测试类文件
- 简化视频处理流程,去除冗余操作步骤
2025-12-19 17:21:41 +08:00
677893272a refactor(pricing): 移除冗余的产品属性键缓存逻辑
- 删除了产品属性键的缓存处理逻辑
- 简化了价格计算服务中的商品处理流程
- 移除了不再使用的产品属性键设置方法调用
2025-12-19 09:21:23 +08:00
956ace77a8 fix(pricing): 调整优惠券配置插入字段顺序
- 修改了 price_coupon_config 表的插入字段顺序
- 确保 SQL 插入语句字段与值一一对应
- 避免因字段顺序不一致导致的数据插入错误
2025-12-18 19:51:35 +08:00
08e2a4ebec fix(face): 更新拼图模板分组名称
- 将拼图模板分组名称从 "plog" 更改为 "氛围拼图"
- 确保与产品需求文档中的命名一致
- 避免因分组名称不明确导致的内容分类错误
2025-12-18 19:46:28 +08:00
3c8b3b0ace refactor(printer): 重构人脸素材查询接口
- 移除对SourceMapper的依赖
- 新增MemberRelationRepository和SourceRepository依赖
- 修改getSourceByFaceSampleId方法参数faceSampleId为faceId
- 方法返回类型由SourceEntity改为List<SourceEntity>
- 使用memberRelationRepository查询关联素材
- 当查询结果为空时返回空集合而非失败响应
- 通过sourceRepository获取具体资源信息并转换返回
2025-12-18 19:40:24 +08:00
94b37d47ec feat(printer): 添加照片属性键功能支持
- 引入SourceRepository依赖以获取设备ID
- 在打印服务中新增对照片来源属性的处理逻辑
- 根据用户照片列表生成唯一的设备ID集合
- 将生成的属性键列表关联到普通照片项中
- 优化打印数量统计逻辑,确保数据准确性
- 增强打印任务构建时的数据完整性校验
2025-12-18 11:18:45 +08:00
cd4422eb23 feat(printer): 优化自动发券逻辑并支持多种产品类型
- 移除了对source类型为3的检查逻辑
- 简化了自动发券的触发条件判断
- 新增对effectCount大于0时发放PHOTO_PRINT_FX类型优惠券的支持
- 保留了原有的异常处理机制确保不影响主流程
- 维持了mobileCount相关的业务逻辑不变
2025-12-18 11:13:38 +08:00
8dc0e993e1 refactor(utils): 替换雪花ID生成工具实现
- 移除自定义的雪花ID生成逻辑
- 引入Hutool的Snowflake工具类
- 简化ID生成方法,提高代码可维护性
- 移除相关的测试类文件
- 删除不再使用的UniqueId和UniqueIdMetaData模型类
2025-12-18 10:55:58 +08:00
2432cf496f test(puzzle): 更新测试用例以适配新的方法签名并增强唯一性验证
- 在 PuzzleGenerateServiceDeduplicationTest 中引入 CustomFaceSearchStage 类
- 添加 SpringBootTest 注解以支持完整的上下文加载
- 使用 InjectMocks 替代手动构造服务实例
- 修改 recordMapper.updateSuccess 方法调用,增加一个 String 参数
- 新增 SnowFlakeUtilTest 类用于测试雪花ID生成器的唯一性和性能
- 添加高并发环境下的ID唯一性校验逻辑
- 引入对潜在时间戳溢出问题的检测机制
- 增加单线程性能测试方法 testPerformanceSingleThread
2025-12-18 10:35:19 +08:00
95a5977ae2 feat(puzzle): 添加模板打印相关字段
- 在 PuzzleTemplateDTO 中新增 autoAddPrint 字段表示自动添加到打印队列
- 在 PuzzleTemplateDTO 中新增 canPrint 字段表示是否可以打印
- 在 PuzzleTemplateDTO 中新增 userArea 字段表示用户查看区域
- 在 TemplateCreateRequest 中新增 autoAddPrint 字段表示自动添加到打印队列
- 在 TemplateCreateRequest 中新增 canPrint 字段表示是否可以打印
- 在 TemplateCreateRequest 中新增 userArea 字段表示用户查看区域
2025-12-18 10:32:21 +08:00
7e157eaba9 feat(pricing): 新增优惠券属性门槛校验功能
- 在PriceCouponConfig实体中新增requiredAttributeKeys字段,用于配置优惠券使用门槛
- 修改MyBatis Mapper SQL语句,支持新字段的插入和更新操作
- 在CouponManagementServiceImpl中增加对requiredAttributeKeys的格式校验逻辑
- 更新CouponServiceImpl的优惠券适用性检查逻辑,增加属性门槛判断
- 在PriceCalculationServiceImpl中实现商品属性Key的自动计算与填充
- 优化价格计算服务中的能力缓存与属性Key构建逻辑
- 更新CLAUDE.md文档,补充属性门槛特性的说明
2025-12-17 23:49:20 +08:00
00dd6a16a3 refactor(face): 调整人脸识别匹配逻辑以支持场景参数
- 修改 PrinterTvController 中 faceUpload 方法的 scene 参数值从 print 改为 tv
- 在 FaceServiceImpl 中为人脸匹配方法增加 scene 参数支持
- 更新 FaceMatchingOrchestrator 的 orchestrateMatching 方法签名以接收 scene 参数
- 在 FaceService 接口中新增带 scene 参数的 matchFaceId 方法定义
- 更新 VideoTaskGenerator 中调用 matchFaceId 方法时传入 scene 参数
2025-12-17 23:16:48 +08:00
9e6b623b0e feat(printer): 优化打印机大屏人脸识别接口
- 移除多余依赖和字段声明
- 简化人脸识别逻辑,复用 faceUpload 方法
- 保留人脸去重检测和自动添加打印逻辑
- 修改返回结构为 FaceRecognizeResp
- 删除旧有的照片上传与素材查询流程
2025-12-17 23:12:49 +08:00
10b39ec4c1 feat(puzzle): 支持拼图原图保存与自动打印功能
- 在PuzzleGenerationRecordEntity中新增originalImageUrl字段用于存储未裁切的原图URL
- 在PuzzleTemplateEntity中新增autoAddPrint、canPrint和userArea字段支持打印配置
- 更新PuzzleGenerationRecordMapper.xml以支持新字段的读写操作
- 在PuzzleGenerateServiceImpl中实现原图上传、用户区域裁切以及自动添加到打印队列逻辑
- 新增cropImage方法处理图片按指定区域裁切
- 集成PrinterService实现拼图完成后自动添加到打印队列功能
- 优化生成流程日志记录,区分原图和最终图片的URL信息
2025-12-17 22:56:50 +08:00
3e938ad171 refactor(order): 移除优惠券相关业务逻辑
- 删除 CouponBiz 类及其所有方法调用
- 移除 OrderBiz 中对优惠券使用的处理逻辑
- 清理 PriceBiz 中与优惠券查询相关的代码
- 移除 GoodsServiceImpl 中的优惠券查询功能
- 删除 OrderServiceImpl 中创建订单时的优惠券计算逻辑
- 移除多个类中对 CouponBiz 的依赖注入
- 清理与优惠券记录和折扣价格计算相关的冗余代码
2025-12-17 22:16:16 +08:00
6c404e210e feat(source): 新增虚拟订单创建接口
- 添加 createVirtualOrder 接口用于后台创建0元订单
- 引入 PrinterService 和相关请求实体类
- 支持通过 sourceId、scenicId 和 printerId 创建订单
- 实现异常捕获并返回失败响应
- 扩展 SourceController 功能模块
2025-12-17 22:09:27 +08:00
25681806c3 feat(printer): 支持查询不同类型的图像素材
- 为getSourceByFaceSampleId接口添加type参数,默认值为2
- 修改sourceMapper调用,传入动态type参数
- 移除未使用的scenicService和faceService依赖
- 引入Map类但尚未使用,可能为后续功能预留
2025-12-17 22:08:58 +08:00
99d0b9c340 feat(printer): 实现虚拟用户0元订单创建功能
- 新增CreateVirtualOrderRequest请求参数类
- 实现createVirtualOrder服务方法,支持根据source创建虚拟用户订单
- 自动生成虚拟用户ID并创建对应的人脸记录
- 创建member_print记录并设置照片数量
- 支持指定或自动选择景区内可用打印机
- 创建0元订单并触发购买后逻辑
- 返回订单相关信息包括orderId、faceId等
- 添加详细的日志记录便于调试和追踪
- 实现完整的异常处理和参数校验
2025-12-17 22:07:42 +08:00
8e0990832b feat(ai-cam): 实现AI相机免费照片赠送逻辑
- 新增统计免费关联记录数的Mapper方法
- 在AppAiCamServiceImpl中注入ScenicRepository依赖
- 删除旧关系前查询已有免费记录避免重复赠送
- 根据景区配置获取免费照片数量
- 随机选择免费照片并标记为免费状态
- 保留已存在的免费照片记录
- 更新日志记录以区分普通和免费照片数量
2025-12-17 20:47:21 +08:00
144c338972 feat(profitshare): 添加接收人ID字段
- 在CreateRecipientRequest类中新增id字段
- 支持接收人唯一标识的传递
- 完善接收人创建请求的数据结构
2025-12-17 17:53:09 +08:00
2dcb736df5 fix(pay): 微信支付退款失败时记录错误信息
- 在退款响应中添加状态信息字段
- 当退款失败时设置并返回具体的错误状态
- 更新订单退款状态时增加对退款失败情况的日志记录
2025-12-17 17:52:58 +08:00
c8560e3aca fix(video): 修复任务状态查询逻辑并优化Redis过期策略
- 修改人脸模板渲染状态存储逻辑,增加默认过期时间
- 移除AppTaskController中冗余的JwtInfo获取代码
- 优化GoodsServiceImpl中任务状态判断逻辑,增强空值检查
- 修复视频任务状态返回不准确的问题,完善边界条件处理
2025-12-17 16:42:09 +08:00
171932c05c feat(face): 优化模板渲染状态查询逻辑
- 引入 TaskMapper 依赖以支持任务查询
- 移除带过期时间的模板渲染状态设置方法
- 在缓存缺失时查询最新任务记录以确定渲染状态
- 新增 listLastFaceTemplateTask 方法用于获取最新的人脸模板任务
- 实现根据任务状态自动设置模板渲染状态的逻辑
- 添加对任务状态为 1 和 2 时的渲染状态映射处理
2025-12-17 16:18:37 +08:00
6cd47649fc fix(wxpay): 修复 Kafka 生产者空指针异常
- 添加对 profitShareKafkaProducer 的空值检查
- 在发送消息前确保 Kafka 生产者已注入
- 使用 CompletableFuture 处理异步退款消息发送
- 设置 Kafka 生产者的注入模式为 required=false
- 避免因 Kafka 生产者缺失导致的服务启动失败
2025-12-17 15:52:41 +08:00
00890c764e feat(basic): 添加模板片段更新状态缓存支持
- 在FaceStatusManager中新增按模板ID区分的人脸片段更新状态缓存键
- 更新TaskTaskServiceImpl以设置模板渲染状态
- 在任务回调逻辑中增加对模板渲染状态的更新操作
- 修改任务删除逻辑为更新状态加10的临时解决方案
- 移除旧有的切割任务状态更新逻辑,统一使用模板渲染状态管理
2025-12-17 15:49:24 +08:00
a9c33352f7 feat(profit-share): 实现分账消息发送功能
- 修改 ProfitShareKafkaProducer 的 sendRefundMessage 方法返回 CompletableFuture
- 在 WxMpPayAdapter 中增加 transactionId 和 refundTransactionId 字段解析
- 在 PayResponse 和 RefundResponse 中新增 transactionId 相关字段
- 在 WxPayServiceImpl 中注入 ProfitShareKafkaProducer 并发送分账消息
- 调整退款逻辑以异步方式发送分账退款消息后再执行退款操作
2025-12-16 17:58:20 +08:00
a9555d612a feat(profitshare): 添加支付分账调用配置字段
- 在CreateRecipientRequest中新增needPaymentCall字段
- 用于控制是否需要调用支付分账接口
- 字段类型为Integer,支持空值处理
- 添加对应的JSON序列化注解
- 更新实体类文档注释说明用途
2025-12-16 17:23:38 +08:00
c1f35e1f3a Merge branch 'profitshare' 2025-12-16 10:45:30 +08:00
a5903a9831 feat(integration): 支持TypeReference泛型的降级缓存机制
- 在IntegrationFallbackService中新增支持TypeReference的executeWithFallback方法
- 新增getFallbackFromCache和parseFallbackCacheValue方法处理泛型缓存
- 更新DeviceStatusIntegrationService使用TypeReference保留泛型信息
- 更新RenderWorkerConfigIntegrationService使用TypeReference并修正缓存键
- 更新ScenicConfigIntegrationService使用TypeReference保留泛型信息
- 添加必要的Jackson TypeReference导入依赖
2025-12-16 10:00:49 +08:00
b207b5805a fix(face): 优化人脸匹配流程中的拼图模板生成逻辑
- 仅在新增人脸时异步生成拼图模板
- 避免重复生成已存在的人脸拼图模板
- 提升人脸匹配服务的执行效率
2025-12-15 18:32:15 +08:00
5d7fe1638e feat(integration): 优化降级缓存策略,支持优先使用1分钟内缓存
- 新增优先缓存判断逻辑,1分钟内的缓存优先返回
- 调整默认缓存TTL常量命名以避免混淆
- 重构缓存读取流程,优先解析已读取的缓存值
- 提取缓存值解析方法,增强代码复用性
- 完善缓存存储与读取的日志记录
- 修复缓存TTL单位使用不一致的问题
2025-12-15 18:32:15 +08:00
c0f07ee9f4 refactor(task): 重构任务拍摄时间获取逻辑
- 将 getTaskShotDate 方法从 TaskTaskServiceImpl 移至 VideoTaskRepository
- 删除对 TaskService 和 TaskTaskServiceImpl 的依赖注入
- 更新 LyCompatibleController 和 GoodsServiceImpl 中的调用方式
- 简化日期解析逻辑,提高代码可读性
- 移除冗余的 VideoMapper 和 TaskService 接口方法声明
- 统一使用 VideoTaskRepository 处理任务相关数据查询
2025-12-15 17:33:40 +08:00
832f6a2339 refactor(order): 简化faceId获取逻辑
- 移除通过task获取faceId的中间步骤
- 直接从video实体获取faceId
- 更新相关服务和控制器中的调用逻辑
- 优化日志记录中的faceId来源
- 提高代码可读性和执行效率
2025-12-15 17:29:53 +08:00
7348994427 refactor(video): 将视频实体中的workerId字段改为faceId
- 修改VideoEntity类中字段workerId为faceId,并更新注释
- 更新TaskTaskServiceImpl中设置视频信息的逻辑,使用faceId替代workerId
- 修改VideoMapper.xml中插入视频记录的SQL语句,字段由workerId改为faceId
- 调整VideoMapper.xml中更新视频记录的SQL条件,使用faceId进行筛选
- 更新VideoMapper.xml中查询视频列表和单个视频详情的SQL语句,字段名由workerId改为faceId
- 优化查询条件中对faceId的处理逻辑,直接关联video表的face_id字段
2025-12-15 16:51:04 +08:00
0665eef37d feat(videoreview): 添加视频购买状态检查功能
- 在VideoReviewController中新增/check-purchase接口用于检查视频是否被购买
- 扩展OrderMapper接口,增加根据视频ID和模板ID查询订单ID的方法
- 在VideoReviewServiceImpl中实现checkVideoPurchase方法,支持两种购买方式判断
- 完善相关DTO类引入及Mapper XML配置文件的SQL查询逻辑
- 实现直接购买视频和通过模板购买的双重购买状态检测机制
- 添加详细的日志记录便于后续追踪与调试
2025-12-15 16:49:20 +08:00
adabe88648 fix(video): 调整视频处理任务参数以优化性能
- 将探测大小从 32M 减小到 16M 以减少内存占用
- 修改线程池关闭前的等待时间从 5 分钟缩短至 3 分钟
- 添加注释说明批量定时停止的目的
2025-12-15 13:56:54 +08:00
3c838ec36e refactor(video): 优化视频切割逻辑,使用concat demuxer提升性能
- 引入concat demuxer方式替代原有转码流程,提高处理效率
- 新增PROBE_SIZE常量用于控制探测大小,优化文件解析
- 重构runFfmpegForMultipleFile1方法,简化多文件处理逻辑
- 添加quickVideoCutWithConcatDemuxer方法实现无转码快速切割
- 调整ffmpeg命令参数顺序及新增选项,如-probesize、-analyzeduration等
- 在多个ffmpeg调用中统一增加-genpts标志和避免负时间戳处理
- 完善临时文件清理机制,确保执行过程中的资源回收
- 更新相关ffmpeg命令构建逻辑以适配新的处理流程
2025-12-15 10:53:12 +08:00
5bef712b1c feat(face): 优化人脸内容购买逻辑
- 引入OrderRepository依赖以查询用户购买状态
- 修改内容购买检查逻辑,优先检查人脸项目购买情况
- 添加模板购买状态的二次校验机制
- 移除冗余的订单业务层调用逻辑
- 简化购买状态判断流程,提高代码可读性
2025-12-15 08:34:11 +08:00
f08d590a3d fix(price): 修复价格分享逻辑错误
- 移除了错误的价格分享判断条件
- 确保价格分享状态正确设置为false
- 优化了价格业务逻辑的代码结构
2025-12-15 08:33:58 +08:00
844bc318ae refactor(videoreview): 简化机位评价数据结构
- 修改机位评价数据结构从嵌套Map改为简单Map
- 更新数据库映射文件中的类型处理器配置
- 调整评价统计逻辑以适应新的数据结构
- 优化导出功能以支持新格式的机位评价展示
- 更新相关实体类、DTO类及Mapper接口定义
- 移除不再使用的嵌套Map相关代码和依赖
2025-12-15 08:33:48 +08:00
c9c4d9454a feat(goods): 优化商品详情购买状态判断逻辑
- 引入OrderRepository依赖以支持新的购买状态查询
- 替换原有的视频购买状态判断逻辑,使用更准确的checkUserBuyFaceItem方法
- 增加对模板ID的购买状态检查,提高判断准确性
- 简化价格查询前的条件判断流程
- 移除冗余的实体查询和复杂的嵌套判断逻辑
- 保持原有价格展示逻辑不变,确保前端显示一致
2025-12-14 12:51:51 +08:00
398a3750f8 todo: 逻辑弄反了 2025-12-14 12:20:48 +08:00
aceea9af18 feat(order): 添加拼图商品项到订单列表
- 在订单服务中新增拼图商品项实体
- 设置拼图商品的订单ID和景区ID
- 指定拼图商品类型为5
- 将拼图商品项加入订单项列表
- 保留原有价格配置逻辑不变
2025-12-14 12:11:59 +08:00
54088f46d9 fix(biz): 调整pLog图商品添加逻辑
- 将pLog图<景区打包>商品添加到列表末尾改为添加到列表开头
- 当puzzleList非空时才添加打包商品
- 确保打包商品始终显示在商品列表第一位
2025-12-14 00:07:45 +08:00
8064c68b8b feat(profit-share): 调整分账与退款消息结构并优化接口
- 修改手动分账接口路径为 /manual 并支持请求体参数
- 更新计算分账结果接口路径为 /calculate
- 将退款消息主题从 zt-refund 更改为 zt-profitshare-refund
-重构退款消息对象字段,增加退款类型和原订单 ID
- 更新退款消息生产者逻辑以适配新字段和主题配置
- 强化退款消息校验规则,确保必要字段完整性
2025-10-24 20:10:44 +08:00
bdeb41bead feat(profit-share): 实现分账管理V2版本功能
- 新增分账规则的创建、查询、更新、启用、禁用和删除接口
- 新增分账记录的查询接口,支持按景区、订单ID等多种方式查询
- 新增手动触发分账和计算分账结果的功能接口
- 新增获取支持类型的接口,方便前端展示和选择- 集成分账服务Feign客户端,实现与zt-profitshare微服务通信
- 添加Kafka消息配置,支持分账和退款消息的发送
- 完善DTO结构定义,包括规则、记录、明细及消息相关实体类
- 实现集成服务层,封装对分账服务的操作并提供fallback机制
- 控制器层增加参数校验和异常处理逻辑,提高系统健壮性- 所有接口均遵循RESTful设计规范,并提供详细的日志记录
2025-10-13 20:30:46 +08:00
138 changed files with 5621 additions and 1899 deletions

View File

@@ -286,6 +286,12 @@
<artifactId>spring-kafka</artifactId> <artifactId>spring-kafka</artifactId>
</dependency> </dependency>
<!-- Caffeine Cache -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- Apache POI - 处理Excel文件 --> <!-- Apache POI - 处理Excel文件 -->
<dependency> <dependency>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>

View File

@@ -7,7 +7,7 @@ import com.ycwl.basic.mapper.StatisticsMapper;
import com.ycwl.basic.model.pc.broker.entity.BrokerRecord; import com.ycwl.basic.model.pc.broker.entity.BrokerRecord;
import com.ycwl.basic.model.pc.broker.resp.BrokerRespVO; import com.ycwl.basic.model.pc.broker.resp.BrokerRespVO;
import com.ycwl.basic.model.pc.order.entity.OrderEntity; import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity; import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.repository.OrderRepository; import com.ycwl.basic.repository.OrderRepository;
import com.ycwl.basic.repository.ScenicRepository; import com.ycwl.basic.repository.ScenicRepository;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -43,14 +43,14 @@ public class BrokerBiz {
log.info("订单不存在,订单ID:{}", orderId); log.info("订单不存在,订单ID:{}", orderId);
return; return;
} }
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(order.getScenicId()); ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(order.getScenicId());
if (scenicConfig == null) { if (scenicConfig == null) {
log.info("景区不存在,订单ID:{}", orderId); log.info("景区不存在,订单ID:{}", orderId);
return; return;
} }
int expireDay = 3; int expireDay = 3;
if (scenicConfig.getSampleStoreDay() != null) { if (scenicConfig.getInteger("sample_store_day") != null) {
expireDay = scenicConfig.getSampleStoreDay(); expireDay = scenicConfig.getInteger("sample_store_day");
} }
List<Long> brokerIdList = statisticsMapper.getBrokerIdListForUser(order.getMemberId(), DateUtil.offsetDay(DateUtil.beginOfDay(order.getCreateAt()), -expireDay), order.getCreateAt()); List<Long> brokerIdList = statisticsMapper.getBrokerIdListForUser(order.getMemberId(), DateUtil.offsetDay(DateUtil.beginOfDay(order.getCreateAt()), -expireDay), order.getCreateAt());
if (brokerIdList == null || brokerIdList.isEmpty()) { if (brokerIdList == null || brokerIdList.isEmpty()) {
@@ -103,7 +103,7 @@ public class BrokerBiz {
BigDecimal realRate = broker.getBrokerRate(); BigDecimal realRate = broker.getBrokerRate();
BigDecimal brokerPrice = order.getPayPrice().multiply(realRate).divide(BigDecimal.valueOf(100), 2, RoundingMode.DOWN); BigDecimal brokerPrice = order.getPayPrice().multiply(realRate).divide(BigDecimal.valueOf(100), 2, RoundingMode.DOWN);
// todo 需要计算实际提成比例 // todo 需要计算实际提成比例
BigDecimal firstRate = scenicConfig.getBrokerDirectRate(); BigDecimal firstRate = scenicConfig.getBigDecimal("broker_direct_rate");
if (firstRate == null) { if (firstRate == null) {
firstRate = BigDecimal.ZERO; firstRate = BigDecimal.ZERO;
} }

View File

@@ -1,91 +0,0 @@
package com.ycwl.basic.biz;
import com.ycwl.basic.mapper.CouponMapper;
import com.ycwl.basic.mapper.CouponRecordMapper;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
import com.ycwl.basic.model.pc.couponRecord.entity.CouponRecordEntity;
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
import java.util.Optional;
@Component
public class CouponBiz {
@Autowired
private CouponMapper couponMapper;
@Autowired
private CouponRecordMapper couponRecordMapper;
public CouponRecordQueryResp queryUserCouponRecord(Long scenicId, Long memberId, Long faceId, String goodsId) {
CouponRecordQueryResp resp = new CouponRecordQueryResp();
List<CouponRecordEntity> recordList = couponRecordMapper.queryByUserWithGoodsId(scenicId, memberId, goodsId);
if (recordList != null && !recordList.isEmpty()) {
Optional<CouponRecordEntity> record = recordList.stream().filter(item -> item.getStatus() == 0).filter(item -> item.getFaceId() == null || item.getFaceId().equals(faceId)).findAny();
if (record.isPresent()) {
CouponRecordEntity recordEntity = record.get();
resp.setExist(true);
resp.setId(recordEntity.getId());
resp.setCouponId(recordEntity.getCouponId());
CouponEntity coupon = couponMapper.getById(recordEntity.getCouponId());
if (coupon != null) {
resp.setMemberId(recordEntity.getMemberId());
resp.setFaceId(recordEntity.getFaceId());
resp.setStatus(recordEntity.getStatus());
resp.setCreateTime(recordEntity.getCreateTime());
resp.setUsedTime(recordEntity.getUsedTime());
resp.setUsedOrderId(recordEntity.getUsedOrderId());
resp.setCoupon(coupon);
} else {
resp.setExist(false);
}
} else {
Optional<CouponRecordEntity> usedRecord = recordList.stream().filter(item -> item.getStatus() != 0).filter(item -> item.getFaceId() == null || item.getFaceId().equals(faceId)).findAny();
if (usedRecord.isPresent()) {
CouponRecordEntity recordEntity = usedRecord.get();
resp.setExist(true);
resp.setId(recordEntity.getId());
resp.setCouponId(recordEntity.getCouponId());
CouponEntity coupon = couponMapper.getById(recordEntity.getCouponId());
if (coupon != null) {
resp.setMemberId(recordEntity.getMemberId());
resp.setFaceId(recordEntity.getFaceId());
resp.setStatus(recordEntity.getStatus());
resp.setCreateTime(recordEntity.getCreateTime());
resp.setUsedTime(recordEntity.getUsedTime());
resp.setUsedOrderId(recordEntity.getUsedOrderId());
resp.setCoupon(coupon);
} else {
resp.setExist(false);
}
}
}
}
return resp;
}
public boolean userGetCoupon(Long memberId, Long faceId, Integer couponId) {
CouponEntity coupon = couponMapper.getById(couponId);
if (coupon == null) {
return false;
}
CouponRecordEntity entity = new CouponRecordEntity();
entity.setCouponId(couponId);
entity.setFaceId(faceId);
entity.setMemberId(memberId);
entity.setStatus(0);
entity.setCreateTime(new Date());
return couponRecordMapper.insert(entity) > 0;
}
public boolean userUseCoupon(Long memberId, Long faceId, Integer couponRecordId, Long orderId) {
CouponRecordEntity entity = new CouponRecordEntity();
entity.setId(couponRecordId);
entity.setStatus(1);
entity.setUsedTime(new Date());
entity.setUsedOrderId(orderId);
return couponRecordMapper.updateById(entity) > 0;
}
}

View File

@@ -0,0 +1,296 @@
package com.ycwl.basic.biz;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.ycwl.basic.enums.FaceCutStatus;
import com.ycwl.basic.enums.FacePieceUpdateStatus;
import com.ycwl.basic.enums.TemplateRenderStatus;
import com.ycwl.basic.mapper.TaskMapper;
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* 人脸状态缓存管理器
* 统一管理人脸相关的内存缓存状态(使用Caffeine)
*/
@Slf4j
@Component
public class FaceStatusManager {
/**
* 默认过期时间:1小时
*/
private static final long DEFAULT_EXPIRE_SECONDS = 3600L;
/**
* 人脸切片状态缓存
*/
private final Cache<String, Integer> faceCutStatusCache;
/**
* 人脸片段更新状态缓存(全局和模板级)
* 键存在=无新片段,键不存在=有新片段
*/
private final Cache<String, Boolean> faceNoPieceUpdateCache;
/**
* 人脸模板渲染状态缓存
*/
private final Cache<String, Integer> templateRenderCache;
@Autowired
private TaskMapper taskMapper;
public FaceStatusManager() {
// 初始化三个独立的缓存实例
this.faceCutStatusCache = Caffeine.newBuilder()
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
this.faceNoPieceUpdateCache = Caffeine.newBuilder()
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
this.templateRenderCache = Caffeine.newBuilder()
.expireAfterWrite(DEFAULT_EXPIRE_SECONDS, TimeUnit.SECONDS)
.maximumSize(10000)
.build();
}
// ==================== 切片状态相关方法 ====================
/**
* 设置人脸切片状态
* @param faceId 人脸ID
* @param status 切片状态
*/
public void setFaceCutStatus(Long faceId, FaceCutStatus status) {
if (faceId == null || status == null) {
log.warn("设置切片状态参数为空: faceId={}, status={}", faceId, status);
return;
}
faceCutStatusCache.put(String.valueOf(faceId), status.getCode());
log.debug("设置切片状态: faceId={}, status={}", faceId, status.getDescription());
}
/**
* 获取人脸切片状态
* @param faceId 人脸ID
* @return 切片状态,缓存不存在时返回 COMPLETED(已完成)
*/
public FaceCutStatus getFaceCutStatus(Long faceId) {
if (faceId == null) {
log.warn("获取切片状态参数为空: faceId={}", faceId);
return FaceCutStatus.COMPLETED;
}
Integer code = faceCutStatusCache.getIfPresent(String.valueOf(faceId));
if (code == null) {
log.debug("切片状态缓存不存在,返回默认值COMPLETED: faceId={}", faceId);
return FaceCutStatus.COMPLETED;
}
return FaceCutStatus.fromCodeOrDefault(code, FaceCutStatus.COMPLETED);
}
/**
* 删除人脸切片状态缓存
* @param faceId 人脸ID
*/
public void deleteFaceCutStatus(Long faceId) {
if (faceId == null) {
return;
}
faceCutStatusCache.invalidate(String.valueOf(faceId));
log.debug("删除切片状态缓存: faceId={}", faceId);
}
// ==================== 片段更新状态相关方法 ====================
/**
* 标记无新片段(设置缓存键)
* @param faceId 人脸ID
* @param templateId 模板ID(可选,为null时标记全局状态)
*/
public void markNoNewPieces(Long faceId, Long templateId) {
if (faceId == null) {
log.warn("标记无新片段参数为空: faceId={}", faceId);
return;
}
if (templateId == null) {
// 全局标记:该人脸的所有模板都无新片段
faceNoPieceUpdateCache.put(String.valueOf(faceId), Boolean.TRUE);
log.debug("标记无新片段(全局): faceId={}", faceId);
} else {
// 模板级标记:该人脸在该模板下无新片段
faceNoPieceUpdateCache.put(faceId + ":" + templateId, Boolean.TRUE);
log.debug("标记无新片段(模板): faceId={}, templateId={}", faceId, templateId);
}
}
/**
* 标记有新片段(删除缓存键)
* @param faceId 人脸ID
* @param templateId 模板ID(可选,为null时标记全局状态)
*/
public void markHasNewPieces(Long faceId, Long templateId) {
if (faceId == null) {
log.warn("标记有新片段参数为空: faceId={}", faceId);
return;
}
if (templateId == null) {
// 全局标记:该人脸有新片段
faceNoPieceUpdateCache.invalidate(String.valueOf(faceId));
log.debug("标记有新片段(全局): faceId={}", faceId);
} else {
// 模板级标记:该人脸在该模板下有新片段
faceNoPieceUpdateCache.invalidate(faceId + ":" + templateId);
log.debug("标记有新片段(模板): faceId={}, templateId={}", faceId, templateId);
}
}
/**
* 获取人脸片段更新状态
* @param faceId 人脸ID
* @param templateId 模板ID(可选,为null时查询全局状态)
* @return 片段更新状态,键存在=无新片段,键不存在=有新片段
*/
public FacePieceUpdateStatus getFacePieceUpdateStatus(Long faceId, Long templateId) {
if (faceId == null) {
log.warn("获取片段更新状态参数为空: faceId={}", faceId);
return FacePieceUpdateStatus.HAS_NEW_PIECES;
}
String key = templateId == null ? String.valueOf(faceId) : faceId + ":" + templateId;
boolean exists = faceNoPieceUpdateCache.getIfPresent(key) != null;
FacePieceUpdateStatus status = FacePieceUpdateStatus.fromKeyExists(exists);
if (templateId == null) {
log.debug("获取片段更新状态(全局): faceId={}, status={}", faceId, status.getDescription());
} else {
log.debug("获取片段更新状态(模板): faceId={}, templateId={}, status={}",
faceId, templateId, status.getDescription());
}
return status;
}
/**
* 获取人脸片段更新状态 - 全局版本
* @param faceId 人脸ID
* @return 片段更新状态,键存在=无新片段,键不存在=有新片段
*/
public FacePieceUpdateStatus getFacePieceUpdateStatus(Long faceId) {
return getFacePieceUpdateStatus(faceId, null);
}
/**
* 判断是否有新片段
* @param faceId 人脸ID
* @param templateId 模板ID(可选,为null时查询全局状态)
* @return true=有新片段,false=无新片段;如果templateId为null则默认返回true(有新片段)
*/
public boolean hasNewPieces(Long faceId, Long templateId) {
if (templateId == null) {
// 如果没有指定templateId,默认认为有新片段
log.debug("未指定templateId,默认返回有新片段: faceId={}", faceId);
return true;
}
return getFacePieceUpdateStatus(faceId, templateId).hasNewPieces();
}
/**
* 判断是否有新片段 - 全局版本
* @param faceId 人脸ID
* @return true=有新片段,false=无新片段
*/
public boolean hasNewPieces(Long faceId) {
return getFacePieceUpdateStatus(faceId, null).hasNewPieces();
}
// ==================== 模板渲染状态相关方法 ====================
/**
* 设置人脸模板渲染状态
* @param faceId 人脸ID
* @param templateId 模板ID
* @param status 渲染状态
*/
public void setTemplateRenderStatus(Long faceId, Long templateId, TemplateRenderStatus status) {
if (faceId == null || templateId == null || status == null) {
log.warn("设置模板渲染状态参数为空: faceId={}, templateId={}, status={}", faceId, templateId, status);
return;
}
templateRenderCache.put(faceId + ":" + templateId, status.getCode());
log.debug("设置模板渲染状态: faceId={}, templateId={}, status={}", faceId, templateId, status.getDescription());
}
/**
* 获取人脸模板渲染状态
* @param faceId 人脸ID
* @param templateId 模板ID
* @return 渲染状态,缓存不存在时返回 null
*/
public TemplateRenderStatus getTemplateRenderStatus(Long faceId, Long templateId) {
if (faceId == null || templateId == null) {
log.warn("获取模板渲染状态参数为空: faceId={}, templateId={}", faceId, templateId);
return null;
}
Integer code = templateRenderCache.getIfPresent(faceId + ":" + templateId);
if (code == null) {
log.debug("模板渲染状态缓存不存在: faceId={}, templateId={}", faceId, templateId);
// 查询数据库
TaskEntity task = taskMapper.listLastFaceTemplateTask(faceId, templateId);
if (task == null) {
setTemplateRenderStatus(faceId, templateId, TemplateRenderStatus.NONE);
return TemplateRenderStatus.NONE;
}
if (Integer.valueOf(2).equals(task.getStatus())) {
setTemplateRenderStatus(faceId, templateId, TemplateRenderStatus.RENDERING);
}
if (Integer.valueOf(1).equals(task.getStatus())) {
setTemplateRenderStatus(faceId, templateId, TemplateRenderStatus.RENDERED);
}
return TemplateRenderStatus.NONE;
}
return TemplateRenderStatus.fromCode(code);
}
/**
* 删除人脸模板渲染状态缓存
* @param faceId 人脸ID
* @param templateId 模板ID
*/
public void deleteTemplateRenderStatus(Long faceId, Long templateId) {
if (faceId == null || templateId == null) {
return;
}
templateRenderCache.invalidate(faceId + ":" + templateId);
log.debug("删除模板渲染状态缓存: faceId={}, templateId={}", faceId, templateId);
}
/**
* 删除人脸的所有模板渲染状态(使用模式匹配)
* 注意:此操作可能影响性能,谨慎使用
* @param faceId 人脸ID
*/
public void deleteAllTemplateRenderStatus(Long faceId) {
if (faceId == null) {
return;
}
String prefix = faceId + ":";
long count = templateRenderCache.asMap().keySet().stream()
.filter(key -> key.startsWith(prefix))
.peek(templateRenderCache::invalidate)
.count();
if (count > 0) {
log.debug("批量删除模板渲染状态缓存: faceId={}, count={}", faceId, count);
}
}
}

View File

@@ -3,23 +3,14 @@ package com.ycwl.basic.biz;
import com.ycwl.basic.enums.StatisticEnum; import com.ycwl.basic.enums.StatisticEnum;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager; import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.OrderMapper; import com.ycwl.basic.mapper.OrderMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.mapper.StatisticsMapper; import com.ycwl.basic.mapper.StatisticsMapper;
import com.ycwl.basic.mapper.VideoMapper;
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
import com.ycwl.basic.model.mobile.order.IsBuyRespVO; import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
import com.ycwl.basic.model.mobile.order.PriceObj; import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq; import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
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;
import com.ycwl.basic.model.pc.order.entity.OrderEntity; import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.order.entity.OrderItemEntity; import com.ycwl.basic.model.pc.order.entity.OrderItemEntity;
import com.ycwl.basic.model.pc.order.resp.OrderAppRespVO; import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.model.pc.order.resp.OrderItemVO;
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
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.VideoEntity; import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.pricing.dto.PriceCalculationRequest; import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
@@ -42,16 +33,12 @@ import org.springframework.stereotype.Component;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Optional;
@Component @Component
public class OrderBiz { public class OrderBiz {
@Autowired
private VideoMapper videoMapper;
@Autowired @Autowired
private ScenicRepository scenicRepository; private ScenicRepository scenicRepository;
@Autowired @Autowired
@@ -69,16 +56,12 @@ public class OrderBiz {
@Autowired @Autowired
private OrderMapper orderMapper; private OrderMapper orderMapper;
@Autowired @Autowired
private SourceMapper sourceMapper;
@Autowired
private ProfitSharingBiz profitSharingBiz; private ProfitSharingBiz profitSharingBiz;
@Autowired @Autowired
private VideoTaskRepository videoTaskRepository; private VideoTaskRepository videoTaskRepository;
@Autowired @Autowired
private BrokerBiz brokerBiz; private BrokerBiz brokerBiz;
@Autowired @Autowired
private CouponBiz couponBiz;
@Autowired
@Lazy @Lazy
private PrinterService printerService; private PrinterService printerService;
@Autowired @Autowired
@@ -88,9 +71,9 @@ public class OrderBiz {
PriceObj priceObj = new PriceObj(); PriceObj priceObj = new PriceObj();
priceObj.setGoodsType(goodsType); priceObj.setGoodsType(goodsType);
priceObj.setGoodsId(goodsId); priceObj.setGoodsId(goodsId);
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(scenicId); ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
if (scenicConfig != null) { if (scenicConfig != null) {
if (Boolean.TRUE.equals(scenicConfig.getAllFree())) { if (Boolean.TRUE.equals(scenicConfig.getBoolean("all_free"))) {
// 景区全免 // 景区全免
priceObj.setFree(true); priceObj.setFree(true);
priceObj.setPrice(BigDecimal.ZERO); priceObj.setPrice(BigDecimal.ZERO);
@@ -104,10 +87,7 @@ public class OrderBiz {
if (video == null) { if (video == null) {
return null; return null;
} }
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId()); priceObj.setFaceId(video.getFaceId());
if (task != null) {
priceObj.setFaceId(task.getFaceId());
}
TemplateRespVO template = templateRepository.getTemplate(video.getTemplateId()); TemplateRespVO template = templateRepository.getTemplate(video.getTemplateId());
if (template == null) { if (template == null) {
return priceObj; return priceObj;
@@ -247,10 +227,6 @@ public class OrderBiz {
} }
}); });
orderRepository.clearOrderCache(orderId); // 更新完了,清理下 orderRepository.clearOrderCache(orderId); // 更新完了,清理下
Integer couponRecordId = order.getCouponRecordId();
if (couponRecordId != null) {
couponBiz.userUseCoupon(order.getMemberId(), order.getFaceId(), couponRecordId, orderId);
}
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq(); StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
statisticsRecordAddReq.setMemberId(order.getMemberId()); statisticsRecordAddReq.setMemberId(order.getMemberId());
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt()); Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
@@ -313,15 +289,4 @@ public class OrderBiz {
profitSharingBiz.revokeProfitSharing(order.getScenicId(), orderId, "订单已退款"); profitSharingBiz.revokeProfitSharing(order.getScenicId(), orderId, "订单已退款");
} }
/**
* 检查用户是否购买了指定商品,并额外校验订单的faceId是否匹配
* @param userId 用户ID
* @param faceId 人脸ID
* @param goodsType 商品类型
* @param goodsId 商品ID
* @return 是否已购买且faceId匹配
*/
public boolean checkUserBuyFaceItem(Long userId, Long faceId, int goodsType, Long goodsId) {
return orderRepository.checkUserBuyFaceItem(userId, faceId, goodsType, goodsId);
}
} }

View File

@@ -8,7 +8,7 @@ import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity; 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.price.resp.SimpleGoodsRespVO; import com.ycwl.basic.model.pc.price.resp.SimpleGoodsRespVO;
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity; import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
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.model.pc.video.entity.MemberVideoEntity;
import com.ycwl.basic.product.capability.ProductTypeCapability; import com.ycwl.basic.product.capability.ProductTypeCapability;
@@ -17,6 +17,7 @@ import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper; import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
import com.ycwl.basic.repository.FaceRepository; import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository; import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.OrderRepository;
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;
@@ -45,16 +46,13 @@ public class PriceBiz {
@Autowired @Autowired
private FaceRepository faceRepository; private FaceRepository faceRepository;
@Autowired @Autowired
@Lazy
private FaceService faceService;
@Autowired
private CouponBiz couponBiz;
@Autowired
private MemberRelationRepository memberRelationRepository; private MemberRelationRepository memberRelationRepository;
@Autowired @Autowired
private PuzzleTemplateMapper puzzleTemplateMapper; private PuzzleTemplateMapper puzzleTemplateMapper;
@Autowired @Autowired
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService; private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
@Autowired
private OrderRepository orderRepository;
public List<GoodsListRespVO> listGoodsByScenic(Long scenicId) { public List<GoodsListRespVO> listGoodsByScenic(Long scenicId) {
List<GoodsListRespVO> goodsList = new ArrayList<>(); List<GoodsListRespVO> goodsList = new ArrayList<>();
@@ -67,12 +65,12 @@ public class PriceBiz {
goods.setGoodsType(0); goods.setGoodsType(0);
return goods; return goods;
}).forEach(goodsList::add); }).forEach(goodsList::add);
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(scenicId); ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
if (scenicConfig != null) { if (scenicConfig != null) {
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceVideo())) { if (!Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_video"))) {
goodsList.add(new GoodsListRespVO(1L, "录像集", 1)); goodsList.add(new GoodsListRespVO(1L, "录像集", 1));
} }
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceImage())) { if (!Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_image"))) {
goodsList.add(new GoodsListRespVO(2L, "照片集", 2)); goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
} }
} }
@@ -133,11 +131,13 @@ public class PriceBiz {
case "PHOTO_LOG": case "PHOTO_LOG":
// 从 template 表查询pLog模板 // 从 template 表查询pLog模板
goodsList.add(new SimpleGoodsRespVO(scenicId, "pLog图<景区打包>", productType));
List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null); List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null);
puzzleList.stream() puzzleList.stream()
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType)) .map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
.forEach(goodsList::add); .forEach(goodsList::add);
if (!puzzleList.isEmpty()) {
goodsList.addFirst(new SimpleGoodsRespVO(scenicId, "pLog图<景区打包>", productType));
}
break; break;
case "RECORDING_SET": case "RECORDING_SET":
@@ -220,39 +220,15 @@ public class PriceBiz {
if (face != null && !face.getMemberId().equals(userId)) { if (face != null && !face.getMemberId().equals(userId)) {
return null; return null;
} }
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(scenicId); ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
if (scenicConfig != null) { if (scenicConfig != null) {
if (Boolean.TRUE.equals(scenicConfig.getAllFree())) { if (Boolean.TRUE.equals(scenicConfig.getBoolean("all_free"))) {
// 景区全免 // 景区全免
respVO.setFree(true); respVO.setFree(true);
respVO.setSlashPrice(BigDecimal.ZERO); respVO.setSlashPrice(BigDecimal.ZERO);
return respVO; return respVO;
} }
} }
switch (type) {
case 0: // 单个定价
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, faceId, goodsIds);
if (recordQueryResp.isUsable()) {
respVO.setCouponId(recordQueryResp.getCouponId());
respVO.setCouponRecordId(recordQueryResp.getId());
CouponEntity coupon = recordQueryResp.getCoupon();
if (coupon != null) {
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceConfig.getPrice()));
}
}
break;
case -1:
CouponRecordQueryResp oneCouponRecordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, faceId, "-1");
if (oneCouponRecordQueryResp.isUsable()) {
respVO.setCouponId(oneCouponRecordQueryResp.getCouponId());
respVO.setCouponRecordId(oneCouponRecordQueryResp.getId());
CouponEntity coupon = oneCouponRecordQueryResp.getCoupon();
if (coupon != null) {
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceConfig.getPrice()));
}
}
break;
}
respVO.setConfigId(priceConfig.getId()); respVO.setConfigId(priceConfig.getId());
respVO.setGoodsIds(goodsIds); respVO.setGoodsIds(goodsIds);
respVO.setType(type); respVO.setType(type);
@@ -281,7 +257,7 @@ public class PriceBiz {
allContentsPurchased = false; allContentsPurchased = false;
break; break;
} }
boolean hasPurchasedTemplate = orderBiz.checkUserBuyFaceItem(userId, faceId, -1, videoEntities.getFirst().getVideoId()); boolean hasPurchasedTemplate = orderRepository.checkUserBuyFaceItem(userId, faceId, -1, videoEntities.getFirst().getVideoId());
if (!hasPurchasedTemplate) { if (!hasPurchasedTemplate) {
allContentsPurchased = false; allContentsPurchased = false;
break; break;
@@ -292,16 +268,16 @@ public class PriceBiz {
if (allContentsPurchased) { if (allContentsPurchased) {
if (scenicConfig != null) { if (scenicConfig != null) {
// 检查录像集 // 检查录像集
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceVideo())) { if (!Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_video"))) {
boolean hasPurchasedRecording = orderBiz.checkUserBuyFaceItem(userId, faceId, 1, faceId); boolean hasPurchasedRecording = orderRepository.checkUserBuyFaceItem(userId, faceId, 1, faceId);
if (!hasPurchasedRecording) { if (!hasPurchasedRecording) {
allContentsPurchased = false; allContentsPurchased = false;
} }
} }
// 检查照片集 // 检查照片集
if (allContentsPurchased && !Boolean.TRUE.equals(scenicConfig.getDisableSourceImage())) { if (allContentsPurchased && !Boolean.TRUE.equals(scenicConfig.getBoolean("disable_source_image"))) {
boolean hasPurchasedPhoto = orderBiz.checkUserBuyFaceItem(userId, faceId, 2, faceId); boolean hasPurchasedPhoto = orderRepository.checkUserBuyFaceItem(userId, faceId, 2, faceId);
if (!hasPurchasedPhoto) { if (!hasPurchasedPhoto) {
allContentsPurchased = false; allContentsPurchased = false;
} }
@@ -315,9 +291,6 @@ public class PriceBiz {
} }
} }
respVO.setShare(false); respVO.setShare(false);
if (face == null || !face.getMemberId().equals(userId)) {
respVO.setShare(true);
}
return respVO; return respVO;
} }
} }

View File

@@ -1,192 +0,0 @@
package com.ycwl.basic.biz;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.mapper.TaskMapper;
import com.ycwl.basic.mapper.VideoMapper;
import com.ycwl.basic.model.mobile.goods.VideoTaskStatusVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.model.pc.task.req.TaskReqQuery;
import com.ycwl.basic.model.pc.task.resp.TaskRespVO;
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.TemplateRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Component
public class TaskStatusBiz {
public static final String TASK_STATUS_USER_CACHE_KEY = "task:status:user:%s:face:%s";
public static final String TASK_STATUS_FACE_CACHE_KEY = "task:status:face:%s";
public static final String TASK_STATUS_FACE_CACHE_KEY_CUT = "task:status:face:%s:cut";
public static final String TASK_STATUS_FACE_CACHE_KEY_TEMPLATE = "task:status:face:%s:tpl:%s";
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private FaceRepository faceRepository;
@Autowired
private TemplateRepository templateRepository;
@Autowired
private FaceMapper faceMapper;
@Autowired
private TaskMapper taskMapper;
@Autowired
private VideoMapper videoMapper;
@Autowired
private TemplateBiz templateBiz;
public boolean getUserHaveFace(Long userId, Long faceId) {
if (userId == null || faceId == null) {
return false;
}
if (redisTemplate.hasKey(String.format(TASK_STATUS_USER_CACHE_KEY, userId, faceId))) {
return true;
}
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
return false;
}
if (face.getMemberId().equals(userId)) {
redisTemplate.opsForValue().set(String.format(TASK_STATUS_USER_CACHE_KEY, userId, faceId), "1", 3600, TimeUnit.SECONDS);
return true;
} else {
return false;
}
}
public void setFaceCutStatus(Long faceId, int status) {
redisTemplate.opsForValue().set(String.format(TASK_STATUS_FACE_CACHE_KEY_CUT, faceId), String.valueOf(status), 3600, TimeUnit.SECONDS);
}
public void setFaceTemplateStatus(Long faceId, Long templateId, Long videoId) {
redisTemplate.opsForValue().set(String.format(TASK_STATUS_FACE_CACHE_KEY_TEMPLATE, faceId, templateId), String.valueOf(videoId), 3600, TimeUnit.SECONDS);
}
public VideoTaskStatusVO getScenicUserStatus(Long scenicId, Long userId) {
FaceRespVO lastFace = faceMapper.findLastFaceByScenicAndUserId(scenicId, userId);
VideoTaskStatusVO response = new VideoTaskStatusVO();
if (lastFace == null) {
response.setStatus(-1);
return response;
}
return getFaceStatus(lastFace.getId());
}
public VideoTaskStatusVO getFaceStatus(Long faceId) {
FaceEntity face = faceRepository.getFace(faceId);
VideoTaskStatusVO response = new VideoTaskStatusVO();
if (face == null) {
response.setStatus(-1);
return response;
}
response.setScenicId(face.getScenicId());
response.setFaceId(faceId);
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(face.getScenicId());
response.setMaxCount(templateList.size());
int alreadyFinished = 0;
for (TemplateRespVO template : templateList) {
response.setTemplateId(template.getId());
long videoId = getFaceTemplateVideoId(faceId, template.getId());
if (videoId <= 0) {
response.setStatus(2);
} else {
response.setVideoId(videoId);
alreadyFinished++;
}
}
response.setCount(alreadyFinished);
if (alreadyFinished == 0) {
response.setStatus(0);
} else {
response.setStatus(1);
}
if (alreadyFinished == 0) {
int faceCutStatus = getFaceCutStatus(faceId);
if (faceCutStatus != 1) {
// 正在切片
if (templateBiz.determineTemplateCanGenerate(templateList.getFirst().getId(), faceId, false)) {
response.setStatus(2);
} else {
response.setStatus(0);
}
}
}
return response;
}
public VideoTaskStatusVO getFaceTemplateStatus(Long faceId, Long templateId) {
FaceEntity face = faceRepository.getFace(faceId);
VideoTaskStatusVO response = new VideoTaskStatusVO();
if (face == null) {
response.setStatus(-1);
return response;
}
response.setScenicId(face.getScenicId());
response.setFaceId(faceId);
response.setTemplateId(templateId);
long videoId = getFaceTemplateVideoId(faceId, templateId);
if (videoId < 0) {
int faceCutStatus = getFaceCutStatus(faceId);
if (faceCutStatus != 1) {
// 正在切片
response.setStatus(2);
return response;
}
} else if (videoId == 0) {
response.setStatus(2);
} else {
response.setVideoId(videoId);
response.setStatus(1);
}
return response;
}
public int getFaceCutStatus(Long faceId) {
if (redisTemplate.hasKey(String.format(TASK_STATUS_FACE_CACHE_KEY_CUT, faceId))) {
String status = redisTemplate.opsForValue().get(String.format(TASK_STATUS_FACE_CACHE_KEY_CUT, faceId));
if (status != null) {
return Integer.parseInt(status);
}
}
return 1;
}
public long getFaceTemplateVideoId(Long faceId, Long templateId) {
if (redisTemplate.hasKey(String.format(TASK_STATUS_FACE_CACHE_KEY_TEMPLATE, faceId, templateId))) {
String status = redisTemplate.opsForValue().get(String.format(TASK_STATUS_FACE_CACHE_KEY_TEMPLATE, faceId, templateId));
if (status != null) {
return Long.parseLong(status);
}
}
TaskReqQuery taskReqQuery = new TaskReqQuery();
taskReqQuery.setFaceId(faceId);
taskReqQuery.setTemplateId(templateId);
List<TaskRespVO> list = taskMapper.list(taskReqQuery);
Optional<TaskRespVO> min = list.stream().min(Comparator.comparing(TaskRespVO::getCreateTime));
if (min.isPresent()) {
TaskRespVO task = min.get();
long taskStatus = 0;
if (task.getStatus() == 1) {
// 已完成
VideoEntity video = videoMapper.findByTaskId(task.getId());
if (video != null) {
taskStatus = video.getId();
}
}
setFaceTemplateStatus(faceId, templateId, taskStatus);
} else {
// 从来没生成过
setFaceTemplateStatus(faceId, templateId, -1L);
return -1;
}
return 0;
}
}

View File

@@ -1,6 +1,8 @@
package com.ycwl.basic.controller; package com.ycwl.basic.controller;
import com.github.pagehelper.PageInfo; import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.pc.videoreview.dto.VideoPurchaseCheckReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoPurchaseCheckRespDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO; import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO; import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO; import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
@@ -91,4 +93,20 @@ public class VideoReviewController {
throw new RuntimeException("导出失败: " + e.getMessage()); throw new RuntimeException("导出失败: " + e.getMessage());
} }
} }
/**
* 检查视频是否已被购买
* 购买条件:
* 1. 直接购买视频(order_item中goods_type=0且goods_id=视频id)
* 2. 购买整个模板(order的face_id与video关联的task的face_id相同,goods_type=-1,goods_id为video的templateId)
*
* @param reqDTO 查询条件
* @return 购买状态及订单ID列表
*/
@PostMapping("/check-purchase")
public ApiResponse<VideoPurchaseCheckRespDTO> checkVideoPurchase(@RequestBody VideoPurchaseCheckReqDTO reqDTO) {
log.info("检查视频购买状态,videoId: {}", reqDTO.getVideoId());
VideoPurchaseCheckRespDTO respDTO = videoReviewService.checkVideoPurchase(reqDTO);
return ApiResponse.success(respDTO);
}
} }

View File

@@ -58,10 +58,6 @@ public class LyCompatibleController {
@Autowired @Autowired
private VideoRepository videoRepository; private VideoRepository videoRepository;
@Autowired @Autowired
private VideoMapper videoMapper;
@Autowired
private TaskTaskServiceImpl taskTaskServiceImpl;
@Autowired
private RedisTemplate<String, String> redisTemplate; private RedisTemplate<String, String> redisTemplate;
@Autowired @Autowired
private VideoTaskRepository videoTaskRepository; private VideoTaskRepository videoTaskRepository;
@@ -213,12 +209,11 @@ public class LyCompatibleController {
VideoEntity videoRespVO = videoRepository.getVideo(contentPageVO.getContentId()); VideoEntity videoRespVO = videoRepository.getVideo(contentPageVO.getContentId());
map.put("id", videoRespVO.getId().toString()); map.put("id", videoRespVO.getId().toString());
map.put("task_id", videoRespVO.getTaskId().toString()); map.put("task_id", videoRespVO.getTaskId().toString());
TaskEntity task = videoTaskRepository.getTaskById(videoRespVO.getTaskId()); if (videoRespVO.getFaceId() != null) {
if (task != null) { map.put("face_id", String.valueOf(videoRespVO.getFaceId()));
map.put("face_id", String.valueOf(task.getFaceId()));
} }
map.put("template_cover_image", contentPageVO.getTemplateCoverUrl()); map.put("template_cover_image", contentPageVO.getTemplateCoverUrl());
Date taskShotDate = taskTaskServiceImpl.getTaskShotDate(videoRespVO.getTaskId()); Date taskShotDate = videoTaskRepository.getTaskShotDate(videoRespVO.getTaskId());
map.put("shoottime", DateUtil.format(taskShotDate, "yyyy-MM-dd HH:mm")); map.put("shoottime", DateUtil.format(taskShotDate, "yyyy-MM-dd HH:mm"));
map.put("openid", openId); map.put("openid", openId);
map.put("scenicname", contentPageVO.getScenicName()); map.put("scenicname", contentPageVO.getScenicName());

View File

@@ -104,7 +104,6 @@ public class AppGoodsController {
// 查询用户当前景区的具体模版视频合成任务状态 1 合成中 2 合成成功 // 查询用户当前景区的具体模版视频合成任务状态 1 合成中 2 合成成功
@GetMapping("/task/face/{faceId}/template/{templateId}") @GetMapping("/task/face/{faceId}/template/{templateId}")
public ApiResponse<VideoTaskStatusVO> getTemplateTaskStatus(@PathVariable("faceId") Long faceId, @PathVariable("templateId") Long templateId) { public ApiResponse<VideoTaskStatusVO> getTemplateTaskStatus(@PathVariable("faceId") Long faceId, @PathVariable("templateId") Long templateId) {
JwtInfo worker = JwtTokenUtil.getWorker();
return ApiResponse.success(goodsService.getTaskStatusByTemplateId(faceId, templateId)); return ApiResponse.success(goodsService.getTaskStatusByTemplateId(faceId, templateId));
} }

View File

@@ -85,8 +85,7 @@ public class AppOrderV2Controller {
switch (productItem.getProductType()) { switch (productItem.getProductType()) {
case VLOG_VIDEO -> { case VLOG_VIDEO -> {
VideoEntity video = videoRepository.getVideo(Long.valueOf(productItem.getProductId())); VideoEntity video = videoRepository.getVideo(Long.valueOf(productItem.getProductId()));
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId()); request.setFaceId(video.getFaceId());
request.setFaceId(task.getFaceId());
} }
case RECORDING_SET, PHOTO_SET, AI_CAM_PHOTO_SET -> request.setFaceId(Long.valueOf(productItem.getProductId())); case RECORDING_SET, PHOTO_SET, AI_CAM_PHOTO_SET -> request.setFaceId(Long.valueOf(productItem.getProductId()));
} }

View File

@@ -27,7 +27,6 @@ public class AppTaskController {
@GetMapping("/face/{faceId}") @GetMapping("/face/{faceId}")
@IgnoreLogReq @IgnoreLogReq
public ApiResponse<VideoTaskStatusVO> getTaskStatusByFaceId(@PathVariable("faceId") Long faceId) { public ApiResponse<VideoTaskStatusVO> getTaskStatusByFaceId(@PathVariable("faceId") Long faceId) {
JwtInfo worker = JwtTokenUtil.getWorker();
return ApiResponse.success(goodsService.getTaskStatusByFaceId(faceId)); return ApiResponse.success(goodsService.getTaskStatusByFaceId(faceId));
} }
@GetMapping("/scenic/{scenicId}") @GetMapping("/scenic/{scenicId}")
@@ -49,7 +48,6 @@ public class AppTaskController {
@GetMapping("/face/{faceId}/template/{templateId}") @GetMapping("/face/{faceId}/template/{templateId}")
@IgnoreLogReq @IgnoreLogReq
public ApiResponse<VideoTaskStatusVO> getTemplateTaskStatus(@PathVariable("faceId") Long faceId, @PathVariable("templateId") Long templateId) { public ApiResponse<VideoTaskStatusVO> getTemplateTaskStatus(@PathVariable("faceId") Long faceId, @PathVariable("templateId") Long templateId) {
JwtInfo worker = JwtTokenUtil.getWorker();
return ApiResponse.success(goodsService.getTaskStatusByTemplateId(faceId, templateId)); return ApiResponse.success(goodsService.getTaskStatusByTemplateId(faceId, templateId));
} }

View File

@@ -0,0 +1,48 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.mapper.TemplateMapper;
import com.ycwl.basic.model.pc.template.entity.TemplateEntity;
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
import com.ycwl.basic.repository.TemplateRepository;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 移动端模板接口
*/
@RestController
@RequestMapping("/api/mobile/template/v1")
@RequiredArgsConstructor
public class AppTemplateController {
private final TemplateRepository templateRepository;
/**
* 根据模板ID获取封面URL
*
* @param templateId 模板ID
* @return 模板封面URL
*/
@GetMapping("/cover/{templateId}")
public ApiResponse<String> getTemplateCoverUrl(@PathVariable("templateId") Long templateId) {
if (templateId == null) {
return ApiResponse.fail("模板ID不能为空");
}
TemplateRespVO template = templateRepository.getTemplate(templateId);
if (template == null) {
return ApiResponse.fail("未找到对应的模板");
}
String coverUrl = template.getCoverUrl();
if (coverUrl == null || coverUrl.isEmpty()) {
return ApiResponse.fail("该模板没有封面地址");
}
return ApiResponse.success(coverUrl);
}
}

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.constant.BaseContextHandler; import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.model.mobile.video.dto.VideoViewPermissionDTO; import com.ycwl.basic.model.mobile.video.dto.VideoViewPermissionDTO;
import com.ycwl.basic.model.pc.video.resp.VideoRespVO;
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.service.mobile.VideoViewPermissionService;
@@ -13,6 +14,7 @@ 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.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Slf4j @Slf4j
@@ -90,4 +92,33 @@ public class AppVideoController {
return ApiResponse.fail("权限检查失败,请稍后重试"); return ApiResponse.fail("权限检查失败,请稍后重试");
} }
} }
/**
* 通过faceId和templateId(可选)查询最新的视频记录
*
* @param faceId 人脸ID
* @param templateId 模板ID(可选)
* @return 最新的视频记录
*/
@GetMapping("/latest")
public ApiResponse<VideoRespVO> getLatestByFaceId(
@RequestParam("faceId") Long faceId,
@RequestParam(value = "templateId", required = false) Long templateId) {
try {
log.debug("查询最新视频记录: faceId={}, templateId={}", faceId, templateId);
VideoRespVO video = videoRepository.queryLatestByFaceIdAndTemplateId(faceId, templateId);
if (video == null) {
log.info("未找到视频记录: faceId={}, templateId={}", faceId, templateId);
return ApiResponse.fail("未找到视频记录");
}
return ApiResponse.success(video);
} catch (Exception e) {
log.error("查询最新视频记录失败: faceId={}, templateId={}", faceId, templateId, e);
return ApiResponse.fail("查询失败,请稍后重试");
}
}
} }

View File

@@ -0,0 +1,150 @@
package com.ycwl.basic.controller.mobile.manage;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.model.mobile.video.dto.HlsStreamRequest;
import com.ycwl.basic.model.mobile.video.dto.HlsStreamResponse;
import com.ycwl.basic.service.mobile.HlsStreamService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 移动端视频流控制器
* 提供HLS视频流播放列表生成功能
*
* @author Claude Code
* @date 2025-12-26
*/
@Slf4j
@RestController
@RequestMapping("/api/mobile/video-stream/v1")
@RequiredArgsConstructor
public class AppVideoStreamController {
private final HlsStreamService hlsStreamService;
/**
* 生成设备视频的HLS播放列表(JSON格式)
* 返回包含m3u8内容和视频片段信息的JSON对象
*
* @param request HLS流请求参数
* @return HLS播放列表响应
*/
@PostMapping("/hls/playlist")
public ApiResponse<HlsStreamResponse> generateHlsPlaylist(@Validated @RequestBody HlsStreamRequest request) {
log.info("收到HLS播放列表生成请求: deviceId={}, durationMinutes={}",
request.getDeviceId(), request.getDurationMinutes());
try {
HlsStreamResponse response = hlsStreamService.generateHlsPlaylist(request);
log.info("HLS播放列表生成成功: deviceId={}, segmentCount={}, totalDuration={}s",
response.getDeviceId(), response.getSegmentCount(), response.getTotalDurationSeconds());
return ApiResponse.success(response);
} catch (Exception e) {
log.error("生成HLS播放列表失败: deviceId={}", request.getDeviceId(), e);
return ApiResponse.buildResponse(500, null, "生成失败: " + e.getMessage());
}
}
/**
* 生成设备视频的HLS播放列表(m3u8文件格式)
* 直接返回m3u8文件内容,可被视频播放器直接使用
*
* @param deviceId 设备ID
* @param durationMinutes 视频时长(分钟),默认2分钟
* @param eventPlaylist 是否为Event播放列表,默认true
* @param response HTTP响应对象
*/
@GetMapping("/hls/playlist.m3u8")
@IgnoreToken
public void generateHlsPlaylistFile(
@RequestParam Long deviceId,
@RequestParam(defaultValue = "2") Integer durationMinutes,
@RequestParam(defaultValue = "true") Boolean eventPlaylist,
HttpServletResponse response) {
log.info("收到m3u8文件生成请求: deviceId={}, durationMinutes={}",
deviceId, durationMinutes);
try {
// 构建请求参数
HlsStreamRequest request = new HlsStreamRequest();
request.setDeviceId(deviceId);
request.setDurationMinutes(durationMinutes);
request.setEventPlaylist(eventPlaylist);
// 生成播放列表
HlsStreamResponse hlsResponse = hlsStreamService.generateHlsPlaylist(request);
log.info("m3u8文件生成成功: deviceId={}, segmentCount={}, totalDuration={}s",
deviceId, hlsResponse.getSegmentCount(), hlsResponse.getTotalDurationSeconds());
// 设置响应头
response.setContentType("application/vnd.apple.mpegurl");
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setHeader("Content-Disposition", "inline; filename=\"playlist.m3u8\"");
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
// 写入m3u8内容
response.getWriter().write(hlsResponse.getPlaylistContent());
response.getWriter().flush();
} catch (Exception e) {
log.error("生成m3u8文件失败: deviceId={}", deviceId, e);
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write("{\"code\":500,\"message\":\"生成失败: " + e.getMessage() + "\"}");
response.getWriter().flush();
} catch (IOException ioException) {
log.error("写入错误响应失败", ioException);
}
}
}
/**
* 获取设备最近的视频片段信息
* 仅返回视频片段列表,不包含m3u8内容
*
* @param deviceId 设备ID
* @param durationMinutes 视频时长(分钟),默认2分钟
* @return 视频片段列表
*/
@GetMapping("/segments")
public ApiResponse<HlsStreamResponse> getVideoSegments(
@RequestParam Long deviceId,
@RequestParam(defaultValue = "2") Integer durationMinutes) {
log.info("收到视频片段查询请求: deviceId={}, durationMinutes={}",
deviceId, durationMinutes);
try {
HlsStreamRequest request = new HlsStreamRequest();
request.setDeviceId(deviceId);
request.setDurationMinutes(durationMinutes);
request.setEventPlaylist(true);
HlsStreamResponse response = hlsStreamService.generateHlsPlaylist(request);
log.info("视频片段查询成功: deviceId={}, segmentCount={}",
deviceId, response.getSegmentCount());
return ApiResponse.success(response);
} catch (Exception e) {
log.error("查询视频片段失败: deviceId={}", deviceId, e);
return ApiResponse.buildResponse(500, null, "查询失败: " + e.getMessage());
}
}
}

View File

@@ -1,14 +1,24 @@
package com.ycwl.basic.controller.pc; package com.ycwl.basic.controller.pc;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache; import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.device.req.VideoContinuityReportReq;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask; import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/** /**
* 设备视频连续性检查控制器 * 设备视频连续性检查控制器
* 提供查询设备视频连续性检查结果的接口 * 提供查询设备视频连续性检查结果的接口
@@ -23,10 +33,12 @@ import org.springframework.web.bind.annotation.*;
public class DeviceVideoContinuityController { public class DeviceVideoContinuityController {
private static final String REDIS_KEY_PREFIX = "device:video:continuity:"; private static final String REDIS_KEY_PREFIX = "device:video:continuity:";
private static final int CACHE_TTL_HOURS = 24; // 缓存24小时
private final RedisTemplate<String, String> redisTemplate; private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final DeviceVideoContinuityCheckTask checkTask; private final DeviceVideoContinuityCheckTask checkTask;
private final DeviceRepository deviceRepository;
/** /**
* 查询设备最近的视频连续性检查结果 * 查询设备最近的视频连续性检查结果
@@ -103,4 +115,72 @@ public class DeviceVideoContinuityController {
return ApiResponse.buildResponse(500, null, "删除失败: " + e.getMessage()); return ApiResponse.buildResponse(500, null, "删除失败: " + e.getMessage());
} }
} }
/**
* 外部工具上报视频连续性检查结果
* 通过设备编号(deviceNo)上报检查结果,无需认证
*
* @param reportReq 上报请求
* @return 上报结果
*/
@PostMapping("/report")
@IgnoreToken
public ApiResponse<DeviceVideoContinuityCache> reportContinuityResult(
@Validated @RequestBody VideoContinuityReportReq reportReq) {
log.info("外部工具上报设备 {} 的视频连续性检查结果", reportReq.getDeviceNo());
try {
// 1. 根据设备编号查询设备ID
DeviceEntity device = deviceRepository.getDeviceByDeviceNo(reportReq.getDeviceNo());
if (device == null) {
log.warn("设备编号 {} 不存在", reportReq.getDeviceNo());
return ApiResponse.buildResponse(404, null, "设备不存在: " + reportReq.getDeviceNo());
}
Long deviceId = device.getId();
// 2. 构建缓存对象
DeviceVideoContinuityCache cache = new DeviceVideoContinuityCache();
cache.setDeviceId(deviceId);
cache.setCheckTime(new Date());
cache.setStartTime(reportReq.getStartTime());
cache.setEndTime(reportReq.getEndTime());
cache.setSupport(reportReq.getSupport());
cache.setContinuous(reportReq.getContinuous());
cache.setTotalVideos(reportReq.getTotalVideos());
cache.setTotalDurationMs(reportReq.getTotalDurationMs());
cache.setMaxAllowedGapMs(reportReq.getMaxAllowedGapMs() != null
? reportReq.getMaxAllowedGapMs() : 2000L);
cache.setGapCount(reportReq.getGaps() != null ? reportReq.getGaps().size() : 0);
// 3. 转换间隙信息
if (reportReq.getGaps() != null && !reportReq.getGaps().isEmpty()) {
List<DeviceVideoContinuityCache.GapInfo> gapInfos = reportReq.getGaps().stream()
.map(gap -> new DeviceVideoContinuityCache.GapInfo(
gap.getBeforeFileName(),
gap.getAfterFileName(),
gap.getGapMs(),
gap.getGapStartTime(),
gap.getGapEndTime()
))
.collect(Collectors.toList());
cache.setGaps(gapInfos);
}
// 4. 存储到Redis
String redisKey = REDIS_KEY_PREFIX + deviceId;
String cacheJson = objectMapper.writeValueAsString(cache);
redisTemplate.opsForValue().set(redisKey, cacheJson, CACHE_TTL_HOURS, TimeUnit.HOURS);
log.info("设备 {} (ID: {}) 视频连续性检查结果上报成功: continuous={}, videos={}, gaps={}",
reportReq.getDeviceNo(), deviceId, cache.getContinuous(),
cache.getTotalVideos(), cache.getGapCount());
return ApiResponse.success(cache);
} catch (Exception e) {
log.error("外部工具上报设备 {} 视频连续性检查结果失败", reportReq.getDeviceNo(), e);
return ApiResponse.buildResponse(500, null, "上报失败: " + e.getMessage());
}
}
} }

View File

@@ -0,0 +1,44 @@
package com.ycwl.basic.controller.pc;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.pc.extraDevice.req.ExtraDevicePageQueryReq;
import com.ycwl.basic.model.pc.extraDevice.resp.ExtraDeviceRespVO;
import com.ycwl.basic.service.pc.ExtraDeviceService;
import com.ycwl.basic.utils.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 外部设备管理控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/extra_device/v1")
@RequiredArgsConstructor
public class ExtraDeviceController {
private final ExtraDeviceService extraDeviceService;
/**
* 分页查询外部设备列表
*
* @param req 查询请求参数,包含scenicId(可选)、pageNum、pageSize
* @return 分页查询结果,包含设备ID、景区ID、景区名称、设备名称、标识、状态、心跳时间、在线状态
*/
@PostMapping("/page")
public ApiResponse<PageInfo<ExtraDeviceRespVO>> page(@RequestBody ExtraDevicePageQueryReq req) {
log.info("分页查询外部设备列表, scenicId: {}, pageNum: {}, pageSize: {}",
req.getScenicId(), req.getPageNum(), req.getPageSize());
PageInfo<ExtraDeviceRespVO> pageInfo = extraDeviceService.pageQuery(req);
log.info("外部设备列表查询完成, total: {}, pages: {}",
pageInfo.getTotal(), pageInfo.getPages());
return ApiResponse.success(pageInfo);
}
}

View File

@@ -0,0 +1,257 @@
package com.ycwl.basic.controller.pc;
import com.ycwl.basic.integration.profitshare.dto.CalculateResultVO;
import com.ycwl.basic.integration.profitshare.dto.CalculateShareRequest;
import com.ycwl.basic.integration.profitshare.dto.ManualShareRequest;
import com.ycwl.basic.integration.profitshare.dto.TypesVO;
import com.ycwl.basic.integration.profitshare.dto.record.RecordDetailVO;
import com.ycwl.basic.integration.profitshare.dto.record.RecordVO;
import com.ycwl.basic.integration.profitshare.dto.rule.CreateRuleRequest;
import com.ycwl.basic.integration.profitshare.dto.rule.RuleVO;
import com.ycwl.basic.integration.profitshare.service.ProfitShareIntegrationService;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.utils.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 分账管理 V2 版本控制器 - 基于 zt-profitshare 集成服务
*
* @author Claude Code
* @date 2025-01-11
*/
@Slf4j
@RestController
@RequestMapping("/api/profit-share/v2")
@RequiredArgsConstructor
public class ProfitShareV2Controller {
private final ProfitShareIntegrationService profitShareIntegrationService;
// ========== 分账规则管理 ==========
/**
* 创建分账规则
*/
@PostMapping("/rules")
public ApiResponse<RuleVO> createRule(@Valid @RequestBody CreateRuleRequest request) {
log.info("创建分账规则, scenicId: {}, ruleName: {}, ruleType: {}",
request.getScenicId(), request.getRuleName(), request.getRuleType());
try {
RuleVO rule = profitShareIntegrationService.createRule(request);
return ApiResponse.success(rule);
} catch (Exception e) {
log.error("创建分账规则失败", e);
return ApiResponse.fail("创建分账规则失败: " + e.getMessage());
}
}
/**
* 查询分账规则列表
*/
@GetMapping("/rules")
public ApiResponse<PageResponse<RuleVO>> listRules(
@RequestParam(required = false) Long scenicId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String ruleType,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize) {
log.info("查询分账规则列表, scenicId: {}, status: {}, ruleType: {}, page: {}, pageSize: {}",
scenicId, status, ruleType, page, pageSize);
// 参数验证:限制pageSize最大值为100
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<RuleVO> response = profitShareIntegrationService.listRules(scenicId, status, ruleType, page, pageSize);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("查询分账规则列表失败", e);
return ApiResponse.fail("查询分账规则列表失败: " + e.getMessage());
}
}
/**
* 获取分账规则详情
*/
@GetMapping("/rules/{id}")
public ApiResponse<RuleVO> getRule(@PathVariable Long id) {
log.info("获取分账规则详情, id: {}", id);
try {
RuleVO rule = profitShareIntegrationService.getRule(id);
return ApiResponse.success(rule);
} catch (Exception e) {
log.error("获取分账规则详情失败, id: {}", id, e);
return ApiResponse.fail("获取分账规则详情失败: " + e.getMessage());
}
}
/**
* 更新分账规则
*/
@PutMapping("/rules/{id}")
public ApiResponse<RuleVO> updateRule(@PathVariable Long id, @Valid @RequestBody CreateRuleRequest request) {
log.info("更新分账规则, id: {}", id);
try {
RuleVO rule = profitShareIntegrationService.updateRule(id, request);
return ApiResponse.success(rule);
} catch (Exception e) {
log.error("更新分账规则失败, id: {}", id, e);
return ApiResponse.fail("更新分账规则失败: " + e.getMessage());
}
}
/**
* 启用分账规则
*/
@PutMapping("/rules/{id}/enable")
public ApiResponse<String> enableRule(@PathVariable Long id) {
log.info("启用分账规则, id: {}", id);
try {
profitShareIntegrationService.enableRule(id);
return ApiResponse.success("规则已启用");
} catch (Exception e) {
log.error("启用分账规则失败, id: {}", id, e);
return ApiResponse.fail("启用分账规则失败: " + e.getMessage());
}
}
/**
* 禁用分账规则
*/
@PutMapping("/rules/{id}/disable")
public ApiResponse<String> disableRule(@PathVariable Long id) {
log.info("禁用分账规则, id: {}", id);
try {
profitShareIntegrationService.disableRule(id);
return ApiResponse.success("规则已禁用");
} catch (Exception e) {
log.error("禁用分账规则失败, id: {}", id, e);
return ApiResponse.fail("禁用分账规则失败: " + e.getMessage());
}
}
/**
* 删除分账规则
*/
@DeleteMapping("/rules/{id}")
public ApiResponse<String> deleteRule(@PathVariable Long id) {
log.info("删除分账规则, id: {}", id);
try {
profitShareIntegrationService.deleteRule(id);
return ApiResponse.success("规则已删除");
} catch (Exception e) {
log.error("删除分账规则失败, id: {}", id, e);
return ApiResponse.fail("删除分账规则失败: " + e.getMessage());
}
}
// ========== 分账记录查询 ==========
/**
* 查询景区分账记录
*/
@GetMapping("/records/scenic/{scenicId}")
public ApiResponse<PageResponse<RecordVO>> getRecordsByScenic(
@PathVariable Long scenicId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer pageSize) {
log.info("查询景区分账记录, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
// 参数验证:限制pageSize最大值为100
if (pageSize > 100) {
pageSize = 100;
}
try {
PageResponse<RecordVO> response = profitShareIntegrationService.getRecordsByScenic(scenicId, page, pageSize);
return ApiResponse.success(response);
} catch (Exception e) {
log.error("查询景区分账记录失败, scenicId: {}", scenicId, e);
return ApiResponse.fail("查询景区分账记录失败: " + e.getMessage());
}
}
/**
* 查询分账记录详情
*/
@GetMapping("/records/{id}")
public ApiResponse<RecordDetailVO> getRecordById(@PathVariable Long id) {
log.info("查询分账记录详情, id: {}", id);
try {
RecordDetailVO record = profitShareIntegrationService.getRecordById(id);
return ApiResponse.success(record);
} catch (Exception e) {
log.error("查询分账记录详情失败, id: {}", id, e);
return ApiResponse.fail("查询分账记录详情失败: " + e.getMessage());
}
}
/**
* 按订单ID查询分账记录
*/
@GetMapping("/records/order/{orderId}")
public ApiResponse<RecordDetailVO> getRecordByOrderId(@PathVariable String orderId) {
log.info("按订单ID查询分账记录, orderId: {}", orderId);
try {
RecordDetailVO record = profitShareIntegrationService.getRecordByOrderId(orderId);
return ApiResponse.success(record);
} catch (Exception e) {
log.error("按订单ID查询分账记录失败, orderId: {}", orderId, e);
return ApiResponse.fail("按订单ID查询分账记录失败: " + e.getMessage());
}
}
// ========== 手动分账与计算 ==========
/**
* 手动触发分账
*/
@PostMapping("/manual")
public ApiResponse<String> manualShare(@Valid @RequestBody ManualShareRequest request) {
log.info("手动触发分账, orderId: {}", request.getOrderId());
try {
profitShareIntegrationService.manualShare(request.getOrderId());
return ApiResponse.success("手动分账触发成功");
} catch (Exception e) {
log.error("手动触发分账失败, orderId: {}", request.getOrderId(), e);
return ApiResponse.fail("手动触发分账失败: " + e.getMessage());
}
}
/**
* 计算分账结果(不执行)
*/
@PostMapping("/calculate")
public ApiResponse<CalculateResultVO> calculateShare(@Valid @RequestBody CalculateShareRequest request) {
log.info("计算分账结果, scenicId: {}, totalAmount: {}", request.getScenicId(), request.getTotalAmount());
try {
CalculateResultVO result = profitShareIntegrationService.calculateShare(request);
return ApiResponse.success(result);
} catch (Exception e) {
log.error("计算分账结果失败", e);
return ApiResponse.fail("计算分账结果失败: " + e.getMessage());
}
}
// ========== 类型查询 ==========
/**
* 获取支持的类型列表
*/
@GetMapping("/types")
public ApiResponse<TypesVO> getSupportedTypes() {
log.info("获取支持的类型列表");
try {
TypesVO types = profitShareIntegrationService.getSupportedTypes();
return ApiResponse.success(types);
} catch (Exception e) {
log.error("获取支持的类型列表失败", e);
return ApiResponse.fail("获取支持的类型列表失败: " + e.getMessage());
}
}
}

View File

@@ -4,12 +4,16 @@ import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.model.jwt.JwtInfo; import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.pc.source.entity.SourceEntity; import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.model.pc.source.req.SourceReqQuery; import com.ycwl.basic.model.pc.source.req.SourceReqQuery;
import com.ycwl.basic.model.printer.req.CreateVirtualOrderRequest;
import com.ycwl.basic.service.pc.SourceService; import com.ycwl.basic.service.pc.SourceService;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil; import com.ycwl.basic.utils.JwtTokenUtil;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map;
/** /**
* @Author:longbinbin * @Author:longbinbin
* @Date:2024/12/3 15:45 * @Date:2024/12/3 15:45
@@ -21,6 +25,8 @@ public class SourceController {
@Autowired @Autowired
private SourceService sourceService; private SourceService sourceService;
@Autowired
private PrinterService printerService;
// 分页查询视频源 // 分页查询视频源
@PostMapping("/page") @PostMapping("/page")
@@ -45,5 +51,26 @@ public class SourceController {
return sourceService.deleteById(id); return sourceService.deleteById(id);
} }
/**
* 创建虚拟用户0元订单
* 用于后台直接从source创建订单,不需要真实用户
*
* @param request 请求参数
* @return 订单信息
*/
@PostMapping("/createVirtualOrder")
public ApiResponse<Map<String, Object>> createVirtualOrder(@RequestBody CreateVirtualOrderRequest request) {
try {
Map<String, Object> result = printerService.createVirtualOrder(
request.getSourceId(),
request.getScenicId(),
request.getPrinterId()
);
return ApiResponse.success(result);
} catch (Exception e) {
return ApiResponse.fail(e.getMessage());
}
}
} }

View File

@@ -1,36 +1,23 @@
package com.ycwl.basic.controller.printer; package com.ycwl.basic.controller.printer;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.SourceRepository;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.service.task.TaskFaceService;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.annotation.IgnoreToken; import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO; import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO; import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.mapper.SourceMapper; import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity; import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.mp.MpConfigEntity; import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery; import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity; import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.model.printer.FaceRecognizeWithSourcesResp;
import com.ycwl.basic.repository.DeviceRepository; import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository; import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.MemberRelationRepository;
import com.ycwl.basic.repository.ScenicRepository; import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.repository.SourceRepository;
import com.ycwl.basic.service.pc.FaceService; import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.storage.utils.StorageUtil;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.SnowFlakeUtil;
import com.ycwl.basic.utils.WxMpUtil; import com.ycwl.basic.utils.WxMpUtil;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -45,12 +32,8 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.util.ArrayList; import java.util.Collections;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.UUID;
import static com.ycwl.basic.constant.StorageConstant.USER_FACE;
@IgnoreToken @IgnoreToken
// 打印机大屏对接接口 // 打印机大屏对接接口
@@ -62,14 +45,9 @@ public class PrinterTvController {
private final DeviceRepository deviceRepository; private final DeviceRepository deviceRepository;
private final ScenicRepository scenicRepository; private final ScenicRepository scenicRepository;
private final FaceRepository faceRepository; private final FaceRepository faceRepository;
private final TaskFaceService faceService;
private final FaceService pcFaceService; private final FaceService pcFaceService;
private final ScenicService scenicService;
private final SourceMapper sourceMapper;
private final FaceMapper faceMapper;
private final MemberRelationRepository memberRelationRepository; private final MemberRelationRepository memberRelationRepository;
private final SourceRepository sourceRepository; private final SourceRepository sourceRepository;
private final PrinterService printerService;
/** /**
* 获取景区列表 * 获取景区列表
@@ -191,85 +169,37 @@ public class PrinterTvController {
/** /**
* 根据人脸样本ID查询图像素材 * 根据人脸样本ID查询图像素材
* *
* @param faceSampleId 人脸样本ID * @param faceId 人脸样本ID
* @return type=2且face_sample_id匹配的source记录 * @return 匹配的source记录
*/ */
@GetMapping("/{faceSampleId}/source") @GetMapping("/{faceId}/source")
public ApiResponse<SourceEntity> getSourceByFaceSampleId(@PathVariable Long faceSampleId) { public ApiResponse<List<SourceEntity>> getSourceByFaceId(@PathVariable Long faceId, @RequestParam(name = "type", required = false, defaultValue = "2") Integer type) {
SourceEntity source = sourceMapper.getBySampleIdAndType(faceSampleId, 2); List<MemberSourceEntity> source = memberRelationRepository.listSourceByFaceRelation(faceId, type);
if (source == null) { if (source == null) {
return ApiResponse.fail("未找到对应的图像素材"); return ApiResponse.success(Collections.emptyList());
} }
return ApiResponse.success(source); return ApiResponse.success(source.stream().map(item -> sourceRepository.getSource(item.getSourceId())).toList());
} }
/** /**
* 打印机大屏人脸识别 * 打印机大屏人脸识别
* 上传照片,在景区人脸库中搜索匹配的人脸样本,返回识别结果和匹配到的图像素材 * 上传照片,在景区人脸库中搜索匹配的人脸样本,返回识别结果
*
* 使用 USER_FACE_DB_NAME+scenicId 对人脸进行去重检测:
* - 如果已存在相同人脸(打印机大屏用户,memberId=0),则返回已存在的 faceId
* - 否则创建新的人脸记录并添加到人脸库
* *
* @param file 人脸照片文件 * @param file 人脸照片文件
* @param scenicId 景区ID * @param scenicId 景区ID
* @return 人脸识别结果和匹配的source列表 * @return 人脸识别结果
*/ */
@PostMapping("/{scenicId}/faceRecognize") @PostMapping("/{scenicId}/faceRecognize")
public ApiResponse<FaceRecognizeWithSourcesResp> faceRecognize( public ApiResponse<FaceRecognizeResp> faceRecognize(
@RequestParam("file") MultipartFile file, @RequestParam("file") MultipartFile file,
@PathVariable Long scenicId) throws Exception { @PathVariable Long scenicId) {
// 复用 faceUpload 方法的去重逻辑
// 1. 上传人脸照片到存储 // memberId=0L 表示打印机大屏用户,scene="tv" 为试点场景:仅执行识别/补救/落库/建关系
IStorageAdapter adapter = StorageFactory.use("faces"); FaceRecognizeResp resp = pcFaceService.faceUpload(file, scenicId, 0L, "tv");
String filePath = StorageUtil.joinPath(USER_FACE, DateUtil.format(new Date(), "yyyy-MM-dd"));
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.split("\\.", 2)[1];
String fileName = UUID.randomUUID() + "." + suffix;
String faceUrl = adapter.uploadFile(file, filePath, fileName);
// 2. 保存人脸数据到数据库
Long faceId = SnowFlakeUtil.getLongId();
FaceEntity faceEntity = new FaceEntity();
faceEntity.setId(faceId);
faceEntity.setScenicId(scenicId);
faceEntity.setFaceUrl(faceUrl);
faceEntity.setCreateAt(new Date());
faceEntity.setMemberId(0L); // 打印机大屏端没有用户ID
faceMapper.add(faceEntity);
// 3. 在景区人脸库中搜索(注意:这里使用scenicId作为数据库名,搜索的是景区内的人脸样本)
pcFaceService.matchFaceId(faceId);
// 4. 自动添加照片到打印列表,并获取添加成功的照片列表
List<SourceEntity> addedSources = printerService.autoAddPhotosToPreferPrint(faceId);
// 5. 根据自动添加结果决定返回的sources
List<SourceEntity> sources;
if (addedSources != null && !addedSources.isEmpty()) {
// 如果自动添加成功,返回添加的照片列表
sources = addedSources;
} else {
// 如果自动添加为空,按原逻辑查询匹配到的图像素材(type=2)
sources = new ArrayList<>();
List<MemberSourceEntity> memberSourceEntities = memberRelationRepository.listSourceByFaceRelation(faceId, 2);
for (MemberSourceEntity memberSourceEntity : memberSourceEntities) {
SourceEntity source = sourceRepository.getSource(memberSourceEntity.getSourceId());
if (source != null) {
sources.add(source);
}
}
}
// 6. 构造响应
FaceRecognizeWithSourcesResp resp = new FaceRecognizeWithSourcesResp();
resp.setUrl(faceUrl);
resp.setFaceId(faceId);
resp.setScenicId(scenicId);
resp.setSources(sources);
// 只有当添加了照片时才返回二维码URL
if (addedSources != null && !addedSources.isEmpty()) {
resp.setQrcodeUrl("https://zhentuai.com/printer/v1/tv/face/" + faceId + "/qrcode");
} else {
resp.setQrcodeUrl(null);
}
return ApiResponse.success(resp); return ApiResponse.success(resp);
} }

View File

@@ -0,0 +1,65 @@
package com.ycwl.basic.enums;
/**
* 人脸视频切片状态枚举
*/
public enum FaceCutStatus {
/**
* 正在切片中
*/
CUTTING(0, "正在切片中"),
/**
* 切片已完成
*/
COMPLETED(1, "切片已完成"),
/**
* 等待用户选择模板
*/
WAITING_USER_SELECT(2, "等待用户选择模板");
private final int code;
private final String description;
FaceCutStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据code获取枚举
*/
public static FaceCutStatus fromCode(int code) {
for (FaceCutStatus status : values()) {
if (status.code == code) {
return status;
}
}
throw new IllegalArgumentException("Unknown FaceCutStatus code: " + code);
}
/**
* 根据code获取枚举,如果不存在则返回默认值
* @param code 状态码
* @param defaultStatus 默认状态
* @return 枚举值
*/
public static FaceCutStatus fromCodeOrDefault(int code, FaceCutStatus defaultStatus) {
for (FaceCutStatus status : values()) {
if (status.code == code) {
return status;
}
}
return defaultStatus;
}
}

View File

@@ -0,0 +1,64 @@
package com.ycwl.basic.enums;
/**
* 人脸片段更新状态枚举
* 用于标记人脸对应的视频片段是否有新增更新
*/
public enum FacePieceUpdateStatus {
/**
* 有新片段
* Redis键不存在时的默认状态,代表有新的视频片段产生
*/
HAS_NEW_PIECES(0, "有新片段"),
/**
* 无新片段
* Redis键存在时的状态,代表当前没有新的视频片段
*/
NO_NEW_PIECES(1, "无新片段");
private final int code;
private final String description;
FacePieceUpdateStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据code获取枚举
*/
public static FacePieceUpdateStatus fromCode(int code) {
for (FacePieceUpdateStatus status : values()) {
if (status.code == code) {
return status;
}
}
throw new IllegalArgumentException("Unknown FacePieceUpdateStatus code: " + code);
}
/**
* 根据Redis键是否存在判断状态
* @param keyExists Redis键是否存在
* @return 键存在返回NO_NEW_PIECES,键不存在返回HAS_NEW_PIECES
*/
public static FacePieceUpdateStatus fromKeyExists(boolean keyExists) {
return keyExists ? NO_NEW_PIECES : HAS_NEW_PIECES;
}
/**
* 判断是否有新片段
*/
public boolean hasNewPieces() {
return this == HAS_NEW_PIECES;
}
}

View File

@@ -0,0 +1,75 @@
package com.ycwl.basic.enums;
/**
* 人脸对应模板渲染状态枚举
*/
public enum TemplateRenderStatus {
NONE(0, "没有渲染"),
/**
* 正在渲染中
*/
RENDERING(1, "正在渲染中"),
/**
* 已渲染完成
*/
RENDERED(2, "已渲染完成");
private final int code;
private final String description;
TemplateRenderStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据code获取枚举
*/
public static TemplateRenderStatus fromCode(int code) {
for (TemplateRenderStatus status : values()) {
if (status.code == code) {
return status;
}
}
throw new IllegalArgumentException("Unknown TemplateRenderStatus code: " + code);
}
/**
* 根据code获取枚举,如果不存在则返回默认值
* @param code 状态码
* @param defaultStatus 默认状态
* @return 枚举值
*/
public static TemplateRenderStatus fromCodeOrDefault(int code, TemplateRenderStatus defaultStatus) {
for (TemplateRenderStatus status : values()) {
if (status.code == code) {
return status;
}
}
return defaultStatus;
}
/**
* 判断是否已完成渲染
*/
public boolean isRendered() {
return this == RENDERED;
}
/**
* 判断是否正在渲染
*/
public boolean isRendering() {
return this == RENDERING;
}
}

View File

@@ -0,0 +1,159 @@
package com.ycwl.basic.enums;
/**
* 视频任务状态枚举
* 用于前端展示任务状态
*/
public enum VideoTaskStatus {
/**
* 无效人脸(景区级别)
*/
INVALID_FACE_SCENIC(-2, "尚未录入有效人脸"),
/**
* 无效人脸(人脸级别)
*/
INVALID_FACE(-1, "尚未录入有效人脸"),
/**
* 待制作
* 人脸已录入,但尚未开始合成视频
*/
PENDING(0, "专属视频待制作"),
/**
* 合成成功
* 已为用户合成视频
*/
SUCCESS(1, "AI已为您合成视频"),
/**
* 合成中
* 正在合成专属视频
*/
PROCESSING(2, "专属视频合成中"),
/**
* 合成失败
*/
FAILED(3, "视频合成失败"),
/**
* 切片中
* 正在检索新的视频片段
*/
CUTTING(4, "正在检索新的视频片段");
private final int code;
private final String description;
VideoTaskStatus(int code, String description) {
this.code = code;
this.description = description;
}
public int getCode() {
return code;
}
public String getDescription() {
return description;
}
/**
* 根据code获取枚举
*/
public static VideoTaskStatus fromCode(int code) {
for (VideoTaskStatus status : values()) {
if (status.code == code) {
return status;
}
}
throw new IllegalArgumentException("Unknown VideoTaskStatus code: " + code);
}
/**
* 根据code获取枚举,如果不存在则返回默认值
* @param code 状态码
* @param defaultStatus 默认状态
* @return 枚举值
*/
public static VideoTaskStatus fromCodeOrDefault(int code, VideoTaskStatus defaultStatus) {
for (VideoTaskStatus status : values()) {
if (status.code == code) {
return status;
}
}
return defaultStatus;
}
/**
* 获取前端展示文案
* @param count 已合成视频数量(仅SUCCESS状态使用)
* @return 展示文案
*/
public String getDisplayText(long count) {
if (this == SUCCESS && count > 0) {
return "AI已为您合成" + count + "个视频";
}
return description;
}
/**
* 根据业务逻辑判断最终展示状态
* @param taskStatus 任务状态码
* @param cutStatus 切片状态码(来自FaceCutStatus)
* @param count 已合成视频数量
* @return 最终展示的状态
*/
public static VideoTaskStatus resolveDisplayStatus(int taskStatus, int cutStatus, long count) {
VideoTaskStatus status = fromCodeOrDefault(taskStatus, PENDING);
// 优先级1: 无效人脸状态
if (status == INVALID_FACE_SCENIC || status == INVALID_FACE) {
return status;
}
// 优先级2: 切片状态优先(当任务状态为待制作且切片状态为正在切片时)
if (status == PENDING && cutStatus == 0) {
return CUTTING;
}
// 优先级3: 返回任务状态
return status;
}
/**
* 获取最终展示文案
* @param taskStatus 任务状态码
* @param cutStatus 切片状态码
* @param count 已合成视频数量
* @return 展示文案
*/
public static String getDisplayText(int taskStatus, int cutStatus, long count) {
VideoTaskStatus status = resolveDisplayStatus(taskStatus, cutStatus, count);
return status.getDisplayText(count);
}
/**
* 判断是否为成功状态
*/
public boolean isSuccess() {
return this == SUCCESS;
}
/**
* 判断是否为处理中状态
*/
public boolean isProcessing() {
return this == PROCESSING || this == CUTTING;
}
/**
* 判断是否为失败状态
*/
public boolean isFailed() {
return this == FAILED || this == INVALID_FACE || this == INVALID_FACE_SCENIC;
}
}

View File

@@ -21,5 +21,11 @@ public enum FaceMatchingScene {
* 仅识别 * 仅识别
* 只执行人脸识别,不处理后续业务逻辑(源文件关联、任务创建等) * 只执行人脸识别,不处理后续业务逻辑(源文件关联、任务创建等)
*/ */
RECOGNITION_ONLY RECOGNITION_ONLY,
/**
* 打印机大屏识别试点
* 仅执行:识别、补救、落库、建关系
*/
PRINTER_TV_RECOGNIZE
} }

View File

@@ -71,6 +71,40 @@ public class FaceMatchingPipelineFactory {
@Autowired @Autowired
private ScenicConfigFacade scenicConfigFacade; private ScenicConfigFacade scenicConfigFacade;
/**
* 创建打印机大屏识别试点Pipeline
* 仅执行:识别、补救、落库、建关系
*
* 说明:
* - “准备上下文”属于基础能力,默认包含
* - “建关系”包含构建关联与持久化两步
*/
public Pipeline<FaceMatchingContext> createPrinterTvRecognizePipeline() {
PipelineBuilder<FaceMatchingContext> builder = new PipelineBuilder<>("PrinterTvRecognize");
// 1. 准备上下文
builder.addStage(prepareContextStage);
// 2. 执行人脸识别
builder.addStage(faceRecognitionStage);
// 3. 人脸识别补救
builder.addStage(faceRecoveryStage);
// 4. 更新人脸结果(落库)
builder.addStage(updateFaceResultStage);
// 5. 构建源文件关联(建关系)
builder.addStage(buildSourceRelationStage);
// 6. 持久化关联关系(建关系)
builder.addStage(persistRelationsStage);
log.debug("创建打印机大屏识别试点Pipeline: stageCount={}", builder.build().getStageCount());
return builder.build();
}
/** /**
* 创建自动人脸匹配Pipeline * 创建自动人脸匹配Pipeline
* *
@@ -219,6 +253,7 @@ public class FaceMatchingPipelineFactory {
case AUTO_MATCHING -> createAutoMatchingPipeline(isNew); case AUTO_MATCHING -> createAutoMatchingPipeline(isNew);
case CUSTOM_MATCHING -> createCustomMatchingPipeline(); case CUSTOM_MATCHING -> createCustomMatchingPipeline();
case RECOGNITION_ONLY -> createRecognitionOnlyPipeline(); case RECOGNITION_ONLY -> createRecognitionOnlyPipeline();
case PRINTER_TV_RECOGNIZE -> createPrinterTvRecognizePipeline();
}; };
} }

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.face.pipeline.stages; package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.biz.TaskStatusBiz; import com.ycwl.basic.biz.FaceStatusManager;
import com.ycwl.basic.enums.FaceCutStatus;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig; import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage; import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
@@ -37,7 +38,7 @@ public class CreateTaskStage extends AbstractPipelineStage<FaceMatchingContext>
private TaskService taskService; private TaskService taskService;
@Autowired @Autowired
private TaskStatusBiz taskStatusBiz; private FaceStatusManager faceStatusManager;
@Override @Override
public String getName() { public String getName() {
@@ -59,7 +60,7 @@ public class CreateTaskStage extends AbstractPipelineStage<FaceMatchingContext>
return StageResult.success("自动创建任务成功"); return StageResult.success("自动创建任务成功");
} else { } else {
// 配置为等待用户选择 // 配置为等待用户选择
taskStatusBiz.setFaceCutStatus(faceId, 2); faceStatusManager.setFaceCutStatus(faceId, FaceCutStatus.WAITING_USER_SELECT);
log.debug("景区配置 face_select_first=true,跳过自动创建任务: faceId={}", faceId); log.debug("景区配置 face_select_first=true,跳过自动创建任务: faceId={}", faceId);
return StageResult.skipped("等待用户手动选择"); return StageResult.skipped("等待用户手动选择");
} }

View File

@@ -72,8 +72,8 @@ public class PrepareContextStage extends AbstractPipelineStage<FaceMatchingConte
} }
// 3. 加载景区配置 // 3. 加载景区配置
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId()); ScenicConfigManager configManager = scenicRepository.getScenicConfigManager(face.getScenicId());
context.setScenicConfig(scenicConfig); context.setScenicConfig(configManager);
log.debug("加载景区配置成功: scenicId={}", face.getScenicId()); log.debug("加载景区配置成功: scenicId={}", face.getScenicId());
// 4. 加载人脸识别适配器 // 4. 加载人脸识别适配器

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.face.pipeline.stages; package com.ycwl.basic.face.pipeline.stages;
import com.ycwl.basic.biz.TaskStatusBiz; import com.ycwl.basic.biz.FaceStatusManager;
import com.ycwl.basic.enums.FaceCutStatus;
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext; import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
import com.ycwl.basic.pipeline.annotation.StageConfig; import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage; import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
@@ -28,7 +29,7 @@ import org.springframework.stereotype.Component;
public class SetTaskStatusStage extends AbstractPipelineStage<FaceMatchingContext> { public class SetTaskStatusStage extends AbstractPipelineStage<FaceMatchingContext> {
@Autowired @Autowired
private TaskStatusBiz taskStatusBiz; private FaceStatusManager faceStatusManager;
@Override @Override
public String getName() { public String getName() {
@@ -56,7 +57,7 @@ public class SetTaskStatusStage extends AbstractPipelineStage<FaceMatchingContex
} }
try { try {
taskStatusBiz.setFaceCutStatus(faceId, 0); faceStatusManager.setFaceCutStatus(faceId, FaceCutStatus.CUTTING);
log.debug("设置新用户任务状态: faceId={}, status=0", faceId); log.debug("设置新用户任务状态: faceId={}, status=0", faceId);
return StageResult.success("任务状态已设置"); return StageResult.success("任务状态已设置");

View File

@@ -25,6 +25,7 @@ Currently implemented:
- **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 - **Questionnaire Integration** (`com.ycwl.basic.integration.questionnaire`): ZT-Questionnaire microservice integration
- **Profit Share Integration** (`com.ycwl.basic.integration.profitshare`): ZT-ProfitShare microservice integration for revenue sharing
- **Message Integration** (`com.ycwl.basic.integration.message`): ZT-Message Kafka producer integration - **Message Integration** (`com.ycwl.basic.integration.message`): ZT-Message Kafka producer integration
### Integration Pattern ### Integration Pattern
@@ -1715,3 +1716,417 @@ integration:
- Cache frequently accessed questionnaires - Cache frequently accessed questionnaires
- Monitor response submission patterns - Monitor response submission patterns
- Implement rate limiting for public questionnaires - Implement rate limiting for public questionnaires
## Profit Share Integration (ZT-ProfitShare Microservice)
### Overview
The zt-profitshare microservice provides comprehensive revenue sharing management for scenic areas, supporting multiple payment systems (Alipay, WeChat, UnionPay) and various distribution rules. It offers both HTTP REST API for management operations and Kafka messaging for automatic profit sharing triggered by payment events.
### Key Components
#### Feign Client
- **ProfitShareClient**: Complete profit share operations (rules, records, manual sharing, calculations)
#### Services
- **ProfitShareIntegrationService**: High-level profit share operations (with automatic fallback for queries)
- **ProfitShareKafkaProducer**: Kafka message producer for profit share and refund events
#### Configuration
```yaml
integration:
profitshare:
enabled: true
serviceName: zt-profitshare
connectTimeout: 5000
readTimeout: 10000
retryEnabled: false
maxRetries: 3
fallback:
profitshare:
enabled: true
ttlDays: 7
kafka:
enabled: true
profit-share-topic: zt-profitshare
refund-topic: zt-refund
```
### Usage Examples
#### Rule Management Operations
```java
@Autowired
private ProfitShareIntegrationService profitShareService;
// Create profit share rule (direct operation, fails immediately on error)
CreateRuleRequest ruleRequest = new CreateRuleRequest();
ruleRequest.setScenicId(1001L);
ruleRequest.setRuleName("标准分账规则");
ruleRequest.setRuleType("percentage");
ruleRequest.setDescription("平台收取5%手续费");
// Add platform recipient
CreateRecipientRequest platform = new CreateRecipientRequest();
platform.setRecipientName("平台手续费");
platform.setRecipientType("platform");
platform.setAccountInfo("platform_001");
platform.setShareType("percentage");
platform.setShareValue(5.0);
platform.setPriority(1);
Map<String, Object> platformExt = new HashMap<>();
platformExt.put("payment_system", "alipay");
platformExt.put("sub_merchant_id", "platform_001");
platform.setExtConfig(platformExt);
// Add scenic recipient
CreateRecipientRequest scenic = new CreateRecipientRequest();
scenic.setRecipientName("景区收款账户");
scenic.setRecipientType("merchant");
scenic.setAccountInfo("merchant_001");
scenic.setShareType("percentage");
scenic.setShareValue(95.0);
scenic.setPriority(2);
Map<String, Object> scenicExt = new HashMap<>();
scenicExt.put("payment_system", "alipay");
scenicExt.put("sub_merchant_id", "scenic_001");
scenicExt.put("settle_period", "T+1");
scenic.setExtConfig(scenicExt);
ruleRequest.setRecipients(Arrays.asList(platform, scenic));
RuleVO createdRule = profitShareService.createRule(ruleRequest);
log.info("分账规则创建成功: {}", createdRule.getId());
// Get rule details (automatically falls back to cache on failure)
RuleVO rule = profitShareService.getRule(ruleId);
log.info("规则名称: {}, 状态: {}", rule.getRuleName(), rule.getStatus());
// List rules (automatically falls back to cache on failure)
PageResponse<RuleVO> rules = profitShareService.listRules(1001L, "active", "percentage", 1, 10);
log.info("查询到 {} 条规则", rules.getData().getTotal());
// Update rule (direct operation, fails immediately on error)
CreateRuleRequest updateRequest = new CreateRuleRequest();
updateRequest.setRuleName("更新后的分账规则");
RuleVO updated = profitShareService.updateRule(ruleId, updateRequest);
// Enable/disable rule (direct operations, fail immediately on error)
profitShareService.enableRule(ruleId);
profitShareService.disableRule(ruleId);
// Delete rule (direct operation, fails immediately on error)
profitShareService.deleteRule(ruleId);
```
#### Record Query Operations (with Automatic Fallback)
```java
// Get scenic profit share records (automatically falls back to cache on failure)
PageResponse<RecordVO> records = profitShareService.getRecordsByScenic(1001L, 1, 10);
log.info("景区分账记录: {} 条", records.getData().getTotal());
records.getData().getList().forEach(record -> {
log.info("订单: {}, 金额: {}, 状态: {}",
record.getOrderId(), record.getTotalAmount(), record.getStatus());
});
// Get record detail by ID (automatically falls back to cache on failure)
RecordDetailVO detail = profitShareService.getRecordById(recordId);
log.info("分账记录详情:");
detail.getDetails().forEach(shareDetail -> {
log.info(" 接收人: {}, 金额: {}, 状态: {}",
shareDetail.getRecipientName(), shareDetail.getShareAmount(), shareDetail.getStatus());
});
// Get record by order ID (automatically falls back to cache on failure)
RecordDetailVO recordByOrder = profitShareService.getRecordByOrderId("ORDER_123456");
```
#### Kafka Message Production
```java
@Autowired
private ProfitShareKafkaProducer profitShareProducer;
// Send profit share message after payment success
@Transactional
public void handleOrderPaymentSuccess(Order order) {
// 1. Update order status
order.setStatus("PAID");
order.setPaymentTime(new Date());
orderRepository.save(order);
// 2. Build profit share message
OrderMessage message = OrderMessage.of(
order.getOrderId(),
order.getScenicId(),
order.getTotalAmount().doubleValue(),
order.getPaymentChannel(), // "alipay", "wechat", "union"
order.getPaymentOrderId()
);
// 3. Send to Kafka (async profit sharing)
profitShareProducer.sendProfitShareMessage(message);
log.info("订单支付成功,已发送分账消息: orderId={}, amount={}",
order.getOrderId(), order.getTotalAmount());
}
// Send refund message after refund success
@Transactional
public void handleOrderRefundSuccess(Order order, BigDecimal refundAmount) {
// 1. Update order status
order.setStatus("REFUNDED");
orderRepository.save(order);
// 2. Build refund message
RefundMessage message = RefundMessage.of(
order.getOrderId(),
order.getScenicId(),
refundAmount.doubleValue(),
order.getPaymentChannel(),
order.getRefundOrderId()
);
// 3. Send refund message
profitShareProducer.sendRefundMessage(message);
log.info("订单退款成功,已发送退款消息: orderId={}, amount={}",
order.getOrderId(), refundAmount);
}
```
#### Manual Profit Sharing
```java
// Manual trigger profit sharing (direct operation, fails immediately on error)
// Used for compensation scenarios or delayed profit sharing
profitShareService.manualShare("ORDER_123456");
log.info("手动分账触发成功");
```
#### Calculate Profit Share
```java
// Calculate profit share without execution (automatically falls back to cache on failure)
CalculateShareRequest calcRequest = new CalculateShareRequest();
calcRequest.setScenicId(1001L);
calcRequest.setTotalAmount(1000.0);
calcRequest.setRuleType("percentage");
calcRequest.setRecipients(Arrays.asList(platform, scenic));
CalculateResultVO result = profitShareService.calculateShare(calcRequest);
log.info("总金额: {}, 分账明细:", result.getTotalAmount());
result.getDetails().forEach(detail -> {
log.info(" {}: {} 元", detail.getRecipientName(), detail.getShareAmount());
});
// Get supported types (automatically falls back to cache on failure)
TypesVO types = profitShareService.getSupportedTypes();
log.info("支持的规则类型: {}", types.getRuleTypes());
log.info("支持的接收人类型: {}", types.getRecipientTypes());
```
#### Fallback Cache Management
```java
@Autowired
private IntegrationFallbackService fallbackService;
// Check fallback cache status
boolean hasRuleCache = fallbackService.hasFallbackCache("zt-profitshare", "rule:1001");
boolean hasRecordsCache = fallbackService.hasFallbackCache("zt-profitshare", "records:scenic:1001:1:10");
// Get cache statistics
IntegrationFallbackService.FallbackCacheStats stats = fallbackService.getFallbackCacheStats("zt-profitshare");
log.info("Profit share fallback cache: {} items, TTL: {} days",
stats.getTotalCacheCount(), stats.getFallbackTtlDays());
// Clear specific cache
fallbackService.clearFallbackCache("zt-profitshare", "rule:1001");
// Clear all profit share caches
fallbackService.clearAllFallbackCache("zt-profitshare");
```
### Rule Types and Configuration
#### Rule Types
- **percentage**: Percentage-based distribution (e.g., platform 5%, merchant 95%)
- **fixed_amount**: Fixed amount distribution
- **scaled_amount**: Tiered amount distribution based on order amount
#### Recipient Types
- **platform**: Platform service fee recipient
- **merchant**: Merchant/scenic area recipient
- **agent**: Agent/distributor recipient
#### Share Types
- **percentage**: Share as percentage of total amount
- **fixed_amount**: Share as fixed amount
#### Payment Systems
- **alipay**: Alipay payment system
- **wechat**: WeChat Pay payment system
- **union**: UnionPay payment system
### Extended Configuration Examples
#### Alipay Configuration
```json
{
"payment_system": "alipay",
"sub_merchant_id": "2088xxx",
"settle_period": "T+1",
"account_type": "ALIPAY_LOGON_ID"
}
```
#### WeChat Configuration
```json
{
"payment_system": "wechat",
"sub_mch_id": "1234567890",
"settle_period": "T+0",
"account_type": "MERCHANT_ID"
}
```
#### Tiered Sharing Configuration
```json
{
"scales": [
{
"min_amount": 0,
"max_amount": 1000,
"share_value": 3.0,
"share_type": "percentage"
},
{
"min_amount": 1000,
"max_amount": 5000,
"share_value": 5.0,
"share_type": "percentage"
},
{
"min_amount": 5000,
"max_amount": 0,
"share_value": 8.0,
"share_type": "percentage"
}
]
}
```
### Record Status
- **pending**: Profit share request pending
- **processing**: Profit share in progress
- **success**: Profit share completed successfully
- **failed**: Profit share failed (requires manual intervention)
### Cache Key Design
- `rule:{id}` - Individual rule cache
- `rules:list:{scenicId}:{status}:{ruleType}:{page}:{size}` - Rule list cache
- `record:{id}` - Individual record cache
- `record:order:{orderId}` - Record by order ID cache
- `records:scenic:{scenicId}:{page}:{size}` - Scenic records cache
- `calculate:{scenicId}:{amount}` - Calculation cache
- `types` - Supported types cache
### Best Practices
#### When to Use HTTP API vs Kafka
- **HTTP API**:
- Rule management (create, update, enable/disable, delete)
- Query operations (records, statistics)
- Manual compensation scenarios
- Profit share calculation (dry run)
- **Kafka Messages** (Recommended):
- Automatic profit sharing after order payment
- Automatic reversal after order refund
- Asynchronous processing with retry capability
- Decoupled from main order flow
#### Idempotency Handling
```java
// Check before sending Kafka message
if (profitShareRecordRepository.existsByOrderId(orderId)) {
log.warn("订单已发起分账,跳过: orderId={}", orderId);
return;
}
// Record profit share request
ProfitShareRequest request = new ProfitShareRequest();
request.setOrderId(orderId);
request.setStatus("PENDING");
request.setCreatedAt(new Date());
profitShareRecordRepository.save(request);
// Send Kafka message
profitShareProducer.sendProfitShareMessage(message);
```
#### Monitoring and Alerts
```java
// Monitor Kafka message send success rate
metricRegistry.counter("profit_share.kafka.send.success").inc();
metricRegistry.counter("profit_share.kafka.send.failure").inc();
// Monitor profit share record status
metricRegistry.gauge("profit_share.records.pending", () ->
profitShareRecordRepository.countByStatus("pending"));
metricRegistry.gauge("profit_share.records.failed", () ->
profitShareRecordRepository.countByStatus("failed"));
// Alert on failures
if (!response.getSuccess()) {
alertService.send("分账服务异常", response.getMessage());
}
```
### Common Issues and Solutions
#### Q1: Kafka message sent but no profit share record created?
**A**: Troubleshooting steps:
1. Check Kafka broker connectivity
2. Verify topic `zt-profitshare` exists
3. Check profit share service logs for consumption errors
4. Query record by order ID to verify processing status
#### Q2: Profit share calculation incorrect?
**A**: Verify:
- Amount unit is in Yuan (), not Fen ()
- Percentage shares sum up to 100% or less
- Check `min_amount` and `max_amount` limits
- Review recipient priority ordering
#### Q3: How to handle profit share failures?
**A**: Profit share service automatically retries (max 3 times). If still fails:
1. Query record detail for error message
2. Fix data or rule issues
3. Call manual share API to retry
4. Set up alerts for failed records
#### Q4: Can I modify rule after orders are processed?
**A**: Yes, but:
- New orders use the updated rule
- Existing profit share records are not affected
- Consider creating new rule instead of modifying active one
- Disable old rule and enable new one for clean transition
### Testing Profit Share Integration
```bash
# Run profit share integration tests
mvn test -Dtest=ProfitShareIntegrationServiceTest
# Run all integration tests
mvn test -Dtest="com.ycwl.basic.integration.*Test"
```

View File

@@ -1,225 +1,153 @@
package com.ycwl.basic.integration.common.service; package com.ycwl.basic.integration.common.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.ycwl.basic.integration.common.config.IntegrationProperties; import com.ycwl.basic.integration.common.config.IntegrationProperties;
import com.ycwl.basic.utils.JacksonUtil; import com.ycwl.basic.utils.JacksonUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Set; import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors;
/** /**
* 集成服务通用失败降级处理 * 集成服务通用失败降级处理
* 提供统一的降级策略,支持所有微服务集成 * 提供统一的降级策略,支持所有微服务集成
* 使用 Caffeine 内存缓存,缓存命中时直接返回避免打崩下游服务
*/ */
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor
public class IntegrationFallbackService { public class IntegrationFallbackService {
private final RedisTemplate<String, String> redisTemplate;
private final IntegrationProperties integrationProperties; private final IntegrationProperties integrationProperties;
private final Cache<String, String> fallbackCache;
// 默认降级缓存配置
private static final String DEFAULT_FALLBACK_PREFIX = "integration:fallback:"; private static final String DEFAULT_FALLBACK_PREFIX = "integration:fallback:";
private static final long DEFAULT_FALLBACK_TTL = 7; // 7天 private static final long CACHE_TTL_MINUTES = 1;
private static final long MAX_CACHE_SIZE = 50000;
/** public IntegrationFallbackService(IntegrationProperties integrationProperties) {
* 执行操作,失败时降级到缓存结果 this.integrationProperties = integrationProperties;
* this.fallbackCache = Caffeine.newBuilder()
* @param serviceName 服务名称 (如: zt-device, zt-scenic) .expireAfterWrite(CACHE_TTL_MINUTES, TimeUnit.MINUTES)
* @param cacheKey 缓存键 .maximumSize(MAX_CACHE_SIZE)
* @param operation 主要操作
* @param resultClass 结果类型
* @param <T> 结果类型
* @return 操作结果或缓存的结果
*/
public <T> T executeWithFallback(String serviceName, String cacheKey, Supplier<T> operation, Class<T> resultClass) {
try {
T result = operation.get();
if (result != null) {
// 操作成功,保存结果用于将来的降级
storeFallbackCache(serviceName, cacheKey, result);
}
return result;
} catch (Exception e) {
log.warn("[{}] 操作失败,尝试降级到缓存结果, cacheKey: {}", serviceName, cacheKey, e);
T fallbackResult = getFallbackFromCache(serviceName, cacheKey, resultClass);
if (fallbackResult == null) {
log.error("[{}] 操作失败且无缓存数据, cacheKey: {}", serviceName, cacheKey);
throw e;
}
log.info("[{}] 成功从降级缓存获取结果, cacheKey: {}", serviceName, cacheKey);
return fallbackResult;
}
}
/**
* 执行操作,失败时降级到缓存结果,无返回值版本
*
* @param serviceName 服务名称
* @param cacheKey 缓存键
* @param operation 主要操作
*/
public void executeWithFallback(String serviceName, String cacheKey, Runnable operation) {
try {
operation.run();
// 操作成功,记录成功状态
storeFallbackCache(serviceName, cacheKey + ":success", "true");
log.debug("[{}] 操作成功,已记录成功状态, cacheKey: {}", serviceName, cacheKey);
} catch (Exception e) {
log.warn("[{}] 操作失败,检查是否有历史成功记录, cacheKey: {}", serviceName, cacheKey, e);
String successRecord = getFallbackFromCache(serviceName, cacheKey + ":success", String.class);
if (successRecord == null) {
log.error("[{}] 操作失败且无历史成功记录, cacheKey: {}", serviceName, cacheKey);
throw e;
}
log.info("[{}] 操作失败但有历史成功记录,忽略此次失败, cacheKey: {}", serviceName, cacheKey);
}
}
/**
* 存储降级缓存
*/
private void storeFallbackCache(String serviceName, String cacheKey, Object value) {
try {
String fullKey = buildFullCacheKey(serviceName, cacheKey);
String jsonValue = JacksonUtil.toJSONString(value);
long ttl = getFallbackTtl(serviceName);
redisTemplate.opsForValue().set(fullKey, jsonValue, ttl, TimeUnit.DAYS);
log.debug("[{}] 存储降级缓存成功, key: {}", serviceName, fullKey);
} catch (Exception e) {
log.warn("[{}] 存储降级缓存失败, cacheKey: {}", serviceName, cacheKey, e);
}
}
/**
* 从降级缓存获取结果
*/
private <T> T getFallbackFromCache(String serviceName, String cacheKey, Class<T> resultClass) {
try {
String fullKey = buildFullCacheKey(serviceName, cacheKey);
String cachedValue = redisTemplate.opsForValue().get(fullKey);
if (cachedValue != null) {
log.debug("[{}] 从降级缓存获取结果, key: {}", serviceName, fullKey);
if (resultClass == String.class) {
return resultClass.cast(cachedValue);
}
return JacksonUtil.parseObject(cachedValue, resultClass);
}
} catch (Exception e) {
log.warn("[{}] 从降级缓存获取结果失败, cacheKey: {}", serviceName, cacheKey, e);
}
return null;
}
/**
* 清除降级缓存
*
* @param serviceName 服务名称
* @param cacheKey 缓存键
*/
public void clearFallbackCache(String serviceName, String cacheKey) {
String fullKey = buildFullCacheKey(serviceName, cacheKey);
redisTemplate.delete(fullKey);
log.debug("[{}] 清除降级缓存, key: {}", serviceName, fullKey);
}
/**
* 批量清除服务的所有降级缓存
*
* @param serviceName 服务名称
*/
public void clearAllFallbackCache(String serviceName) {
String pattern = buildFullCacheKey(serviceName, "*");
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
log.info("[{}] 批量清除降级缓存,共删除 {} 个缓存项", serviceName, keys.size());
}
}
/**
* 检查是否有降级缓存
*
* @param serviceName 服务名称
* @param cacheKey 缓存键
* @return 是否存在降级缓存
*/
public boolean hasFallbackCache(String serviceName, String cacheKey) {
String fullKey = buildFullCacheKey(serviceName, cacheKey);
return Boolean.TRUE.equals(redisTemplate.hasKey(fullKey));
}
/**
* 获取服务的降级缓存统计信息
*
* @param serviceName 服务名称
* @return 缓存统计信息
*/
public FallbackCacheStats getFallbackCacheStats(String serviceName) {
String pattern = buildFullCacheKey(serviceName, "*");
Set<String> keys = redisTemplate.keys(pattern);
int totalCount = keys != null ? keys.size() : 0;
return FallbackCacheStats.builder()
.serviceName(serviceName)
.totalCacheCount(totalCount)
.cacheKeyPattern(pattern)
.fallbackTtlDays(getFallbackTtl(serviceName))
.build(); .build();
} }
/** /**
* 构建完整的缓存键 * 执行操作,优先返回缓存结果
* 策略:有缓存直接返回,无缓存调用远程并缓存结果
* 同一 cacheKey 有互斥锁,避免并发请求打崩下游服务
*/ */
public <T> T executeWithFallback(String serviceName, String cacheKey, Supplier<T> operation, Class<T> resultClass) {
String fullKey = buildFullCacheKey(serviceName, cacheKey);
// Caffeine.get() 内置互斥锁:同一 key 只有一个线程执行 loader,其他线程等待
String cachedValue = fallbackCache.get(fullKey, k -> {
log.debug("[{}] 缓存未命中,调用远程, cacheKey: {}", serviceName, cacheKey);
T result = operation.get();
return result != null ? JacksonUtil.toJSONString(result) : null;
});
if (cachedValue == null) {
return null;
}
return parseValue(serviceName, cacheKey, cachedValue, resultClass);
}
/**
* 执行操作,优先返回缓存结果(支持TypeReference泛型)
* 同一 cacheKey 有互斥锁,避免并发请求打崩下游服务
*/
public <T> T executeWithFallback(String serviceName, String cacheKey, Supplier<T> operation, TypeReference<T> typeReference) {
String fullKey = buildFullCacheKey(serviceName, cacheKey);
// Caffeine.get() 内置互斥锁:同一 key 只有一个线程执行 loader,其他线程等待
String cachedValue = fallbackCache.get(fullKey, k -> {
log.debug("[{}] 缓存未命中,调用远程, cacheKey: {}", serviceName, cacheKey);
T result = operation.get();
return result != null ? JacksonUtil.toJSONString(result) : null;
});
if (cachedValue == null) {
return null;
}
return parseValue(serviceName, cacheKey, cachedValue, typeReference);
}
private <T> T parseValue(String serviceName, String cacheKey, String value, Class<T> resultClass) {
try {
return JacksonUtil.parseObject(value, resultClass);
} catch (Exception e) {
log.warn("[{}] 解析缓存失败, cacheKey: {}", serviceName, cacheKey, e);
return null;
}
}
private <T> T parseValue(String serviceName, String cacheKey, String value, TypeReference<T> typeReference) {
try {
return JacksonUtil.parseObject(value, typeReference);
} catch (Exception e) {
log.warn("[{}] 解析缓存失败, cacheKey: {}", serviceName, cacheKey, e);
return null;
}
}
public void clearFallbackCache(String serviceName, String cacheKey) {
String fullKey = buildFullCacheKey(serviceName, cacheKey);
fallbackCache.invalidate(fullKey);
log.debug("[{}] 清除缓存, key: {}", serviceName, fullKey);
}
public void clearAllFallbackCache(String serviceName) {
String prefix = buildFullCacheKey(serviceName, "");
List<String> keysToRemove = fallbackCache.asMap().keySet().stream()
.filter(key -> key.startsWith(prefix))
.collect(Collectors.toList());
if (!keysToRemove.isEmpty()) {
fallbackCache.invalidateAll(keysToRemove);
log.info("[{}] 批量清除缓存,共删除 {} 项", serviceName, keysToRemove.size());
}
}
public boolean hasFallbackCache(String serviceName, String cacheKey) {
String fullKey = buildFullCacheKey(serviceName, cacheKey);
return fallbackCache.getIfPresent(fullKey) != null;
}
public FallbackCacheStats getFallbackCacheStats(String serviceName) {
String prefix = buildFullCacheKey(serviceName, "");
long totalCount = fallbackCache.asMap().keySet().stream()
.filter(key -> key.startsWith(prefix))
.count();
return FallbackCacheStats.builder()
.serviceName(serviceName)
.totalCacheCount((int) totalCount)
.cacheKeyPattern(prefix + "*")
.cacheTtlMinutes(CACHE_TTL_MINUTES)
.build();
}
private String buildFullCacheKey(String serviceName, String cacheKey) { private String buildFullCacheKey(String serviceName, String cacheKey) {
String prefix = getFallbackPrefix(serviceName); String prefix = getFallbackPrefix(serviceName);
return prefix + serviceName + ":" + cacheKey; return prefix + serviceName + ":" + cacheKey;
} }
/**
* 获取服务的降级缓存前缀
*/
private String getFallbackPrefix(String serviceName) { private String getFallbackPrefix(String serviceName) {
if (!integrationProperties.getFallback().isEnabled()) { if (!integrationProperties.getFallback().isEnabled()) {
return DEFAULT_FALLBACK_PREFIX; return DEFAULT_FALLBACK_PREFIX;
} }
// 获取服务特定的缓存前缀
IntegrationProperties.ServiceFallbackConfig serviceConfig = getServiceFallbackConfig(serviceName); IntegrationProperties.ServiceFallbackConfig serviceConfig = getServiceFallbackConfig(serviceName);
if (serviceConfig != null && serviceConfig.getCachePrefix() != null) { if (serviceConfig != null && serviceConfig.getCachePrefix() != null) {
return serviceConfig.getCachePrefix(); return serviceConfig.getCachePrefix();
} }
// 使用全局配置的前缀
return integrationProperties.getFallback().getCachePrefix(); return integrationProperties.getFallback().getCachePrefix();
} }
/**
* 获取服务的降级缓存TTL
*/
private long getFallbackTtl(String serviceName) {
if (!integrationProperties.getFallback().isEnabled()) {
return DEFAULT_FALLBACK_TTL;
}
// 获取服务特定的TTL
IntegrationProperties.ServiceFallbackConfig serviceConfig = getServiceFallbackConfig(serviceName);
if (serviceConfig != null && serviceConfig.getTtlDays() > 0) {
return serviceConfig.getTtlDays();
}
// 使用全局配置的TTL
return integrationProperties.getFallback().getDefaultTtlDays();
}
/**
* 获取服务特定的降级配置
*/
private IntegrationProperties.ServiceFallbackConfig getServiceFallbackConfig(String serviceName) { private IntegrationProperties.ServiceFallbackConfig getServiceFallbackConfig(String serviceName) {
switch (serviceName.toLowerCase()) { switch (serviceName.toLowerCase()) {
case "zt-scenic": case "zt-scenic":
@@ -231,27 +159,20 @@ public class IntegrationFallbackService {
} }
} }
/**
* 检查服务是否启用降级功能
*/
public boolean isFallbackEnabled(String serviceName) { public boolean isFallbackEnabled(String serviceName) {
if (!integrationProperties.getFallback().isEnabled()) { if (!integrationProperties.getFallback().isEnabled()) {
return false; return false;
} }
IntegrationProperties.ServiceFallbackConfig serviceConfig = getServiceFallbackConfig(serviceName); IntegrationProperties.ServiceFallbackConfig serviceConfig = getServiceFallbackConfig(serviceName);
return serviceConfig == null || serviceConfig.isEnabled(); return serviceConfig == null || serviceConfig.isEnabled();
} }
/**
* 降级缓存统计信息
*/
@lombok.Builder @lombok.Builder
@lombok.Data @lombok.Data
public static class FallbackCacheStats { public static class FallbackCacheStats {
private String serviceName; private String serviceName;
private int totalCacheCount; private int totalCacheCount;
private String cacheKeyPattern; private String cacheKeyPattern;
private long fallbackTtlDays; private long cacheTtlMinutes;
} }
} }

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.integration.device.service; package com.ycwl.basic.integration.device.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.ycwl.basic.integration.common.exception.IntegrationException; import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse; import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService; import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
@@ -49,6 +50,9 @@ public class DeviceStatusIntegrationService {
); );
} }
/**
* 获取所有在线设备(带降级,使用TypeReference保留泛型信息)
*/
public List<DeviceStatusDTO> getAllOnlineDevices() { public List<DeviceStatusDTO> getAllOnlineDevices() {
log.debug("获取所有在线设备"); log.debug("获取所有在线设备");
return fallbackService.executeWithFallback( return fallbackService.executeWithFallback(
@@ -58,7 +62,7 @@ public class DeviceStatusIntegrationService {
CommonResponse<List<DeviceStatusDTO>> response = deviceStatusClient.getAllOnlineDevices(); CommonResponse<List<DeviceStatusDTO>> response = deviceStatusClient.getAllOnlineDevices();
return handleResponse(response, "获取所有在线设备失败"); return handleResponse(response, "获取所有在线设备失败");
}, },
List.class new TypeReference<List<DeviceStatusDTO>>() {}
); );
} }

View File

@@ -12,6 +12,8 @@ public class KafkaIntegrationProperties {
private boolean enabled = false; private boolean enabled = false;
private String bootstrapServers = "100.64.0.12:39092"; private String bootstrapServers = "100.64.0.12:39092";
private String ztMessageTopic = "zt-message"; // topic for zt-message microservice private String ztMessageTopic = "zt-message"; // topic for zt-message microservice
private String profitShareTopic = "zt-profitshare"; // topic for profit share messages
private String refundTopic = "zt-profitshare-refund"; // topic for refund messages
private Consumer consumer = new Consumer(); private Consumer consumer = new Consumer();
private Producer producer = new Producer(); private Producer producer = new Producer();

View File

@@ -0,0 +1,109 @@
package com.ycwl.basic.integration.profitshare.client;
import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.response.PageResponse;
import com.ycwl.basic.integration.profitshare.dto.CalculateResultVO;
import com.ycwl.basic.integration.profitshare.dto.CalculateShareRequest;
import com.ycwl.basic.integration.profitshare.dto.ManualShareRequest;
import com.ycwl.basic.integration.profitshare.dto.TypesVO;
import com.ycwl.basic.integration.profitshare.dto.record.RecordDetailVO;
import com.ycwl.basic.integration.profitshare.dto.record.RecordVO;
import com.ycwl.basic.integration.profitshare.dto.rule.CreateRuleRequest;
import com.ycwl.basic.integration.profitshare.dto.rule.RuleVO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
/**
* 分账服务Feign客户端
*
* @author Claude Code
* @date 2025-01-11
*/
@FeignClient(name = "zt-profitshare", contextId = "profit-share-v2", path = "/api/profit-share/v2")
public interface ProfitShareClient {
/**
* 创建分账规则
*/
@PostMapping("/rules")
CommonResponse<RuleVO> createRule(@RequestBody CreateRuleRequest request);
/**
* 查询分账规则列表
*/
@GetMapping("/rules")
CommonResponse<PageResponse<RuleVO>> listRules(@RequestParam(value = "scenic_id", required = false) Long scenicId,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "rule_type", required = false) String ruleType,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "page_size", defaultValue = "10") Integer pageSize);
/**
* 获取分账规则详情
*/
@GetMapping("/rules/{id}")
CommonResponse<RuleVO> getRule(@PathVariable("id") Long ruleId);
/**
* 更新分账规则
*/
@PutMapping("/rules/{id}")
CommonResponse<RuleVO> updateRule(@PathVariable("id") Long ruleId,
@RequestBody CreateRuleRequest request);
/**
* 删除分账规则
*/
@DeleteMapping("/rules/{id}")
CommonResponse<Void> deleteRule(@PathVariable("id") Long ruleId);
/**
* 启用规则
*/
@PutMapping("/rules/{id}/enable")
CommonResponse<Void> enableRule(@PathVariable("id") Long ruleId);
/**
* 禁用规则
*/
@PutMapping("/rules/{id}/disable")
CommonResponse<Void> disableRule(@PathVariable("id") Long ruleId);
/**
* 查询景区分账记录
*/
@GetMapping("/records/scenic/{scenic_id}")
CommonResponse<PageResponse<RecordVO>> getRecordsByScenic(@PathVariable("scenic_id") Long scenicId,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "page_size", defaultValue = "10") Integer pageSize);
/**
* 查询分账记录详情
*/
@GetMapping("/records/{id}")
CommonResponse<RecordDetailVO> getRecordById(@PathVariable("id") Long recordId);
/**
* 按订单ID查询分账记录
*/
@GetMapping("/records/order/{order_id}")
CommonResponse<RecordDetailVO> getRecordByOrderId(@PathVariable("order_id") String orderId);
/**
* 手动触发分账
*/
@PostMapping("/manual")
CommonResponse<Void> manualShare(@RequestBody ManualShareRequest request);
/**
* 计算分账金额(不执行)
*/
@PostMapping("/calculate")
CommonResponse<CalculateResultVO> calculateShare(@RequestBody CalculateShareRequest request);
/**
* 获取支持的类型
*/
@GetMapping("/types")
CommonResponse<TypesVO> getSupportedTypes();
}

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.integration.profitshare.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* 分账服务集成配置
*
* @author Claude Code
* @date 2025-01-11
*/
@Slf4j
@Configuration
@ConfigurationProperties(prefix = "integration.profitshare")
public class ProfitShareIntegrationConfig {
public ProfitShareIntegrationConfig() {
log.info("ZT-ProfitShare集成配置初始化完成");
}
}

View File

@@ -0,0 +1,77 @@
package com.ycwl.basic.integration.profitshare.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
/**
* 计算分账结果VO
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CalculateResultVO {
/**
* 总金额
*/
@JsonProperty("total_amount")
private Double totalAmount;
/**
* 分账明细列表
*/
@JsonProperty("details")
private List<CalculateDetailVO> details;
/**
* 分账明细VO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class CalculateDetailVO {
/**
* 接收人名称
*/
@JsonProperty("recipient_name")
private String recipientName;
/**
* 接收人类型
*/
@JsonProperty("recipient_type")
private String recipientType;
/**
* 分账金额
*/
@JsonProperty("share_amount")
private Double shareAmount;
/**
* 分账类型
*/
@JsonProperty("share_type")
private String shareType;
/**
* 分账值
*/
@JsonProperty("share_value")
private Double shareValue;
}
}

View File

@@ -0,0 +1,49 @@
package com.ycwl.basic.integration.profitshare.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.ycwl.basic.integration.profitshare.dto.rule.CreateRecipientRequest;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 计算分账请求
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CalculateShareRequest {
/**
* 景区ID
*/
@JsonProperty("scenic_id")
private Long scenicId;
/**
* 总金额
*/
@JsonProperty("total_amount")
private Double totalAmount;
/**
* 规则类型
*/
@JsonProperty("rule_type")
private String ruleType;
/**
* 分账接收人列表
*/
@JsonProperty("recipients")
private List<CreateRecipientRequest> recipients;
}

View File

@@ -0,0 +1,28 @@
package com.ycwl.basic.integration.profitshare.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 手动分账请求
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ManualShareRequest {
/**
* 订单ID
*/
@JsonProperty("order_id")
private String orderId;
}

View File

@@ -0,0 +1,48 @@
package com.ycwl.basic.integration.profitshare.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 支持的类型VO
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class TypesVO {
/**
* 规则类型列表
*/
@JsonProperty("rule_types")
private List<String> ruleTypes;
/**
* 接收人类型列表
*/
@JsonProperty("recipient_types")
private List<String> recipientTypes;
/**
* 分账类型列表
*/
@JsonProperty("share_types")
private List<String> shareTypes;
/**
* 状态列表
*/
@JsonProperty("statuses")
private List<String> statuses;
}

View File

@@ -0,0 +1,72 @@
package com.ycwl.basic.integration.profitshare.dto.message;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 订单分账消息(发送到 zt-profitshare topic)
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OrderMessage {
/**
* 订单ID
*/
@JsonProperty("order_id")
private String orderId;
/**
* 景区ID
*/
@JsonProperty("scenic_id")
private Long scenicId;
/**
* 总金额(单位:元)
*/
@JsonProperty("total_amount")
private Double totalAmount;
/**
* 支付系统(alipay, wechat, union)
*/
@JsonProperty("payment_system")
private String paymentSystem;
/**
* 支付订单ID
*/
@JsonProperty("payment_order_id")
private String paymentOrderId;
/**
* Unix 时间戳(秒)
*/
@JsonProperty("timestamp")
private Long timestamp;
/**
* 快速创建订单消息
*/
public static OrderMessage of(String orderId, Long scenicId, Double totalAmount, String paymentSystem, String paymentOrderId) {
return OrderMessage.builder()
.orderId(orderId)
.scenicId(scenicId)
.totalAmount(totalAmount)
.paymentSystem(paymentSystem)
.paymentOrderId(paymentOrderId)
.timestamp(System.currentTimeMillis() / 1000)
.build();
}
}

View File

@@ -0,0 +1,72 @@
package com.ycwl.basic.integration.profitshare.dto.message;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 退款消息(发送到 zt-profitshare-refund topic)
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RefundMessage {
/**
* 退款订单ID
*/
@JsonProperty("refund_order_id")
private String refundOrderId;
/**
* 原订单ID
*/
@JsonProperty("original_order_id")
private String originalOrderId;
/**
* 退款金额(单位:元)
*/
@JsonProperty("refund_amount")
private Double refundAmount;
/**
* 退款类型(full: 全额退款, partial: 部分退款)
*/
@JsonProperty("refund_type")
private String refundType;
/**
* 支付系统(alipay, wechat, union)
*/
@JsonProperty("payment_system")
private String paymentSystem;
/**
* Unix 时间戳(秒)
*/
@JsonProperty("timestamp")
private Long timestamp;
/**
* 快速创建退款消息
*/
public static RefundMessage of(String refundOrderId, String originalOrderId, Double refundAmount, String refundType, String paymentSystem) {
return RefundMessage.builder()
.refundOrderId(refundOrderId)
.originalOrderId(originalOrderId)
.refundAmount(refundAmount)
.refundType(refundType)
.paymentSystem(paymentSystem)
.timestamp(System.currentTimeMillis() / 1000)
.build();
}
}

View File

@@ -0,0 +1,96 @@
package com.ycwl.basic.integration.profitshare.dto.record;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分账记录详情VO
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RecordDetailVO {
/**
* 记录ID
*/
@JsonProperty("id")
private Long id;
/**
* 订单ID
*/
@JsonProperty("order_id")
private String orderId;
/**
* 景区ID
*/
@JsonProperty("scenic_id")
private Long scenicId;
/**
* 规则ID
*/
@JsonProperty("rule_id")
private Long ruleId;
/**
* 总金额
*/
@JsonProperty("total_amount")
private Double totalAmount;
/**
* 支付系统
*/
@JsonProperty("payment_system")
private String paymentSystem;
/**
* 支付订单ID
*/
@JsonProperty("payment_order_id")
private String paymentOrderId;
/**
* 状态
*/
@JsonProperty("status")
private String status;
/**
* 错误信息
*/
@JsonProperty("error_message")
private String errorMessage;
/**
* 分账明细列表
*/
@JsonProperty("details")
private List<ShareDetailVO> details;
/**
* 创建时间
*/
@JsonProperty("created_at")
private String createdAt;
/**
* 更新时间
*/
@JsonProperty("updated_at")
private String updatedAt;
}

View File

@@ -0,0 +1,88 @@
package com.ycwl.basic.integration.profitshare.dto.record;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 分账记录VO
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RecordVO {
/**
* 记录ID
*/
@JsonProperty("id")
private Long id;
/**
* 订单ID
*/
@JsonProperty("order_id")
private String orderId;
/**
* 景区ID
*/
@JsonProperty("scenic_id")
private Long scenicId;
/**
* 规则ID
*/
@JsonProperty("rule_id")
private Long ruleId;
/**
* 总金额
*/
@JsonProperty("total_amount")
private Double totalAmount;
/**
* 支付系统
*/
@JsonProperty("payment_system")
private String paymentSystem;
/**
* 支付订单ID
*/
@JsonProperty("payment_order_id")
private String paymentOrderId;
/**
* 状态(pending, processing, success, failed)
*/
@JsonProperty("status")
private String status;
/**
* 错误信息
*/
@JsonProperty("error_message")
private String errorMessage;
/**
* 创建时间
*/
@JsonProperty("created_at")
private String createdAt;
/**
* 更新时间
*/
@JsonProperty("updated_at")
private String updatedAt;
}

View File

@@ -0,0 +1,64 @@
package com.ycwl.basic.integration.profitshare.dto.record;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 分账明细VO
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ShareDetailVO {
/**
* 明细ID
*/
@JsonProperty("id")
private Long id;
/**
* 接收人名称
*/
@JsonProperty("recipient_name")
private String recipientName;
/**
* 接收人类型
*/
@JsonProperty("recipient_type")
private String recipientType;
/**
* 账户信息
*/
@JsonProperty("account_info")
private String accountInfo;
/**
* 分账金额
*/
@JsonProperty("share_amount")
private Double shareAmount;
/**
* 状态
*/
@JsonProperty("status")
private String status;
/**
* 错误信息
*/
@JsonProperty("error_message")
private String errorMessage;
}

View File

@@ -0,0 +1,86 @@
package com.ycwl.basic.integration.profitshare.dto.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 分账接收人请求
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CreateRecipientRequest {
private Integer id;
/**
* 接收人名称
*/
@JsonProperty("recipient_name")
private String recipientName;
/**
* 接收人类型(platform, merchant, agent)
*/
@JsonProperty("recipient_type")
private String recipientType;
/**
* 账户信息
*/
@JsonProperty("account_info")
private String accountInfo;
/**
* 分账类型(percentage, fixed_amount)
*/
@JsonProperty("share_type")
private String shareType;
/**
* 分账值(百分比或固定金额)
*/
@JsonProperty("share_value")
private Double shareValue;
/**
* 最小分账金额
*/
@JsonProperty("min_amount")
private Double minAmount;
/**
* 最大分账金额
*/
@JsonProperty("max_amount")
private Double maxAmount;
/**
* 优先级
*/
@JsonProperty("priority")
private Integer priority;
/**
* 扩展配置
*/
@JsonProperty("ext_config")
private Map<String, Object> extConfig;
/**
* 是否需要调支付分账
*/
@JsonProperty("need_payment_call")
private Integer needPaymentCall;
}

View File

@@ -0,0 +1,54 @@
package com.ycwl.basic.integration.profitshare.dto.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 创建分账规则请求
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CreateRuleRequest {
/**
* 景区ID
*/
@JsonProperty("scenic_id")
private Long scenicId;
/**
* 规则名称
*/
@JsonProperty("rule_name")
private String ruleName;
/**
* 规则类型(percentage, fixed_amount, scaled_amount)
*/
@JsonProperty("rule_type")
private String ruleType;
/**
* 规则描述
*/
@JsonProperty("description")
private String description;
/**
* 分账接收人列表
*/
@JsonProperty("recipients")
private List<CreateRecipientRequest> recipients;
}

View File

@@ -0,0 +1,90 @@
package com.ycwl.basic.integration.profitshare.dto.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Map;
/**
* 分账接收人VO
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RecipientVO {
/**
* 接收人ID
*/
@JsonProperty("id")
private Long id;
/**
* 接收人名称
*/
@JsonProperty("recipient_name")
private String recipientName;
/**
* 接收人类型
*/
@JsonProperty("recipient_type")
private String recipientType;
/**
* 账户信息
*/
@JsonProperty("account_info")
private String accountInfo;
/**
* 分账类型
*/
@JsonProperty("share_type")
private String shareType;
/**
* 分账值
*/
@JsonProperty("share_value")
private Double shareValue;
/**
* 最小分账金额
*/
@JsonProperty("min_amount")
private Double minAmount;
/**
* 最大分账金额
*/
@JsonProperty("max_amount")
private Double maxAmount;
/**
* 优先级
*/
@JsonProperty("priority")
private Integer priority;
/**
* 扩展配置
*/
@JsonProperty("ext_config")
private Map<String, Object> extConfig;
/**
* 状态
*/
@JsonProperty("status")
private String status;
}

View File

@@ -0,0 +1,78 @@
package com.ycwl.basic.integration.profitshare.dto.rule;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分账规则VO
*
* @author Claude Code
* @date 2025-01-11
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class RuleVO {
/**
* 规则ID
*/
@JsonProperty("id")
private Long id;
/**
* 景区ID
*/
@JsonProperty("scenic_id")
private Long scenicId;
/**
* 规则名称
*/
@JsonProperty("rule_name")
private String ruleName;
/**
* 规则类型
*/
@JsonProperty("rule_type")
private String ruleType;
/**
* 规则描述
*/
@JsonProperty("description")
private String description;
/**
* 状态(active, inactive)
*/
@JsonProperty("status")
private Integer status;
/**
* 分账接收人列表
*/
@JsonProperty("recipients")
private List<RecipientVO> recipients;
/**
* 创建时间
*/
@JsonProperty("created_at")
private String createdAt;
/**
* 更新时间
*/
@JsonProperty("updated_at")
private String updatedAt;
}

View File

@@ -0,0 +1,224 @@
package com.ycwl.basic.integration.profitshare.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.profitshare.client.ProfitShareClient;
import com.ycwl.basic.integration.profitshare.dto.CalculateResultVO;
import com.ycwl.basic.integration.profitshare.dto.CalculateShareRequest;
import com.ycwl.basic.integration.profitshare.dto.ManualShareRequest;
import com.ycwl.basic.integration.profitshare.dto.TypesVO;
import com.ycwl.basic.integration.profitshare.dto.record.RecordDetailVO;
import com.ycwl.basic.integration.profitshare.dto.record.RecordVO;
import com.ycwl.basic.integration.profitshare.dto.rule.CreateRuleRequest;
import com.ycwl.basic.integration.profitshare.dto.rule.RuleVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 分账集成服务
*
* @author Claude Code
* @date 2025-01-11
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProfitShareIntegrationService {
private final ProfitShareClient profitShareClient;
private final IntegrationFallbackService fallbackService;
private static final String SERVICE_NAME = "zt-profitshare";
// ==================== 规则管理 ====================
/**
* 创建分账规则(直接操作,无fallback)
*/
public RuleVO createRule(CreateRuleRequest request) {
log.debug("创建分账规则, scenicId: {}, ruleName: {}", request.getScenicId(), request.getRuleName());
CommonResponse<RuleVO> response = profitShareClient.createRule(request);
return handleResponse(response, "创建分账规则失败");
}
/**
* 查询分账规则列表(带fallback)
*/
public PageResponse<RuleVO> listRules(Long scenicId, String status, String ruleType, Integer page, Integer pageSize) {
log.debug("查询分账规则列表, scenicId: {}, status: {}, ruleType: {}, page: {}, pageSize: {}",
scenicId, status, ruleType, page, pageSize);
return fallbackService.executeWithFallback(
SERVICE_NAME,
String.format("rules:list:%s:%s:%s:%d:%d", scenicId, status, ruleType, page, pageSize),
() -> {
CommonResponse<PageResponse<RuleVO>> response = profitShareClient.listRules(scenicId, status, ruleType, page, pageSize);
return handleResponse(response, "查询分账规则列表失败");
},
PageResponse.class
);
}
/**
* 获取分账规则详情(带fallback)
*/
public RuleVO getRule(Long ruleId) {
log.debug("获取分账规则详情, ruleId: {}", ruleId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"rule:" + ruleId,
() -> {
CommonResponse<RuleVO> response = profitShareClient.getRule(ruleId);
return handleResponse(response, "获取分账规则详情失败");
},
RuleVO.class
);
}
/**
* 更新分账规则(直接操作,无fallback)
*/
public RuleVO updateRule(Long ruleId, CreateRuleRequest request) {
log.debug("更新分账规则, ruleId: {}", ruleId);
CommonResponse<RuleVO> response = profitShareClient.updateRule(ruleId, request);
return handleResponse(response, "更新分账规则失败");
}
/**
* 删除分账规则(直接操作,无fallback)
*/
public void deleteRule(Long ruleId) {
log.debug("删除分账规则, ruleId: {}", ruleId);
CommonResponse<Void> response = profitShareClient.deleteRule(ruleId);
handleResponse(response, "删除分账规则失败");
}
/**
* 启用规则(直接操作,无fallback)
*/
public void enableRule(Long ruleId) {
log.debug("启用分账规则, ruleId: {}", ruleId);
CommonResponse<Void> response = profitShareClient.enableRule(ruleId);
handleResponse(response, "启用分账规则失败");
}
/**
* 禁用规则(直接操作,无fallback)
*/
public void disableRule(Long ruleId) {
log.debug("禁用分账规则, ruleId: {}", ruleId);
CommonResponse<Void> response = profitShareClient.disableRule(ruleId);
handleResponse(response, "禁用分账规则失败");
}
// ==================== 分账记录查询 ====================
/**
* 查询景区分账记录(带fallback)
*/
public PageResponse<RecordVO> getRecordsByScenic(Long scenicId, Integer page, Integer pageSize) {
log.debug("查询景区分账记录, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
return fallbackService.executeWithFallback(
SERVICE_NAME,
String.format("records:scenic:%d:%d:%d", scenicId, page, pageSize),
() -> {
CommonResponse<PageResponse<RecordVO>> response = profitShareClient.getRecordsByScenic(scenicId, page, pageSize);
return handleResponse(response, "查询景区分账记录失败");
},
PageResponse.class
);
}
/**
* 查询分账记录详情(带fallback)
*/
public RecordDetailVO getRecordById(Long recordId) {
log.debug("查询分账记录详情, recordId: {}", recordId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"record:" + recordId,
() -> {
CommonResponse<RecordDetailVO> response = profitShareClient.getRecordById(recordId);
return handleResponse(response, "查询分账记录详情失败");
},
RecordDetailVO.class
);
}
/**
* 按订单ID查询分账记录(带fallback)
*/
public RecordDetailVO getRecordByOrderId(String orderId) {
log.debug("按订单ID查询分账记录, orderId: {}", orderId);
return fallbackService.executeWithFallback(
SERVICE_NAME,
"record:order:" + orderId,
() -> {
CommonResponse<RecordDetailVO> response = profitShareClient.getRecordByOrderId(orderId);
return handleResponse(response, "按订单ID查询分账记录失败");
},
RecordDetailVO.class
);
}
// ==================== 分账操作 ====================
/**
* 手动触发分账(直接操作,无fallback)
*/
public void manualShare(String orderId) {
log.debug("手动触发分账, orderId: {}", orderId);
ManualShareRequest request = ManualShareRequest.builder()
.orderId(orderId)
.build();
CommonResponse<Void> response = profitShareClient.manualShare(request);
handleResponse(response, "手动触发分账失败");
}
/**
* 计算分账金额(不执行)(带fallback)
*/
public CalculateResultVO calculateShare(CalculateShareRequest request) {
log.debug("计算分账金额, scenicId: {}, totalAmount: {}", request.getScenicId(), request.getTotalAmount());
return fallbackService.executeWithFallback(
SERVICE_NAME,
String.format("calculate:%d:%.2f", request.getScenicId(), request.getTotalAmount()),
() -> {
CommonResponse<CalculateResultVO> response = profitShareClient.calculateShare(request);
return handleResponse(response, "计算分账金额失败");
},
CalculateResultVO.class
);
}
/**
* 获取支持的类型(带fallback)
*/
public TypesVO getSupportedTypes() {
log.debug("获取支持的类型");
return fallbackService.executeWithFallback(
SERVICE_NAME,
"types",
() -> {
CommonResponse<TypesVO> response = profitShareClient.getSupportedTypes();
return handleResponse(response, "获取支持的类型失败");
},
TypesVO.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,134 @@
package com.ycwl.basic.integration.profitshare.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.profitshare.dto.message.OrderMessage;
import com.ycwl.basic.integration.profitshare.dto.message.RefundMessage;
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.kafka.support.SendResult;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
/**
* 分账Kafka消息生产者
*
* @author Claude Code
* @date 2025-01-11
*/
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(name = "kafka.enabled", havingValue = "true")
public class ProfitShareKafkaProducer {
public static final String DEFAULT_PROFITSHARE_TOPIC = "zt-profitshare";
public static final String DEFAULT_REFUND_TOPIC = "zt-profitshare-refund";
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
private final KafkaIntegrationProperties kafkaProps;
/**
* 发送分账消息(订单支付成功后调用)
*/
public void sendProfitShareMessage(OrderMessage message) {
validate(message);
String topic = kafkaProps != null && StringUtils.isNotBlank(kafkaProps.getProfitShareTopic())
? kafkaProps.getProfitShareTopic()
: DEFAULT_PROFITSHARE_TOPIC;
String key = message.getOrderId();
String payload = toJson(message);
log.info("[PROFIT-SHARE] producing to topic={}, key={}, orderId={}, scenicId={}, amount={}",
topic, key, message.getOrderId(), message.getScenicId(), message.getTotalAmount());
kafkaTemplate.send(topic, key, payload).whenComplete((metadata, ex) -> {
if (ex != null) {
log.error("[PROFIT-SHARE] produce failed: orderId={}, error={}", message.getOrderId(), ex.getMessage(), ex);
} else if (metadata != null) {
log.info("[PROFIT-SHARE] produced: orderId={}, partition={}, offset={}",
message.getOrderId(), metadata.getRecordMetadata().partition(), metadata.getRecordMetadata().offset());
}
});
}
/**
* 发送退款消息(订单退款成功后调用)
*/
public CompletableFuture<SendResult<String, String>> sendRefundMessage(RefundMessage message) {
validateRefund(message);
String topic = kafkaProps != null && StringUtils.isNotBlank(kafkaProps.getRefundTopic())
? kafkaProps.getRefundTopic()
: DEFAULT_REFUND_TOPIC;
String key = message.getOriginalOrderId();
String payload = toJson(message);
log.info("[REFUND] producing to topic={}, key={}, refundOrderId={}, originalOrderId={}, amount={}, type={}",
topic, key, message.getRefundOrderId(), message.getOriginalOrderId(), message.getRefundAmount(), message.getRefundType());
return kafkaTemplate.send(topic, key, payload).whenComplete((metadata, ex) -> {
if (ex != null) {
log.error("[REFUND] produce failed: refundOrderId={}, error={}", message.getRefundOrderId(), ex.getMessage(), ex);
} else if (metadata != null) {
log.info("[REFUND] produced: refundOrderId={}, partition={}, offset={}",
message.getRefundOrderId(), metadata.getRecordMetadata().partition(), metadata.getRecordMetadata().offset());
}
});
}
private void validate(OrderMessage msg) {
if (msg == null) {
throw new IllegalArgumentException("OrderMessage is null");
}
if (StringUtils.isBlank(msg.getOrderId())) {
throw new IllegalArgumentException("orderId is required");
}
if (msg.getScenicId() == null || msg.getScenicId() <= 0) {
throw new IllegalArgumentException("scenicId is required and must be positive");
}
if (msg.getTotalAmount() == null || msg.getTotalAmount() <= 0) {
throw new IllegalArgumentException("totalAmount is required and must be positive");
}
if (StringUtils.isBlank(msg.getPaymentSystem())) {
throw new IllegalArgumentException("paymentSystem is required");
}
if (StringUtils.isBlank(msg.getPaymentOrderId())) {
throw new IllegalArgumentException("paymentOrderId is required");
}
}
private void validateRefund(RefundMessage msg) {
if (msg == null) {
throw new IllegalArgumentException("RefundMessage is null");
}
if (StringUtils.isBlank(msg.getRefundOrderId())) {
throw new IllegalArgumentException("refundOrderId is required");
}
if (StringUtils.isBlank(msg.getOriginalOrderId())) {
throw new IllegalArgumentException("originalOrderId is required");
}
if (msg.getRefundAmount() == null || msg.getRefundAmount() <= 0) {
throw new IllegalArgumentException("refundAmount is required and must be positive");
}
if (StringUtils.isBlank(msg.getRefundType())) {
throw new IllegalArgumentException("refundType is required");
}
if (StringUtils.isBlank(msg.getPaymentSystem())) {
throw new IllegalArgumentException("paymentSystem is required");
}
}
private String toJson(Object obj) {
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("failed to serialize message", e);
}
}
}

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.integration.render.service; package com.ycwl.basic.integration.render.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.ycwl.basic.integration.common.exception.IntegrationException; import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse; import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService; import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
@@ -29,7 +30,7 @@ public class RenderWorkerConfigIntegrationService {
private static final String SERVICE_NAME = "zt-render-worker"; private static final String SERVICE_NAME = "zt-render-worker";
/** /**
* 获取工作器所有配置(带降级) * 获取工作器所有配置(带降级,使用TypeReference保留泛型信息
*/ */
public List<RenderWorkerConfigV2DTO> getWorkerConfigs(Long workerId) { public List<RenderWorkerConfigV2DTO> getWorkerConfigs(Long workerId) {
log.debug("获取渲染工作器配置列表, workerId: {}", workerId); log.debug("获取渲染工作器配置列表, workerId: {}", workerId);
@@ -42,7 +43,7 @@ public class RenderWorkerConfigIntegrationService {
List<RenderWorkerConfigV2DTO> configs = handleResponse(response, "获取渲染工作器配置列表失败"); List<RenderWorkerConfigV2DTO> configs = handleResponse(response, "获取渲染工作器配置列表失败");
return configs != null ? configs : Collections.emptyList(); return configs != null ? configs : Collections.emptyList();
}, },
List.class new TypeReference<List<RenderWorkerConfigV2DTO>>() {}
); );
} }
@@ -70,12 +71,12 @@ public class RenderWorkerConfigIntegrationService {
log.debug("获取渲染工作器平铺配置, workerId: {}", workerId); log.debug("获取渲染工作器平铺配置, workerId: {}", workerId);
return fallbackService.executeWithFallback( return fallbackService.executeWithFallback(
SERVICE_NAME, SERVICE_NAME,
"worker:config:" + workerId, "worker:flat:config:" + workerId,
() -> { () -> {
List<RenderWorkerConfigV2DTO> configs = getWorkerConfigsInternal(workerId); List<RenderWorkerConfigV2DTO> configs = getWorkerConfigsInternal(workerId);
return flattenConfigs(configs); return flattenConfigs(configs);
}, },
Map.class new TypeReference<Map<String, Object>>() {}
); );
} }

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.integration.scenic.service; package com.ycwl.basic.integration.scenic.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.ycwl.basic.integration.common.exception.IntegrationException; import com.ycwl.basic.integration.common.exception.IntegrationException;
import com.ycwl.basic.integration.common.response.CommonResponse; import com.ycwl.basic.integration.common.response.CommonResponse;
import com.ycwl.basic.integration.common.service.IntegrationFallbackService; import com.ycwl.basic.integration.common.service.IntegrationFallbackService;
@@ -22,6 +23,9 @@ public class ScenicConfigIntegrationService {
private static final String SERVICE_NAME = "zt-scenic"; private static final String SERVICE_NAME = "zt-scenic";
/**
* 获取景区配置列表(带降级,使用TypeReference保留泛型信息)
*/
public List<ScenicConfigV2DTO> listConfigs(Long scenicId) { public List<ScenicConfigV2DTO> listConfigs(Long scenicId) {
log.debug("获取景区配置列表, scenicId: {}", scenicId); log.debug("获取景区配置列表, scenicId: {}", scenicId);
return fallbackService.executeWithFallback( return fallbackService.executeWithFallback(
@@ -31,7 +35,7 @@ public class ScenicConfigIntegrationService {
CommonResponse<List<ScenicConfigV2DTO>> response = scenicConfigV2Client.listConfigs(scenicId); CommonResponse<List<ScenicConfigV2DTO>> response = scenicConfigV2Client.listConfigs(scenicId);
return handleResponse(response, "获取景区配置列表失败"); return handleResponse(response, "获取景区配置列表失败");
}, },
List.class new TypeReference<List<ScenicConfigV2DTO>>() {}
); );
} }

View File

@@ -1,11 +1,20 @@
package com.ycwl.basic.mapper; package com.ycwl.basic.mapper;
import com.ycwl.basic.model.pc.device.resp.DeviceRespVO; import com.ycwl.basic.model.pc.device.resp.DeviceRespVO;
import com.ycwl.basic.model.pc.extraDevice.resp.ExtraDeviceRespVO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List; import java.util.List;
@Mapper @Mapper
public interface ExtraDeviceMapper { public interface ExtraDeviceMapper {
List<DeviceRespVO> listExtraDeviceByScenicId(Long scenicId); List<DeviceRespVO> listExtraDeviceByScenicId(Long scenicId);
/**
* 分页查询外部设备列表
* @param scenicId 景区ID (可选)
* @return 外部设备列表
*/
List<ExtraDeviceRespVO> pageQuery(@Param("scenicId") Long scenicId);
} }

View File

@@ -9,6 +9,7 @@ import com.ycwl.basic.model.pc.order.req.OrderReqQuery;
import com.ycwl.basic.model.pc.order.resp.OrderAppRespVO; import com.ycwl.basic.model.pc.order.resp.OrderAppRespVO;
import com.ycwl.basic.model.pc.order.resp.OrderRespVO; import com.ycwl.basic.model.pc.order.resp.OrderRespVO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List; import java.util.List;
@@ -61,4 +62,19 @@ public interface OrderMapper {
List<OrderItemEntity> getOrderItems(Long orderId); List<OrderItemEntity> getOrderItems(Long orderId);
OrderEntity getUserBuyFaceItem(Long memberId, Long faceId, int goodsType, Long goodsId); OrderEntity getUserBuyFaceItem(Long memberId, Long faceId, int goodsType, Long goodsId);
/**
* 查询购买了指定视频的所有订单ID(直接购买)
* @param videoId 视频ID
* @return 订单ID列表
*/
List<Long> getOrderIdsByVideoId(Long videoId);
/**
* 查询购买了指定模板和faceId的所有订单ID
* @param faceId 人脸ID
* @param templateId 模板ID
* @return 订单ID列表
*/
List<Long> getOrderIdsByFaceIdAndTemplateId(@Param("faceId") Long faceId, @Param("templateId") Long templateId);
} }

View File

@@ -165,4 +165,12 @@ public interface SourceMapper {
* @return 删除的记录数 * @return 删除的记录数
*/ */
int deleteRelationsByFaceIdAndType(Long faceId, Integer type); int deleteRelationsByFaceIdAndType(Long faceId, Integer type);
/**
* 统计指定faceId和type的免费关联记录数
* @param faceId 人脸ID
* @param type 素材类型
* @return 免费记录数
*/
int countFreeRelationsByFaceIdAndType(Long faceId, Integer type);
} }

View File

@@ -57,4 +57,6 @@ public interface TaskMapper {
List<TaskRespVO> selectNotRunningByScenicList(String scenicOnly); List<TaskRespVO> selectNotRunningByScenicList(String scenicOnly);
List<TaskEntity> selectAllFailed(); List<TaskEntity> selectAllFailed();
TaskEntity listLastFaceTemplateTask(Long faceId, Long templateId);
} }

View File

@@ -65,4 +65,12 @@ public interface VideoMapper {
* @return 已购买记录数量 * @return 已购买记录数量
*/ */
int countBuyRecordByVideoId(Long videoId); int countBuyRecordByVideoId(Long videoId);
/**
* 通过faceId和templateId(可选)查询最新的视频记录
* @param faceId 人脸ID
* @param templateId 模板ID(可选)
* @return 最新的视频记录
*/
VideoRespVO queryLatestByFaceIdAndTemplateId(@NonNull Long faceId, Long templateId);
} }

View File

@@ -65,7 +65,7 @@ public interface VideoReviewMapper extends BaseMapper<VideoReviewEntity> {
/** /**
* 查询所有机位评价数据(用于后端计算平均值) * 查询所有机位评价数据(用于后端计算平均值)
* *
* @return 机位评价列表(嵌套Map结构) * @return 机位评价列表(Map结构: 机位ID -> 评分)
*/ */
List<Map<String, Map<String, Integer>>> selectAllCameraPositionRatings(); List<Map<String, Integer>> selectAllCameraPositionRatings();
} }

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.model.mobile.video.dto;
import lombok.Data;
import jakarta.validation.constraints.NotNull;
/**
* HLS视频流请求参数
* 用于生成设备视频的HLS播放列表
*
* @author Claude Code
* @date 2025-12-26
*/
@Data
public class HlsStreamRequest {
/**
* 设备ID
*/
@NotNull(message = "设备ID不能为空")
private Long deviceId;
/**
* 视频时长(分钟),默认2分钟
* 获取最近N分钟的视频
*/
private Integer durationMinutes = 2;
/**
* 是否为Event播放列表
* true: 使用EVENT类型(适合固定时长的视频回放)
* false: 使用VOD类型(适合点播)
*/
private Boolean eventPlaylist = true;
}

View File

@@ -0,0 +1,86 @@
package com.ycwl.basic.model.mobile.video.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* HLS视频流响应
* 包含生成的m3u8播放列表内容
*
* @author Claude Code
* @date 2025-12-26
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HlsStreamResponse {
/**
* 设备ID
*/
private Long deviceId;
/**
* m3u8播放列表内容
*/
private String playlistContent;
/**
* 视频片段数量
*/
private Integer segmentCount;
/**
* 总时长(秒)
*/
private Double totalDurationSeconds;
/**
* 视频片段列表
*/
private List<VideoSegment> segments;
/**
* 播放列表类型(EVENT/VOD)
*/
private String playlistType;
/**
* 视频片段信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class VideoSegment {
/**
* 视频片段URL
*/
private String url;
/**
* 片段时长(秒)
*/
private Double duration;
/**
* 片段序号
*/
private Integer sequence;
/**
* 片段开始时间
*/
private String startTime;
/**
* 片段结束时间
*/
private String endTime;
}
}

View File

@@ -0,0 +1,112 @@
package com.ycwl.basic.model.pc.device.req;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/**
* 外部工具上报视频连续性检查结果的请求DTO
*
* @author Claude Code
* @date 2025-12-30
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class VideoContinuityReportReq {
/**
* 设备编号(必填)
*/
@NotBlank(message = "设备编号不能为空")
private String deviceNo;
/**
* 检查的开始时间(必填)
*/
@NotNull(message = "检查开始时间不能为空")
private Date startTime;
/**
* 检查的结束时间(必填)
*/
@NotNull(message = "检查结束时间不能为空")
private Date endTime;
/**
* 是否支持连续性检查(必填)
*/
@NotNull(message = "是否支持连续性检查不能为空")
private Boolean support;
/**
* 视频是否连续(必填)
* true: 所有间隙都在允许范围内
* false: 存在超出允许范围的间隙
*/
@NotNull(message = "视频是否连续不能为空")
private Boolean continuous;
/**
* 视频总数(必填)
*/
@NotNull(message = "视频总数不能为空")
private Integer totalVideos;
/**
* 总时长,单位毫秒(必填)
*/
@NotNull(message = "总时长不能为空")
private Long totalDurationMs;
/**
* 允许的最大间隙,单位毫秒(选填,默认2000ms)
*/
private Long maxAllowedGapMs;
/**
* 间隙列表(选填,当continuous=false时应提供)
*/
private List<GapInfoReq> gaps = Collections.emptyList();
/**
* 间隙信息
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class GapInfoReq {
/**
* 前一个文件名
*/
private String beforeFileName;
/**
* 后一个文件名
*/
private String afterFileName;
/**
* 间隙时长,单位毫秒
*/
private Long gapMs;
/**
* 间隙开始时间
*/
private Date gapStartTime;
/**
* 间隙结束时间
*/
private Date gapEndTime;
}
}

View File

@@ -0,0 +1,24 @@
package com.ycwl.basic.model.pc.extraDevice.req;
import lombok.Data;
/**
* 外部设备分页查询请求
*/
@Data
public class ExtraDevicePageQueryReq {
/**
* 景区ID (支持 Long 或字符串格式的Long)
*/
private Long scenicId;
/**
* 页码,默认1
*/
private Integer pageNum = 1;
/**
* 每页大小,默认20
*/
private Integer pageSize = 20;
}

View File

@@ -0,0 +1,53 @@
package com.ycwl.basic.model.pc.extraDevice.resp;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import lombok.Data;
/**
* 外部设备响应VO
* 对应文档中的 ExtraDeviceResp 结构
*/
@Data
public class ExtraDeviceRespVO {
/**
* 设备ID (JSON输出为字符串)
*/
private Long id;
/**
* 景区ID (JSON输出为字符串)
*/
private Long scenicId;
/**
* 景区名称 (从景区服务获取)
*/
private String scenicName;
/**
* 设备名称
*/
private String name;
/**
* 设备标识
*/
private String ident;
/**
* 数据库状态
*/
private Integer status;
/**
* 心跳时间 (格式: yyyy-MM-dd HH:mm:ss)
*/
private String keepaliveAt;
/**
* 在线状态: 1=在线, 0=离线
* 判断逻辑:5分钟内有心跳则在线
*/
private Integer online;
}

View File

@@ -35,21 +35,6 @@ public class MemberRespVO {
*/ */
// 真实姓名 // 真实姓名
private String realName; private String realName;
/**
* 推客优惠码
*/
// 推客优惠码
private String promoCode;
/**
* 推客id
*/
// 推客id
private Long brokerId;
/**
* 是否同意用户协议,1同意0未同意
*/
// 是否同意用户协议,1同意0未同意
private Integer agreement;
/** /**
* 电话号码 * 电话号码
*/ */
@@ -70,11 +55,4 @@ public class MemberRespVO {
*/ */
// 城市 // 城市
private String city; private String city;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createDate;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date updateAt;
private Integer orderCount;
// 是否开启服务通知 0关闭 1开启
private Integer isServiceNotification;
} }

View File

@@ -18,6 +18,7 @@ import java.util.Date;
*/ */
@Data @Data
@TableName("scenic_config") @TableName("scenic_config")
@Deprecated
public class ScenicConfigEntity { public class ScenicConfigEntity {
@TableId @TableId
private Long id; private Long id;
@@ -102,10 +103,6 @@ public class ScenicConfigEntity {
private Float faceScoreThreshold; private Float faceScoreThreshold;
private StorageType storeType; private StorageType storeType;
private String storeConfigJson; private String storeConfigJson;
private StorageType tmpStoreType;
private String tmpStoreConfigJson;
private StorageType localStoreType;
private String localStoreConfigJson;
private BigDecimal brokerDirectRate; private BigDecimal brokerDirectRate;
private Integer faceDetectHelperThreshold; private Integer faceDetectHelperThreshold;

View File

@@ -34,9 +34,9 @@ public class VideoEntity {
*/ */
private Long taskId; private Long taskId;
/** /**
* 执行任务的机器ID,render_worker.id * 人脸ID,对应face.id
*/ */
private Long workerId; private Long faceId;
/** /**
* 视频链接 * 视频链接
*/ */

View File

@@ -0,0 +1,15 @@
package com.ycwl.basic.model.pc.videoreview.dto;
import lombok.Data;
/**
* 视频购买检查请求DTO
*/
@Data
public class VideoPurchaseCheckReqDTO {
/**
* 视频ID
*/
private Long videoId;
}

View File

@@ -0,0 +1,27 @@
package com.ycwl.basic.model.pc.videoreview.dto;
import lombok.Data;
import java.util.List;
/**
* 视频购买检查响应DTO
*/
@Data
public class VideoPurchaseCheckRespDTO {
/**
* 视频ID
*/
private Long videoId;
/**
* 是否已被购买
*/
private Boolean isPurchased;
/**
* 购买该视频的订单ID列表(包括直接购买和通过模板购买)
*/
private List<Long> orderIds;
}

View File

@@ -27,9 +27,8 @@ public class VideoReviewAddReqDTO {
/** /**
* 机位评价JSON(可选) * 机位评价JSON(可选)
* 格式: {"12345": {"清晰度":5,"构图":4,"色彩":5,"整体效果":4}, "12346": {...}} * 格式: {"12345": 5, "12346": 4}
* 外层key为机位ID,内层Map为该机位的各维度评分 * key为机位ID,value为该机位的评分(1-5)
* 评分维度: 清晰度, 构图, 色彩, 整体效果
*/ */
private Map<String, Map<String, Integer>> cameraPositionRating; private Map<String, Integer> cameraPositionRating;
} }

View File

@@ -69,10 +69,10 @@ public class VideoReviewRespDTO {
/** /**
* 机位评价JSON * 机位评价JSON
* 格式: {"12345": {"清晰度":5,"构图":4,"色彩":5,"整体效果":4}, "12346": {...}} * 格式: {"12345": 5, "12346": 4}
* 外层key为机位ID,内层Map为该机位的各维度评分 * key为机位ID,value为该机位的评分(1-5)
*/ */
private Map<String, Map<String, Integer>> cameraPositionRating; private Map<String, Integer> cameraPositionRating;
/** /**
* 创建时间 * 创建时间

View File

@@ -40,8 +40,8 @@ public class VideoReviewStatisticsRespDTO {
private List<ScenicReviewRank> scenicRankList; private List<ScenicReviewRank> scenicRankList;
/** /**
* 机位评价维度统计 * 机位评价统计
* key: 维度名称, value: 平均分 * key: 机位ID, value: 该机位的平均
*/ */
private Map<String, BigDecimal> cameraPositionAverage; private Map<String, BigDecimal> cameraPositionAverage;

View File

@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.ycwl.basic.handler.NestedMapTypeHandler; import com.ycwl.basic.handler.MapTypeHandler;
import lombok.Data; import lombok.Data;
import org.apache.ibatis.type.JdbcType; import org.apache.ibatis.type.JdbcType;
@@ -51,11 +51,11 @@ public class VideoReviewEntity {
/** /**
* 机位评价JSON * 机位评价JSON
* 格式: {"12345": {"清晰度":5,"构图":4,"色彩":5,"整体效果":4}, "12346": {...}} * 格式: {"12345": 5, "12346": 4}
* 外层key为机位ID,内层Map为该机位的各维度评分 * key为机位ID,value为该机位的评分(1-5)
*/ */
@TableField(typeHandler = NestedMapTypeHandler.class, jdbcType = JdbcType.VARCHAR) @TableField(typeHandler = MapTypeHandler.class, jdbcType = JdbcType.VARCHAR)
private Map<String, Map<String, Integer>> cameraPositionRating; private Map<String, Integer> cameraPositionRating;
/** /**
* 创建时间 * 创建时间

View File

@@ -0,0 +1,24 @@
package com.ycwl.basic.model.printer.req;
import lombok.Data;
/**
* 创建虚拟用户0元订单请求参数
*/
@Data
public class CreateVirtualOrderRequest {
/**
* source记录ID
*/
private Long sourceId;
/**
* 景区ID
*/
private Long scenicId;
/**
* 打印机ID(可选)
*/
private Integer printerId;
}

View File

@@ -1,50 +0,0 @@
package com.ycwl.basic.model.snowFlake;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* @author Created by liuhongguang on 2019年10月27日
* @Description
*/
@NoArgsConstructor
@AllArgsConstructor
@Data
public class UniqueId implements Serializable {
/**
* 0 + 41 + 5 + 5 + 12
* 固定 + 时间戳 + 工作机器ID + 数据中心ID + 序列号
*/
@Serial
private static final long serialVersionUID = 8632670752020316524L;
/**
* 工作机器ID、数据中心ID、序列号、上次生成ID的时间戳
*/
// 机器ID
private long machineId;
// 数据中心ID
private long datacenterId;
// 毫秒内序列
private long sequence;
// 时间戳
private long timestamp;
@Override
public String toString() {
return "UniqueIdRespVo{" +
"服务机器ID=" + machineId +
", 数据中心ID=" + datacenterId +
", 毫秒内的序列=" + sequence +
", 生成时间与预设时间戳间隔=" + timestamp +
'}';
}
}

View File

@@ -1,84 +0,0 @@
package com.ycwl.basic.model.snowFlake;
public class UniqueIdMetaData {
/**
* 取当前系统启动时间为参考起始时间,
* 取1995-04-01为参考日
*/
// public static final long START_TIME = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli();
public static final long START_TIME = 796665600000L;
/**
* 机器ID所占位数
*/
// 机器位数
public static final long MACHINE_ID_BITS = 5L;
/**
* 机器ID最大值31,0-31
*/
// 机器ID最大
public static final long MAX_MACHINE_ID = ~(-1L << MACHINE_ID_BITS);
/**
* 数据中心ID所占位数
*/
// 数据中心ID所占位数
public static final long DATACENTER_ID_BITS = 5L;
/**
* 数据中心ID最大值31,0-31
*/
// 数据中心ID最大值
public static final long MAX_DATACENTER_ID = ~(-1L << MACHINE_ID_BITS);
/**
* Sequence所占位数
*/
// 序列所占位数
public static final long SEQUENCE_BITS = 12L;
/**
* 机器ID偏移量12
*/
// 机器ID偏移量
public static final long MACHINE_SHIFT_BITS = SEQUENCE_BITS;
/**
* 数据中心ID偏移量12+5=17
*/
// 数据中心ID偏移量
public static final long DATACENTER_SHIFT_BITS = SEQUENCE_BITS + MACHINE_ID_BITS;
/**
* 时间戳的偏移量12+5+5=22
*/
// 时间戳偏移量
public static final long TIMESTAMP_LEFT_SHIFT_BITS = SEQUENCE_BITS + MACHINE_ID_BITS + DATACENTER_ID_BITS;
/**
* Sequence掩码4095
*/
// 序列掩码
public static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BITS);
/**
* 机器ID掩码1023
*/
// 机器ID掩码
public static final long MACHINE_MASK = ~(-1L << MACHINE_ID_BITS);
/**
* 数据中心掩码1023
*/
// 数据中心掩码
public static final long DATACENTER_MASK = ~(-1L << MACHINE_ID_BITS);
/**
* 时间戳掩码2的41次方减1
*/
// 时间戳掩码
public static final long TIMESTAMP_MASK = ~(-1L << 41L);
}

View File

@@ -200,6 +200,7 @@ public class WxMpPayAdapter implements IPayAdapter {
Transaction parse = parser.parse(requestParam, Transaction.class); Transaction parse = parser.parse(requestParam, Transaction.class);
resp.setValid(true); resp.setValid(true);
resp.setOrderNo(parse.getOutTradeNo()); resp.setOrderNo(parse.getOutTradeNo());
resp.setTransactionId(parse.getTransactionId());
if (parse.getAmount() != null) { if (parse.getAmount() != null) {
resp.setOrderPrice(parse.getAmount().getTotal()); resp.setOrderPrice(parse.getAmount().getTotal());
resp.setPayPrice(parse.getAmount().getPayerTotal()); resp.setPayPrice(parse.getAmount().getPayerTotal());
@@ -281,6 +282,7 @@ public class WxMpPayAdapter implements IPayAdapter {
resp.setRefundNo(refund.getOutRefundNo()); resp.setRefundNo(refund.getOutRefundNo());
} else { } else {
resp.setSuccess(false); resp.setSuccess(false);
resp.setMessage(refund.getStatus().name());
} }
return resp; return resp;
} }
@@ -313,6 +315,7 @@ public class WxMpPayAdapter implements IPayAdapter {
.build(); .build();
RefundNotification parse = parser.parse(requestParam, RefundNotification.class); RefundNotification parse = parser.parse(requestParam, RefundNotification.class);
resp.setValid(true); resp.setValid(true);
resp.setRefundTransactionId(parse.getRefundId());
resp.setOriginalResponse(parse); resp.setOriginalResponse(parse);
if (parse.getRefundStatus() == SUCCESS) { if (parse.getRefundStatus() == SUCCESS) {
//退款成功 //退款成功

View File

@@ -9,6 +9,7 @@ import java.math.BigDecimal;
public class PayResponse { public class PayResponse {
private boolean valid; private boolean valid;
private String orderNo; private String orderNo;
private String transactionId;
@JsonIgnore @JsonIgnore
private Object originalResponse; private Object originalResponse;
private Integer orderPrice; private Integer orderPrice;

View File

@@ -6,4 +6,5 @@ import lombok.Data;
public class RefundOrderResponse { public class RefundOrderResponse {
private boolean success; private boolean success;
private String refundNo; private String refundNo;
private String message;
} }

View File

@@ -10,6 +10,7 @@ public class RefundResponse {
private boolean valid; private boolean valid;
private String orderNo; private String orderNo;
private String refundNo; private String refundNo;
private String refundTransactionId;
@JsonIgnore @JsonIgnore
private Object originalResponse; private Object originalResponse;
private Integer orderPrice; private Integer orderPrice;

View File

@@ -120,6 +120,7 @@ public enum CouponStatus { CLAIMED("claimed", ...), USED("used", ...), EXPIRED("
#### 关键特性 #### 关键特性
- 商品类型限制:通过 JSON 字段(结合 `ProductTypeListTypeHandler`)控制适用商品 - 商品类型限制:通过 JSON 字段(结合 `ProductTypeListTypeHandler`)控制适用商品
- 属性门槛:通过 `requiredAttributeKeys`(JSON) 配置,要求在可折扣商品范围内任一商品出现任一属性Key(属性Key为后端与运营约定的字符串);商品属性由服务端根据商品能力配置(`ProductTypeCapability.metadata.pricingAttributeKeys`)计算写入 `ProductItem.attributeKeys`
- 消费限制:支持最小消费金额、最大折扣限制 - 消费限制:支持最小消费金额、最大折扣限制
- 时效性:基于时间的有效期控制 - 时效性:基于时间的有效期控制
- **用户领取数量限制**:通过 `userClaimLimit` 字段控制单个用户可领取优惠券的最大数量(v1.0.0新增) - **用户领取数量限制**:通过 `userClaimLimit` 字段控制单个用户可领取优惠券的最大数量(v1.0.0新增)

View File

@@ -4,6 +4,7 @@ import com.ycwl.basic.pricing.enums.ProductType;
import lombok.Data; import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
/** /**
* 商品项DTO * 商品项DTO
@@ -50,4 +51,9 @@ public class ProductItem {
* 景区ID * 景区ID
*/ */
private String scenicId; private String scenicId;
/**
* 商品属性Key列表(服务端计算填充,客户端传入会被忽略)
*/
private List<String> attributeKeys;
} }

View File

@@ -51,6 +51,12 @@ public class PriceCouponConfig {
*/ */
private String applicableProducts; private String applicableProducts;
/**
* 优惠券使用门槛:要求在可折扣商品范围内出现指定属性Key(JSON)
* 为空表示不限制
*/
private String requiredAttributeKeys;
/** /**
* 发行总量 * 发行总量
*/ */

View File

@@ -51,10 +51,10 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
* 插入优惠券配置 * 插入优惠券配置
*/ */
@Insert("INSERT INTO price_coupon_config (coupon_name, coupon_type, discount_value, min_amount, " + @Insert("INSERT INTO price_coupon_config (coupon_name, coupon_type, discount_value, min_amount, " +
"max_discount, applicable_products, total_quantity, used_quantity, valid_from, valid_until, " + "max_discount, applicable_products, required_attribute_keys, total_quantity, used_quantity, valid_from, valid_until, " +
"is_active, scenic_id, create_time, update_time) VALUES " + "is_active, scenic_id, create_time, update_time) VALUES " +
"(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " + "(#{couponName}, #{couponType}, #{discountValue}, #{minAmount}, #{maxDiscount}, " +
"#{applicableProducts}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " + "#{applicableProducts}, #{requiredAttributeKeys}, #{totalQuantity}, #{usedQuantity}, #{validFrom}, #{validUntil}, " +
"#{isActive}, #{scenicId}, NOW(), NOW())") "#{isActive}, #{scenicId}, NOW(), NOW())")
int insertCoupon(PriceCouponConfig coupon); int insertCoupon(PriceCouponConfig coupon);
@@ -63,7 +63,7 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
*/ */
@Update("UPDATE price_coupon_config SET coupon_name = #{couponName}, coupon_type = #{couponType}, " + @Update("UPDATE price_coupon_config SET coupon_name = #{couponName}, coupon_type = #{couponType}, " +
"discount_value = #{discountValue}, min_amount = #{minAmount}, max_discount = #{maxDiscount}, " + "discount_value = #{discountValue}, min_amount = #{minAmount}, max_discount = #{maxDiscount}, " +
"applicable_products = #{applicableProducts}, total_quantity = #{totalQuantity}, " + "applicable_products = #{applicableProducts}, required_attribute_keys = #{requiredAttributeKeys}, total_quantity = #{totalQuantity}, " +
"valid_from = #{validFrom}, valid_until = #{validUntil}, is_active = #{isActive}, " + "valid_from = #{validFrom}, valid_until = #{validUntil}, is_active = #{isActive}, " +
"scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}") "scenic_id = #{scenicId}, update_time = NOW() WHERE id = #{id}")
int updateCoupon(PriceCouponConfig coupon); int updateCoupon(PriceCouponConfig coupon);

View File

@@ -1,5 +1,7 @@
package com.ycwl.basic.pricing.service.impl; package com.ycwl.basic.pricing.service.impl;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo; import com.github.pagehelper.PageInfo;
import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord; import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord;
@@ -27,8 +29,11 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class CouponManagementServiceImpl implements ICouponManagementService { public class CouponManagementServiceImpl implements ICouponManagementService {
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private final PriceCouponConfigMapper couponConfigMapper; private final PriceCouponConfigMapper couponConfigMapper;
private final PriceCouponClaimRecordMapper claimRecordMapper; private final PriceCouponClaimRecordMapper claimRecordMapper;
private final ObjectMapper objectMapper;
// ==================== 优惠券配置管理 ==================== // ==================== 优惠券配置管理 ====================
@@ -37,6 +42,8 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
public Long createCouponConfig(PriceCouponConfig config) { public Long createCouponConfig(PriceCouponConfig config) {
log.info("创建优惠券配置: {}", config.getCouponName()); log.info("创建优惠券配置: {}", config.getCouponName());
validateCouponConfig(config);
// 设置默认值 // 设置默认值
if (config.getUsedQuantity() == null) { if (config.getUsedQuantity() == null) {
config.setUsedQuantity(0); config.setUsedQuantity(0);
@@ -60,6 +67,8 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
public boolean updateCouponConfig(PriceCouponConfig config) { public boolean updateCouponConfig(PriceCouponConfig config) {
log.info("更新优惠券配置,ID: {}", config.getId()); log.info("更新优惠券配置,ID: {}", config.getId());
validateCouponConfig(config);
PriceCouponConfig existing = couponConfigMapper.selectById(config.getId()); PriceCouponConfig existing = couponConfigMapper.selectById(config.getId());
if (existing == null) { if (existing == null) {
log.error("优惠券配置不存在,ID: {}", config.getId()); log.error("优惠券配置不存在,ID: {}", config.getId());
@@ -76,6 +85,32 @@ public class CouponManagementServiceImpl implements ICouponManagementService {
} }
} }
private void validateCouponConfig(PriceCouponConfig config) {
validateRequiredAttributeKeys(config.getRequiredAttributeKeys());
}
private void validateRequiredAttributeKeys(String requiredAttributeKeys) {
if (requiredAttributeKeys == null || requiredAttributeKeys.isBlank()) {
return;
}
List<String> keys;
try {
keys = objectMapper.readValue(requiredAttributeKeys, STRING_LIST_TYPE);
} catch (Exception e) {
throw new IllegalArgumentException("requiredAttributeKeys格式错误,必须是JSON数组字符串,例如 [\"TYPE_3\"]");
}
if (keys == null || keys.isEmpty()) {
return;
}
boolean hasBlankKey = keys.stream().anyMatch(key -> key == null || key.trim().isEmpty());
if (hasBlankKey) {
throw new IllegalArgumentException("requiredAttributeKeys不能包含空值");
}
}
@Override @Override
@Transactional @Transactional
public boolean deleteCouponConfig(Long id) { public boolean deleteCouponConfig(Long id) {

View File

@@ -118,23 +118,63 @@ public class CouponServiceImpl implements ICouponService {
} }
} }
// 3. 检查商品类型限制 // 3. 检查商品类型限制(用于确定可折扣商品范围)
if (coupon.getApplicableProducts() == null || coupon.getApplicableProducts().isEmpty()) { List<ProductItem> discountableProducts = products;
if (coupon.getApplicableProducts() != null && !coupon.getApplicableProducts().isEmpty()) {
try {
List<String> applicableProductTypes = objectMapper.readValue(
coupon.getApplicableProducts(), new TypeReference<List<String>>() {});
discountableProducts = products.stream()
.filter(product -> applicableProductTypes.contains(product.getProductType().getCode()))
.toList();
if (discountableProducts.isEmpty()) {
return false;
}
} catch (Exception e) {
log.error("解析适用商品类型失败", e);
return false;
}
}
// 4. 检查属性门槛:要求在可折扣商品范围内,任一商品出现任一属性Key
if (coupon.getRequiredAttributeKeys() == null || coupon.getRequiredAttributeKeys().isEmpty()) {
return true; return true;
} }
try { try {
List<String> applicableProductTypes = objectMapper.readValue( List<String> requiredAttributeKeys = objectMapper.readValue(
coupon.getApplicableProducts(), new TypeReference<List<String>>() {}); coupon.getRequiredAttributeKeys(), new TypeReference<List<String>>() {});
if (requiredAttributeKeys == null || requiredAttributeKeys.isEmpty()) {
return true;
}
for (ProductItem product : products) { for (ProductItem product : discountableProducts) {
if (applicableProductTypes.contains(product.getProductType().getCode())) { List<String> attributeKeys = product.getAttributeKeys();
return true; if (attributeKeys == null || attributeKeys.isEmpty()) {
continue;
}
for (String requiredKey : requiredAttributeKeys) {
if (requiredKey == null) {
continue;
}
String key = requiredKey.trim();
if (key.isEmpty()) {
continue;
}
if (attributeKeys.contains(key)) {
return true;
}
} }
} }
return false; return false;
} catch (Exception e) { } catch (Exception e) {
log.error("解析适用商品类型失败", e); log.error("解析优惠券属性门槛失败", e);
return false; return false;
} }
} }

View File

@@ -17,7 +17,11 @@ import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
/** /**
* 价格计算服务实现 * 价格计算服务实现
@@ -27,6 +31,8 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PriceCalculationServiceImpl implements IPriceCalculationService { public class PriceCalculationServiceImpl implements IPriceCalculationService {
private static final String CAPABILITY_METADATA_ATTRIBUTE_KEYS = "pricingAttributeKeys";
private final IProductConfigService productConfigService; private final IProductConfigService productConfigService;
private final ICouponService couponService; private final ICouponService couponService;
private final IPriceBundleService bundleService; private final IPriceBundleService bundleService;
@@ -46,6 +52,13 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED; return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED;
} }
private boolean isQuantityBasedPricing(ProductTypeCapability capability) {
if (capability == null) {
return false;
}
return capability.getPricingModeEnum() == PricingMode.QUANTITY_BASED;
}
@Override @Override
public PriceCalculationResult calculatePrice(PriceCalculationRequest request) { public PriceCalculationResult calculatePrice(PriceCalculationRequest request) {
if (request.getProducts() == null || request.getProducts().isEmpty()) { if (request.getProducts() == null || request.getProducts().isEmpty()) {
@@ -166,9 +179,16 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
BigDecimal totalAmount = BigDecimal.ZERO; BigDecimal totalAmount = BigDecimal.ZERO;
BigDecimal originalTotalAmount = BigDecimal.ZERO; BigDecimal originalTotalAmount = BigDecimal.ZERO;
Map<String, ProductTypeCapability> capabilityCache = new HashMap<>();
Map<String, List<String>> attributeKeysCache = new HashMap<>();
for (ProductItem product : products) { for (ProductItem product : products) {
String productTypeCode = product.getProductType().getCode();
ProductTypeCapability capability = capabilityCache.computeIfAbsent(
productTypeCode, productTypeCapabilityService::getCapability);
// 计算实际价格和原价(传入景区ID) // 计算实际价格和原价(传入景区ID)
ProductPriceInfo priceInfo = calculateSingleProductPriceWithOriginal(product, scenicId); ProductPriceInfo priceInfo = calculateSingleProductPriceWithOriginal(product, scenicId, capability);
product.setUnitPrice(priceInfo.getActualPrice()); product.setUnitPrice(priceInfo.getActualPrice());
product.setOriginalPrice(priceInfo.getOriginalPrice()); product.setOriginalPrice(priceInfo.getOriginalPrice());
@@ -188,6 +208,51 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
); );
} }
private List<String> buildProductAttributeKeys(ProductTypeCapability capability) {
if (capability == null || capability.getMetadata() == null) {
return List.of();
}
Object rawValue = capability.getMetadata().get(CAPABILITY_METADATA_ATTRIBUTE_KEYS);
if (rawValue == null) {
return List.of();
}
Set<String> result = new LinkedHashSet<>();
if (rawValue instanceof List<?> rawList) {
for (Object item : rawList) {
if (item instanceof String rawKey) {
addAttributeKey(result, rawKey);
}
}
} else if (rawValue instanceof String rawString) {
String[] parts = rawString.split(",");
for (String part : parts) {
addAttributeKey(result, part);
}
} else {
log.warn("商品类型能力metadata中{}字段类型不支持: productType={}, valueType={}",
CAPABILITY_METADATA_ATTRIBUTE_KEYS,
capability.getProductType(),
rawValue.getClass().getName());
}
return result.isEmpty() ? List.of() : List.copyOf(result);
}
private void addAttributeKey(Set<String> target, String rawKey) {
if (rawKey == null) {
return;
}
String key = rawKey.trim();
if (key.isEmpty()) {
return;
}
target.add(key);
}
private BigDecimal calculateSingleProductPrice(ProductItem product) { private BigDecimal calculateSingleProductPrice(ProductItem product) {
ProductType productType = product.getProductType(); ProductType productType = product.getProductType();
String productId = product.getProductId() != null ? product.getProductId() : "default"; String productId = product.getProductId() != null ? product.getProductId() : "default";
@@ -245,7 +310,8 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
throw new PriceCalculationException("无法计算商品价格: " + productType.getDescription() + ", productId: " + productId); throw new PriceCalculationException("无法计算商品价格: " + productType.getDescription() + ", productId: " + productId);
} }
private ProductPriceInfo calculateSingleProductPriceWithOriginal(ProductItem product, Long scenicId) { private ProductPriceInfo calculateSingleProductPriceWithOriginal(ProductItem product, Long scenicId,
ProductTypeCapability capability) {
ProductType productType = product.getProductType(); ProductType productType = product.getProductType();
String productId = product.getProductId() != null ? product.getProductId() : "default"; String productId = product.getProductId() != null ? product.getProductId() : "default";
@@ -269,7 +335,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = baseConfig.getBasePrice(); actualPrice = baseConfig.getBasePrice();
originalPrice = baseConfig.getOriginalPrice(); originalPrice = baseConfig.getOriginalPrice();
if (isQuantityBasedPricing(productType.getCode())) { if (isQuantityBasedPricing(capability)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) { if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -289,7 +355,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = defaultConfig.getBasePrice(); actualPrice = defaultConfig.getBasePrice();
originalPrice = defaultConfig.getOriginalPrice(); originalPrice = defaultConfig.getOriginalPrice();
if (isQuantityBasedPricing(productType.getCode())) { if (isQuantityBasedPricing(capability)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) { if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -308,7 +374,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = baseConfig.getBasePrice(); actualPrice = baseConfig.getBasePrice();
originalPrice = baseConfig.getOriginalPrice(); originalPrice = baseConfig.getOriginalPrice();
if (isQuantityBasedPricing(productType.getCode())) { if (isQuantityBasedPricing(capability)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity())); actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) { if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity())); originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));

View File

@@ -82,8 +82,8 @@ public class VoucherPrintServiceImpl implements VoucherPrintService {
// 验证faceId是否属于当前用户 // 验证faceId是否属于当前用户
validateFaceOwnership(request.getFaceId(), currentUserId); validateFaceOwnership(request.getFaceId(), currentUserId);
ScenicConfigManager config = scenicRepository.getScenicConfigManager(face.getScenicId()); ScenicConfigManager configManager = scenicRepository.getScenicConfigManager(face.getScenicId());
Long brokerId = config.getLong("voucher_broker_id"); Long brokerId = configManager.getLong("voucher_broker_id");
if (brokerId != null) { if (brokerId != null) {
if (!request.getBrokerId().equals(brokerId)) { if (!request.getBrokerId().equals(brokerId)) {
return null; return null;
@@ -192,11 +192,11 @@ public class VoucherPrintServiceImpl implements VoucherPrintService {
} }
request.setScenicId(face.getScenicId()); request.setScenicId(face.getScenicId());
ScenicConfigManager config = scenicRepository.getScenicConfigManager(face.getScenicId()); ScenicConfigManager configManager = scenicRepository.getScenicConfigManager(face.getScenicId());
if (!Boolean.TRUE.equals(config.getBoolean("booking_enable"))) { if (!Boolean.TRUE.equals(configManager.getBoolean("booking_enable"))) {
return null; return null;
} }
Long brokerId = config.getLong("booking_broker_id"); Long brokerId = configManager.getLong("booking_broker_id");
if (brokerId != null) { if (brokerId != null) {
if (!request.getBrokerId().equals(brokerId)) { if (!request.getBrokerId().equals(brokerId)) {
return null; return null;
@@ -370,9 +370,9 @@ public class VoucherPrintServiceImpl implements VoucherPrintService {
content += "<CB>"+voucherPrintResp.getCode()+"</CB>"; content += "<CB>"+voucherPrintResp.getCode()+"</CB>";
content += "<C>"+voucherPrintResp.getType()+"</C>"; content += "<C>"+voucherPrintResp.getType()+"</C>";
content += "<C>有效期:"+sdf2.format(new Date())+"</C>"; content += "<C>有效期:"+sdf2.format(new Date())+"</C>";
ScenicConfigManager config = scenicRepository.getScenicConfigManager(face.getScenicId()); ScenicConfigManager configManager = scenicRepository.getScenicConfigManager(face.getScenicId());
if (Strings.isNotBlank(config.getString("ticket_print_sn"))) { if (Strings.isNotBlank(configManager.getString("ticket_print_sn"))) {
FeiETicketPrinter.doPrint(config.getString("ticket_print_sn"), content, 1); FeiETicketPrinter.doPrint(configManager.getString("ticket_print_sn"), content, 1);
} else { } else {
log.warn("打印没有配置->内容:\n{}", content); log.warn("打印没有配置->内容:\n{}", content);
} }

View File

@@ -78,6 +78,21 @@ public class PuzzleTemplateDTO {
*/ */
private Long scenicId; private Long scenicId;
/**
* 自动添加到打印队列:1-开启 0-关闭
*/
private Integer autoAddPrint;
/**
* 是否可以打印:1-可以 0-不可以
*/
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
private String userArea;
/** /**
* 元素列表 * 元素列表
*/ */

View File

@@ -61,6 +61,21 @@ public class TemplateCreateRequest {
*/ */
private String category; private String category;
/**
* 自动添加到打印队列:1-开启 0-关闭
*/
private Integer autoAddPrint;
/**
* 是否可以打印:1-可以 0-不可以
*/
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
private String userArea;
/** /**
* 状态:0-禁用 1-启用 * 状态:0-禁用 1-启用
*/ */

View File

@@ -76,6 +76,12 @@ public class PuzzleGenerationRecordEntity {
@TableField("result_image_url") @TableField("result_image_url")
private String resultImageUrl; private String resultImageUrl;
/**
* 原始图片URL(未裁切的图片,用于打印)
*/
@TableField("original_image_url")
private String originalImageUrl;
/** /**
* 文件大小(字节) * 文件大小(字节)
*/ */

View File

@@ -97,6 +97,24 @@ public class PuzzleTemplateEntity {
@TableField("scenic_id") @TableField("scenic_id")
private Long scenicId; private Long scenicId;
/**
* 自动添加到打印队列:1-开启 0-关闭
*/
@TableField("auto_add_print")
private Integer autoAddPrint;
/**
* 是否可以打印:1-可以 0-不可以
*/
@TableField("can_print")
private Integer canPrint;
/**
* 用户查看区域(裁切区域),格式:x,y,w,h
*/
@TableField("user_area")
private String userArea;
/** /**
* 创建时间 * 创建时间
*/ */

View File

@@ -52,6 +52,7 @@ public interface PuzzleGenerationRecordMapper {
*/ */
int updateSuccess(@Param("id") Long id, int updateSuccess(@Param("id") Long id,
@Param("resultImageUrl") String resultImageUrl, @Param("resultImageUrl") String resultImageUrl,
@Param("originalImageUrl") String originalImageUrl,
@Param("resultFileSize") Long resultFileSize, @Param("resultFileSize") Long resultFileSize,
@Param("resultWidth") Integer resultWidth, @Param("resultWidth") Integer resultWidth,
@Param("resultHeight") Integer resultHeight, @Param("resultHeight") Integer resultHeight,

View File

@@ -17,6 +17,7 @@ import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector; import com.ycwl.basic.puzzle.util.PuzzleDuplicationDetector;
import com.ycwl.basic.puzzle.util.PuzzleImageRenderer; import com.ycwl.basic.puzzle.util.PuzzleImageRenderer;
import com.ycwl.basic.repository.ScenicRepository; import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory; import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.utils.WxMpUtil; import com.ycwl.basic.utils.WxMpUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -60,6 +61,8 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
private final ScenicRepository scenicRepository; private final ScenicRepository scenicRepository;
@Lazy @Lazy
private final PuzzleDuplicationDetector duplicationDetector; private final PuzzleDuplicationDetector duplicationDetector;
@Lazy
private final PrinterService printerService;
@Override @Override
public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) { public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) {
@@ -136,30 +139,64 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
// 9. 渲染图片 // 9. 渲染图片
BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData); BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData);
// 10. 上传到OSS // 10. 上传原图到OSS(未裁切)
String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality()); String originalImageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality());
log.info("上传成功: url={}", imageUrl); log.info("图上传成功: url={}", originalImageUrl);
// 11. 更新记录为成功 // 11. 处理用户区域裁切
String finalImageUrl = originalImageUrl; // 默认使用原图
BufferedImage finalImage = resultImage;
if (StrUtil.isNotBlank(template.getUserArea())) {
try {
BufferedImage croppedImage = cropImage(resultImage, template.getUserArea());
finalImageUrl = uploadImage(croppedImage, template.getCode() + "_cropped", request.getOutputFormat(), request.getQuality());
finalImage = croppedImage;
log.info("裁切后图片上传成功: userArea={}, url={}", template.getUserArea(), finalImageUrl);
} catch (Exception e) {
log.error("图片裁切失败,使用原图: userArea={}", template.getUserArea(), e);
// 裁切失败时使用原图
}
}
// 12. 更新记录为成功
long duration = (int) (System.currentTimeMillis() - startTime); long duration = (int) (System.currentTimeMillis() - startTime);
long fileSize = estimateFileSize(resultImage, request.getOutputFormat()); long fileSize = estimateFileSize(finalImage, request.getOutputFormat());
recordMapper.updateSuccess( recordMapper.updateSuccess(
record.getId(), record.getId(),
imageUrl, finalImageUrl,
originalImageUrl,
fileSize, fileSize,
resultImage.getWidth(), finalImage.getWidth(),
resultImage.getHeight(), finalImage.getHeight(),
(int) duration (int) duration
); );
log.info("拼图生成成功(新生成): recordId={}, imageUrl={}, duration={}ms", log.info("拼图生成成功(新生成): recordId={}, originalUrl={}, finalUrl={}, duration={}ms",
record.getId(), imageUrl, duration); record.getId(), originalImageUrl, finalImageUrl, duration);
// 13. 检查是否自动添加到打印队列
if (template.getAutoAddPrint() != null && template.getAutoAddPrint() == 1) {
try {
Integer printRecordId = printerService.addUserPhotoFromPuzzle(
request.getUserId(),
resolvedScenicId,
request.getFaceId(),
originalImageUrl, // 使用原图URL添加到打印队列
record.getId()
);
log.info("自动添加到打印队列成功: recordId={}, printRecordId={}", record.getId(), printRecordId);
} catch (Exception e) {
log.error("自动添加到打印队列失败: recordId={}", record.getId(), e);
// 添加失败不影响拼图生成流程
}
}
return PuzzleGenerateResponse.success( return PuzzleGenerateResponse.success(
imageUrl, finalImageUrl,
fileSize, fileSize,
resultImage.getWidth(), finalImage.getWidth(),
resultImage.getHeight(), finalImage.getHeight(),
(int) duration, (int) duration,
record.getId(), record.getId(),
false, // isDuplicate=false false, // isDuplicate=false
@@ -405,4 +442,43 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService {
return templateScenicId; return templateScenicId;
} }
/**
* 裁切图片
* @param image 原图
* @param userArea 裁切区域,格式:x,y,w,h
* @return 裁切后的图片
*/
private BufferedImage cropImage(BufferedImage image, String userArea) {
if (StrUtil.isBlank(userArea)) {
return image;
}
try {
String[] parts = userArea.split(",");
if (parts.length != 4) {
throw new IllegalArgumentException("userArea格式错误,应为:x,y,w,h");
}
int x = Integer.parseInt(parts[0].trim());
int y = Integer.parseInt(parts[1].trim());
int w = Integer.parseInt(parts[2].trim());
int h = Integer.parseInt(parts[3].trim());
// 边界检查
if (x < 0 || y < 0 || w <= 0 || h <= 0) {
throw new IllegalArgumentException("裁切区域参数必须为正数");
}
if (x + w > image.getWidth() || y + h > image.getHeight()) {
throw new IllegalArgumentException("裁切区域超出图片边界");
}
// 执行裁切
return image.getSubimage(x, y, w, h);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("userArea格式错误,参数必须为数字", e);
}
}
} }

View File

@@ -12,7 +12,6 @@ import com.ycwl.basic.mapper.MpNotifyConfigMapper;
import com.ycwl.basic.model.pc.mp.MpConfigEntity; import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.model.pc.mp.MpNotifyConfigEntity; import com.ycwl.basic.model.pc.mp.MpNotifyConfigEntity;
import com.ycwl.basic.model.pc.mp.ScenicMpNotifyVO; import com.ycwl.basic.model.pc.mp.ScenicMpNotifyVO;
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity; import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery; import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
import com.ycwl.basic.pay.enums.PayAdapterType; import com.ycwl.basic.pay.enums.PayAdapterType;
@@ -59,78 +58,6 @@ public class ScenicRepository {
return scenicEntity; return scenicEntity;
} }
@Deprecated
public ScenicConfigEntity getScenicConfig(Long scenicId) {
ScenicConfigManager scenicConfigManager = getScenicConfigManager(scenicId);
ScenicConfigEntity config = new ScenicConfigEntity();
// 基础配置
config.setScenicId(scenicId);
if (scenicConfigManager == null) {
return config;
}
// 业务流程配置
config.setBookRoutine(scenicConfigManager.getInteger("book_routine"));
config.setForceFinishTime(scenicConfigManager.getInteger("force_finish_time"));
config.setTourTime(scenicConfigManager.getInteger("tour_time"));
// 存储时间配置
config.setSampleStoreDay(scenicConfigManager.getInteger("sample_store_day"));
config.setFaceStoreDay(scenicConfigManager.getInteger("face_store_day"));
config.setVideoStoreDay(scenicConfigManager.getInteger("video_store_day"));
config.setVideoSourceStoreDay(scenicConfigManager.getInteger("video_source_store_day"));
config.setImageSourceStoreDay(scenicConfigManager.getInteger("image_source_store_day"));
config.setUserSourceExpireDay(scenicConfigManager.getInteger("user_source_expire_day"));
// 功能开关配置
config.setAllFree(scenicConfigManager.getBoolean("all_free"));
config.setDisableSourceVideo(scenicConfigManager.getBoolean("disable_source_video"));
config.setDisableSourceImage(scenicConfigManager.getBoolean("disable_source_image"));
config.setVoucherEnable(scenicConfigManager.getBoolean("voucher_enable"));
// 模板和防录屏配置
config.setTemplateNewVideoType(scenicConfigManager.getInteger("template_new_video_type"));
config.setAntiScreenRecordType(scenicConfigManager.getInteger("anti_screen_record_type"));
// 人脸识别配置
config.setFaceScoreThreshold(scenicConfigManager.getFloat("face_score_threshold"));
config.setFaceDetectHelperThreshold(scenicConfigManager.getInteger("face_detect_helper_threshold"));
config.setFaceType(scenicConfigManager.getEnum("face_type", FaceBodyAdapterType.class));
config.setFaceConfigJson(scenicConfigManager.getString("face_config_json"));
// 存储配置
config.setStoreType(scenicConfigManager.getEnum("store_type", StorageType.class));
config.setStoreConfigJson(scenicConfigManager.getString("store_config_json"));
config.setTmpStoreType(scenicConfigManager.getEnum("tmp_store_type", StorageType.class));
config.setTmpStoreConfigJson(scenicConfigManager.getString("tmp_store_config_json"));
config.setLocalStoreType(scenicConfigManager.getEnum("local_store_type", StorageType.class));
config.setLocalStoreConfigJson(scenicConfigManager.getString("local_store_config_json"));
// 支付配置
config.setPayType(scenicConfigManager.getEnum("pay_type", PayAdapterType.class));
config.setPayConfigJson(scenicConfigManager.getString("pay_config_json"));
// 推客配置
config.setBrokerDirectRate(scenicConfigManager.getBigDecimal("broker_direct_rate"));
// 水印配置
config.setWatermarkType(scenicConfigManager.getString("watermark_type"));
config.setWatermarkScenicText(scenicConfigManager.getString("watermark_scenic_text"));
config.setWatermarkDtFormat(scenicConfigManager.getString("watermark_dt_format"));
// 提示信息配置
config.setImageSourcePackHint(scenicConfigManager.getString("image_source_pack_hint"));
config.setVideoSourcePackHint(scenicConfigManager.getString("video_source_pack_hint"));
config.setExtraNotificationTime(scenicConfigManager.getString("extra_notification_time"));
// 免费数量配置
config.setPhotoFreeNum(scenicConfigManager.getInteger("photo_free_num"));
config.setVideoFreeNum(scenicConfigManager.getInteger("video_free_num"));
return config;
}
public MpConfigEntity getScenicMpConfig(Long scenicId) { public MpConfigEntity getScenicMpConfig(Long scenicId) {
if (redisTemplate.hasKey(String.format(SCENIC_MP_CACHE_KEY, scenicId))) { if (redisTemplate.hasKey(String.format(SCENIC_MP_CACHE_KEY, scenicId))) {
return JacksonUtil.parseObject(redisTemplate.opsForValue().get(String.format(SCENIC_MP_CACHE_KEY, scenicId)), MpConfigEntity.class); return JacksonUtil.parseObject(redisTemplate.opsForValue().get(String.format(SCENIC_MP_CACHE_KEY, scenicId)), MpConfigEntity.class);

View File

@@ -27,11 +27,6 @@ public class VideoRepository {
public static final String VIDEO_CACHE_KEY = "video:%s"; public static final String VIDEO_CACHE_KEY = "video:%s";
public static final String VIDEO_BY_TASK_ID_CACHE_KEY = "video:task:%s"; public static final String VIDEO_BY_TASK_ID_CACHE_KEY = "video:task:%s";
@Autowired @Autowired
@Lazy
private PriceBiz priceBiz;
@Autowired
private IVoucherService iVoucherService;
@Autowired
private MemberRelationRepository memberRelationRepository; private MemberRelationRepository memberRelationRepository;
public VideoEntity getVideo(Long videoId) { public VideoEntity getVideo(Long videoId) {
@@ -134,4 +129,14 @@ public class VideoRepository {
} }
/**
* 通过faceId和templateId(可选)查询最新的视频记录
* @param faceId 人脸ID
* @param templateId 模板ID(可选)
* @return 最新的视频记录
*/
public VideoRespVO queryLatestByFaceIdAndTemplateId(Long faceId, Long templateId) {
return videoMapper.queryLatestByFaceIdAndTemplateId(faceId, templateId);
}
} }

View File

@@ -53,29 +53,6 @@ public class VideoTaskRepository {
redisTemplate.delete(String.format(TASK_CACHE_KEY, taskId)); redisTemplate.delete(String.format(TASK_CACHE_KEY, taskId));
} }
public Date getTaskShotDate(Long taskId) {
TaskRespVO taskRespVO = taskMapper.getById(taskId);
if (taskRespVO == null) {
return null;
}
Date shotTime = taskRespVO.getCreateTime();
JacksonUtil.JSONObjectCompat paramJson = JacksonUtil.JSONObjectCompat.parseObject(taskRespVO.getTaskParams());
if (paramJson != null) {
Optional<String> any = paramJson.keySet().stream().filter(StringUtils::isNumeric).findAny();
if (any.isPresent()) {
var jsonArray = paramJson.getJSONArray(any.get());
if (jsonArray != null && !jsonArray.isEmpty()) {
JacksonUtil.JSONObjectCompat jsonObject = jsonArray.get(0);
if (jsonObject.getString("createTime") != null) {
shotTime = DateUtil.parse(jsonObject.getString("createTime"));
}
}
}
}
return shotTime;
}
public Integer getTaskDeviceNum(Long taskId) { public Integer getTaskDeviceNum(Long taskId) {
TaskEntity task = getTaskById(taskId); TaskEntity task = getTaskById(taskId);
if (task == null) { if (task == null) {
@@ -131,6 +108,32 @@ public class VideoTaskRepository {
return deviceCount.get(); return deviceCount.get();
} }
public Date getTaskShotDate(Long taskId) {
TaskEntity task = getTaskById(taskId);
if (task == null) {
return null;
}
Date shotTime = task.getCreateTime();
Map<String, Object> paramJson = JacksonUtil.parseObject(task.getTaskParams(), Map.class);
if (paramJson != null) {
Optional<String> any = paramJson.keySet().stream().filter(StringUtils::isNumeric).findAny();
if (any.isPresent()) {
@SuppressWarnings("unchecked")
List<Map<String, Object>> jsonArray = (List<Map<String, Object>>) paramJson.get(any.get());
if (jsonArray != null && !jsonArray.isEmpty()) {
Map<String, Object> jsonObject = jsonArray.get(0);
if (jsonObject.containsKey("createTime")) {
Object createTimeObj = jsonObject.get("createTime");
if (createTimeObj instanceof Number) {
shotTime = new Date(((Number) createTimeObj).longValue());
}
}
}
}
}
return shotTime;
}
/** /**
* 检查任务是否可以更新 * 检查任务是否可以更新
* @param taskId 任务ID * @param taskId 任务ID

View File

@@ -1,6 +1,8 @@
package com.ycwl.basic.service; package com.ycwl.basic.service;
import com.github.pagehelper.PageInfo; import com.github.pagehelper.PageInfo;
import com.ycwl.basic.model.pc.videoreview.dto.VideoPurchaseCheckReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoPurchaseCheckRespDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO; import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO; import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO; import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
@@ -45,4 +47,15 @@ public interface VideoReviewService {
* @throws IOException IO异常 * @throws IOException IO异常
*/ */
void exportReviews(VideoReviewListReqDTO reqDTO, OutputStream outputStream) throws IOException; void exportReviews(VideoReviewListReqDTO reqDTO, OutputStream outputStream) throws IOException;
/**
* 检查视频是否已被购买
* 购买条件:
* 1. 直接购买视频(order_item中goods_type=0且goods_id=视频id)
* 2. 购买整个模板(order的face_id与video关联的task的face_id相同,goods_type=-1,goods_id为video的templateId)
*
* @param reqDTO 查询条件
* @return 购买状态及订单ID列表
*/
VideoPurchaseCheckRespDTO checkVideoPurchase(VideoPurchaseCheckReqDTO reqDTO);
} }

View File

@@ -7,9 +7,15 @@ import com.github.pagehelper.PageInfo;
import com.ycwl.basic.constant.BaseContextHandler; import com.ycwl.basic.constant.BaseContextHandler;
import com.ycwl.basic.exception.BaseException; import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.exception.BizException; import com.ycwl.basic.exception.BizException;
import com.ycwl.basic.mapper.OrderMapper;
import com.ycwl.basic.mapper.TaskMapper;
import com.ycwl.basic.mapper.VideoMapper; import com.ycwl.basic.mapper.VideoMapper;
import com.ycwl.basic.mapper.VideoReviewMapper; import com.ycwl.basic.mapper.VideoReviewMapper;
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
import com.ycwl.basic.model.pc.video.entity.VideoEntity; import com.ycwl.basic.model.pc.video.entity.VideoEntity;
import com.ycwl.basic.model.pc.videoreview.dto.VideoPurchaseCheckReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoPurchaseCheckRespDTO;
import com.ycwl.basic.repository.DeviceRepository; import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO; import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO; import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
@@ -31,7 +37,6 @@ import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
/** /**
* 视频评价Service实现类 * 视频评价Service实现类
@@ -49,6 +54,12 @@ public class VideoReviewServiceImpl implements VideoReviewService {
@Autowired @Autowired
private DeviceRepository deviceRepository; private DeviceRepository deviceRepository;
@Autowired
private OrderMapper orderMapper;
@Autowired
private TaskMapper taskMapper;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@Override @Override
@@ -160,7 +171,7 @@ public class VideoReviewServiceImpl implements VideoReviewService {
// 2. 收集所有机位ID并批量查询机位名称 // 2. 收集所有机位ID并批量查询机位名称
Set<Long> allDeviceIds = new LinkedHashSet<>(); Set<Long> allDeviceIds = new LinkedHashSet<>();
for (VideoReviewRespDTO review : list) { for (VideoReviewRespDTO review : list) {
Map<String, Map<String, Integer>> cameraRating = review.getCameraPositionRating(); Map<String, Integer> cameraRating = review.getCameraPositionRating();
if (cameraRating != null && !cameraRating.isEmpty()) { if (cameraRating != null && !cameraRating.isEmpty()) {
// 收集机位ID (按顺序) // 收集机位ID (按顺序)
for (String deviceIdStr : cameraRating.keySet()) { for (String deviceIdStr : cameraRating.keySet()) {
@@ -195,12 +206,7 @@ public class VideoReviewServiceImpl implements VideoReviewService {
headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); headerStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
// 5. 创建单元格自动换行样式 // 5. 生成动态表头 - 使用机位名称作为表头
CellStyle wrapStyle = workbook.createCellStyle();
wrapStyle.setWrapText(true);
wrapStyle.setVerticalAlignment(VerticalAlignment.TOP);
// 6. 生成动态表头 - 使用机位名称作为表头
Row headerRow = sheet.createRow(0); Row headerRow = sheet.createRow(0);
List<String> headerList = new ArrayList<>(); List<String> headerList = new ArrayList<>();
headerList.add("评价ID"); headerList.add("评价ID");
@@ -228,7 +234,7 @@ public class VideoReviewServiceImpl implements VideoReviewService {
cell.setCellStyle(headerStyle); cell.setCellStyle(headerStyle);
} }
// 7. 填充数据 // 6. 填充数据
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
int rowNum = 1; int rowNum = 1;
@@ -246,37 +252,21 @@ public class VideoReviewServiceImpl implements VideoReviewService {
row.createCell(colIndex++).setCellValue(review.getContent()); row.createCell(colIndex++).setCellValue(review.getContent());
// 机位评价列 - 按表头顺序填充 // 机位评价列 - 按表头顺序填充
Map<String, Map<String, Integer>> cameraRating = review.getCameraPositionRating(); Map<String, Integer> cameraRating = review.getCameraPositionRating();
for (Long deviceId : sortedDeviceIds) { for (Long deviceId : sortedDeviceIds) {
String deviceIdStr = String.valueOf(deviceId); String deviceIdStr = String.valueOf(deviceId);
Map<String, Integer> dimensions = null; Integer rating = null;
if (cameraRating != null && cameraRating.containsKey(deviceIdStr)) { if (cameraRating != null && cameraRating.containsKey(deviceIdStr)) {
dimensions = cameraRating.get(deviceIdStr); rating = cameraRating.get(deviceIdStr);
}
// 构建单元格内容: 只显示评分维度(不再重复机位名称)
StringBuilder cellContent = new StringBuilder();
if (dimensions != null && !dimensions.isEmpty()) {
// 按维度名排序,保证一致性
List<Map.Entry<String, Integer>> sortedDimensions = dimensions.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.collect(Collectors.toList());
boolean first = true;
for (Map.Entry<String, Integer> dimEntry : sortedDimensions) {
if (!first) {
cellContent.append("\n");
}
cellContent.append(dimEntry.getKey()).append("").append(dimEntry.getValue());
first = false;
}
} }
Cell cell = row.createCell(colIndex++); Cell cell = row.createCell(colIndex++);
cell.setCellValue(cellContent.toString()); if (rating != null) {
cell.setCellStyle(wrapStyle); cell.setCellValue(rating);
} else {
cell.setCellValue("");
}
} }
// 时间列 // 时间列
@@ -284,50 +274,92 @@ public class VideoReviewServiceImpl implements VideoReviewService {
row.createCell(colIndex).setCellValue(review.getUpdateTime() != null ? sdf.format(review.getUpdateTime()) : ""); row.createCell(colIndex).setCellValue(review.getUpdateTime() != null ? sdf.format(review.getUpdateTime()) : "");
} }
// 8. 自动调整列宽 // 7. 自动调整列宽
for (int i = 0; i < headerList.size(); i++) { for (int i = 0; i < headerList.size(); i++) {
sheet.autoSizeColumn(i); sheet.autoSizeColumn(i);
// 对于机位列,设置最小宽度以便换行内容显示完整
if (i >= 7 && i < 7 + sortedDeviceIds.size()) {
int currentWidth = sheet.getColumnWidth(i);
sheet.setColumnWidth(i, Math.max(currentWidth, 5000)); // 最小25个字符宽度
}
} }
// 9. 写入输出流 // 8. 写入输出流
workbook.write(outputStream); workbook.write(outputStream);
workbook.close(); workbook.close();
log.info("导出视频评价数据成功,共{}条,机位数:{}", list.size(), sortedDeviceIds.size()); log.info("导出视频评价数据成功,共{}条,机位数:{}", list.size(), sortedDeviceIds.size());
} }
@Override
public VideoPurchaseCheckRespDTO checkVideoPurchase(VideoPurchaseCheckReqDTO reqDTO) {
// 参数校验
if (reqDTO.getVideoId() == null) {
throw new BaseException("视频ID不能为空");
}
Long videoId = reqDTO.getVideoId();
// 查询视频信息
VideoEntity video = videoMapper.getEntity(videoId);
if (video == null) {
throw new BaseException("视频不存在");
}
VideoPurchaseCheckRespDTO respDTO = new VideoPurchaseCheckRespDTO();
respDTO.setVideoId(videoId);
List<Long> allOrderIds = new ArrayList<>();
// 情况1:直接购买视频的订单(goods_type=0, goods_id=视频id)
List<Long> directOrderIds = orderMapper.getOrderIdsByVideoId(videoId);
if (directOrderIds != null && !directOrderIds.isEmpty()) {
allOrderIds.addAll(directOrderIds);
log.info("视频[{}]直接购买订单数: {}", videoId, directOrderIds.size());
}
// 情况2:通过购买模板间接拥有(goods_type=-1, goods_id=templateId)
// 通过video的faceId查询购买模板的订单
if (video.getFaceId() != null && video.getTemplateId() != null) {
List<Long> templateOrderIds = orderMapper.getOrderIdsByFaceIdAndTemplateId(
video.getFaceId(),
video.getTemplateId()
);
if (templateOrderIds != null && !templateOrderIds.isEmpty()) {
allOrderIds.addAll(templateOrderIds);
log.info("视频[{}]通过模板[{}]购买订单数: {}, faceId: {}",
videoId, video.getTemplateId(), templateOrderIds.size(), video.getFaceId());
}
}
respDTO.setOrderIds(allOrderIds);
respDTO.setIsPurchased(!allOrderIds.isEmpty());
log.info("视频[{}]购买检查完成,是否被购买: {}, 总订单数: {}",
videoId, respDTO.getIsPurchased(), allOrderIds.size());
return respDTO;
}
/** /**
* 计算机位评价各维度的平均 * 计算机位的平均评分
*/ */
private Map<String, BigDecimal> calculateCameraPositionAverage() { private Map<String, BigDecimal> calculateCameraPositionAverage() {
List<Map<String, Map<String, Integer>>> allRatings = videoReviewMapper.selectAllCameraPositionRatings(); List<Map<String, Integer>> allRatings = videoReviewMapper.selectAllCameraPositionRatings();
if (allRatings == null || allRatings.isEmpty()) { if (allRatings == null || allRatings.isEmpty()) {
return new HashMap<>(); return new HashMap<>();
} }
// 统计各维度的总分和次数 // 统计各机位的总分和次数
Map<String, List<Integer>> dimensionScores = new HashMap<>(); Map<String, List<Integer>> deviceScores = new HashMap<>();
for (Map<String, Map<String, Integer>> rating : allRatings) { for (Map<String, Integer> rating : allRatings) {
if (rating == null) continue; if (rating == null) continue;
// 遍历每个机位 // 遍历每个机位的评分
for (Map<String, Integer> deviceRatings : rating.values()) { for (Map.Entry<String, Integer> entry : rating.entrySet()) {
if (deviceRatings == null) continue; deviceScores.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(entry.getValue());
// 遍历该机位的每个维度
for (Map.Entry<String, Integer> entry : deviceRatings.entrySet()) {
dimensionScores.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).add(entry.getValue());
}
} }
} }
// 计算平均值 // 计算平均值
Map<String, BigDecimal> result = new HashMap<>(); Map<String, BigDecimal> result = new HashMap<>();
for (Map.Entry<String, List<Integer>> entry : dimensionScores.entrySet()) { for (Map.Entry<String, List<Integer>> entry : deviceScores.entrySet()) {
double avg = entry.getValue().stream().mapToInt(Integer::intValue).average().orElse(0.0); double avg = entry.getValue().stream().mapToInt(Integer::intValue).average().orElse(0.0);
result.put(entry.getKey(), BigDecimal.valueOf(avg).setScale(2, RoundingMode.HALF_UP)); result.put(entry.getKey(), BigDecimal.valueOf(avg).setScale(2, RoundingMode.HALF_UP));
} }

View File

@@ -0,0 +1,231 @@
package com.ycwl.basic.service.mobile;
import com.ycwl.basic.device.DeviceFactory;
import com.ycwl.basic.device.entity.common.FileObject;
import com.ycwl.basic.device.operator.IDeviceStorageOperator;
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
import com.ycwl.basic.model.mobile.video.dto.HlsStreamRequest;
import com.ycwl.basic.model.mobile.video.dto.HlsStreamResponse;
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
import com.ycwl.basic.repository.DeviceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
/**
* HLS视频流服务
* 用于生成设备视频的HLS播放列表(m3u8)
*
* @author Claude Code
* @date 2025-12-26
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class HlsStreamService {
private final DeviceIntegrationService deviceIntegrationService;
private final DeviceRepository deviceRepository;
private static final String M3U8_HEADER = "#EXTM3U";
private static final String M3U8_VERSION = "#EXT-X-VERSION:3";
private static final String M3U8_MEDIA_SEQUENCE = "#EXT-X-MEDIA-SEQUENCE:0";
private static final String M3U8_ALLOW_CACHE = "#EXT-X-ALLOW-CACHE:YES";
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/**
* 生成设备视频的HLS播放列表
*
* @param request HLS流请求参数
* @return HLS播放列表响应
*/
public HlsStreamResponse generateHlsPlaylist(HlsStreamRequest request) {
log.info("开始生成HLS播放列表: deviceId={}, durationMinutes={}",
request.getDeviceId(), request.getDurationMinutes());
try {
// 获取设备信息
DeviceV2DTO device = deviceIntegrationService.getDevice(request.getDeviceId());
if (device == null) {
throw new RuntimeException("设备不存在: " + request.getDeviceId());
}
// 获取设备配置
DeviceConfigEntity config = deviceRepository.getDeviceConfig(request.getDeviceId());
if (config == null) {
throw new RuntimeException("设备配置不存在: " + request.getDeviceId());
}
// 获取设备的存储操作器
IDeviceStorageOperator operator = DeviceFactory.getDeviceStorageOperator(device, config);
if (operator == null) {
throw new RuntimeException("设备未配置存储操作器: " + request.getDeviceId());
}
// 计算时间范围:当前时间向前N分钟
Calendar calendar = Calendar.getInstance();
Date endDate = calendar.getTime();
calendar.add(Calendar.MINUTE, -request.getDurationMinutes());
Date startDate = calendar.getTime();
log.info("查询视频文件范围: deviceId={}, startDate={}, endDate={}",
request.getDeviceId(), DATE_FORMAT.format(startDate), DATE_FORMAT.format(endDate));
// 获取视频文件列表
List<FileObject> fileList = operator.getFileListByDtRange(startDate, endDate);
if (fileList == null || fileList.isEmpty()) {
log.warn("未找到视频文件: deviceId={}, startDate={}, endDate={}",
request.getDeviceId(), DATE_FORMAT.format(startDate), DATE_FORMAT.format(endDate));
return buildEmptyResponse(request.getDeviceId(), request.getEventPlaylist());
}
// 按创建时间排序(升序)
fileList.sort(Comparator.comparing(FileObject::getCreateTime));
log.info("找到 {} 个视频文件", fileList.size());
// 生成播放列表
return buildHlsResponse(request.getDeviceId(), fileList, request.getEventPlaylist());
} catch (Exception e) {
log.error("生成HLS播放列表失败: deviceId={}", request.getDeviceId(), e);
throw new RuntimeException("生成HLS播放列表失败: " + e.getMessage(), e);
}
}
/**
* 构建HLS响应
*
* @param deviceId 设备ID
* @param fileList 视频文件列表
* @param isEventPlaylist 是否为Event播放列表
* @return HLS响应
*/
private HlsStreamResponse buildHlsResponse(Long deviceId, List<FileObject> fileList, Boolean isEventPlaylist) {
StringBuilder m3u8Content = new StringBuilder();
List<HlsStreamResponse.VideoSegment> segments = new ArrayList<>();
// 添加m3u8头部
m3u8Content.append(M3U8_HEADER).append("\n");
m3u8Content.append(M3U8_VERSION).append("\n");
// 设置播放列表类型
String playlistType = Boolean.TRUE.equals(isEventPlaylist) ? "EVENT" : "VOD";
m3u8Content.append("#EXT-X-PLAYLIST-TYPE:").append(playlistType).append("\n");
// 计算目标时长(使用视频片段的平均时长)
double avgDuration = calculateAverageDuration(fileList);
int targetDuration = (int) Math.ceil(avgDuration);
m3u8Content.append("#EXT-X-TARGETDURATION:").append(targetDuration).append("\n");
m3u8Content.append(M3U8_MEDIA_SEQUENCE).append("\n");
m3u8Content.append(M3U8_ALLOW_CACHE).append("\n");
// 添加视频片段
double totalDuration = 0.0;
int sequence = 0;
for (FileObject file : fileList) {
// 计算片段时长(秒)
double duration = calculateSegmentDuration(file);
totalDuration += duration;
// 添加片段信息到m3u8
m3u8Content.append("#EXTINF:").append(String.format("%.3f", duration)).append(",\n");
m3u8Content.append(file.getUrl().replace("-internal.aliyuncs.com", ".aliyuncs.com")).append("\n");
// 记录片段信息
HlsStreamResponse.VideoSegment segment = HlsStreamResponse.VideoSegment.builder()
.url(file.getUrl())
.duration(duration)
.sequence(sequence++)
.startTime(file.getCreateTime() != null ? DATE_FORMAT.format(file.getCreateTime()) : null)
.endTime(file.getEndTime() != null ? DATE_FORMAT.format(file.getEndTime()) : null)
.build();
segments.add(segment);
}
// 添加结束标记(VOD类型需要)
if (!"EVENT".equals(playlistType)) {
m3u8Content.append("#EXT-X-ENDLIST\n");
}
return HlsStreamResponse.builder()
.deviceId(deviceId)
.playlistContent(m3u8Content.toString())
.segmentCount(fileList.size())
.totalDurationSeconds(totalDuration)
.segments(segments)
.playlistType(playlistType)
.build();
}
/**
* 构建空响应
*/
private HlsStreamResponse buildEmptyResponse(Long deviceId, Boolean isEventPlaylist) {
String playlistType = Boolean.TRUE.equals(isEventPlaylist) ? "EVENT" : "VOD";
StringBuilder m3u8Content = new StringBuilder();
m3u8Content.append(M3U8_HEADER).append("\n");
m3u8Content.append(M3U8_VERSION).append("\n");
m3u8Content.append("#EXT-X-PLAYLIST-TYPE:").append(playlistType).append("\n");
m3u8Content.append("#EXT-X-TARGETDURATION:0\n");
m3u8Content.append(M3U8_MEDIA_SEQUENCE).append("\n");
if (!"EVENT".equals(playlistType)) {
m3u8Content.append("#EXT-X-ENDLIST\n");
}
return HlsStreamResponse.builder()
.deviceId(deviceId)
.playlistContent(m3u8Content.toString())
.segmentCount(0)
.totalDurationSeconds(0.0)
.segments(Collections.emptyList())
.playlistType(playlistType)
.build();
}
/**
* 计算视频片段的平均时长(秒)
*/
private double calculateAverageDuration(List<FileObject> fileList) {
if (fileList == null || fileList.isEmpty()) {
return 10.0; // 默认10秒
}
List<Double> durations = fileList.stream()
.map(this::calculateSegmentDuration)
.filter(d -> d > 0)
.collect(Collectors.toList());
if (durations.isEmpty()) {
return 10.0; // 默认10秒
}
return durations.stream()
.mapToDouble(Double::doubleValue)
.average()
.orElse(10.0);
}
/**
* 计算单个视频片段的时长(秒)
*/
private double calculateSegmentDuration(FileObject file) {
if (file.getCreateTime() != null && file.getEndTime() != null) {
long durationMs = file.getEndTime().getTime() - file.getCreateTime().getTime();
if (durationMs > 0) {
return durationMs / 1000.0;
}
}
// 如果无法计算,返回默认值10秒
return 10.0;
}
}

View File

@@ -53,10 +53,9 @@ public class VideoViewPermissionService {
log.warn("视频缺少景区信息: videoId={}", videoId); log.warn("视频缺少景区信息: videoId={}", videoId);
return createErrorPermission("视频信息不完整"); return createErrorPermission("视频信息不完整");
} }
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
// 检查用户是否已购买 // 检查用户是否已购买
IsBuyRespVO buy = orderBiz.isBuy(scenicId, userId, taskById.getFaceId(), 0, videoId); IsBuyRespVO buy = orderBiz.isBuy(scenicId, userId, video.getFaceId(), 0, videoId);
if (buy != null && (buy.isBuy() || buy.isFree())) { if (buy != null && (buy.isBuy() || buy.isFree())) {
// 已购买,不限制查看 // 已购买,不限制查看
log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId); log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId);
@@ -123,10 +122,9 @@ public class VideoViewPermissionService {
if (scenicId == null) { if (scenicId == null) {
return createErrorPermission("视频信息不完整"); return createErrorPermission("视频信息不完整");
} }
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
// 检查用户是否已购买 // 检查用户是否已购买
IsBuyRespVO buy = orderBiz.isBuy(scenicId, userId, taskById.getFaceId(), 0, videoId); IsBuyRespVO buy = orderBiz.isBuy(scenicId, userId, video.getFaceId(), 0, videoId);
if (buy != null && (buy.isBuy() || buy.isFree())) { if (buy != null && (buy.isBuy() || buy.isFree())) {
// 已购买,不限制查看 // 已购买,不限制查看
log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId); log.debug("用户已购买视频,无查看限制: userId={}, videoId={}", userId, videoId);

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