Compare commits

...

48 Commits

Author SHA1 Message Date
1b9bebf7e4 fix(facebody): 优化人脸搜索失败时的重试机制
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 提取error_code到局部变量避免重复调用
- 增加对错误码22204的处理逻辑,当URL图片无法访问时尝试下载并转换为base64后重试
- 添加重试成功后的完整响应处理流程
- 记录详细的重试过程日志便于问题排查
- 保留原始错误响应的日志记录
- 确保所有异常路径都返回有效的响应对象
2025-11-17 10:31:52 +08:00
fa287f36ae Revert "feat(facebody): 实现人脸识别搜索的重试机制"
This reverts commit 7c42c5c462.
2025-11-17 10:27:38 +08:00
0c3ada97f9 feat(facebody): 添加图片下载及base64重试机制
- 当添加人脸因无法访问URL图片失败时,自动下载图片并转换为base64后重试
- 新增downloadImageAsBase64方法用于图片下载与编码
- 替换阿里云OSS域名以支持内网访问
- 增加对png和jpg格式图片的支持
- 使用hutool库进行base64编码
- 添加详细的日志记录以便追踪重试过程
2025-11-17 10:21:10 +08:00
a7a7e30364 Merge branch 'coupon_user_limit' 2025-11-17 10:12:52 +08:00
17a33ada9f fix(printer): 修复打印机更新逻辑以使用传入的实体数据
- 在更新方法中先获取现有打印机实体
- 检查景区是否启用打印功能
- 使用传入的payload实体进行更新操作
- 保持原有的成功或失败响应结构
2025-11-17 10:12:43 +08:00
8efd16ba56 fix(coupon): 优化事务回滚标记逻辑
- 添加对无事务上下文情况的处理
- 避免在非事务环境下抛出异常
- 提高优惠券领取失败时的系统稳定性
2025-11-17 08:52:00 +08:00
7c906d5529 feat(pricing): 优化优惠券领取逻辑与并发控制
- 为 CouponInvalidException 添加错误码支持
- 在 countUserCouponClaims 查询中添加 FOR UPDATE 锁
- 新增 incrementClaimedQuantityIfAvailable 方法用于原子性增加已领取数量
- 移除重复的用户优惠券领取检查逻辑
- 调整领取流程步骤编号并优化事务回滚处理
- 增加对优惠券库存耗尽情况的业务异常处理
- 使用
2025-11-17 00:53:48 +08:00
88ad6d6b6f fix(pricing): 修复优惠券库存检查逻辑
- 修正totalQuantity为NULL或0时不限制总量的判断逻辑
- 优化claimedQuantity为空时的默认值处理
- 仅在totalQuantity大于0时更新已领取数量
- 完善MD文档中字段语义描述和配置示例
- 更新SQL表字段说明及典型配置组合示例
2025-11-17 00:30:58 +08:00
7835283f0f feat(pricing): 添加优惠券用户领取数量限制功能
- 新增用户领取数量限制字段 userClaimLimit
- 区分已领取数量 claimedQuantity 和已使用数量 usedQuantity
- 添加用户领取次数统计方法 countUserCouponClaims
- 实现领取上限检查逻辑和错误码 CLAIM_LIMIT_REACHED
- 更新数据库表结构和索引优化建议
- 完善文档说明和版本更新记录
2025-11-17 00:26:15 +08:00
9ee466bd5e fix(pricing): 移除自动发券中不必要的faceId校验
- 删除了对faceId参数的非空校验逻辑
- 保持其他参数校验不变
- 更新了日志记录中的参数列表
2025-11-16 13:17:53 +08:00
1a25848102 fix(pricing): 调整用户优惠券查询逻辑
- 修改查询条件,移除状态限制,支持查询所有状态的优惠券记录
- 更新注释说明,明确查询目的为检查用户是否领取过指定优惠券
2025-11-15 16:29:05 +08:00
c319398c58 feat(printer): 添加预览模式和订单ID支持
- 在价格计算请求中添加 previewOnly 参数以支持仅查询价格
- 为订单价格计算设置 orderId 参数
- 完善价格计算服务调用逻辑
2025-11-15 15:57:29 +08:00
afc589bb39 feat(order): 添加价格预览模式支持
- 在VLOG商品价格计算中添加previewOnly标志
- 在普通商品价格计算中添加previewOnly标志
- 确保价格查询时不实际使用优惠券
- 支持仅查询最终价格和原始价格的功能
2025-11-15 15:55:15 +08:00
4ccb563557 feat(pricing): 调整价格计算请求参数并完善优惠券使用逻辑
- 将 previewOnly 默认值从 false 改为 true
- 新增 orderId 字段用于实际使用优惠时的订单标识
- 实现优惠券使用标记逻辑,调用 couponService.useCoupon
- 添加优惠券使用日志记录,包括 couponId、userId 和 orderId
- 补充 scenicId 参数转换处理逻辑
2025-11-15 15:54:13 +08:00
11face7935 fix(printer): 修复支付价格比较逻辑和数据返回问题
- 将支付价格比较方式从 equals 修改为 compareTo 方法以确保精度正确性
- 调整 orderId 返回逻辑的位置,确保在所有情况下都能正确返回
2025-11-15 15:53:47 +08:00
515f68a6f4 feat(pricing): 移除已弃用的优惠券使用接口
- 删除 /coupons/use 接口的实现代码
- 清理相关注释和日志记录代码
- 保留查询用户可用优惠券接口不变
2025-11-15 15:53:23 +08:00
3f396b4cb8 Merge branch 'print_sku' 2025-11-15 15:30:57 +08:00
a1b0687526 feat(pricing): 添加折扣优先级设置并优化价格计算请求
- 在折扣信息中增加优先级字段,提升折扣策略的灵活性
- 为价格计算请求默认启用自动使用优惠券功能
- 设置价格预览模式为非预览状态,确保实际计算准确性
- 完善打印服务中的价格计算逻辑,提高系统稳定性
2025-11-15 14:52:53 +08:00
932081abf0 refactor(pricing): 重构自动发券服务方法命名及逻辑
- 将 autoGrantFirstPrintCoupon 方法重命名为 autoGrantCoupon
- 修改 findFirstPrintCouponId 方法名为 findFirstCouponId
- 调整优惠券名称匹配逻辑,移除对"first"关键字的检查
- 更新调用方 PrinterServiceImpl 中的方法引用
- 优化自动发券异常处理,确保不影响主流程
2025-11-15 14:28:56 +08:00
6462037dcd fix(printer): 优化人脸识别逻辑避免重复添加照片
- 在人脸匹配成功后,仅当不存在已匹配的人脸时才自动添加照片到预打印列表
- 更新了用户照片列表的获取条件,确保只在必要时执行此操作
- 保留了对源实体存在的检查,以维持原有业务流程的完整性
2025-11-15 14:11:35 +08:00
9b9e69cf52 feat(printer): add print task review and reprint functionality
- Add reprint endpoint with printer name update
- Implement pending review task query and management
- Add task URL update for pending review tasks
- Support bulk approve/reject of pending tasks
- Extend task status enum with review-related states
- Create ReprintRequest DTO for printer information
- Update mapper to handle status transitions and queries
- Modify service layer to support review workflow
- Adjust XML mapper for new database operations
2025-11-15 14:05:37 +08:00
6246d6ef46 feat(printer): add pagination to scenic list query
- Set default page number to 1
- Set default page size to 1000
- Enable pagination for scenic list retrieval
2025-11-15 14:04:45 +08:00
19fae4bd00 feat(pricing): 实现首次打印自动发券功能
- 新增自动发券服务接口 IAutoCouponService- 实现自动发券逻辑,包括参数校验、优惠券配置查询和发券记录检查
- 在打印服务中集成自动发券调用,确保首次打印时触发发券- 添加异常处理,避免发券失败影响主流程
- 支持通过优惠券名称和商品类型匹配规则查找目标优惠券
2025-11-14 09:10:06 +08:00
661aa4567f feat(print): 支持多种照片打印类型的价格计算
- 新增手机照片打印(PHOTO_PRINT_MU)和特效照片打印(PHOTO_PRINT_FX)枚举类型
- 在价格计算服务中增加isPrintProduct方法统一判断打印类商品
- 修改订单服务跳过打印类商品重复购买检查逻辑
-优化打印机服务根据sourceId分类统计不同照片类型数量
- 分别计算普通、手机、特效照片打印的价格和数量- 更新价格计算逻辑以支持多种打印类型商品项
2025-11-14 01:08:12 +08:00
ec34437e9d feat(scenic): 添加打印功能开关配置
- 在AppScenicController中新增printEnable字段返回
- 在ScenicConfigResp中添加printEnable属性
- 默认关闭打印功能,可通过配置开启
2025-11-11 17:02:14 +08:00
dde9f5d542 fix(printer): 更新二维码链接参数并优化水印配置读取
- 在生成微信小程序二维码的链接中添加 force=1 参数
- 从景区配置中读取水印景区文本,为空时使用空字符串
-从景区配置中读取日期格式,为空时使用默认格式 yyyy.MM.dd
2025-11-11 15:36:52 +08:00
72e60c95e0 fix(printer):优化水印处理逻辑并增强人脸匹配容错
- 限制水印处理仅在sourceId不为空时执行
- 调整水印处理代码结构,提高可读性
- 增加人脸匹配异常捕获,避免影响主流程
-保持原有打印任务创建逻辑不变
2025-11-09 23:16:09 +08:00
9df30a84e0 1 2025-11-08 18:01:52 +08:00
acfaebfffa fix(printer):修复打印机选择逻辑并优化查询
- 调整打印机选择逻辑,确保正确获取打印机ID
- 优化SourceMapper查询,按创建时间倒序并限制结果数量- 修复可能因逻辑错误导致的打印机选择异常问题
2025-11-08 17:38:44 +08:00
72e215c552 feat(printer): 添加sourceId字段并优化打印逻辑
- 在MemberPrintResp中新增sourceId字段
- 优化PrinterServiceImpl中的水印处理逻辑
- 添加sourceId为空时的返回判断,避免空指针异常
2025-11-08 17:09:10 +08:00
ee5cc81864 fix(printer): 修改二维码生成路径
- 将视频合成页面路径更改为打印机样本页面路径
- 确保二维码指向正确的页面处理逻辑
2025-11-08 17:07:17 +08:00
1bbfe8d092 feat(printer): 添加照片来源ID字段并更新相关逻辑
- 在MemberPrintEntity中新增sourceId字段用于记录照片来源
- 更新addUserPhoto方法签名,增加sourceId参数
- 修改照片上传接口,支持传递sourceId参数
- 完善自动添加照片到打印列表的逻辑,关联sourceId
- 更新数据库映射文件,添加source_id字段的读写配置- 优化重复照片检测逻辑,确保数据一致性
2025-11-08 15:12:15 +08:00
88c31d4fdc feat(printer):优化人脸样本使用逻辑并增强景区列表查询
- 修改 useSample 接口返回类型为 FaceRecognizeResp
- 增加根据样本ID和类型查询来源实体的逻辑
- 在景区列表查询中添加参数校验和异常处理
- 完善景区信息处理流程,增加设备数量统计
-优化景区距离计算与筛选逻辑
- 增加人脸匹配后自动添加照片到用户相册的功能
- 添加 XML 映射文件中新的查询语句实现
2025-11-08 15:04:50 +08:00
fb75cbf230 feat(printer):优化订单打印纸张尺寸获取逻辑
- 引入Redis缓存存储打印纸张尺寸信息
- 优先从Redis中获取纸张尺寸,减少数据库查询
- 在创建订单时将打印机偏好纸张存入Redis,有效期60秒
- 修复打印机对象作用域问题,避免空指针异常
- 统一打印机状态校验逻辑,提高代码可读性
2025-11-08 11:04:22 +08:00
eda4ed2955 1 2025-11-08 10:35:43 +08:00
c41611e5d0 feat(printer): 实现人脸照片自动添加到优先打印列表功能
- 引入DeviceConfigManager和SourceEntity依赖
- 替换TaskFaceService为FaceService并注入DeviceRepository
- 新增autoAddPhotosToPreferPrint方法实现自动添加逻辑
- 根据景区和设备配置筛选并添加符合条件的照片
- 支持按设备分组处理和优先打印数量控制
- 添加详细的日志记录和异常处理机制
2025-11-08 10:20:37 +08:00
747081901f feat(printer): 实现人脸样本使用功能
- 新增人脸样本使用逻辑,支持查询或创建face记录
- 集成人脸识别相关实体和映射器
- 添加人脸样本URL校验和重复检测机制
- 返回包含人脸URL、ID及景区ID的响应对象
- 引入雪花算法生成唯一face ID
- 记录创建新face或使用已有face的日志信息
2025-11-08 01:17:39 +08:00
616ab217e4 feat(printer): 新增打印机大屏对接接口
- 添加获取景区列表接口
- 实现根据景区ID查询设备列表功能
- 支持生成并返回二维码图片- 集成设备、景区和人脸样本数据查询- 提供微信小程序二维码生成功能
- 实现文件流输出与临时文件清理机制
2025-11-08 00:47:33 +08:00
00db16e7bd feat(watermark): 调整水印默认字体及位置参数
- 更改默认字体文件路径为 "/PingFang_SC_t.ttf"
-修正字体资源加载类为 PrinterDefaultWatermarkOperator
- 调整垂直偏移量 OFFSET_Y 从20 到15
- 微调二维码左边距比例从0.07 到 0.075
- 修改二维码垂直偏移量 QRCODE_OFFSET_Y从 -20 到 -35
- 设置景区名称和时间字体样式为粗体 (BOLD)
2025-11-07 23:01:58 +08:00
1821ba9f58 feat(image): 添加打印机默认水印操作器并优化图片处理逻辑- 新增 PrinterDefaultWatermarkOperator 实现自定义水印处理
- 在 ImageWatermarkOperatorEnum 中添加 PRINTER_DEFAULT 类型
- 更新 ImageWatermarkFactory 以支持新的水印操作器
- 调整日期格式为 yyyy.MM.dd 用于打印场景
-优化 ImageUtils 中的图片旋转逻辑,仅支持270度旋转
- 移除对90度旋转的支持以简化处理流程
2025-11-07 22:38:02 +08:00
ea48f03bbc fix(order):修复订单打印信息获取逻辑
- 当 printTaskResp 为空时,从 printerMapper 获取打印机信息
- 设置商品的打印机名称和纸张类型字段
- 确保即使任务响应为空也能正确显示打印机数据
2025-11-07 22:02:16 +08:00
6b2089a9bc feat(order): 添加商品数量字段并初始化
- 在 OrderItemVO 中新增 count 字段用于记录商品数量- 根据不同商品类型设置默认数量为1
- 针对打印订单类型从照片信息中获取实际数量进行赋值
- 完善订单项数据封装逻辑以支持数量统计需求
2025-11-07 21:58:19 +08:00
25c0e629c6 feat(image): 添加二维码圆形白色背景
- 创建比二维码大10像素的白色圆形背景
- 绘制白色圆形背景以突出显示二维码
- 裁剪二维码为圆形并绘制在白色背景上
2025-11-07 21:47:30 +08:00
48c8518ae6 feat(printer):优化打印任务逻辑并添加水印景区文案
- 根据订单项循环设置用户购买状态
- 添加水印信息中的景区文案配置
- 调整打印任务创建流程顺序
2025-11-07 21:08:33 +08:00
96d001dfc0 feat(printer): 新增样品使用功能并优化打印流程
- 在AppPrinterController中新增/useSample接口用于使用样品- PrinterServiceImpl中实现生成微信小程序二维码逻辑
- 更新setUserIsBuyItem方法签名以支持异常抛出- 添加useSample方法占位实现
- 引入相关依赖类如MpConfigEntity、OrderRepository和WxMpUtil
2025-11-07 20:43:15 +08:00
fb3a08fdcf feat(printer): 实现照片自动裁剪功能
- 添加打印尺寸获取逻辑,优先从打印机配置读取
- 实现默认尺寸 fallback 机制 (1020x1520)
- 集成 smartCropAndFill 图片裁剪算法
- 添加裁剪后图片上传和临时文件清理
- 增加异常处理,裁剪失败时回退到原图
-优化图片 URL 内部访问地址配置
2025-11-06 15:32:11 +08:00
dcc8cdeb6a fix(printer): 修改 faceId 参数类型为字符串并增加解析逻辑
- 将 getListFor、upload、uploadFromSource、queryPrice、createOrder 等接口中的 faceId 参数类型从 Long 改为 String
- 新增 parseFaceId 方法用于将字符串类型的 faceId 安全转换为 Long 类型
- 在所有涉及 faceId 的业务方法调用处使用 parseFaceId 进行参数传递
- 增加对无效 faceId 字符串的处理,避免 NumberFormatException 异常
- 保持原有功能不变,提升接口健壮性和兼容性
2025-11-05 17:00:26 +08:00
054958ebf5 fix(printer):修复用户照片列表查询逻辑
- 修正了当faceId为null时的过滤条件判断
- 调整了listRelation方法的过滤逻辑,确保正确返回无faceId关联的数据
-保证了getUserPhotoList接口在不同参数下的正确性
2025-11-05 15:26:09 +08:00
50 changed files with 1866 additions and 1229 deletions

View File

@@ -118,6 +118,7 @@ public class OrderBiz {
vlogProductItem.setScenicId(scenicId.toString());
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
priceObj.setPrice(vlogCalculationResult.getFinalAmount());
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
@@ -138,6 +139,7 @@ public class OrderBiz {
calculationRequest.setUserId(face.getMemberId());
}
calculationRequest.setFaceId(goodsId);
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
priceObj.setPrice(priceCalculationResult.getFinalAmount());
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());

View File

@@ -1,6 +1,7 @@
package com.ycwl.basic.controller.mobile;
import com.github.pagehelper.PageInfo;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.biz.OrderBiz;
import com.ycwl.basic.biz.PriceBiz;
import com.ycwl.basic.constant.BaseContextHandler;
@@ -51,6 +52,7 @@ public class AppOrderController {
// 用户端订单详情查询
@GetMapping("getOrderDetails/{id}")
@IgnoreToken
public ApiResponse<OrderAppRespVO> getOrderDetails(@PathVariable("id") Long id) {
return orderService.appDetail(id);
}

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp;
import com.ycwl.basic.model.pc.printer.resp.PrinterResp;
import com.ycwl.basic.model.printer.req.FromSourceReq;
@@ -36,10 +37,16 @@ public class AppPrinterController {
return ApiResponse.success(printerService.listByScenicId(scenicId));
}
@GetMapping("/getListFor/{scenicId}")
public ApiResponse<List<MemberPrintResp>> getListFor(@PathVariable("scenicId") Long scenicId, @RequestParam(required = false) Long faceId) {
@GetMapping("/useSample/{sampleId}")
public ApiResponse<FaceRecognizeResp> useSample(@PathVariable("sampleId") Long sampleId) throws IOException {
JwtInfo worker = JwtTokenUtil.getWorker();
return ApiResponse.success(printerService.getUserPhotoList(worker.getUserId(), scenicId, faceId));
return ApiResponse.success(printerService.useSample(worker.getUserId(), sampleId));
}
@GetMapping("/getListFor/{scenicId}")
public ApiResponse<List<MemberPrintResp>> getListFor(@PathVariable("scenicId") Long scenicId, @RequestParam(required = false) String faceId) {
JwtInfo worker = JwtTokenUtil.getWorker();
return ApiResponse.success(printerService.getUserPhotoList(worker.getUserId(), scenicId, parseFaceId(faceId)));
}
@GetMapping("/getItem/{scenicId}/{id}")
@@ -61,11 +68,11 @@ public class AppPrinterController {
@PostMapping("/uploadTo/{scenicId}")
public ApiResponse<?> upload(@PathVariable("scenicId") Long scenicId,
@RequestParam(value = "file") MultipartFile file,
@RequestParam(value = "faceId", required = false) Long faceId) {
@RequestParam(value = "faceId", required = false) String faceId) {
String[] split = file.getOriginalFilename().split("\\.");
String ext = split[split.length - 1];
String url = StorageFactory.use().uploadFile(file, "printer", UUID.randomUUID() + "." + ext);
Integer id = printerService.addUserPhoto(JwtTokenUtil.getWorker().getUserId(), scenicId, url, faceId);
Integer id = printerService.addUserPhoto(JwtTokenUtil.getWorker().getUserId(), scenicId, url, parseFaceId(faceId), null);
return ApiResponse.success(id);
}
@PostMapping(value = "/uploadTo/{scenicId}/cropped/{id}", consumes = "multipart/form-data")
@@ -82,8 +89,8 @@ public class AppPrinterController {
@PostMapping("/uploadTo/{scenicId}/formSource")
public ApiResponse<?> uploadFromSource(@PathVariable("scenicId") Long scenicId,
@RequestBody FromSourceReq req,
@RequestParam(value = "faceId", required = false) Long faceId) {
List<Integer> list = printerService.addUserPhotoFromSource(JwtTokenUtil.getWorker().getUserId(), scenicId, req, faceId);
@RequestParam(value = "faceId", required = false) String faceId) {
List<Integer> list = printerService.addUserPhotoFromSource(JwtTokenUtil.getWorker().getUserId(), scenicId, req, parseFaceId(faceId));
return ApiResponse.success(list);
}
@@ -100,19 +107,34 @@ public class AppPrinterController {
}
@GetMapping("/price/{scenicId}")
public ApiResponse<?> queryPrice(@PathVariable("scenicId") Long scenicId,
@RequestParam(value = "faceId", required = false) Long faceId) {
return ApiResponse.success(printerService.queryPrice(JwtTokenUtil.getWorker().getUserId(), scenicId, faceId));
@RequestParam(value = "faceId", required = false) String faceId) {
return ApiResponse.success(printerService.queryPrice(JwtTokenUtil.getWorker().getUserId(), scenicId, parseFaceId(faceId)));
}
@PostMapping("/order/{scenicId}")
public ApiResponse<Map<String, Object>> createOrder(@PathVariable("scenicId") Long scenicId,
@RequestParam(value = "faceId", required = false) Long faceId) {
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, null, faceId));
@RequestParam(value = "faceId", required = false) String faceId) {
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, null, parseFaceId(faceId)));
}
@PostMapping("/order/{scenicId}/toPrinter/{printerId}")
public ApiResponse<Map<String, Object>> createOrderToPrinter(@PathVariable("scenicId") Long scenicId,
@PathVariable("printerId") Integer printerId,
@RequestParam(value = "faceId", required = false) Long faceId) {
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, printerId, faceId));
@RequestParam(value = "faceId", required = false) String faceId) {
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, printerId, parseFaceId(faceId)));
}
/**
* 解析 faceId 字符串为 Long 类型
* 如果字符串不是有效数字,则返回 null
*/
private Long parseFaceId(String faceId) {
if (faceId == null || faceId.trim().isEmpty()) {
return null;
}
try {
return Long.parseLong(faceId.trim());
} catch (NumberFormatException e) {
return null;
}
}
}

View File

@@ -86,6 +86,7 @@ public class AppScenicController {
resp.setPrintForceFaceUpload(scenicConfig.getBoolean("print_force_face_upload", false));
resp.setPrintEnableManual(scenicConfig.getBoolean("print_enable_manual", true));
resp.setSceneMode(scenicConfig.getInteger("scene_mode", 0));
resp.setPrintEnable(scenicConfig.getBoolean("print_enable", false));
return ApiResponse.success(resp);
}

View File

@@ -6,6 +6,7 @@ import com.ycwl.basic.mapper.PrintTaskMapper;
import com.ycwl.basic.model.pc.printer.entity.PrintTaskEntity;
import com.ycwl.basic.model.pc.printer.entity.PrinterEntity;
import com.ycwl.basic.model.pc.printer.req.PrintTaskReqQuery;
import com.ycwl.basic.model.pc.printer.req.ReprintRequest;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.utils.ApiResponse;
import org.springframework.beans.factory.annotation.Autowired;
@@ -67,10 +68,55 @@ public class PrinterController {
return ApiResponse.success(pageInfo);
}
// 重新打印(将状态设置为0-未开始)
// 重新打印(将状态设置为0-未开始,并更新打印机名称)
@PostMapping("/task/reprint/{id}")
public ApiResponse<Integer> reprint(@PathVariable("id") Integer id) {
int result = printTaskMapper.updateStatus(id, 0);
public ApiResponse<Integer> reprint(@PathVariable("id") Integer id, @RequestBody ReprintRequest request) {
int result = printTaskMapper.updateStatusAndPrinter(id, 0, request.getPrinterName());
return ApiResponse.success(result);
}
/**
* 查询待审核的打印任务
* @param printerId 打印机ID(可选)
* @return 待审核任务列表
*/
@GetMapping("/task/pending-review")
public ApiResponse<List<PrintTaskEntity>> getPendingReviewTasks(Integer printerId) {
List<PrintTaskEntity> tasks = printerService.getPendingReviewTasks(printerId);
return ApiResponse.success(tasks);
}
/**
* 更新待审核任务的URL(重新处理水印等)
* @param taskId 任务ID
* @param url 新的打印URL
* @return 操作结果
*/
@PostMapping("/task/{taskId}/url")
public ApiResponse<Boolean> updateTaskUrl(@PathVariable("taskId") Integer taskId, @RequestBody String url) {
boolean success = printerService.updatePendingReviewTaskUrl(taskId, url);
return ApiResponse.success(success);
}
/**
* 批准待审核任务,下发到打印队列
* @param taskIds 任务ID列表
* @return 成功数量
*/
@PostMapping("/task/approve")
public ApiResponse<Integer> approveTasks(@RequestBody List<Integer> taskIds) {
int count = printerService.approvePrintTasks(taskIds);
return ApiResponse.success(count);
}
/**
* 拒绝待审核任务
* @param taskIds 任务ID列表
* @return 成功数量
*/
@PostMapping("/task/reject")
public ApiResponse<Integer> rejectTasks(@RequestBody List<Integer> taskIds) {
int count = printerService.rejectPrintTasks(taskIds);
return ApiResponse.success(count);
}
}

View File

@@ -0,0 +1,99 @@
package com.ycwl.basic.controller.printer;
import com.ycwl.basic.annotation.IgnoreToken;
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.WxMpUtil;
import jakarta.websocket.server.PathParam;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
@IgnoreToken
// 打印机大屏对接接口
@RestController
@RequestMapping("/printer/v1/tv")
@RequiredArgsConstructor
public class PrinterTvController {
private final DeviceRepository deviceRepository;
private final ScenicRepository scenicRepository;
private final FaceRepository faceRepository;
/**
* 获取景区列表
*
* @return 景区列表
*/
@GetMapping("/scenic/list")
public ApiResponse<List<ScenicV2DTO>> getScenicList() {
ScenicReqQuery query = new ScenicReqQuery();
query.setStatus("1"); // 只查询启用状态的景区
query.setPageNum(1);
query.setPageSize(1000);
return ApiResponse.success(scenicRepository.list(query));
}
/**
* 根据景区ID查询设备列表
*
* @param scenicId 景区ID
* @return 设备列表
*/
@GetMapping("/device/list")
public ApiResponse<List<DeviceV2DTO>> getDeviceListByScenicId(@RequestParam Long scenicId) {
List<DeviceV2DTO> result = deviceRepository.getAllDeviceByScenicId(scenicId);
return ApiResponse.success(result);
}
@GetMapping("/{sampleId}/qrcode")
public void getQrcode(@PathVariable("sampleId") Long sampleId, HttpServletResponse response) throws Exception {
File qrcode = new File("qrcode_"+sampleId+".jpg");
try {
FaceSampleEntity faceSample = faceRepository.getFaceSample(sampleId);
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(faceSample.getScenicId());
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), "pages/printer/from_sample", sampleId.toString(), qrcode);
// 设置响应头
response.setContentType("image/jpeg");
response.setHeader("Content-Disposition", "inline; filename=\"" + qrcode.getName() + "\"");
// 将二维码文件写入响应输出流
try (FileInputStream fis = new FileInputStream(qrcode);
OutputStream os = response.getOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.flush();
}
} finally {
// 删除临时文件
if (qrcode.exists()) {
qrcode.delete();
}
}
}
}

View File

@@ -1,22 +1,24 @@
package com.ycwl.basic.facebody.adapter;
import cn.hutool.core.codec.Base64;
import com.baidu.aip.face.AipFace;
import com.ycwl.basic.facebody.entity.AddFaceResp;
import com.ycwl.basic.facebody.entity.BceFaceBodyConfig;
import com.ycwl.basic.facebody.entity.SearchFaceResp;
import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
import com.ycwl.basic.facebody.exceptions.BceErrorCodeClassifier;
import com.ycwl.basic.facebody.exceptions.FaceBodyException;
import com.ycwl.basic.facebody.exceptions.NonRetryableFaceBodyException;
import com.ycwl.basic.facebody.exceptions.RetryableFaceBodyException;
import com.ycwl.basic.utils.ratelimiter.FixedRateLimiter;
import com.ycwl.basic.utils.ratelimiter.IRateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONArray;
import org.json.JSONObject;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -152,10 +154,34 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
} catch (InterruptedException ignored) {
}
JSONObject response = client.addUser(faceUrl, "URL", dbName, entityId, options);
if (response.getInt("error_code") == 0) {
int errorCode = response.getInt("error_code");
if (errorCode == 0) {
AddFaceResp resp = new AddFaceResp();
resp.setScore(100f);
return resp;
} else if (errorCode == 222204) {
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
String base64Image = downloadImageAsBase64(faceUrl);
if (base64Image != null) {
try {
addEntityLimiter.acquire();
} catch (InterruptedException ignored) {
}
JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options);
if (retryResponse.getInt("error_code") == 0) {
log.info("使用base64重试添加人脸成功,entityId: {}", entityId);
AddFaceResp resp = new AddFaceResp();
resp.setScore(100f);
return resp;
} else {
log.warn("使用base64重试添加人脸仍失败!{}", retryResponse);
return null;
}
} else {
log.error("下载图片转base64失败,无法重试,URL: {}", faceUrl);
return null;
}
} else {
log.warn("创建人脸失败!{}", response);
return null;
@@ -277,70 +303,18 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
@Override
public SearchFaceResp searchFace(String dbName, String faceUrl) {
int retryCount = 0;
while (true) {
try {
return doSearchFace(dbName, faceUrl);
} catch (RetryableFaceBodyException e) {
// 获取建议的最大重试次数
Integer maxRetries = BceErrorCodeClassifier.getSuggestedMaxRetries(e.getErrorCode());
if (maxRetries == null) {
maxRetries = 1; // 默认重试1次
}
if (retryCount >= maxRetries) {
log.error("搜索人脸重试{}次后仍失败,错误码: {}, 错误信息: {}",
retryCount, e.getErrorCode(), e.getMessage());
// 返回空结果而不是抛出异常,保持与原有逻辑一致
return null;
}
// 计算延迟时间
Long delay = BceErrorCodeClassifier.getSuggestedRetryDelay(e.getErrorCode(), retryCount);
if (delay == null) {
delay = 500L; // 默认延迟500ms
}
log.warn("搜索人脸失败[错误码: {}],{}ms后进行第{}次重试,错误信息: {}",
e.getErrorCode(), delay, retryCount + 1, e.getMessage());
try {
if (delay > 0) {
Thread.sleep(delay);
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
retryCount++;
} catch (NonRetryableFaceBodyException e) {
// 不可重试错误,直接返回
log.error("搜索人脸失败(不可重试),错误码: {}, 类别: {}, 错误信息: {}",
e.getErrorCode(), e.getCategory(), e.getMessage());
// 返回空结果而不是抛出异常,保持与原有逻辑一致
return null;
}
}
}
private SearchFaceResp doSearchFace(String dbName, String faceUrl) {
IRateLimiter searchFaceLimiter = getLimiter(LOCK_TYPE.SEARCH_FACE);
SearchFaceResp resp = new SearchFaceResp();
try {
AipFace client = getClient();
HashMap<String, Object> options = new HashMap<>();
options.put("max_user_num", "50");
try {
searchFaceLimiter.acquire();
} catch (InterruptedException ignored) {
}
JSONObject response = client.search(faceUrl, "URL", dbName, options);
int errorCode = response.getInt("error_code");
if (errorCode == 0) {
resp.setOriginalFaceScore(100f);
JSONObject resultObj = response.getJSONObject("result");
@@ -364,19 +338,58 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
resp.setFirstMatchRate(result.getFirst().getScore());
}
return resp;
} else if (errorCode == 222204) {
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
log.warn("搜索人脸时无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
String base64Image = downloadImageAsBase64(faceUrl);
if (base64Image != null) {
try {
searchFaceLimiter.acquire();
} catch (InterruptedException ignored) {
}
JSONObject retryResponse = client.search(base64Image, "BASE64", dbName, options);
if (retryResponse.getInt("error_code") == 0) {
log.info("使用base64重试搜索人脸成功");
resp.setOriginalFaceScore(100f);
JSONObject resultObj = retryResponse.getJSONObject("result");
if (resultObj == null) {
resp.setFirstMatchRate(0f);
return resp;
}
JSONArray userList = resultObj.getJSONArray("user_list");
List<SearchFaceResultItem> result = new ArrayList<>();
for (int i = 0; i < userList.length(); i++) {
JSONObject user = userList.getJSONObject(i);
SearchFaceResultItem item = new SearchFaceResultItem();
item.setDbName(dbName);
item.setFaceId(user.getString("user_id"));
item.setExtData(user.getString("user_info"));
item.setScore(user.getBigDecimal("score").divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP).floatValue());
result.add(item);
}
resp.setResult(result);
if (!result.isEmpty()) {
resp.setFirstMatchRate(result.getFirst().getScore());
}
return resp;
} else {
log.warn("使用base64重试搜索人脸仍失败!{}", retryResponse);
resp.setOriginalFaceScore(0f);
return resp;
}
} else {
log.error("下载图片转base64失败,无法重试,URL: {}", faceUrl);
resp.setOriginalFaceScore(0f);
return resp;
}
} else {
// 使用错误码分类器创建相应的异常
String errorMsg = response.optString("error_msg", "未知错误");
throw BceErrorCodeClassifier.createException(errorCode,
"人脸搜索失败[" + errorCode + "]: " + errorMsg);
log.warn("搜索人脸失败,错误码: {}, 响应: {}", errorCode, response);
resp.setOriginalFaceScore(0f);
return resp;
}
} catch (FaceBodyException e) {
// 重新抛出 FaceBodyException
throw e;
} catch (Exception e) {
// 其他异常(如网络异常)包装为可重试异常
log.error("搜索人脸网络异常", e);
throw new RetryableFaceBodyException("搜索人脸网络异常: " + e.getMessage(), e);
log.error("搜索人脸失败!", e);
return null;
}
}
@@ -396,6 +409,52 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
}
}
/**
* 下载图片并转换为base64字符串
*
* @param imageUrl 图片URL
* @return base64编码的图片字符串,失败返回null
*/
private String downloadImageAsBase64(String imageUrl) {
BufferedImage image = null;
ByteArrayOutputStream baos = null;
try {
// 下载图片
URL url = new URL(imageUrl.replace("oss-cn-shanghai.aliyuncs.com", "oss-cn-shanghai-internal.aliyuncs.com"));
image = ImageIO.read(url);
if (image == null) {
log.error("无法读取图片,URL: {}", imageUrl);
return null;
}
// 转换为字节数组
baos = new ByteArrayOutputStream();
String format = "jpg";
if (imageUrl.toLowerCase().endsWith(".png")) {
format = "png";
}
ImageIO.write(image, format, baos);
byte[] imageBytes = baos.toByteArray();
// 编码为base64
return Base64.encode(imageBytes);
} catch (IOException e) {
log.error("下载图片或转换base64失败,URL: {}", imageUrl, e);
return null;
} finally {
if (image != null) {
image.flush();
}
if (baos != null) {
try {
baos.close();
} catch (IOException e) {
log.warn("关闭ByteArrayOutputStream失败", e);
}
}
}
}
private IRateLimiter getLimiter(LOCK_TYPE type) {
return switch (type) {
case ADD_DB ->

View File

@@ -1,405 +0,0 @@
package com.ycwl.basic.facebody.exceptions;
import java.util.Set;
/**
* 百度云人脸识别错误码分类器
*
* <p>根据百度云人脸识别 API 错误码文档,对错误进行分类:
* <ul>
* <li>可重试错误:网络问题、限流、服务端临时故障等</li>
* <li>不可重试错误:参数错误、认证失败、资源不存在、业务规则违反等</li>
* </ul>
*
* <p>参考文档: https://cloud.baidu.com/doc/FACE/s/5k37c1ujz
*
* @see RetryableFaceBodyException
* @see NonRetryableFaceBodyException
*/
public class BceErrorCodeClassifier {
/**
* 可重试的错误码集合
*/
private static final Set<Integer> RETRYABLE_ERROR_CODES = Set.of(
// ========== 接口流控及鉴权错误码 ==========
2, // Service temporarily unavailable - 服务暂不可用
4, // Open api request limit reached - 集群超限额
17, // Open api daily request limit reached - 每天流量超限额
18, // Open api qps request limit reached - QPS超限额
19, // Open api total request limit reached - 请求总量超限额
110, // Access token invalid or no longer valid - Access Token失效
111, // Access token expired - Access token过期
// ========== 网络及服务端临时故障 ==========
222201, // network not available - 服务端请求失败
222204, // image_url_download_fail - 从图片的url下载图片失败
222205, // network not available - 服务端请求失败
222206, // rtse service return fail - 服务端请求失败
222302, // system error - 服务端请求失败
222301, // get face fail - 获取人脸图片失败
222303, // get face fail - 获取人脸图片失败
222361, // network not available - 公安服务连接失败
// ========== 系统繁忙 ==========
222901, 222902, 222903, 222904, 222905, 222906,
222907, 222908, 222909, 222910, 222911, 222912,
222913, 222914, 222915, 222916, // system busy - 系统繁忙相关
// ========== H5活体检测接口临时错误 ==========
216430, // rtse/face service error - rtse/face 服务异常
216431, // voice service error - 语音识别服务异常
216432, // video service call fail - 视频解析服务调用失败
216433, // video service error - 视频解析服务发生错误
216505, // redis connect error - redis连接失败
216506, // redis operation error - redis操作失败
216612, // system busy - 系统繁忙
// ========== H5方案临时错误 ==========
283400, // 服务异常,请稍后再试
283460, // 视频文件过大,核验请求超时
283438, // 视频转码失败,请重试
283436, // Token生成失败,请重试
283502, // 视频文件上传 bos 失败
283468, // BOS文件上传失败
283447 // 验证失败,请稍后重新尝试
);
/**
* 明确不可重试的错误码集合(参数错误、认证失败、资源不存在、业务规则违反等)
*/
private static final Set<Integer> NON_RETRYABLE_ERROR_CODES = Set.of(
// ========== 接口权限错误(认证失败) ==========
6, // no permission to access data - 没有接口权限
100, // Invalid parameter - 无效的access_token参数
// ========== 参数格式错误 ==========
222001, 222002, 222003, 222004, 222005, 222006, 222007, 222008, 222009, 222010,
222011, 222012, 222013, 222014, 222015, 222016, 222017, 222018, 222019, 222020,
222021, 222022, 222023, 222024, 222025, 222026, 222027, 222028, 222029, 222030,
222039, 222046, 222101, 222102, 222041, 222042, 222038, // param format error 系列
// ========== 图片相关错误 ==========
222200, // request body should be json format - 格式错误
222202, // pic not has face - 图片中没有人脸
222203, // image check fail - 无法解析人脸
222208, // the number of image is incorrect - 图片的数量错误
222213, // face size is too small - 人脸尺寸过小
222214, // face are cartoon images - 卡通图像
222215, // face quality is not acceptable - 人脸属性编辑处理失败
222304, // image size is too large - 图片尺寸太大
222305, // pic storage not support - 当前版本不支持图片存储
222307, // image illegal, reason: porn - 图片非法 鉴黄未通过
222308, // image illegal, reason: sensitive person - 图片非法 含有政治敏感人物
222309, // image size is too small - 图片尺寸过小
// ========== 人脸库管理错误(资源不存在/已存在) ==========
223100, // group is not exist - 操作的用户组不存在
223101, // group is already exist - 该用户组已存在
223102, // user is already exist - 该用户已存在
223103, // user is not exist - 找不到该用户
223105, // face is already exist - 该人脸已存在
223106, // face is not exist - 该人脸不存在
223111, // dst group not exist - 目标用户组不存在
223136, // images exist in this group - 该组内存在关联图片
223128, // group was deleting - 正在清理该用户组的数据
// ========== 业务规则违反 ==========
222104, // group_list is too large - group_list包含组数量过多
222110, // uid_list is too large - uid_list包含数量过多
222117, // app_list is too large - app_list包含app数量过多
222207, // match user is not found - 未找到匹配的用户
222209, // face token not exist - face token不存在
222210, // the number of user's faces is beyond the limit - 人脸数目超过限制
223107, // scene_type not same - 源组与目标组的scene_type不同
223112, // quality_conf format error - quality_conf格式不正确
223118, // quality control error - 质量控制项错误
223119, // liveness control item error - 活体控制项错误
223201, // param[scene_type] format error - scene_type格式错误
223202, // scene_type does not match - scene_type不匹配
// ========== 质量检测未通过(业务规则违反) ==========
223113, // face is covered - 人脸有被遮挡
223114, // face is fuzzy - 人脸模糊
223115, // face light is not good - 人脸光照不好
223116, // incomplete face - 人脸不完整
223129, // face not forward - 人脸未面向正前方
223121, 223122, 223123, 223124, 223125, 223126, 223127, // 各部位遮挡检测未通过
// ========== 活体检测未通过 ==========
223120, // liveness check fail - 活体检测未通过
223130, // spoofing_control item error - spoofing_control参数格式错误
223131, // spoofing check fail - 合成图检测未通过
223133, // video extract image liveness check fail - 视频提取图片活体检测失败
223052, // action identify fail - 视频中的动作验证未通过
// ========== 人脸融合错误 ==========
222211, // template image quality reject - 模板图质量不合格
222212, // merge face fail - 人脸融合失败
222300, // add face fail - 人脸图片添加失败
222514, // face editattrpro operation fail - 人脸属性编辑v2调用服务失败
222152, // param[target] format error - target参数错误
// ========== 人脸实名认证错误 ==========
222350, // police picture is none or low quality - 公安网图片不存在或质量过低
222351, // id number and name not match - 身份证号与姓名不匹配
222354, // id number not exist - 公安库里不存在此身份证号
222355, // police picture not exist - 公安库里没有对应的照片
222356, // person picture is low quality - 人脸图片质量不符合要求
222357, // picture file format error - 图片格式解析失败
222358, // trigger risk interception - 触发数据源风险拦截
282105, // image decrypt error - 图片解密失败
216201, // image format error - 图片格式失败
216100, // invalid param - 参数格式失败
282003, // missing required parameter(s) - 缺少必要参数
282000, // internal error - 服务器内部错误
216600, // 身份证号码格式错误
216601, // 身份证号和名字不匹配
222360, // 身份核验未通过
// ========== H5活体检测错误 ==========
216500, // code digit error - 验证码位数错误
216501, // not found face - 没有找到人脸
216502, // session lapse - 当前会话已失效
216508, // not found video info - 没有找到视频信息
216509, // voice can not identify - 视频中的声音无法识别
216510, // video time is too long - 视频长度超过10s
216511, // voice file error - 语音文件不符合要求
216512, // action verify must post session_id - 必须使用会话id
216513, // detect_model param error - 检测模型参数错误
216908, // 视频中人脸质量较差
216909, // video all image detect over two face - 人脸数超过2
223050, // voice similarity low error - 语音与验证码相似度过低
// ========== H5方案错误 ==========
200, // unsupported operation - 不支持的操作
283456, // 图片为空或格式不正确
283458, // 当前链接已失效
283459, // 请从手机端扫描二维码访问
216434, // 活体检测未通过
223051, // 唇语验证未通过
283738, // 颜色验证未通过
283457, // 当前环境存在安全风险
283501, // 安全检验未通过
283421, // 应用不存在
283437, // Token无效或已过期
283439, // STS_Token 已经生成
283464, // 非法流程
283461, // 人脸和对比源不匹配
283462, // 比对源配置错误
283463, // 人脸图片质量检测未通过
283465, // 人脸图片活体检测未通过
283467, // 该PLAN_ID下未查询到图片文件
283469, // 用户请求的 body 是空
283435, // 方案不存在
283440, // 身份证照片不符合要求
283442, // 身份证信息不合法
283443, // 不可使用语音验证码
283444, // 语音验证码生成失败
283448, // 语音验证码不符合要求
283449, // 活体检测视频不符合要求
283450, // 认证尚未开始
283451, // 认证处理中
283453, // 不可使用照片活体
283454, // 不可使用视频活体
283455, // 超出查询有效期
283503, // 对比源信息未传入
283504, // 请上传正确的身份证照片
283505, // 请上传正确的身份证人像面
283506, // 请上传正确的身份证国徽面
283507, // 不可使用身份证识别
283601, // 重复推送错误信息
300201, // 您已拒绝授权摄像头
300001, // 受当前环境限制
300002, // 受当前环境限制
999999, // 请确保是本人操作且正脸采集
800001, // 采集超时
800002 // 炫瞳检测失败
);
/**
* 判断错误码是否为可重试错误
*
* @param errorCode 百度云返回的错误码
* @return true 如果是可重试错误
*/
public static boolean isRetryable(Integer errorCode) {
if (errorCode == null) {
return false;
}
return RETRYABLE_ERROR_CODES.contains(errorCode);
}
/**
* 判断错误码是否为不可重试错误
*
* @param errorCode 百度云返回的错误码
* @return true 如果是不可重试错误
*/
public static boolean isNonRetryable(Integer errorCode) {
if (errorCode == null) {
return false;
}
return NON_RETRYABLE_ERROR_CODES.contains(errorCode);
}
/**
* 根据错误码和错误消息创建合适的异常
*
* @param errorCode 百度云返回的错误码
* @param errorMessage 错误消息
* @return 对应的异常对象
*/
public static FaceBodyException createException(Integer errorCode, String errorMessage) {
if (isRetryable(errorCode)) {
return new RetryableFaceBodyException(errorMessage, errorCode);
} else if (isNonRetryable(errorCode)) {
return new NonRetryableFaceBodyException(
errorMessage,
errorCode,
categorizeNonRetryableError(errorCode)
);
} else {
// 未知错误码,默认为不可重试
return new NonRetryableFaceBodyException(
errorMessage,
errorCode,
NonRetryableFaceBodyException.ErrorCategory.OTHER
);
}
}
/**
* 对不可重试错误进行分类
*
* @param errorCode 错误码
* @return 错误类别
*/
private static NonRetryableFaceBodyException.ErrorCategory categorizeNonRetryableError(Integer errorCode) {
if (errorCode == null) {
return NonRetryableFaceBodyException.ErrorCategory.OTHER;
}
// 认证/权限错误
if (errorCode == 6 || errorCode == 100) {
return NonRetryableFaceBodyException.ErrorCategory.AUTHENTICATION_ERROR;
}
// 参数验证错误
if ((errorCode >= 222001 && errorCode <= 222046) ||
(errorCode >= 222101 && errorCode <= 222102) ||
(errorCode >= 222041 && errorCode <= 222042) ||
errorCode == 222038 || errorCode == 216100 || errorCode == 282003) {
return NonRetryableFaceBodyException.ErrorCategory.VALIDATION_ERROR;
}
// 资源不存在
if (errorCode == 223100 || errorCode == 223103 || errorCode == 223106 ||
errorCode == 223111 || errorCode == 222207 || errorCode == 222209 ||
errorCode == 222354 || errorCode == 222355 || errorCode == 283435 ||
errorCode == 283467 || errorCode == 283421) {
return NonRetryableFaceBodyException.ErrorCategory.RESOURCE_NOT_FOUND;
}
// 数据冲突
if (errorCode == 223101 || errorCode == 223102 || errorCode == 223105 ||
errorCode == 223136 || errorCode == 283439 || errorCode == 283601) {
return NonRetryableFaceBodyException.ErrorCategory.CONFLICT;
}
// 不支持的操作
if (errorCode == 200 || errorCode == 222305 || errorCode == 283443 ||
errorCode == 283453 || errorCode == 283454 || errorCode == 283507) {
return NonRetryableFaceBodyException.ErrorCategory.UNSUPPORTED_OPERATION;
}
// 业务规则违反(质量检测、活体检测、人脸融合等)
if ((errorCode >= 223113 && errorCode <= 223131) ||
errorCode == 223133 || errorCode == 223052 || errorCode == 223120 ||
(errorCode >= 222202 && errorCode <= 222215) ||
errorCode == 222304 || errorCode == 222307 || errorCode == 222308 ||
errorCode == 222309 || errorCode == 222210 || errorCode == 222211 ||
errorCode == 222212 || errorCode == 222300 || errorCode == 222350 ||
errorCode == 222351 || errorCode == 222356 || errorCode == 222358 ||
errorCode == 216434 || errorCode == 216500 || errorCode == 216501 ||
errorCode == 216508 || errorCode == 216509 || errorCode == 216510 ||
errorCode == 216511 || errorCode == 216908 || errorCode == 216909 ||
errorCode == 223050 || errorCode == 223051 || errorCode == 283738 ||
errorCode == 283456 || errorCode == 283457 || errorCode == 283461 ||
errorCode == 283463 || errorCode == 283465 || errorCode == 283440 ||
errorCode == 283442 || errorCode == 283449 || errorCode == 800001 ||
errorCode == 800002 || errorCode == 999999 || errorCode == 216600 ||
errorCode == 216601 || errorCode == 222360) {
return NonRetryableFaceBodyException.ErrorCategory.BUSINESS_RULE_VIOLATION;
}
return NonRetryableFaceBodyException.ErrorCategory.OTHER;
}
/**
* 根据错误码获取建议的重试次数
*
* @param errorCode 错误码
* @return 建议的重试次数,null 表示使用默认值
*/
public static Integer getSuggestedMaxRetries(Integer errorCode) {
if (errorCode == null || !isRetryable(errorCode)) {
return 0;
}
// QPS/流量限制,建议重试次数较多
if (errorCode == 18 || errorCode == 17 || errorCode == 19 || errorCode == 4) {
return 5;
}
// Token失效,只需重试1次(重新获取token后)
if (errorCode == 110 || errorCode == 111) {
return 1;
}
// 临时服务故障,建议重试3次
if (errorCode == 2 || errorCode == 222201 || errorCode == 222204 || errorCode == 222205 ||
errorCode == 222206 || errorCode == 222302) {
return 3;
}
// 默认重试次数
return 3;
}
/**
* 根据错误码获取建议的重试延迟时间(毫秒)
*
* @param errorCode 错误码
* @param retryCount 当前重试次数(从0开始)
* @return 建议的延迟时间(毫秒),null 表示使用默认指数退避策略
*/
public static Long getSuggestedRetryDelay(Integer errorCode, int retryCount) {
if (errorCode == null || !isRetryable(errorCode)) {
return null;
}
// QPS限制,建议较长的延迟(指数退避)
if (errorCode == 18) {
return (long) (Math.pow(2, retryCount) * 1000); // 1s, 2s, 4s, 8s...
}
// 每天流量超限,建议更长的延迟
if (errorCode == 17 || errorCode == 19) {
return (long) (Math.pow(2, retryCount) * 5000); // 5s, 10s, 20s...
}
// Token失效,立即重试(因为需要先刷新token)
if (errorCode == 110 || errorCode == 111) {
return 0L;
}
// 集群超限,建议短暂延迟
if (errorCode == 4) {
return (long) (Math.pow(1.5, retryCount) * 500); // 500ms, 750ms, 1125ms...
}
// 默认使用指数退避策略
return (long) (Math.pow(2, retryCount) * 500); // 500ms, 1s, 2s, 4s...
}
}

View File

@@ -1,24 +1,7 @@
package com.ycwl.basic.facebody.exceptions;
/**
* 人脸识别异常基类
*
* <p>所有 facebody 包相关的异常都应继承此类。
*
* @see RetryableFaceBodyException
* @see NonRetryableFaceBodyException
*/
public class FaceBodyException extends RuntimeException {
public FaceBodyException(String message) {
super(message);
}
public FaceBodyException(String message, Throwable cause) {
super(message, cause);
}
public FaceBodyException(Throwable cause) {
super(cause);
}
}

View File

@@ -1,185 +0,0 @@
package com.ycwl.basic.facebody.exceptions;
/**
* 不可重试的人脸识别异常
*
* <p>表示操作失败且重试不会改变结果的异常场景,通常由以下原因引起:
* <ul>
* <li>参数错误(如无效的图片 URL、缺失必填字段、参数格式错误)</li>
* <li>认证/授权失败(appId、apiKey、secretKey 错误或权限不足)</li>
* <li>资源不存在(人脸库、用户、人脸不存在)</li>
* <li>业务规则违反(如人脸质量不合格、人脸数量超限)</li>
* <li>不支持的操作或功能</li>
* <li>数据冲突(如尝试创建已存在的资源)</li>
* </ul>
*
* <p>调用方应捕获此异常并进行业务逻辑处理,而非简单重试。
*
* @see FaceBodyException
* @see RetryableFaceBodyException
*/
public class NonRetryableFaceBodyException extends FaceBodyException {
private final Integer errorCode;
private final ErrorCategory category;
/**
* 错误类别枚举
*/
public enum ErrorCategory {
/** 参数验证错误 */
VALIDATION_ERROR,
/** 认证或授权错误 */
AUTHENTICATION_ERROR,
/** 资源不存在 */
RESOURCE_NOT_FOUND,
/** 业务规则违反 */
BUSINESS_RULE_VIOLATION,
/** 不支持的操作 */
UNSUPPORTED_OPERATION,
/** 数据冲突 */
CONFLICT,
/** 其他不可重试错误 */
OTHER
}
/**
* 构造一个不可重试异常
*
* @param message 错误消息
*/
public NonRetryableFaceBodyException(String message) {
super(message);
this.errorCode = null;
this.category = ErrorCategory.OTHER;
}
/**
* 构造一个不可重试异常,包含原始异常信息
*
* @param message 错误消息
* @param cause 原始异常
*/
public NonRetryableFaceBodyException(String message, Throwable cause) {
super(message, cause);
this.errorCode = null;
this.category = ErrorCategory.OTHER;
}
/**
* 构造一个不可重试异常,指定错误类别
*
* @param message 错误消息
* @param category 错误类别
*/
public NonRetryableFaceBodyException(String message, ErrorCategory category) {
super(message);
this.errorCode = null;
this.category = category;
}
/**
* 构造一个不可重试异常,包含错误码和类别
*
* @param message 错误消息
* @param errorCode 第三方服务返回的错误码
* @param category 错误类别
*/
public NonRetryableFaceBodyException(String message, Integer errorCode, ErrorCategory category) {
super(message);
this.errorCode = errorCode;
this.category = category;
}
/**
* 构造一个不可重试异常,包含原始异常、错误码和类别
*
* @param message 错误消息
* @param cause 原始异常
* @param errorCode 第三方服务返回的错误码
* @param category 错误类别
*/
public NonRetryableFaceBodyException(String message, Throwable cause, Integer errorCode, ErrorCategory category) {
super(message, cause);
this.errorCode = errorCode;
this.category = category;
}
/**
* 获取第三方服务返回的错误码
*
* @return 错误码,可能为 null
*/
public Integer getErrorCode() {
return errorCode;
}
/**
* 获取错误类别
*
* @return 错误类别,不会为 null
*/
public ErrorCategory getCategory() {
return category;
}
/**
* 判断是否为参数验证错误
*
* @return true 如果是参数验证错误
*/
public boolean isValidationError() {
return category == ErrorCategory.VALIDATION_ERROR;
}
/**
* 判断是否为认证或授权错误
*
* @return true 如果是认证或授权错误
*/
public boolean isAuthenticationError() {
return category == ErrorCategory.AUTHENTICATION_ERROR;
}
/**
* 判断是否为资源不存在错误
*
* @return true 如果是资源不存在错误
*/
public boolean isResourceNotFound() {
return category == ErrorCategory.RESOURCE_NOT_FOUND;
}
/**
* 判断是否为业务规则违反
*
* @return true 如果是业务规则违反
*/
public boolean isBusinessRuleViolation() {
return category == ErrorCategory.BUSINESS_RULE_VIOLATION;
}
/**
* 判断是否为不支持的操作
*
* @return true 如果是不支持的操作
*/
public boolean isUnsupportedOperation() {
return category == ErrorCategory.UNSUPPORTED_OPERATION;
}
/**
* 判断是否为数据冲突
*
* @return true 如果是数据冲突
*/
public boolean isConflict() {
return category == ErrorCategory.CONFLICT;
}
}

View File

@@ -1,139 +0,0 @@
package com.ycwl.basic.facebody.exceptions;
/**
* 可重试的人脸识别异常
*
* <p>表示操作失败但可以通过重试解决的异常场景,通常由以下原因引起:
* <ul>
* <li>网络连接超时或临时中断</li>
* <li>第三方服务限流(rate limit exceeded)</li>
* <li>服务端临时不可用(5xx 错误)</li>
* <li>并发冲突或资源竞争</li>
* <li>临时资源不足</li>
* </ul>
*
* <p>调用方应捕获此异常并实现重试机制,建议采用指数退避策略。
*
* @see FaceBodyException
* @see NonRetryableFaceBodyException
*/
public class RetryableFaceBodyException extends FaceBodyException {
private final Integer errorCode;
private final Integer maxRetries;
private final Long retryAfterMillis;
/**
* 构造一个可重试异常
*
* @param message 错误消息
*/
public RetryableFaceBodyException(String message) {
super(message);
this.errorCode = null;
this.maxRetries = null;
this.retryAfterMillis = null;
}
/**
* 构造一个可重试异常,包含原始异常信息
*
* @param message 错误消息
* @param cause 原始异常
*/
public RetryableFaceBodyException(String message, Throwable cause) {
super(message, cause);
this.errorCode = null;
this.maxRetries = null;
this.retryAfterMillis = null;
}
/**
* 构造一个可重试异常,包含错误码
*
* @param message 错误消息
* @param errorCode 第三方服务返回的错误码
*/
public RetryableFaceBodyException(String message, Integer errorCode) {
super(message);
this.errorCode = errorCode;
this.maxRetries = null;
this.retryAfterMillis = null;
}
/**
* 构造一个可重试异常,包含完整的重试信息
*
* @param message 错误消息
* @param errorCode 第三方服务返回的错误码
* @param maxRetries 建议的最大重试次数
* @param retryAfterMillis 建议的重试延迟时间(毫秒)
*/
public RetryableFaceBodyException(String message, Integer errorCode, Integer maxRetries, Long retryAfterMillis) {
super(message);
this.errorCode = errorCode;
this.maxRetries = maxRetries;
this.retryAfterMillis = retryAfterMillis;
}
/**
* 构造一个可重试异常,包含原始异常和完整的重试信息
*
* @param message 错误消息
* @param cause 原始异常
* @param errorCode 第三方服务返回的错误码
* @param maxRetries 建议的最大重试次数
* @param retryAfterMillis 建议的重试延迟时间(毫秒)
*/
public RetryableFaceBodyException(String message, Throwable cause, Integer errorCode, Integer maxRetries, Long retryAfterMillis) {
super(message, cause);
this.errorCode = errorCode;
this.maxRetries = maxRetries;
this.retryAfterMillis = retryAfterMillis;
}
/**
* 获取第三方服务返回的错误码
*
* @return 错误码,可能为 null
*/
public Integer getErrorCode() {
return errorCode;
}
/**
* 获取建议的最大重试次数
*
* @return 最大重试次数,可能为 null(表示使用默认值)
*/
public Integer getMaxRetries() {
return maxRetries;
}
/**
* 获取建议的重试延迟时间
*
* @return 重试延迟时间(毫秒),可能为 null(表示使用默认退避策略)
*/
public Long getRetryAfterMillis() {
return retryAfterMillis;
}
/**
* 判断是否有明确的重试延迟时间建议
*
* @return true 如果有明确的延迟时间建议
*/
public boolean hasRetryAfter() {
return retryAfterMillis != null && retryAfterMillis > 0;
}
/**
* 判断是否有建议的最大重试次数
*
* @return true 如果有明确的重试次数限制
*/
public boolean hasMaxRetries() {
return maxRetries != null && maxRetries > 0;
}
}

View File

@@ -6,6 +6,7 @@ import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.image.watermark.operator.DefaultImageWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.LeicaWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.NormalWatermarkOperator;
import com.ycwl.basic.image.watermark.operator.PrinterDefaultWatermarkOperator;
public class ImageWatermarkFactory {
public static IOperator get(String watermarkType) {
@@ -20,6 +21,7 @@ public class ImageWatermarkFactory {
case WATERMARK -> new DefaultImageWatermarkOperator();
case NORMAL -> new NormalWatermarkOperator();
case LEICA -> new LeicaWatermarkOperator();
case PRINTER_DEFAULT -> new PrinterDefaultWatermarkOperator();
default -> throw new ImageWatermarkUnsupportedException("不支持的类型" + type.name());
};
}

View File

@@ -6,7 +6,8 @@ import lombok.Getter;
public enum ImageWatermarkOperatorEnum {
WATERMARK("defW", "jpg"),
LEICA("leica", "png"),
NORMAL("normal", "png");
NORMAL("normal", "png"),
PRINTER_DEFAULT("pDefault", "png");
private final String type;
private final String preferFileType;

View File

@@ -93,8 +93,20 @@ public class NormalWatermarkOperator implements IOperator {
int offsetX = (newImage.getWidth() - newQrcodeWidth - QRCODE_OFFSET_X - Math.max(scenicLineWidth, datetimeLineWidth)) / 2;
int offsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight;
Shape originalClip = g2d.getClip();
Ellipse2D circle = new Ellipse2D.Double(offsetX, offsetY + QRCODE_OFFSET_Y, newQrcodeWidth, newQrcodeHeight);
g2d.setClip(circle);
// 创建比二维码大10像素的白色圆形背景
int whiteCircleSize = Math.max(newQrcodeWidth, newQrcodeHeight) + 10;
int whiteCircleX = offsetX - (whiteCircleSize - newQrcodeWidth) / 2;
int whiteCircleY = offsetY + QRCODE_OFFSET_Y - (whiteCircleSize - newQrcodeHeight) / 2;
// 绘制白色圆形背景
g2d.setColor(Color.WHITE);
Ellipse2D whiteCircle = new Ellipse2D.Double(whiteCircleX, whiteCircleY, whiteCircleSize, whiteCircleSize);
g2d.fill(whiteCircle);
// 用白色圆形尺寸裁切二维码(保持二维码原始尺寸,但用大圆裁切)
Ellipse2D qrcodeCircle = new Ellipse2D.Double(whiteCircleX, whiteCircleY, whiteCircleSize, whiteCircleSize);
g2d.setClip(qrcodeCircle);
g2d.drawImage(qrcodeImage, offsetX, offsetY + QRCODE_OFFSET_Y, newQrcodeWidth, newQrcodeHeight, null);
g2d.setClip(originalClip);

View File

@@ -0,0 +1,215 @@
package com.ycwl.basic.image.watermark.operator;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.exception.ImageWatermarkException;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.geom.Ellipse2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
@Slf4j
public class PrinterDefaultWatermarkOperator implements IOperator {
private static final String FONT_PATH = "/PingFang_SC_t.ttf";
public static String defaultFontName;
public static float FONT_GLOBAL_OFFSET_PERCENT = 0;
static {
try {
// 加载字体文件流
InputStream fontStream = PrinterDefaultWatermarkOperator.class.getResourceAsStream(FONT_PATH);
if (fontStream == null) {
throw new RuntimeException("字体文件未找到!路径:" + FONT_PATH);
}
// 创建字体对象
Font customFont = Font.createFont(Font.TRUETYPE_FONT, fontStream);
// 注册字体到系统
GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
ge.registerFont(customFont);
// 更新默认字体名称为新字体的逻辑名称
defaultFontName = customFont.getName(); // 如 "PingFang SC"
FONT_GLOBAL_OFFSET_PERCENT = -0.3f;
} catch (FontFormatException | IOException e) {
log.error("加载字体文件失败", e);
defaultFontName = "宋体";
}
}
public static int EXTRA_BORDER_PX = 0;
public static int OFFSET_Y = 15;
public static Color BG_COLOR = Color.WHITE;
public static int QRCODE_SIZE = 150;
public static double QRCODE_LEFT_MARGIN_RATIO = 0.075; // 二维码距离左边缘的图片宽度比例
public static int QRCODE_OFFSET_Y = -35;
public static int SCENIC_FONT_SIZE = 42;
public static Color scenicColor = Color.white;
public static int DATETIME_FONT_SIZE = 42;
public static Color datetimeColor = Color.white;
public static double TEXT_RIGHT_MARGIN_RATIO = 0.05; // 文字距离右边缘的图片宽度比例
@Override
public File process(WatermarkInfo info) throws ImageWatermarkException {
BufferedImage baseImage;
BufferedImage qrcodeImage;
BufferedImage faceImage = null;
try {
baseImage = ImageIO.read(info.getOriginalFile());
qrcodeImage = ImageIO.read(info.getQrcodeFile());
if (info.getFaceFile() != null && info.getFaceFile().isFile()) {
try {
faceImage = ImageIO.read(info.getFaceFile());
} catch (IOException e) {
log.warn("头像文件读取失败", e);
}
}
} catch (IOException e) {
throw new ImageWatermarkException("图片打开失败");
}
// 新图像画布
BufferedImage newImage = new BufferedImage(baseImage.getWidth() + 2 * EXTRA_BORDER_PX, baseImage.getHeight() + 2 * EXTRA_BORDER_PX, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = newImage.createGraphics();
g2d.setColor(BG_COLOR);
g2d.fillRect(0, 0, newImage.getWidth(), newImage.getHeight());
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(baseImage, EXTRA_BORDER_PX, EXTRA_BORDER_PX, null);
int newQrcodeHeight = QRCODE_SIZE;
int newQrcodeWidth = (int) (newQrcodeHeight * 1.0 / qrcodeImage.getHeight() * qrcodeImage.getWidth());
Font scenicFont = new Font(defaultFontName, Font.BOLD, SCENIC_FONT_SIZE);
Font datetimeFont = new Font(defaultFontName, Font.BOLD, DATETIME_FONT_SIZE);
FontMetrics scenicFontMetrics = g2d.getFontMetrics(scenicFont);
FontMetrics datetimeFontMetrics = g2d.getFontMetrics(datetimeFont);
int scenicLineHeight = scenicFontMetrics.getHeight();
int dtLineHeight = datetimeFontMetrics.getHeight();
int scenicLineWidth = scenicFontMetrics.stringWidth(info.getScenicLine());
int datetimeLineWidth = datetimeFontMetrics.stringWidth(info.getDatetimeLine());
// 二维码放置在左下角,距离左边缘图片宽度的5%
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO);
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight;
Shape originalClip = g2d.getClip();
// 创建比二维码大10像素的白色圆形背景
int whiteCircleSize = Math.max(newQrcodeWidth, newQrcodeHeight) + 10;
int whiteCircleX = qrcodeOffsetX - (whiteCircleSize - newQrcodeWidth) / 2;
int whiteCircleY = qrcodeOffsetY + QRCODE_OFFSET_Y - (whiteCircleSize - newQrcodeHeight) / 2;
// 绘制白色圆形背景
g2d.setColor(Color.WHITE);
Ellipse2D whiteCircle = new Ellipse2D.Double(whiteCircleX, whiteCircleY, whiteCircleSize, whiteCircleSize);
g2d.fill(whiteCircle);
// 用白色圆形尺寸裁切二维码(保持二维码原始尺寸,但用大圆裁切)
Ellipse2D qrcodeCircle = new Ellipse2D.Double(whiteCircleX, whiteCircleY, whiteCircleSize, whiteCircleSize);
g2d.setClip(qrcodeCircle);
g2d.drawImage(qrcodeImage, qrcodeOffsetX, qrcodeOffsetY + QRCODE_OFFSET_Y, newQrcodeWidth, newQrcodeHeight, null);
g2d.setClip(originalClip);
// 在圆形二维码中央绘制圆形头像
if (faceImage != null) {
// 计算圆形头像的尺寸和位置
int avatarDiameter = (int) (newQrcodeHeight * 0.45);
int avatarX = qrcodeOffsetX + (newQrcodeWidth - avatarDiameter) / 2;
int avatarY = qrcodeOffsetY + QRCODE_OFFSET_Y + (newQrcodeHeight - avatarDiameter) / 2;
// 保存当前的渲染设置
RenderingHints originalHints = g2d.getRenderingHints();
// 设置高质量渲染
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 创建圆形剪切区域
Ellipse2D avatarCircle = new Ellipse2D.Double(avatarX, avatarY, avatarDiameter, avatarDiameter);
g2d.setClip(avatarCircle);
// 实现CSS cover效果的缩放逻辑
double faceWidth = faceImage.getWidth();
double faceHeight = faceImage.getHeight();
double scaleX = avatarDiameter / faceWidth;
double scaleY = avatarDiameter / faceHeight;
double scale = Math.max(scaleX, scaleY); // 使用较大的缩放比例以填满圆形
int scaledWidth = (int) (faceWidth * scale);
int scaledHeight = (int) (faceHeight * scale);
// 计算居中位置
int faceDrawX = avatarX + (avatarDiameter - scaledWidth) / 2;
int faceDrawY = avatarY + (avatarDiameter - scaledHeight) / 2;
// 绘制缩放后的头像
g2d.drawImage(faceImage, faceDrawX, faceDrawY, scaledWidth, scaledHeight, null);
// 恢复原始设置
g2d.setClip(originalClip);
g2d.setRenderingHints(originalHints);
}
// 计算文字与二维码垂直居中对齐的Y坐标
int qrcodeTop = qrcodeOffsetY + QRCODE_OFFSET_Y;
int qrcodeBottom = qrcodeTop + newQrcodeHeight;
int qrcodeCenter = (qrcodeTop + qrcodeBottom) / 2;
// 两行文字的总高度
int totalTextHeight = scenicLineHeight + dtLineHeight;
// 计算第一行文字的Y坐标(基线位置),使两行文字整体垂直居中于二维码
int textStartY = qrcodeCenter - totalTextHeight / 2 + scenicFontMetrics.getAscent();
// 文字右对齐,放置在右下角,距离右边缘图片宽度的5%
int textRightX = newImage.getWidth() - (int) (newImage.getWidth() * TEXT_RIGHT_MARGIN_RATIO);
g2d.setFont(scenicFont);
g2d.setColor(scenicColor);
g2d.drawString(info.getScenicLine(), textRightX - scenicLineWidth, textStartY);
g2d.setFont(datetimeFont);
g2d.setColor(datetimeColor);
g2d.drawString(info.getDatetimeLine(), textRightX - datetimeLineWidth, textStartY + scenicLineHeight);
String fileName = info.getWatermarkedFile().getName();
String formatName = "jpg"; // 默认格式为 jpg
if (fileName.endsWith(".png")) {
formatName = "png";
} else if (fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")) {
formatName = "jpg";
}
ImageWriter writer = ImageIO.getImageWritersByFormatName(formatName).next();
ImageOutputStream ios;
try {
ios = ImageIO.createImageOutputStream(info.getWatermarkedFile());
} catch (IOException e) {
throw new ImageWatermarkException("图片保存失败,目标文件无法写入");
}
writer.setOutput(ios);
try {
// 使用 ImageWriter 设置写入质量
ImageWriteParam writeParam = writer.getDefaultWriteParam();
if (writeParam.canWriteCompressed()) {
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
writeParam.setCompressionQuality(0.75f); // 设置写入质量为 75%
}
writer.write(null, new javax.imageio.IIOImage(newImage, null, null), writeParam);
} catch (IOException e) {
throw new ImageWatermarkException("图片保存失败");
}
finally {
g2d.dispose();
try {
ios.close();
} catch (IOException ignore) {
}
writer.dispose();
}
return info.getWatermarkedFile();
}
}

View File

@@ -14,4 +14,36 @@ public interface PrintTaskMapper extends BaseMapper<PrintTaskEntity> {
List<PrintTaskEntity> queryByCondition(@Param("printerId") Integer printerId, @Param("status") Integer status);
int updateStatus(@Param("id") Integer id, @Param("status") Integer status);
/**
* 更新任务状态和打印机名称
* @param id 任务ID
* @param status 新状态
* @param printerName 打印机名称
* @return 更新行数
*/
int updateStatusAndPrinter(@Param("id") Integer id, @Param("status") Integer status, @Param("printerName") String printerName);
/**
* 查询待审核的打印任务
* @param printerId 打印机ID(可选)
* @return 待审核任务列表
*/
List<PrintTaskEntity> queryPendingReviewTasks(@Param("printerId") Integer printerId);
/**
* 更新任务URL
* @param id 任务ID
* @param url 新的打印URL
* @return 更新行数
*/
int updateTaskUrl(@Param("id") Integer id, @Param("url") String url);
/**
* 批量更新任务状态
* @param ids 任务ID列表
* @param status 新状态
* @return 更新行数
*/
int batchUpdateStatus(@Param("ids") List<Integer> ids, @Param("status") Integer status);
}

View File

@@ -105,4 +105,6 @@ public interface SourceMapper {
* @return 影响行数
*/
int addFromZTSource(SourceEntity source);
SourceEntity getBySampleIdAndType(Long faceSampleId, Integer type);
}

View File

@@ -6,4 +6,5 @@ import lombok.Data;
public class FaceRecognizeResp {
private String url;
private Long faceId;
private Long scenicId;
}

View File

@@ -23,6 +23,7 @@ public class OrderItemVO {
private Long orderId;
// 商品类型,1成片,2源素材
private Integer goodsType;
private Integer count;
/**
* 商品ID,goods_type=1关联video.id,=2关联source.id,=3关联template.id
*/

View File

@@ -15,6 +15,7 @@ public class MemberPrintEntity {
private Long scenicId;
private Long memberId;
private Long faceId;
private Long sourceId;
private String origUrl;
private String cropUrl;
private String printUrl;

View File

@@ -13,7 +13,7 @@ public class PrintTaskReqQuery extends BaseQueryParameterReq {
private Integer printerId;
/**
* 状态:0未开始,1已完成,2正在处理,3已失败
* 状态:0待处理,1已完成,2已失败,3处理中,4待审核,5已取消
*/
private Integer status;
}

View File

@@ -0,0 +1,14 @@
package com.ycwl.basic.model.pc.printer.req;
import lombok.Data;
/**
* 重新打印请求
*/
@Data
public class ReprintRequest {
/**
* 打印机名称
*/
private String printerName;
}

View File

@@ -8,6 +8,7 @@ import java.util.Date;
public class MemberPrintResp {
private Integer id;
private Long scenicId;
private Long sourceId;
private String scenicName;
private Long faceId;
private Long memberId;

View File

@@ -44,6 +44,7 @@ public class ScenicConfigResp {
*/
private Boolean showPhotoWhenWaiting;
private Boolean printEnable;
/**
* 智能抓拍打印开关
*/

View File

@@ -912,6 +912,8 @@ public class OrderServiceImpl implements IOrderService {
checkSetAlreadyPurchased(userId, faceId, scenicId, product.getProductType());
break;
case PHOTO_PRINT:
case PHOTO_PRINT_MU:
case PHOTO_PRINT_FX:
case MACHINE_PRINT:
// 打印类商品允许重复购买,跳过检查
log.debug("跳过打印类商品重复购买检查: productType={}, productId={}",

View File

@@ -122,8 +122,68 @@ public enum CouponStatus { CLAIMED("claimed", ...), USED("used", ...), EXPIRED("
- 商品类型限制:通过 JSON 字段(结合 `ProductTypeListTypeHandler`)控制适用商品
- 消费限制:支持最小消费金额、最大折扣限制
- 时效性:基于时间的有效期控制
- **用户领取数量限制**:通过 `userClaimLimit` 字段控制单个用户可领取优惠券的最大数量(v1.0.0新增)
- **库存精细化管理**:区分 `claimedQuantity`(已领取数量) 和 `usedQuantity`(已使用数量)
- 统计分析:完整的使用统计与分析能力
#### 优惠券数量管理机制 (v1.0.0更新)
```java
// PriceCouponConfig 实体字段
private Integer totalQuantity; // 发行总量(NULL或0表示不限制)
private Integer userClaimLimit; // 每个用户可领取数量限制(NULL或0表示不限制)
private Integer claimedQuantity; // 已领取数量(区分于已使用数量)
private Integer usedQuantity; // 已使用数量(实际消耗时更新)
// 字段语义
totalQuantity: NULL/0=不限总量, >0=限制总量
userClaimLimit: NULL/0=不限用户, >0=限制单用户
claimedQuantity: 仅在totalQuantity>0时更新
usedQuantity: 实际使用时更新
// 领取流程检查顺序
1. 检查优惠券是否存在且启用
2. 检查有效期
3. 检查总量库存: totalQuantity>0 ,检查 claimedQuantity < totalQuantity
4. 检查用户领取限制: userClaimLimit>0 ,检查 countUserClaims(userId, couponId) < userClaimLimit
5. 创建领取记录并更新 claimedQuantity (仅当totalQuantity>0时)
```
#### 用户领取限制配置示例
```java
// 场景1: 新人专享券,每人限领1张,总量1000张
{
"couponName": "新人专享券",
"userClaimLimit": 1,
"totalQuantity": 1000
}
// 场景2: 活动券,每人限领3张,总量5000张
{
"couponName": "618促销券",
"userClaimLimit": 3,
"totalQuantity": 5000
}
// 场景3: 不限制领取次数,不限制总量
{
"couponName": "会员专享券",
"userClaimLimit": null, // 或 0
"totalQuantity": null // 或 0,表示无限量
}
// 场景4: 不限用户次数,但限制总量
{
"couponName": "限量抢购券",
"userClaimLimit": null, // 不限单用户
"totalQuantity": 500 // 总共500张
}
```
#### 错误码扩展
- `CLAIM_LIMIT_REACHED`: 用户已达到该优惠券的领取上限
- `COUPON_OUT_OF_STOCK`: 优惠券库存不足(基于claimedQuantity检查)
- `ALREADY_CLAIMED`: 用户已领取过(兼容旧逻辑)
### 3. 商品配置管理
#### API端点(摘)
@@ -550,9 +610,28 @@ public class PriceCalculationResult {
- `price_product_config`: 商品价格基础配置(包含 `can_use_coupon``can_use_voucher``can_use_one_price` 优惠控制字段)
- `price_tier_config`: 分层定价配置
- `price_bundle_config`: 套餐配置
- `price_coupon_config`: 优惠券配置
- `price_coupon_config`: 优惠券配置(**v1.0.0新增**: `user_claim_limit` 用户领取限制, `claimed_quantity` 已领取数量)
- `price_coupon_claim_record`: 优惠券领取记录
### price_coupon_config 表字段说明 (v1.0.0更新)
```sql
-- 核心数量管理字段
total_quantity INT -- 发行总量(NULL或0表示不限制,>0表示限量)
claimed_quantity INT -- 已领取数量(v1.0.0新增,领取时+1,仅在total_quantity>0时更新)
used_quantity INT -- 已使用数量(实际使用时+1)
user_claim_limit INT -- 每个用户可领取数量限制(v1.0.0新增,NULL或0表示不限制,>0表示限制)
-- 字段关系
-- claimed_quantity >= used_quantity (已领取 >= 已使用)
-- claimed_quantity <= total_quantity (已领取 <= 总量,仅当total_quantity>0时适用)
-- 典型配置组合
-- 1. 无限量发行: total_quantity=NULL/0, user_claim_limit=NULL/0
-- 2. 限量限人: total_quantity=1000, user_claim_limit=1
-- 3. 限量不限人: total_quantity=500, user_claim_limit=NULL/0
-- 4. 不限量但限人: total_quantity=NULL/0, user_claim_limit=3
```
### 新增表结构
- `price_voucher_batch_config`: 券码批次配置表
- `price_voucher_code`: 券码表
@@ -562,6 +641,10 @@ public class PriceCalculationResult {
### 索引优化(示例)
```sql
-- 优惠券领取记录查询优化 (v1.0.0新增)
CREATE INDEX idx_user_coupon ON price_coupon_claim_record(user_id, coupon_id);
CREATE INDEX idx_coupon_status ON price_coupon_claim_record(coupon_id, status);
-- 券码查询优化
CREATE INDEX idx_voucher_code ON price_voucher_code(code);
CREATE INDEX idx_face_scenic ON price_voucher_code(face_id, scenic_id);
@@ -579,10 +662,37 @@ CREATE INDEX idx_print_face_scenic ON voucher_print_record(face_id, scenic_id);
- 券码表可能数据量较大,考虑按景区维度分表或归档
- 定期清理已删除的过期数据
- 使用数据完整性检查 SQL 验证统计数据准确性
- **优惠券领取记录表查询优化** (v1.0.0): 为 `(user_id, coupon_id)` 添加复合索引以加速用户领取次数统计
## 兼容性与注意事项
- 本模块使用 PageHelper(优惠券相关)与 MyBatis‑Plus(券码/一口价等)并存,请根据对应 Service/Mapper 选择分页与查询方式。
- 优惠优先级及叠加规则以各 Provider 与业务配置为准,避免在外层重复实现优先级判断逻辑。
- 若扩展新的优惠类型,务必实现 `IDiscountProvider` 并在 `IDiscountDetectionService` 中完成注册(当前实现通过组件扫描自动注册并排序)。
- **优惠券数量管理** (v1.0.0): 现有代码已调整为领取时更新 `claimedQuantity`,使用时更新 `usedQuantity`。如业务需求不同,请调整 `CouponServiceImpl.claimCoupon()``CouponServiceImpl.useCoupon()` 逻辑。
## 版本更新记录
### v1.0.0 (2025-11-16)
**优惠券用户领取数量限制功能**
新增特性:
- ✅ 支持为每个优惠券配置用户领取数量限制 (`userClaimLimit`)
- ✅ 区分已领取数量 (`claimedQuantity`) 和已使用数量 (`usedQuantity`)
- ✅ 新增错误码 `CLAIM_LIMIT_REACHED` 处理超限场景
- ✅ 新增统计方法 `PriceCouponClaimRecordMapper.countUserCouponClaims()`
数据库变更:
-`price_coupon_config` 新增字段 `user_claim_limit INT`
-`price_coupon_config` 新增字段 `claimed_quantity INT`
- 建议索引 `idx_user_coupon ON price_coupon_claim_record(user_id, coupon_id)`
受影响文件:
- `entity/PriceCouponConfig.java`
- `mapper/PriceCouponClaimRecordMapper.java`
- `service/impl/CouponServiceImpl.java`
- `dto/CouponClaimResult.java`
- `dto/CouponInfo.java`
迁移指南: 详见 `docs/coupon-user-claim-limit-guide.md`

View File

@@ -38,27 +38,6 @@ public class PriceCalculationController {
return ApiResponse.success(result);
}
/**
* 使用优惠券
* 只能通过代码处理,不能通过接口调用
*/
@PostMapping("/coupons/use")
@Deprecated
public ApiResponse<CouponUseResult> useCoupon(@RequestBody CouponUseRequest request) {
// log.info("优惠券使用请求: userId={}, couponId={}, orderId={}",
// request.getUserId(), request.getCouponId(), request.getOrderId());
//
// CouponUseResult result = couponService.useCoupon(request);
//
// log.info("优惠券使用成功: couponId={}, discountAmount={}",
// result.getCouponId(), result.getDiscountAmount());
//
// ApiResponse<CouponUseResult> response = ApiResponse.success(result);
// response.setMsg("优惠券使用成功");
// return response;
return null;
}
/**
* 查询用户可用优惠券
*/

View File

@@ -98,6 +98,7 @@ public class CouponClaimResult {
public static final String ERROR_COUPON_INACTIVE = "COUPON_INACTIVE";
public static final String ERROR_COUPON_OUT_OF_STOCK = "COUPON_OUT_OF_STOCK";
public static final String ERROR_ALREADY_CLAIMED = "ALREADY_CLAIMED";
public static final String ERROR_CLAIM_LIMIT_REACHED = "CLAIM_LIMIT_REACHED";
public static final String ERROR_INVALID_PARAMS = "INVALID_PARAMS";
public static final String ERROR_SYSTEM_ERROR = "SYSTEM_ERROR";
}

View File

@@ -35,4 +35,9 @@ public class CouponInfo {
* 实际优惠金额
*/
private BigDecimal actualDiscountAmount;
/**
* 每个用户可领取数量限制(NULL表示不限制)
*/
private Integer userClaimLimit;
}

View File

@@ -48,5 +48,10 @@ public class PriceCalculationRequest {
/**
* 是否仅预览优惠(不实际使用)
*/
private Boolean previewOnly = false;
private Boolean previewOnly = true;
/**
* 订单ID(在实际使用优惠时必填)
*/
private String orderId;
}

View File

@@ -61,6 +61,16 @@ public class PriceCouponConfig {
*/
private Integer usedQuantity;
/**
* 已领取数量(区分于已使用数量)
*/
private Integer claimedQuantity;
/**
* 每个用户可领取数量限制(NULL表示不限制)
*/
private Integer userClaimLimit;
/**
* 生效时间
*/

View File

@@ -14,6 +14,8 @@ public enum ProductType {
RECORDING_SET("RECORDING_SET", "录像集"),
PHOTO_SET("PHOTO_SET", "照相集"),
PHOTO_PRINT("PHOTO_PRINT", "照片打印"),
PHOTO_PRINT_MU("PHOTO_PRINT_MU", "手机照片打印"),
PHOTO_PRINT_FX("PHOTO_PRINT_FX", "特效照片打印"),
MACHINE_PRINT("MACHINE_PRINT", "一体机打印");
private final String code;

View File

@@ -5,11 +5,26 @@ package com.ycwl.basic.pricing.exception;
*/
public class CouponInvalidException extends RuntimeException {
private final String errorCode;
public CouponInvalidException(String message) {
super(message);
this(null, message, null);
}
public CouponInvalidException(String message, Throwable cause) {
this(null, message, cause);
}
public CouponInvalidException(String errorCode, String message) {
this(errorCode, message, null);
}
public CouponInvalidException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@@ -29,13 +29,20 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
List<PriceCouponClaimRecord> selectUserAvailableCoupons(Long userId);
/**
* 查询用户特定优惠券记录
* 查询用户特定优惠券记录(检查是否领取过,不限状态)
*/
@Select("SELECT * FROM price_coupon_claim_record " +
"WHERE user_id = #{userId} AND coupon_id = #{couponId} AND status = 'CLAIMED'")
"WHERE user_id = #{userId} AND coupon_id = #{couponId}")
PriceCouponClaimRecord selectUserCouponRecord(@Param("userId") Long userId,
@Param("couponId") Long couponId);
/**
* 统计用户领取某优惠券的次数(所有状态)
*/
@Select("SELECT COUNT(*) FROM price_coupon_claim_record " +
"WHERE user_id = #{userId} AND coupon_id = #{couponId} AND deleted = 0 FOR UPDATE")
int countUserCouponClaims(@Param("userId") Long userId, @Param("couponId") Long couponId);
/**
* 更新优惠券使用状态
*/

View File

@@ -39,6 +39,14 @@ public interface PriceCouponConfigMapper extends BaseMapper<PriceCouponConfig> {
"update_time = NOW() WHERE id = #{couponId} AND used_quantity < total_quantity")
int incrementUsedQuantity(Long couponId);
/**
* 原子性增加已领取数量(仅对有限库存的优惠券生效)
*/
@Update("UPDATE price_coupon_config SET claimed_quantity = COALESCE(claimed_quantity, 0) + 1, " +
"update_time = NOW() WHERE id = #{couponId} AND total_quantity IS NOT NULL AND total_quantity > 0 " +
"AND COALESCE(claimed_quantity, 0) < total_quantity")
int incrementClaimedQuantityIfAvailable(@Param("couponId") Long couponId);
/**
* 插入优惠券配置
*/

View File

@@ -0,0 +1,21 @@
package com.ycwl.basic.pricing.service;
import com.ycwl.basic.pricing.enums.ProductType;
/**
* 自动发券服务接口
* 负责在特定场景下自动为用户发放优惠券
*/
public interface IAutoCouponService {
/**
* 自动为用户发放首次打印优惠券
*
* @param memberId 用户ID (member_id)
* @param faceId 人脸ID (face_id)
* @param scenicId 景区ID
* @param productType 商品类型
* @return 是否成功发券
*/
boolean autoGrantCoupon(Long memberId, Long faceId, Long scenicId, ProductType productType);
}

View File

@@ -0,0 +1,116 @@
package com.ycwl.basic.pricing.service.impl;
import com.ycwl.basic.pricing.dto.CouponClaimRequest;
import com.ycwl.basic.pricing.entity.PriceCouponClaimRecord;
import com.ycwl.basic.pricing.entity.PriceCouponConfig;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.mapper.PriceCouponClaimRecordMapper;
import com.ycwl.basic.pricing.mapper.PriceCouponConfigMapper;
import com.ycwl.basic.pricing.service.IAutoCouponService;
import com.ycwl.basic.pricing.service.ICouponService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 自动发券服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AutoCouponServiceImpl implements IAutoCouponService {
private final PriceCouponConfigMapper couponConfigMapper;
private final PriceCouponClaimRecordMapper couponClaimRecordMapper;
private final ICouponService couponService;
@Override
public boolean autoGrantCoupon(Long memberId, Long faceId, Long scenicId, ProductType productType) {
try {
// 1. 校验参数
if (memberId == null || scenicId == null || productType == null) {
log.warn("自动发券参数不完整: memberId={}, faceId={}, scenicId={}, productType={}",
memberId, faceId, scenicId, productType);
return false;
}
// 2. 查找该景区、该商品类型的首次打印优惠券配置
Long couponId = findFirstCouponId(scenicId, productType);
if (couponId == null) {
log.debug("景区未配置首次打印优惠券: scenicId={}, productType={}", scenicId, productType);
return false;
}
// 3. 检查用户是否已领取过该券(领券即消耗首次资格)
PriceCouponClaimRecord existingRecord = couponClaimRecordMapper.selectUserCouponRecord(
memberId,
couponId
);
if (existingRecord != null) {
log.debug("用户已领取过首次优惠券,不重复发券: memberId={}, couponId={}, claimTime={}",
memberId, couponId, existingRecord.getClaimTime());
return false;
}
// 4. 自动发券
CouponClaimRequest request = new CouponClaimRequest(
memberId,
couponId,
scenicId.toString(),
"AUTO_GRANT" // 标记为自动发券来源
);
couponService.claimCoupon(request);
log.info("成功自动发放首次打印优惠券: memberId={}, faceId={}, scenicId={}, productType={}, couponId={}",
memberId, faceId, scenicId, productType, couponId);
return true;
} catch (Exception e) {
log.error("自动发券失败: memberId={}, faceId={}, scenicId={}, productType={}",
memberId, faceId, scenicId, productType, e);
return false;
}
}
/**
* 查找指定景区、指定商品类型的首次打印优惠券ID
* 规则:优惠券名称包含 "首次" 且 适用商品类型包含目标类型
*
* @param scenicId 景区ID
* @param productType 商品类型
* @return 优惠券ID,未找到返回null
*/
private Long findFirstCouponId(Long scenicId, ProductType productType) {
try {
// 查询该景区的有效优惠券
List<PriceCouponConfig> coupons = couponConfigMapper.selectValidCouponsByScenicId(
scenicId.toString()
);
for (PriceCouponConfig coupon : coupons) {
// 检查优惠券名称是否包含"首次"关键字
if (coupon.getCouponName() != null && (coupon.getCouponName().contains("首次"))) {
// 检查适用商品类型
String applicableProducts = coupon.getApplicableProducts();
if (applicableProducts != null &&
applicableProducts.contains(productType.getCode())) {
return coupon.getId();
}
}
}
log.debug("未找到匹配的首次打印优惠券: scenicId={}, productType={}", scenicId, productType);
return null;
} catch (Exception e) {
log.error("查找首次打印优惠券失败: scenicId={}, productType={}", scenicId, productType, e);
return null;
}
}
}

View File

@@ -17,7 +17,9 @@ import lombok.RequiredArgsConstructor;
import java.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.NoTransactionException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -194,6 +196,7 @@ public class CouponServiceImpl implements ICouponService {
info.setDiscountType(coupon.getCouponType());
info.setDiscountValue(coupon.getDiscountValue());
info.setActualDiscountAmount(actualDiscountAmount);
info.setUserClaimLimit(coupon.getUserClaimLimit());
return info;
}
@@ -230,17 +233,24 @@ public class CouponServiceImpl implements ICouponService {
}
// 5. 检查库存(如果有总量限制)
if (coupon.getTotalQuantity() != null && coupon.getUsedQuantity() != null) {
if (coupon.getUsedQuantity() >= coupon.getTotalQuantity()) {
// totalQuantity为NULL或0表示不限制总量
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
int currentClaimed = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity());
if (currentClaimed >= coupon.getTotalQuantity()) {
return CouponClaimResult.failure(CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK, "优惠券已领完");
}
}
// 6. 检查用户是否已经领取过该优惠券
PriceCouponClaimRecord existingRecord = couponClaimRecordMapper.selectUserCouponRecord(
request.getUserId(), request.getCouponId());
if (existingRecord != null) {
return CouponClaimResult.failure(CouponClaimResult.ERROR_ALREADY_CLAIMED, "您已经领取过该优惠券");
// 6. 检查用户领取数量限制
if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) {
int userClaimCount = couponClaimRecordMapper.countUserCouponClaims(
request.getUserId(), request.getCouponId());
// countUserCouponClaims 使用 FOR UPDATE + 复合索引,确保并发下的计数准确
if (userClaimCount >= coupon.getUserClaimLimit()) {
return CouponClaimResult.failure(
CouponClaimResult.ERROR_CLAIM_LIMIT_REACHED,
"您已达到该优惠券的领取上限(" + coupon.getUserClaimLimit() + "张)");
}
}
// 7. 创建领取记录
@@ -263,11 +273,17 @@ public class CouponServiceImpl implements ICouponService {
return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "领取失败,请稍后重试");
}
// 9. 更新优惠券已使用数量(如果有总量限制
if (coupon.getTotalQuantity() != null) {
int updatedUsedQuantity = (coupon.getUsedQuantity() == null ? 0 : coupon.getUsedQuantity()) + 1;
coupon.setUsedQuantity(updatedUsedQuantity);
couponConfigMapper.updateById(coupon);
// 9. 更新优惠券已领取数量(区分于已使用数量)
// 仅在有总量限制时才更新claimedQuantity(totalQuantity为正整数)
if (coupon.getTotalQuantity() != null && coupon.getTotalQuantity() > 0) {
int affected = couponConfigMapper.incrementClaimedQuantityIfAvailable(coupon.getId());
if (affected == 0) {
throw new CouponInvalidException(
CouponClaimResult.ERROR_COUPON_OUT_OF_STOCK,
"优惠券已被领取完,请稍后重试");
}
int updatedClaimedQuantity = (coupon.getClaimedQuantity() == null ? 0 : coupon.getClaimedQuantity()) + 1;
coupon.setClaimedQuantity(updatedClaimedQuantity);
}
log.info("优惠券领取成功: userId={}, couponId={}, claimRecordId={}",
@@ -276,10 +292,27 @@ public class CouponServiceImpl implements ICouponService {
// 10. 返回成功结果
return CouponClaimResult.success(claimRecord, coupon);
} catch (CouponInvalidException e) {
markRollbackOnly();
log.warn("领取优惠券失败(业务校验不通过): userId={}, couponId={}, reason={}",
request.getUserId(), request.getCouponId(), e.getMessage());
String errorCode = e.getErrorCode() == null
? CouponClaimResult.ERROR_SYSTEM_ERROR
: e.getErrorCode();
return CouponClaimResult.failure(errorCode, e.getMessage());
} catch (Exception e) {
markRollbackOnly();
log.error("领取优惠券失败: userId={}, couponId={}",
request.getUserId(), request.getCouponId(), e);
return CouponClaimResult.failure(CouponClaimResult.ERROR_SYSTEM_ERROR, "系统错误,领取失败:" + e.getMessage());
}
}
private void markRollbackOnly() {
try {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
} catch (NoTransactionException ex) {
log.debug("未检测到Spring事务上下文,跳过回滚标记");
}
}
}

View File

@@ -83,6 +83,7 @@ public class OnePricePurchaseDiscountProvider implements IDiscountProvider {
discountInfo.setDiscountAmount(onePriceInfo.getActualDiscountAmount());
discountInfo.setDiscountDescription("景区一口价购买,价格更优惠");
discountInfo.setOnePriceInfo(onePriceInfo);
discountInfo.setPriority(getPriority());
discounts.add(discountInfo);

View File

@@ -30,6 +30,17 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
private final IDiscountDetectionService discountDetectionService;
private final IVoucherService voucherService;
/**
* 判断是否为打印类商品
* 打印类商品的价格计算方式为:单价 × 数量
*/
private boolean isPrintProduct(ProductType productType) {
return productType == ProductType.PHOTO_PRINT
|| productType == ProductType.PHOTO_PRINT_MU
|| productType == ProductType.PHOTO_PRINT_FX
|| productType == ProductType.MACHINE_PRINT;
}
@Override
public PriceCalculationResult calculatePrice(PriceCalculationRequest request) {
if (request.getProducts() == null || request.getProducts().isEmpty()) {
@@ -190,7 +201,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
try {
PriceProductConfig baseConfig = productConfigService.getProductConfig(productType.getCode(), productId);
if (baseConfig != null) {
if (productType == ProductType.PHOTO_PRINT || productType == ProductType.MACHINE_PRINT) {
if (isPrintProduct(productType)) {
return baseConfig.getBasePrice().multiply(BigDecimal.valueOf(product.getQuantity()));
} else {
return baseConfig.getBasePrice();
@@ -205,7 +216,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
try {
PriceProductConfig defaultConfig = productConfigService.getProductConfig(productType.getCode(), "default");
if (defaultConfig != null) {
if (productType == ProductType.PHOTO_PRINT || productType == ProductType.MACHINE_PRINT) {
if (isPrintProduct(productType)) {
return defaultConfig.getBasePrice().multiply(BigDecimal.valueOf(product.getQuantity()));
} else {
return defaultConfig.getBasePrice();
@@ -219,7 +230,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
List<PriceProductConfig> configs = productConfigService.getProductConfig(productType.getCode());
if (!configs.isEmpty()) {
PriceProductConfig baseConfig = configs.get(0); // 使用第一个配置作为默认
if (productType == ProductType.PHOTO_PRINT || productType == ProductType.MACHINE_PRINT) {
if (isPrintProduct(productType)) {
return baseConfig.getBasePrice().multiply(BigDecimal.valueOf(product.getQuantity()));
} else {
return baseConfig.getBasePrice();
@@ -253,7 +264,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = baseConfig.getBasePrice();
originalPrice = baseConfig.getOriginalPrice();
if (productType == ProductType.PHOTO_PRINT || productType == ProductType.MACHINE_PRINT) {
if (isPrintProduct(productType)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -273,7 +284,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = defaultConfig.getBasePrice();
originalPrice = defaultConfig.getOriginalPrice();
if (productType == ProductType.PHOTO_PRINT || productType == ProductType.MACHINE_PRINT) {
if (isPrintProduct(productType)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -292,7 +303,7 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
actualPrice = baseConfig.getBasePrice();
originalPrice = baseConfig.getOriginalPrice();
if (productType == ProductType.PHOTO_PRINT || productType == ProductType.MACHINE_PRINT) {
if (isPrintProduct(productType)) {
actualPrice = actualPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
if (originalPrice != null) {
originalPrice = originalPrice.multiply(BigDecimal.valueOf(product.getQuantity()));
@@ -391,8 +402,19 @@ public class PriceCalculationServiceImpl implements IPriceCalculationService {
log.info("已标记券码为使用: {}", result.getUsedVoucher().getVoucherCode());
}
// 优惠券的使用标记由原有的CouponService处理
// 这里不需要额外处理
// 标记优惠券为已使用
if (result.getUsedCoupon() != null && result.getUsedCoupon().getCouponId() != null) {
CouponUseRequest couponUseRequest = new CouponUseRequest();
couponUseRequest.setCouponId(result.getUsedCoupon().getCouponId());
couponUseRequest.setUserId(request.getUserId());
couponUseRequest.setOrderId(request.getOrderId());
couponUseRequest.setDiscountAmount(result.getUsedCoupon().getActualDiscountAmount());
couponUseRequest.setScenicId(request.getScenicId() != null ? String.valueOf(request.getScenicId()) : null);
couponService.useCoupon(couponUseRequest);
log.info("已标记优惠券为使用: couponId={}, userId={}, orderId={}",
result.getUsedCoupon().getCouponId(), request.getUserId(), request.getOrderId());
}
} catch (Exception e) {
log.error("标记优惠使用状态时发生异常", e);

View File

@@ -220,58 +220,98 @@ public class AppScenicServiceImpl implements AppScenicService {
@Override
public List<ScenicAppVO> scenicListByLnLa(ScenicIndexVO scenicIndexVO) {
// 参数校验
if (scenicIndexVO == null) {
log.warn("scenicListByLnLa 接收到空参数");
return Collections.emptyList();
}
if (scenicIndexVO.getLatitude() == null || scenicIndexVO.getLongitude() == null) {
log.warn("scenicListByLnLa 缺少必要的经纬度参数, latitude={}, longitude={}",
scenicIndexVO.getLatitude(), scenicIndexVO.getLongitude());
return Collections.emptyList();
}
// 从 scenicRepository 获取所有景区(1000个)
ScenicReqQuery query = new ScenicReqQuery();
query.setPageNum(1);
query.setPageSize(1000);
List<ScenicV2DTO> scenicList = scenicRepository.list(query);
if (scenicList == null || scenicList.isEmpty()) {
log.info("未查询到任何景区数据");
return Collections.emptyList();
}
List<ScenicAppVO> list = new ArrayList<>();
// 为每个景区获取详细信息(包含经纬度)
for (ScenicV2DTO scenicDTO : scenicList) {
try {
// ID 格式校验
if (StringUtils.isBlank(scenicDTO.getId())) {
log.warn("景区 ID 为空,跳过该景区");
continue;
}
// 获取景区详细信息(包含经纬度)
ScenicEntity scenicEntity = scenicRepository.getScenic(Long.parseLong(scenicDTO.getId()));
if (scenicEntity != null && scenicEntity.getLatitude() != null && scenicEntity.getLongitude() != null) {
// 计算距离
BigDecimal distance = calculateDistance(
scenicIndexVO.getLatitude(),
scenicIndexVO.getLongitude(),
scenicEntity.getLatitude(),
scenicEntity.getLongitude()
);
// 根据距离和范围筛选景区
if (scenicEntity.getRadius() != null &&
distance.compareTo(scenicEntity.getRadius().multiply(BigDecimal.valueOf(1_000L))) < 0) {
// 转换为 ScenicAppVO
ScenicAppVO scenicAppVO = new ScenicAppVO();
scenicAppVO.setId(scenicEntity.getId());
scenicAppVO.setName(scenicEntity.getName());
scenicAppVO.setPhone(scenicEntity.getPhone());
scenicAppVO.setIntroduction(scenicEntity.getIntroduction());
scenicAppVO.setCoverUrl(scenicEntity.getCoverUrl());
scenicAppVO.setLongitude(scenicEntity.getLongitude());
scenicAppVO.setLatitude(scenicEntity.getLatitude());
scenicAppVO.setRadius(scenicEntity.getRadius());
scenicAppVO.setProvince(scenicEntity.getProvince());
scenicAppVO.setCity(scenicEntity.getCity());
scenicAppVO.setArea(scenicEntity.getArea());
scenicAppVO.setAddress(scenicEntity.getAddress());
scenicAppVO.setDistance(distance);
scenicAppVO.setDeviceNum(deviceRepository.getAllDeviceByScenicId(scenicEntity.getId()).size());
list.add(scenicAppVO);
}
if (scenicEntity == null) {
log.warn("景区详情查询失败, scenicId={}", scenicDTO.getId());
continue;
}
if (scenicEntity.getLatitude() == null || scenicEntity.getLongitude() == null) {
log.warn("景区缺少经纬度信息, scenicId={}, scenicName={}",
scenicEntity.getId(), scenicEntity.getName());
continue;
}
// 计算距离
BigDecimal distance = calculateDistance(
scenicIndexVO.getLatitude(),
scenicIndexVO.getLongitude(),
scenicEntity.getLatitude(),
scenicEntity.getLongitude()
);
// 根据距离和范围筛选景区
if (scenicEntity.getRadius() != null &&
distance.compareTo(scenicEntity.getRadius().multiply(BigDecimal.valueOf(1_000L))) < 0) {
// 转换为 ScenicAppVO
ScenicAppVO scenicAppVO = new ScenicAppVO();
scenicAppVO.setId(scenicEntity.getId());
scenicAppVO.setName(scenicEntity.getName());
scenicAppVO.setPhone(scenicEntity.getPhone());
scenicAppVO.setIntroduction(scenicEntity.getIntroduction());
scenicAppVO.setCoverUrl(scenicEntity.getCoverUrl());
scenicAppVO.setLongitude(scenicEntity.getLongitude());
scenicAppVO.setLatitude(scenicEntity.getLatitude());
scenicAppVO.setRadius(scenicEntity.getRadius());
scenicAppVO.setProvince(scenicEntity.getProvince());
scenicAppVO.setCity(scenicEntity.getCity());
scenicAppVO.setArea(scenicEntity.getArea());
scenicAppVO.setAddress(scenicEntity.getAddress());
scenicAppVO.setDistance(distance);
// 获取设备数量
List<DeviceV2DTO> devices = deviceRepository.getAllDeviceByScenicId(scenicEntity.getId());
scenicAppVO.setDeviceNum(devices != null ? devices.size() : 0);
list.add(scenicAppVO);
}
} catch (NumberFormatException e) {
log.error("景区 ID 格式错误,无法转换为 Long 类型, scenicId={}, error={}",
scenicDTO.getId(), e.getMessage());
} catch (Exception e) {
// 单个景区获取失败,继续处理下一个
continue;
log.error("处理景区信息时发生异常, scenicId={}, error={}",
scenicDTO != null ? scenicDTO.getId() : "unknown", e.getMessage(), e);
}
}
log.info("根据经纬度筛选景区完成, 输入坐标=({}, {}), 符合条件的景区数量={}",
scenicIndexVO.getLatitude(), scenicIndexVO.getLongitude(), list.size());
return list;
}

View File

@@ -173,9 +173,6 @@ public class FaceServiceImpl implements FaceService {
private BuyStatusProcessor buyStatusProcessor;
@Autowired
private VideoRecreationHandler videoRecreationHandler;
@Autowired
private FaceRecoveryStrategy faceRecoveryStrategy;
@Override
public ApiResponse<PageInfo<FaceRespVO>> pageQuery(FaceReqQuery faceReqQuery) {
PageHelper.startPage(faceReqQuery.getPageNum(),faceReqQuery.getPageSize());
@@ -287,11 +284,12 @@ public class FaceServiceImpl implements FaceService {
FaceRecognizeResp resp = new FaceRecognizeResp();
resp.setUrl(faceUrl);
resp.setFaceId(newFaceId);
resp.setScenicId(scenicId);
matchFaceId(newFaceId, oldFaceId == null);
// 异步执行自动添加打印
Long finalFaceId = newFaceId;
Thread thread = new Thread(() -> autoAddPhotosToPreferPrint(finalFaceId), "auto-add-print-" + newFaceId);
Thread thread = new Thread(() -> printerService.autoAddPhotosToPreferPrint(finalFaceId), "auto-add-print-" + newFaceId);
thread.start();
if (org.apache.commons.lang3.Strings.CI.equals("print", scene)) {
try {
@@ -1149,114 +1147,4 @@ public class FaceServiceImpl implements FaceService {
}
return null;
}
/**
* 自动将人脸关联的照片添加到优先打印列表
* 根据景区和设备配置自动添加type=2的照片到用户打印列表
*
* @param faceId 人脸ID
*/
private void autoAddPhotosToPreferPrint(Long faceId) {
try {
// 1. 获取人脸信息
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸不存在,无法自动添加打印: faceId={}", faceId);
return;
}
Long scenicId = face.getScenicId();
Long memberId = face.getMemberId();
// 2. 获取景区配置
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
if (scenicConfig == null) {
log.warn("景区配置不存在,跳过自动添加打印: scenicId={}", scenicId);
return;
}
// 3. 检查景区是否启用打印功能
Boolean printEnable = scenicConfig.getBoolean("print_enable");
if (printEnable == null || !printEnable) {
log.debug("景区未启用打印功能,跳过自动添加: scenicId={}", scenicId);
return;
}
// 4. 查询该faceId关联的所有type=2的照片
List<SourceEntity> imageSources = sourceMapper.listImageSourcesByFaceId(faceId);
if (imageSources == null || imageSources.isEmpty()) {
log.debug("该人脸没有关联的照片,跳过自动添加: faceId={}", faceId);
return;
}
// 5. 按照deviceId分组处理
Map<Long, List<SourceEntity>> sourcesByDevice = imageSources.stream()
.filter(source -> source.getDeviceId() != null)
.collect(Collectors.groupingBy(SourceEntity::getDeviceId));
int totalAdded = 0;
for (Map.Entry<Long, List<SourceEntity>> entry : sourcesByDevice.entrySet()) {
Long deviceId = entry.getKey();
List<SourceEntity> deviceSources = entry.getValue();
// 6. 获取设备配置
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
if (deviceConfig == null) {
log.debug("设备配置不存在,跳过该设备: deviceId={}", deviceId);
continue;
}
// 7. 检查是否启用优先打印
Boolean preferPrintEnable = deviceConfig.getBoolean("prefer_print_enable");
if (preferPrintEnable == null || !preferPrintEnable) {
log.debug("设备未启用优先打印,跳过: deviceId={}", deviceId);
continue;
}
// 8. 获取优先打印数量配置
Integer preferPrintCount = deviceConfig.getInteger("prefer_print_count");
if (preferPrintCount == null) {
log.debug("设备未配置优先打印数量,跳过: deviceId={}", deviceId);
continue;
}
// 9. 根据配置添加照片到打印列表
List<SourceEntity> sourcesToAdd;
if (preferPrintCount > 0) {
// 如果大于0,按照数量限制添加
sourcesToAdd = deviceSources.stream()
.limit(preferPrintCount)
.collect(Collectors.toList());
log.info("设备{}配置优先打印{}张,实际添加{}张",
deviceId, preferPrintCount, sourcesToAdd.size());
} else {
// 如果小于等于0,添加该设备的所有照片
sourcesToAdd = deviceSources;
log.info("设备{}配置优先打印所有照片,实际添加{}张",
deviceId, sourcesToAdd.size());
}
// 10. 批量添加到打印列表
for (SourceEntity source : sourcesToAdd) {
try {
printerService.addUserPhoto(memberId, scenicId, source.getUrl(), faceId);
totalAdded++;
} catch (Exception e) {
log.warn("添加照片到打印列表失败: sourceId={}, url={}, error={}",
source.getId(), source.getUrl(), e.getMessage());
}
}
}
if (totalAdded > 0) {
log.info("自动添加打印完成: faceId={}, 成功添加{}张照片", faceId, totalAdded);
} else {
log.debug("自动添加打印完成: faceId={}, 无符合条件的照片", faceId);
}
} catch (Exception e) {
// 出现异常则放弃,不影响主流程
log.error("自动添加打印失败,已忽略: faceId={}", faceId, e);
}
}
}

View File

@@ -2,6 +2,7 @@ package com.ycwl.basic.service.pc.impl;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.dto.MobileOrderRequest;
import com.ycwl.basic.model.pc.printer.entity.PrinterEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
import com.ycwl.basic.model.printer.resp.PrintTaskResp;
@@ -69,6 +70,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -139,6 +141,8 @@ public class OrderServiceImpl implements OrderService {
private ICouponService iCouponService;
@Autowired
private MemberRelationRepository memberRelationRepository;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public ApiResponse<PageInfo<OrderRespVO>> pageQuery(OrderReqQuery query) {
@@ -356,6 +360,12 @@ public class OrderServiceImpl implements OrderService {
if (printTaskResp != null) {
goods.setPrinterName(printTaskResp.getPrinterName());
goods.setPrinterPaper(printTaskResp.getPaper());
} else {
PrinterEntity printerEntity = printerMapper.getById(sourceEntity.getPrinterId());
if (printerEntity != null) {
goods.setPrinterName(printerEntity.getName());
goods.setPrinterPaper(printerEntity.getPreferPaper());
}
}
goodsList.add(goods);
}
@@ -513,23 +523,29 @@ public class OrderServiceImpl implements OrderService {
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
if (!memberVideoEntityList.isEmpty()) {
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
item.setCount(1);
}
} else if (Integer.valueOf(2).equals(item.getGoodsType())) {
List<SourceEntity> memberVideoEntityList = sourceMapper.listImageByFaceRelation(orderReqQuery.getMemberId(), item.getFaceId());
item.setCoverList(memberVideoEntityList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
if (!memberVideoEntityList.isEmpty()) {
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
item.setCount(1);
}
} else if (Integer.valueOf(0).equals(item.getGoodsType())) {
item.setCoverList(Collections.singletonList(item.getCoverUrl()));
VideoEntity video = videoRepository.getVideo(item.getGoodsId());
if (video != null) {
item.setShootingTime(videoTaskRepository.getTaskShotDate(video.getTaskId()));
item.setCount(1);
}
} else if (Integer.valueOf(3).equals(item.getGoodsType())) {
// 打印订单
List<MemberPrintResp> photo = printerMapper.getUserPhotoByIds(orderItemList.stream().map(OrderItemVO::getGoodsId).collect(Collectors.toList()));
item.setCoverList(photo.stream().map(MemberPrintResp::getCropUrl).collect(Collectors.toList()));
photo.stream().filter(p -> p.getId().equals(item.getGoodsId().intValue())).findAny().ifPresent(p -> {
item.setCount(p.getQuantity());
});
}
});
});
@@ -570,9 +586,14 @@ public class OrderServiceImpl implements OrderService {
if (orderAppRespVO != null && orderAppRespVO.getOrderItemList() != null && !orderAppRespVO.getOrderItemList().isEmpty()) {
orderAppRespVO.getOrderItemList().forEach(orderItem -> {
if (orderItem.getGoodsType() == 3) {
PrintTaskResp printTaskResp = printerMapper.queryTaskByMpId(Math.toIntExact(orderItem.getGoodsId()));
if (printTaskResp != null) {
orderItem.setPrinterPaper(printTaskResp.getPaper());
String size = redisTemplate.opsForValue().get("printer_size:" + id);
if (size != null) {
orderItem.setPrinterPaper(size);
} else {
PrintTaskResp printTaskResp = printerMapper.queryTaskByMpId(Math.toIntExact(orderItem.getGoodsId()));
if (printTaskResp != null) {
orderItem.setPrinterPaper(printTaskResp.getPaper());
}
}
}
});

View File

@@ -1,6 +1,8 @@
package com.ycwl.basic.service.printer;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.pc.printer.entity.PrintTaskEntity;
import com.ycwl.basic.model.pc.printer.entity.PrinterEntity;
import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp;
import com.ycwl.basic.model.pc.printer.resp.PrinterResp;
@@ -38,7 +40,7 @@ public interface PrinterService {
boolean deleteUserPhoto(Long memberId, Long scenicId, Long relationId);
Integer addUserPhoto(Long memberId, Long scenicId, String url, Long faceId);
Integer addUserPhoto(Long memberId, Long scenicId, String url, Long faceId, Long sourceId);
MemberPrintResp getUserPhoto(Long memberId, Long scenicId, Long id);
@@ -55,4 +57,37 @@ public interface PrinterService {
void batchSetUserPhotoListToPrinter(Long memberId, Long scenicId, Integer printerId);
void setUserIsBuyItem(Long memberId, Long id, Long orderId);
FaceRecognizeResp useSample(Long userId, Long sampleId);
void autoAddPhotosToPreferPrint(Long faceId);
/**
* 查询待审核的打印任务
* @param printerId 打印机ID(可选)
* @return 待审核任务列表
*/
List<PrintTaskEntity> getPendingReviewTasks(Integer printerId);
/**
* 更新待审核任务的URL
* @param taskId 任务ID
* @param url 新的打印URL
* @return 是否成功
*/
boolean updatePendingReviewTaskUrl(Integer taskId, String url);
/**
* 批准待审核任务,下发到打印队列
* @param taskIds 任务ID列表
* @return 成功数量
*/
int approvePrintTasks(List<Integer> taskIds);
/**
* 拒绝待审核任务
* @param taskIds 任务ID列表
* @return 成功数量
*/
int rejectPrintTasks(List<Integer> taskIds);
}

View File

@@ -10,20 +10,30 @@ import com.ycwl.basic.image.watermark.ImageWatermarkFactory;
import com.ycwl.basic.image.watermark.entity.WatermarkInfo;
import com.ycwl.basic.image.watermark.enums.ImageWatermarkOperatorEnum;
import com.ycwl.basic.image.watermark.operator.IOperator;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.mapper.FaceSampleMapper;
import com.ycwl.basic.mapper.MemberMapper;
import com.ycwl.basic.mapper.OrderMapper;
import com.ycwl.basic.mapper.PrintTaskMapper;
import com.ycwl.basic.mapper.PrinterMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.order.PriceObj;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
import com.ycwl.basic.model.pc.order.entity.OrderItemEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
import com.ycwl.basic.pricing.dto.ProductItem;
import com.ycwl.basic.pricing.enums.ProductType;
import com.ycwl.basic.pricing.service.IAutoCouponService;
import com.ycwl.basic.pricing.service.IPriceCalculationService;
import com.ycwl.basic.model.pc.printer.entity.MemberPrintEntity;
import com.ycwl.basic.model.pc.printer.entity.PrintTaskEntity;
@@ -36,8 +46,12 @@ import com.ycwl.basic.model.printer.req.PrinterSyncReq;
import com.ycwl.basic.model.printer.req.WorkerAuthReqVo;
import com.ycwl.basic.model.printer.resp.PrintTaskResp;
import com.ycwl.basic.model.wx.WXPayOrderReqVO;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.repository.OrderRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.mobile.WxPayService;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.printer.PrinterService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
@@ -46,6 +60,7 @@ import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.ImageUtils;
import com.ycwl.basic.utils.JacksonUtil;
import com.ycwl.basic.utils.SnowFlakeUtil;
import com.ycwl.basic.utils.WxMpUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
@@ -65,7 +80,11 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@@ -91,7 +110,27 @@ public class PrinterServiceImpl implements PrinterService {
@Autowired
private IPriceCalculationService priceCalculationService;
@Autowired
private IAutoCouponService autoCouponService;
@Autowired
private ScenicRepository scenicRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private FaceSampleMapper faceSampleMapper;
@Autowired
private FaceMapper faceMapper;
@Autowired
private FaceRepository faceRepository;
@Lazy
@Autowired
private FaceService faceService;
@Autowired
private DeviceRepository deviceRepository;
// 用于优先打印的线程池,核心线程数根据实际情况调整
private final ExecutorService preferPrintExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors() * 2
);
@Override
public List<PrinterResp> listByScenicId(Long scenicId) {
@@ -119,13 +158,14 @@ public class PrinterServiceImpl implements PrinterService {
}
@Override
public ApiResponse<Integer> update(PrinterEntity entity) {
public ApiResponse<Integer> update(PrinterEntity payload) {
PrinterEntity entity = printerMapper.getById(payload.getId());
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(entity.getScenicId());
Boolean printEnable = scenicConfig.getBoolean("print_enable");
if (!Boolean.TRUE.equals(printEnable)) {
return ApiResponse.fail("景区没有开启打印功能!");
}
return ApiResponse.success(printerMapper.update(entity));
return ApiResponse.success(printerMapper.update(payload));
}
@Override
@@ -214,10 +254,10 @@ public class PrinterServiceImpl implements PrinterService {
@Override
public List<MemberPrintResp> getUserPhotoList(Long userId, Long scenicId, Long faceId) {
if (faceId != null) {
if (faceId == null) {
List<MemberPrintResp> list = printerMapper.listRelation(userId, scenicId);
return list.stream()
.filter(item -> Objects.nonNull(item.getFaceId()))
.filter(item -> Objects.isNull(item.getFaceId()))
.collect(Collectors.toList());
}
List<MemberPrintResp> list = printerMapper.listRelationByFaceId(userId, scenicId, faceId);
@@ -236,11 +276,12 @@ public class PrinterServiceImpl implements PrinterService {
}
@Override
public Integer addUserPhoto(Long memberId, Long scenicId, String url, Long faceId) {
public Integer addUserPhoto(Long memberId, Long scenicId, String url, Long faceId, Long sourceId) {
MemberPrintEntity entity = new MemberPrintEntity();
entity.setMemberId(memberId);
entity.setScenicId(scenicId);
entity.setFaceId(faceId);
entity.setSourceId(sourceId);
entity.setOrigUrl(url);
// 获取打印尺寸
@@ -317,37 +358,105 @@ public class PrinterServiceImpl implements PrinterService {
@Override
public PriceObj queryPrice(Long memberId, Long scenicId, Long faceId) {
List<MemberPrintResp> userPhotoList = getUserPhotoList(memberId, scenicId, faceId);
// 计算照片总数量
long count = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity()))
.mapToInt(MemberPrintResp::getQuantity)
.sum();
PriceObj obj = new PriceObj();
obj.setScenicId(scenicId);
obj.setGoodsId(faceId);
obj.setFaceId(faceId);
obj.setGoodsType(3);
if (count == 0) {
// 按照 sourceId 分类照片
// sourceId > 0: 普通照片打印 (PHOTO_PRINT)
// sourceId == null: 手机照片打印 (PHOTO_PRINT_MU)
// sourceId == 0: 特效照片打印 (PHOTO_PRINT_FX)
long normalCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() > 0)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long mobileCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() == null)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long effectCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() == 0)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long totalCount = normalCount + mobileCount + effectCount;
if (totalCount == 0) {
// 如果没有照片,返回零价格
obj.setPrice(BigDecimal.ZERO);
obj.setSlashPrice(BigDecimal.ZERO);
obj.setFree(false);
return obj;
}
// 构建价格计算请求
PriceCalculationRequest request = new PriceCalculationRequest();
request.setUserId(memberId);
// 创建照片打印商品项
ProductItem photoItem = new ProductItem();
photoItem.setProductType(ProductType.PHOTO_PRINT);
photoItem.setProductId(scenicId.toString());
photoItem.setQuantity(Long.valueOf(count).intValue());
photoItem.setPurchaseCount(1);
photoItem.setScenicId(scenicId.toString());
// 创建商品项列表
List<ProductItem> productItems = new ArrayList<>();
request.setProducts(Collections.singletonList(photoItem));
// 添加普通照片打印商品项 (sourceId > 0)
if (normalCount > 0) {
ProductItem normalPhotoItem = new ProductItem();
normalPhotoItem.setProductType(ProductType.PHOTO_PRINT);
normalPhotoItem.setProductId(scenicId.toString());
normalPhotoItem.setQuantity(Long.valueOf(normalCount).intValue());
normalPhotoItem.setPurchaseCount(1);
normalPhotoItem.setScenicId(scenicId.toString());
productItems.add(normalPhotoItem);
log.debug("普通照片打印数量: {}", normalCount);
}
// 添加手机照片打印商品项 (sourceId == null)
if (mobileCount > 0) {
ProductItem mobilePhotoItem = new ProductItem();
mobilePhotoItem.setProductType(ProductType.PHOTO_PRINT_MU);
mobilePhotoItem.setProductId(scenicId.toString());
mobilePhotoItem.setQuantity(Long.valueOf(mobileCount).intValue());
mobilePhotoItem.setPurchaseCount(1);
mobilePhotoItem.setScenicId(scenicId.toString());
productItems.add(mobilePhotoItem);
log.debug("手机照片打印数量: {}", mobileCount);
}
// 添加特效照片打印商品项 (sourceId == 0)
if (effectCount > 0) {
ProductItem effectPhotoItem = new ProductItem();
effectPhotoItem.setProductType(ProductType.PHOTO_PRINT_FX);
effectPhotoItem.setProductId(scenicId.toString());
effectPhotoItem.setQuantity(Long.valueOf(effectCount).intValue());
effectPhotoItem.setPurchaseCount(1);
effectPhotoItem.setScenicId(scenicId.toString());
productItems.add(effectPhotoItem);
log.debug("特效照片打印数量: {}", effectCount);
}
request.setProducts(productItems);
if (mobileCount > 0) {
try {
autoCouponService.autoGrantCoupon(
memberId,
faceId,
scenicId,
ProductType.PHOTO_PRINT_MU
);
} catch (Exception e) {
log.warn("自动发券失败,不影响下单流程: memberId={}, faceId={}, scenicId={}, error={}",
memberId, faceId, scenicId, e.getMessage());
}
}
request.setAutoUseCoupon(true);
request.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
// 使用统一价格计算服务
PriceCalculationResult result = priceCalculationService.calculatePrice(request);
@@ -369,12 +478,66 @@ public class PrinterServiceImpl implements PrinterService {
resultIds.add(null);
return;
}
String url = byId.getUrl();
MemberPrintEntity entity = new MemberPrintEntity();
entity.setMemberId(memberId);
entity.setScenicId(scenicId);
entity.setFaceId(faceId);
entity.setOrigUrl(byId.getUrl());
entity.setCropUrl(byId.getUrl());
entity.setSourceId(id);
entity.setOrigUrl(url);
// 获取打印尺寸并裁剪图片
String cropUrl = url; // 默认使用原图
try {
// 从打印机表获取尺寸
Integer printWidth = null;
Integer printHeight = null;
List<PrinterResp> printers = printerMapper.listByScenicId(scenicId);
if (printers != null && !printers.isEmpty()) {
PrinterResp firstPrinter = printers.get(0);
printWidth = firstPrinter.getPreferW();
printHeight = firstPrinter.getPreferH();
log.debug("从打印机获取尺寸: scenicId={}, printerId={}, width={}, height={}",
scenicId, firstPrinter.getId(), printWidth, printHeight);
}
// 如果打印机没有配置或配置无效,使用默认值
if (printWidth == null || printWidth <= 0) {
printWidth = 1020;
log.debug("打印机宽度未配置或无效,使用默认值: width={}", printWidth);
}
if (printHeight == null || printHeight <= 0) {
printHeight = 1520;
log.debug("打印机高度未配置或无效,使用默认值: height={}", printHeight);
}
// 使用smartCropAndFill裁剪图片
File croppedFile = ImageUtils.smartCropAndFill(url, printWidth, printHeight);
try {
// 上传裁剪后的图片
String[] split = url.split("\\.");
String ext = split.length > 0 ? split[split.length - 1] : "jpg";
cropUrl = StorageFactory.use().uploadFile(null, croppedFile, "printer", UUID.randomUUID() + "." + ext);
log.info("照片裁剪成功: memberId={}, scenicId={}, sourceId={}, 原图={}, 裁剪后={}, 尺寸={}x{}",
memberId, scenicId, id, url, cropUrl, printWidth, printHeight);
} finally {
// 清理临时文件
if (croppedFile != null && croppedFile.exists()) {
croppedFile.delete();
}
}
} catch (Exception e) {
log.error("照片裁剪失败,使用原图: memberId={}, scenicId={}, sourceId={}, url={}", memberId, scenicId, id, url, e);
// 出现异常则使用原图
cropUrl = url;
}
entity.setCropUrl(cropUrl);
entity.setStatus(0);
try {
@@ -394,34 +557,58 @@ public class PrinterServiceImpl implements PrinterService {
@Override
public Map<String, Object> createOrder(Long memberId, Long scenicId, Integer printerId, Long faceId) {
PrinterEntity printer = null;
if (printerId == null) {
List<PrinterResp> printerList = printerMapper.listByScenicId(scenicId);
if (printerList.size() != 1) {
throw new BaseException("请选择打印机");
} else {
printerId = printerList.getFirst().getId();
}
} else {
PrinterEntity printer = printerMapper.getById(printerId);
if (printer == null) {
throw new BaseException("打印机不存在");
}
if (printer.getStatus() != 1) {
throw new BaseException("打印机已停用");
}
if (!printer.getScenicId().equals(scenicId)) {
throw new BaseException("打印机不属于该景区");
}
printerId = printerList.getFirst().getId();
}
printer = printerMapper.getById(printerId);
if (printer == null) {
throw new BaseException("打印机不存在");
}
if (printer.getStatus() != 1) {
throw new BaseException("打印机已停用");
}
if (!printer.getScenicId().equals(scenicId)) {
throw new BaseException("打印机不属于该景区");
}
// 验证照片数量
List<MemberPrintResp> userPhotoList = getUserPhotoList(memberId, scenicId, faceId);
long count = userPhotoList.stream().filter(item -> Objects.nonNull(item.getQuantity())).mapToInt(MemberPrintResp::getQuantity).sum();
if (count == 0) {
// 按照 sourceId 分类照片
// sourceId > 0: 普通照片打印 (PHOTO_PRINT)
// sourceId == null: 手机照片打印 (PHOTO_PRINT_MU)
// sourceId == 0: 特效照片打印 (PHOTO_PRINT_FX)
long normalCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() > 0)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long mobileCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() == null)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long effectCount = userPhotoList.stream()
.filter(item -> Objects.nonNull(item.getQuantity())
&& item.getSourceId() != null && item.getSourceId() == 0)
.mapToInt(MemberPrintResp::getQuantity)
.sum();
long totalCount = normalCount + mobileCount + effectCount;
if (totalCount == 0) {
throw new BaseException("没有可打印的照片");
}
OrderEntity order = new OrderEntity();
Long orderId = SnowFlakeUtil.getLongId();
redisTemplate.opsForValue().set("printer_size:"+orderId, printer.getPreferPaper(), 60, TimeUnit.SECONDS);
order.setId(orderId);
order.setMemberId(memberId);
order.setFaceId(faceId);
@@ -445,15 +632,49 @@ public class PrinterServiceImpl implements PrinterService {
request.setUserId(memberId);
request.setScenicId(scenicId);
// 创建照片打印商品项
ProductItem photoItem = new ProductItem();
photoItem.setProductType(ProductType.PHOTO_PRINT);
photoItem.setProductId(scenicId.toString());
photoItem.setQuantity(Long.valueOf(count).intValue());
photoItem.setPurchaseCount(1);
photoItem.setScenicId(scenicId.toString());
// 创建商品项列表
List<ProductItem> productItems = new ArrayList<>();
request.setProducts(Collections.singletonList(photoItem));
// 添加普通照片打印商品项 (sourceId > 0)
if (normalCount > 0) {
ProductItem normalPhotoItem = new ProductItem();
normalPhotoItem.setProductType(ProductType.PHOTO_PRINT);
normalPhotoItem.setProductId(scenicId.toString());
normalPhotoItem.setQuantity(Long.valueOf(normalCount).intValue());
normalPhotoItem.setPurchaseCount(1);
normalPhotoItem.setScenicId(scenicId.toString());
productItems.add(normalPhotoItem);
log.debug("创建订单-普通照片打印数量: {}", normalCount);
}
// 添加手机照片打印商品项 (sourceId == null)
if (mobileCount > 0) {
ProductItem mobilePhotoItem = new ProductItem();
mobilePhotoItem.setProductType(ProductType.PHOTO_PRINT_MU);
mobilePhotoItem.setProductId(scenicId.toString());
mobilePhotoItem.setQuantity(Long.valueOf(mobileCount).intValue());
mobilePhotoItem.setPurchaseCount(1);
mobilePhotoItem.setScenicId(scenicId.toString());
productItems.add(mobilePhotoItem);
log.debug("创建订单-手机照片打印数量: {}", mobileCount);
}
// 添加特效照片打印商品项 (sourceId == 0)
if (effectCount > 0) {
ProductItem effectPhotoItem = new ProductItem();
effectPhotoItem.setProductType(ProductType.PHOTO_PRINT_FX);
effectPhotoItem.setProductId(scenicId.toString());
effectPhotoItem.setQuantity(Long.valueOf(effectCount).intValue());
effectPhotoItem.setPurchaseCount(1);
effectPhotoItem.setScenicId(scenicId.toString());
productItems.add(effectPhotoItem);
log.debug("创建订单-特效照片打印数量: {}", effectCount);
}
request.setProducts(productItems);
request.setAutoUseCoupon(true);
request.setPreviewOnly(false);
request.setOrderId(String.valueOf(orderId)); // 设置订单ID
PriceCalculationResult priceResult = priceCalculationService.calculatePrice(request);
@@ -474,7 +695,7 @@ public class PrinterServiceImpl implements PrinterService {
throw new BaseException("订单添加失败");
}
Map<String, Object> data = new HashMap<>();
if (order.getPayPrice().equals(BigDecimal.ZERO)) {
if (order.getPayPrice().compareTo(BigDecimal.ZERO) == 0) {
orderBiz.paidOrder(order.getId());
data.put("needPay", false);
} else {
@@ -490,8 +711,8 @@ public class PrinterServiceImpl implements PrinterService {
} catch (Exception e) {
throw new BaseException(e);
}
data.put("orderId", orderId);
}
data.put("orderId", orderId);
return data;
}
@@ -507,6 +728,7 @@ public class PrinterServiceImpl implements PrinterService {
private static final int PRINTER_INDEX_EXPIRE_MINUTES = 5;
private static final int TASK_STATUS_PENDING = 0;
private static final int TASK_STATUS_PROCESSING = 3;
private static final int TASK_STATUS_PENDING_REVIEW = 4; // 待审核
private final Lock syncTaskLock = new ReentrantLock();
@Override
@@ -515,109 +737,125 @@ public class PrinterServiceImpl implements PrinterService {
return;
}
redisTemplate.opsForValue().set(USER_PHOTO_LIST_TO_PRINTER + memberId + ":" + orderId, "1", 60, TimeUnit.SECONDS);
printerMapper.setUserIsBuyItem(memberId, id, orderId);
OrderEntity order = orderRepository.getOrder(orderId);
List<OrderItemEntity> orderItems = orderMapper.getOrderItems(orderId);
orderItems.forEach(item -> {
printerMapper.setUserIsBuyItem(memberId, item.getGoodsId(), orderId);
});
// 创建打印任务
List<MemberPrintResp> userPhotoListByOrderId = getUserPhotoListByOrderId(orderId);
File qrCodeFile = new File("qrCodeFile" + orderId + ".png");
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(order.getScenicId());
try {
WxMpUtil.generateWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), scenicMpConfig.getState(), "pages/my/orderDetailNew?force=1&id="+orderId, "qrCodeFile" + orderId + ".png");
} catch (Exception e) {
throw new RuntimeException(e);
}
userPhotoListByOrderId.forEach(item -> {
PrinterEntity printer = printerMapper.getById(item.getPrinterId());
// 水印处理逻辑
// 水印处理逻辑(仅当sourceId不为空时执行)
String printUrl = item.getCropUrl();
try {
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
String printWatermarkType = scenicConfig.getString("print_watermark_type");
if (item.getSourceId() != null) {
try {
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
String printWatermarkType = scenicConfig.getString("print_watermark_type");
if (StringUtils.isNotBlank(printWatermarkType)) {
ImageWatermarkOperatorEnum watermarkType = ImageWatermarkOperatorEnum.getByCode(printWatermarkType);
if (watermarkType != null) {
// 准备存储适配器
IStorageAdapter adapter;
String storeType = scenicConfig.getString("store_type");
if (storeType != null) {
adapter = StorageFactory.get(storeType);
String storeConfigJson = scenicConfig.getString("store_config_json");
if (StringUtils.isNotBlank(storeConfigJson)) {
adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class));
}
} else {
adapter = StorageFactory.use("assets-ext");
}
// 准备水印处理器
IOperator operator = ImageWatermarkFactory.get(watermarkType);
// 生成唯一的处理标识符,避免多线程环境下的文件冲突
String processId = item.getId() + "_" + UUID.randomUUID().toString();
// 下载原图
File originalFile = new File("print_" + processId + ".jpg");
File watermarkedFile = new File("print_" + processId + "_" + watermarkType.getType() + "." + watermarkType.getPreferFileType());
File rotatedOriginalFile = null;
File rotatedWatermarkedFile = null;
boolean needRotation = false;
try {
HttpUtil.downloadFile(item.getCropUrl().replace("oss.zhentuai.com", "frametour-assets.oss-cn-shanghai-internal.aliyuncs.com"), originalFile);
// 判断图片方向并处理旋转
boolean isLandscape = ImageUtils.isLandscape(originalFile);
log.info("打印照片方向检测,照片ID: {}, 是否为横图: {}", item.getId(), isLandscape);
if (!isLandscape) {
// 竖图需要旋转为横图
needRotation = true;
rotatedOriginalFile = new File("print_" + processId + "_rotated.jpg");
ImageUtils.rotateImage90(originalFile, rotatedOriginalFile);
log.info("竖图已旋转为横图,照片ID: {}", item.getId());
if (StringUtils.isNotBlank(printWatermarkType)) {
ImageWatermarkOperatorEnum watermarkType = ImageWatermarkOperatorEnum.getByCode(printWatermarkType);
if (watermarkType != null) {
// 准备存储适配器
IStorageAdapter adapter;
String storeType = scenicConfig.getString("store_type");
if (storeType != null) {
adapter = StorageFactory.get(storeType);
String storeConfigJson = scenicConfig.getString("store_config_json");
if (StringUtils.isNotBlank(storeConfigJson)) {
adapter.loadConfig(JacksonUtil.parseObject(storeConfigJson, Map.class));
}
} else {
adapter = StorageFactory.use("assets-ext");
}
// 处理水印
WatermarkInfo watermarkInfo = new WatermarkInfo();
watermarkInfo.setOriginalFile(needRotation ? rotatedOriginalFile : originalFile);
watermarkInfo.setWatermarkedFile(watermarkedFile);
// 准备水印处理器
IOperator operator = ImageWatermarkFactory.get(watermarkType);
operator.process(watermarkInfo);
// 生成唯一的处理标识符,避免多线程环境下的文件冲突
String processId = item.getId() + "_" + UUID.randomUUID().toString();
// 下载原图
File originalFile = new File("print_" + processId + ".jpg");
File watermarkedFile = new File("print_" + processId + "_" + watermarkType.getType() + "." + watermarkType.getPreferFileType());
File rotatedOriginalFile = null;
File rotatedWatermarkedFile = null;
boolean needRotation = false;
// 如果之前旋转了,需要将水印图片旋转回去
if (needRotation) {
rotatedWatermarkedFile = new File("print_" + processId + "_final_" + watermarkType.getType() + "." + watermarkType.getPreferFileType());
ImageUtils.rotateImage270(watermarkedFile, rotatedWatermarkedFile);
log.info("水印图片已旋转回竖图,照片ID: {}", item.getId());
// 删除中间的横图水印文件
if (watermarkedFile.exists()) {
try {
HttpUtil.downloadFile(item.getCropUrl().replace("oss.zhentuai.com", "frametour-assets.oss-cn-shanghai-internal.aliyuncs.com"), originalFile);
// 判断图片方向并处理旋转
boolean isLandscape = ImageUtils.isLandscape(originalFile);
log.info("打印照片方向检测,照片ID: {}, 是否为横图: {}", item.getId(), isLandscape);
if (!isLandscape) {
// 竖图需要旋转为横图
needRotation = true;
rotatedOriginalFile = new File("print_" + processId + "_rotated.jpg");
ImageUtils.rotateImage90(originalFile, rotatedOriginalFile);
log.info("竖图已旋转为横图,照片ID: {}", item.getId());
}
// 处理水印
WatermarkInfo watermarkInfo = new WatermarkInfo();
watermarkInfo.setScenicLine(scenicConfig.getString("print_watermark_scenic_text", ""));
watermarkInfo.setOriginalFile(needRotation ? rotatedOriginalFile : originalFile);
watermarkInfo.setWatermarkedFile(watermarkedFile);
watermarkInfo.setQrcodeFile(qrCodeFile);
watermarkInfo.setDatetime(new Date());
watermarkInfo.setDtFormat(scenicConfig.getString("print_watermark_dt_format", "yyyy.MM.dd"));
operator.process(watermarkInfo);
// 如果之前旋转了,需要将水印图片旋转回去
if (needRotation) {
rotatedWatermarkedFile = new File("print_" + processId + "_final_" + watermarkType.getType() + "." + watermarkType.getPreferFileType());
ImageUtils.rotateImage270(watermarkedFile, rotatedWatermarkedFile);
log.info("水印图片已旋转回竖图,照片ID: {}", item.getId());
// 删除中间的横图水印文件
if (watermarkedFile.exists()) {
watermarkedFile.delete();
}
// 将最终的竖图水印文件赋值给watermarkedFile
watermarkedFile = rotatedWatermarkedFile;
}
// 上传水印图片
String watermarkedUrl = adapter.uploadFile(null, watermarkedFile, StorageConstant.PHOTO_WATERMARKED_PATH, watermarkedFile.getName());
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH, watermarkedFile.getName());
printUrl = watermarkedUrl;
log.info("水印处理成功,打印照片ID: {}, 水印URL: {}", item.getId(), watermarkedUrl);
} catch (Exception e) {
log.error("水印处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
} finally {
// 清理临时文件
if (originalFile != null && originalFile.exists()) {
originalFile.delete();
}
if (rotatedOriginalFile != null && rotatedOriginalFile.exists()) {
rotatedOriginalFile.delete();
}
if (watermarkedFile != null && watermarkedFile.exists()) {
watermarkedFile.delete();
}
// 将最终的竖图水印文件赋值给watermarkedFile
watermarkedFile = rotatedWatermarkedFile;
}
// 上传水印图片
String watermarkedUrl = adapter.uploadFile(null, watermarkedFile, StorageConstant.PHOTO_WATERMARKED_PATH, watermarkedFile.getName());
adapter.setAcl(StorageAcl.PUBLIC_READ, StorageConstant.PHOTO_WATERMARKED_PATH, watermarkedFile.getName());
printUrl = watermarkedUrl;
log.info("水印处理成功,打印照片ID: {}, 水印URL: {}", item.getId(), watermarkedUrl);
} catch (Exception e) {
log.error("水印处理失败,使用原始照片进行打印。照片ID: {}", item.getId(), e);
} finally {
// 清理临时文件
if (originalFile != null && originalFile.exists()) {
originalFile.delete();
}
if (rotatedOriginalFile != null && rotatedOriginalFile.exists()) {
rotatedOriginalFile.delete();
}
if (watermarkedFile != null && watermarkedFile.exists()) {
watermarkedFile.delete();
}
if (rotatedWatermarkedFile != null && rotatedWatermarkedFile.exists()) {
rotatedWatermarkedFile.delete();
if (rotatedWatermarkedFile != null && rotatedWatermarkedFile.exists()) {
rotatedWatermarkedFile.delete();
}
}
}
}
} catch (Exception e) {
log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e);
}
} catch (Exception e) {
log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e);
}
// 根据数量创建多个打印任务
@@ -629,12 +867,20 @@ public class PrinterServiceImpl implements PrinterService {
for (int i = 0; i < quantity; i++) {
// 获取打印机名称(支持轮询)
String selectedPrinter = getNextPrinter(printer);
// 根据景区配置决定任务初始状态
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(order.getScenicId());
Boolean purchaseNeedReview = scenicConfig.getBoolean("printer_manual_approve");
int initialStatus = (purchaseNeedReview != null && purchaseNeedReview)
? TASK_STATUS_PENDING_REVIEW
: TASK_STATUS_PENDING;
PrintTaskEntity task = new PrintTaskEntity();
task.setPrinterId(printer.getId());
task.setPrinterName(selectedPrinter);
task.setMpId(item.getId());
task.setPaper(printer.getPreferPaper());
task.setStatus(0);
task.setStatus(initialStatus);
task.setUrl(printUrl);
task.setHeight(printer.getPreferH());
task.setWidth(printer.getPreferW());
@@ -645,6 +891,270 @@ public class PrinterServiceImpl implements PrinterService {
});
}
/**
* 查询待审核的打印任务
* @param printerId 打印机ID(可选)
* @return 待审核任务列表
*/
@Override
public List<PrintTaskEntity> getPendingReviewTasks(Integer printerId) {
return printTaskMapper.queryPendingReviewTasks(printerId);
}
/**
* 更新待审核任务的URL(可用于重新添加水印等操作)
* @param taskId 任务ID
* @param url 新的打印URL
* @return 是否成功
*/
@Override
public boolean updatePendingReviewTaskUrl(Integer taskId, String url) {
if (taskId == null || url == null || url.trim().isEmpty()) {
return false;
}
int rows = printTaskMapper.updateTaskUrl(taskId, url);
return rows > 0;
}
/**
* 批准待审核任务,下发到打印队列
* @param taskIds 任务ID列表
* @return 成功数量
*/
@Override
public int approvePrintTasks(List<Integer> taskIds) {
if (taskIds == null || taskIds.isEmpty()) {
return 0;
}
// 将状态从4(待审核)改为0(待处理)
return printTaskMapper.batchUpdateStatus(taskIds, TASK_STATUS_PENDING);
}
/**
* 拒绝待审核任务(取消打印)
* @param taskIds 任务ID列表
* @return 成功数量
*/
@Override
public int rejectPrintTasks(List<Integer> taskIds) {
if (taskIds == null || taskIds.isEmpty()) {
return 0;
}
// 将状态从4(待审核)改为5(已取消)
return printTaskMapper.batchUpdateStatus(taskIds, 5);
}
@Override
public FaceRecognizeResp useSample(Long userId, Long sampleId) {
// 1. 查询 faceSample 获取其 URL
FaceSampleEntity faceSample = faceSampleMapper.getEntity(sampleId);
SourceEntity sourceEntity = sourceMapper.getBySampleIdAndType(sampleId, 2);
if (faceSample == null) {
throw new BaseException("人脸样本不存在");
}
String faceUrl = faceSample.getFaceUrl();
if (StringUtils.isBlank(faceUrl)) {
throw new BaseException("人脸样本URL为空");
}
Long scenicId = faceSample.getScenicId();
// 2. 检查face数据库中有没有同用户、同URL的face记录
FaceEntity existingFace = null;
Long faceId = null;
// 查询该用户在该景区的所有人脸记录
List<FaceRespVO> userFaces = faceMapper.listByScenicAndUserId(scenicId.toString(), userId);
// 查找是否存在相同URL的记录
for (FaceRespVO faceResp : userFaces) {
if (faceUrl.equals(faceResp.getFaceUrl())) {
existingFace = faceMapper.get(faceResp.getId());
faceId = existingFace.getId();
break;
}
}
// 3. 如果不存在,则新建一个face记录
if (existingFace == null) {
faceId = SnowFlakeUtil.getLongId();
FaceEntity newFace = new FaceEntity();
newFace.setId(faceId);
newFace.setCreateAt(new Date());
newFace.setScenicId(scenicId);
newFace.setMemberId(userId);
newFace.setFaceUrl(faceUrl);
faceMapper.add(newFace);
log.info("创建新的face记录, userId: {}, sampleId: {}, faceId: {}, faceUrl: {}",
userId, sampleId, faceId, faceUrl);
} else {
log.info("使用已存在的face记录, userId: {}, sampleId: {}, faceId: {}, faceUrl: {}",
userId, sampleId, faceId, faceUrl);
}
// 4. 返回结果
FaceRecognizeResp resp = new FaceRecognizeResp();
resp.setUrl(faceUrl);
resp.setFaceId(faceId);
resp.setScenicId(scenicId);
try {
faceService.matchFaceId(faceId);
if (existingFace == null) {
autoAddPhotosToPreferPrint(faceId);
}
} catch (Exception e) {
// 人脸匹配失败不可以阻止正常流程
log.error("人脸匹配失败", e);
}
if (sourceEntity != null && existingFace == null) {
List<MemberPrintResp> userPhotoList = getUserPhotoList(userId, scenicId, faceId);
boolean noneMatch = userPhotoList.stream()
.noneMatch(item -> Strings.CI.equals(item.getOrigUrl(), sourceEntity.getUrl()));
if (noneMatch) {
addUserPhoto(userId, scenicId, sourceEntity.getUrl(), faceId, sourceEntity.getId());
}
}
return resp;
}
/**
* 自动将人脸关联的照片添加到优先打印列表
* 根据景区和设备配置自动添加type=2的照片到用户打印列表
*
* @param faceId 人脸ID
*/
@Override
public void autoAddPhotosToPreferPrint(Long faceId) {
try {
// 1. 获取人脸信息
FaceEntity face = faceRepository.getFace(faceId);
if (face == null) {
log.warn("人脸不存在,无法自动添加打印: faceId={}", faceId);
return;
}
Long scenicId = face.getScenicId();
Long memberId = face.getMemberId();
// 2. 获取景区配置
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
if (scenicConfig == null) {
log.warn("景区配置不存在,跳过自动添加打印: scenicId={}", scenicId);
return;
}
// 3. 检查景区是否启用打印功能
Boolean printEnable = scenicConfig.getBoolean("print_enable");
if (printEnable == null || !printEnable) {
log.debug("景区未启用打印功能,跳过自动添加: scenicId={}", scenicId);
return;
}
// 4. 查询该faceId关联的所有type=2的照片
List<SourceEntity> imageSources = sourceMapper.listImageSourcesByFaceId(faceId);
if (imageSources == null || imageSources.isEmpty()) {
log.debug("该人脸没有关联的照片,跳过自动添加: faceId={}", faceId);
return;
}
// 5. 按照deviceId分组处理
Map<Long, List<SourceEntity>> sourcesByDevice = imageSources.stream()
.filter(source -> source.getDeviceId() != null)
.collect(Collectors.groupingBy(SourceEntity::getDeviceId));
// 使用原子计数器统计成功添加的数量
AtomicInteger totalAdded = new AtomicInteger(0);
// 创建异步任务列表
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (Map.Entry<Long, List<SourceEntity>> entry : sourcesByDevice.entrySet()) {
Long deviceId = entry.getKey();
List<SourceEntity> deviceSources = entry.getValue();
// 为每个设备创建一个异步任务
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
try {
// 6. 获取设备配置
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
if (deviceConfig == null) {
log.debug("设备配置不存在,跳过该设备: deviceId={}", deviceId);
return;
}
// 7. 检查是否启用优先打印
Boolean preferPrintEnable = deviceConfig.getBoolean("prefer_print_enable");
if (preferPrintEnable == null || !preferPrintEnable) {
log.debug("设备未启用优先打印,跳过: deviceId={}", deviceId);
return;
}
// 8. 获取优先打印数量配置
Integer preferPrintCount = deviceConfig.getInteger("prefer_print_count");
if (preferPrintCount == null) {
log.debug("设备未配置优先打印数量,跳过: deviceId={}", deviceId);
return;
}
// 9. 根据配置添加照片到打印列表
List<SourceEntity> sourcesToAdd;
if (preferPrintCount > 0) {
// 如果大于0,按照数量限制添加
sourcesToAdd = deviceSources.stream()
.limit(preferPrintCount)
.collect(Collectors.toList());
log.info("设备{}配置优先打印{}张,实际添加{}张",
deviceId, preferPrintCount, sourcesToAdd.size());
} else {
// 如果小于等于0,添加该设备的所有照片
sourcesToAdd = deviceSources;
log.info("设备{}配置优先打印所有照片,实际添加{}张",
deviceId, sourcesToAdd.size());
}
// 10. 批量添加到打印列表
int deviceAdded = 0;
for (SourceEntity source : sourcesToAdd) {
try {
addUserPhoto(memberId, scenicId, source.getUrl(), faceId, source.getId());
deviceAdded++;
} catch (Exception e) {
log.warn("添加照片到打印列表失败: sourceId={}, url={}, error={}",
source.getId(), source.getUrl(), e.getMessage());
}
}
// 累加成功添加的数量
totalAdded.addAndGet(deviceAdded);
} catch (Exception e) {
log.error("处理设备{}的照片添加任务失败: deviceId={}", deviceId, deviceId, e);
}
}, preferPrintExecutor);
futures.add(future);
}
// 等待所有任务完成
try {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
} catch (Exception e) {
log.error("等待照片添加任务完成时发生异常: faceId={}", faceId, e);
}
if (totalAdded.get() > 0) {
log.info("自动添加打印完成: faceId={}, 成功添加{}张照片", faceId, totalAdded.get());
} else {
log.debug("自动添加打印完成: faceId={}, 无符合条件的照片", faceId);
}
} catch (Exception e) {
// 出现异常则放弃,不影响主流程
log.error("自动添加打印失败,已忽略: faceId={}", faceId, e);
}
}
/**
* 获取下一个要使用的打印机名称(轮询逻辑)
*

View File

@@ -263,7 +263,7 @@ public class ImageUtils {
String urlStr = (String) imageSource;
if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
// 从URL加载
java.net.URL url = new java.net.URL(urlStr);
java.net.URL url = new java.net.URL(urlStr.replace("oss.zhentuai.com", "frametour-assets.oss-cn-shanghai-internal.aliyuncs.com"));
return ImageIO.read(url);
} else {
// 作为文件路径处理
@@ -287,10 +287,9 @@ public class ImageUtils {
best.rotationDegrees = 0;
best.pixelsLost = Integer.MAX_VALUE;
// 测试种情况: 不旋转、旋转90度、旋转270度
// 测试种情况: 不旋转、旋转270度
int[][] scenarios = {
{0, srcWidth, srcHeight}, // 不旋转
{90, srcHeight, srcWidth}, // 旋转90度
{270, srcHeight, srcWidth} // 旋转270度
};
@@ -331,34 +330,27 @@ public class ImageUtils {
return source;
}
if (degrees != 270) {
throw new IllegalArgumentException("仅支持270度旋转");
}
int width = source.getWidth();
int height = source.getHeight();
// 90度和270度会交换宽高
BufferedImage rotated;
// 270度会交换宽高
BufferedImage rotated = new BufferedImage(height, width, source.getType());
Graphics2D g2d = null;
try {
if (degrees == 90 || degrees == 270) {
rotated = new BufferedImage(height, width, source.getType());
g2d = rotated.createGraphics();
g2d = rotated.createGraphics();
AffineTransform transform = new AffineTransform();
if (degrees == 90) {
transform.translate(height / 2.0, width / 2.0);
transform.rotate(Math.PI / 2);
transform.translate(-width / 2.0, -height / 2.0);
} else { // 270度
transform.translate(height / 2.0, width / 2.0);
transform.rotate(-Math.PI / 2);
transform.translate(-width / 2.0, -height / 2.0);
}
AffineTransform transform = new AffineTransform();
transform.translate(height / 2.0, width / 2.0);
transform.rotate(-Math.PI / 2);
transform.translate(-width / 2.0, -height / 2.0);
g2d.setTransform(transform);
g2d.drawImage(source, 0, 0, null);
} else {
throw new IllegalArgumentException("仅支持90度和270度旋转");
}
g2d.setTransform(transform);
g2d.drawImage(source, 0, 0, null);
return rotated;
} finally {

Binary file not shown.

View File

@@ -24,4 +24,41 @@
set status = #{status}, update_time = NOW()
where id = #{id}
</update>
<update id="updateStatusAndPrinter">
update print_task
<set>
status = #{status},
<if test="printerName != null and printerName != ''">
printer_name = #{printerName},
</if>
update_time = NOW()
</set>
where id = #{id}
</update>
<select id="queryPendingReviewTasks" resultType="com.ycwl.basic.model.pc.printer.entity.PrintTaskEntity">
select id, printer_id, status, printer_name, url, width, height, mp_id, paper, create_time, update_time
from print_task
where status = 4
<if test="printerId != null">
and printer_id = #{printerId}
</if>
order by create_time desc
</select>
<update id="updateTaskUrl">
update print_task
set url = #{url}, update_time = NOW()
where id = #{id} and status = 4
</update>
<update id="batchUpdateStatus">
update print_task
set status = #{status}, update_time = NOW()
where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</update>
</mapper>

View File

@@ -102,6 +102,7 @@
member_id,
scenic_id,
face_id,
source_id,
orig_url,
crop_url,
quantity,
@@ -112,6 +113,7 @@
#{memberId},
#{scenicId},
#{faceId},
#{sourceId},
#{origUrl},
#{cropUrl},
1,

View File

@@ -354,4 +354,11 @@
inner join member_source ms on s.id = ms.source_id
where ms.face_id = #{faceId} and s.type = 2
</select>
<select id="getBySampleIdAndType" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
select *
from source
where face_sample_id = #{faceSampleId} and type = #{type}
order by create_time desc
limit 1
</select>
</mapper>