You've already forked FrameTour-BE
Compare commits
48 Commits
fa0c3a1a43
...
1b9bebf7e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b9bebf7e4 | |||
| fa287f36ae | |||
| 0c3ada97f9 | |||
| a7a7e30364 | |||
| 17a33ada9f | |||
| 8efd16ba56 | |||
| 7c906d5529 | |||
| 88ad6d6b6f | |||
| 7835283f0f | |||
| 9ee466bd5e | |||
| 1a25848102 | |||
| c319398c58 | |||
| afc589bb39 | |||
| 4ccb563557 | |||
| 11face7935 | |||
| 515f68a6f4 | |||
| 3f396b4cb8 | |||
| a1b0687526 | |||
| 932081abf0 | |||
| 6462037dcd | |||
| 9b9e69cf52 | |||
| 6246d6ef46 | |||
| 19fae4bd00 | |||
| 661aa4567f | |||
| ec34437e9d | |||
| dde9f5d542 | |||
| 72e60c95e0 | |||
| 9df30a84e0 | |||
| acfaebfffa | |||
| 72e215c552 | |||
| ee5cc81864 | |||
| 1bbfe8d092 | |||
| 88c31d4fdc | |||
| fb75cbf230 | |||
| eda4ed2955 | |||
| c41611e5d0 | |||
| 747081901f | |||
| 616ab217e4 | |||
| 00db16e7bd | |||
| 1821ba9f58 | |||
| ea48f03bbc | |||
| 6b2089a9bc | |||
| 25c0e629c6 | |||
| 48c8518ae6 | |||
| 96d001dfc0 | |||
| fb3a08fdcf | |||
| dcc8cdeb6a | |||
| 054958ebf5 |
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
// 使用错误码分类器创建相应的异常
|
||||
String errorMsg = response.optString("error_msg", "未知错误");
|
||||
throw BceErrorCodeClassifier.createException(errorCode,
|
||||
"人脸搜索失败[" + errorCode + "]: " + errorMsg);
|
||||
} 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 {
|
||||
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 ->
|
||||
|
||||
@@ -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...
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -105,4 +105,6 @@ public interface SourceMapper {
|
||||
* @return 影响行数
|
||||
*/
|
||||
int addFromZTSource(SourceEntity source);
|
||||
|
||||
SourceEntity getBySampleIdAndType(Long faceSampleId, Integer type);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ import lombok.Data;
|
||||
public class FaceRecognizeResp {
|
||||
private String url;
|
||||
private Long faceId;
|
||||
private Long scenicId;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.ycwl.basic.model.pc.printer.req;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 重新打印请求
|
||||
*/
|
||||
@Data
|
||||
public class ReprintRequest {
|
||||
/**
|
||||
* 打印机名称
|
||||
*/
|
||||
private String printerName;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -44,6 +44,7 @@ public class ScenicConfigResp {
|
||||
*/
|
||||
private Boolean showPhotoWhenWaiting;
|
||||
|
||||
private Boolean printEnable;
|
||||
/**
|
||||
* 智能抓拍打印开关
|
||||
*/
|
||||
|
||||
@@ -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={}",
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户可用优惠券
|
||||
*/
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -35,4 +35,9 @@ public class CouponInfo {
|
||||
* 实际优惠金额
|
||||
*/
|
||||
private BigDecimal actualDiscountAmount;
|
||||
|
||||
/**
|
||||
* 每个用户可领取数量限制(NULL表示不限制)
|
||||
*/
|
||||
private Integer userClaimLimit;
|
||||
}
|
||||
@@ -48,5 +48,10 @@ public class PriceCalculationRequest {
|
||||
/**
|
||||
* 是否仅预览优惠(不实际使用)
|
||||
*/
|
||||
private Boolean previewOnly = false;
|
||||
private Boolean previewOnly = true;
|
||||
|
||||
/**
|
||||
* 订单ID(在实际使用优惠时必填)
|
||||
*/
|
||||
private String orderId;
|
||||
}
|
||||
@@ -61,6 +61,16 @@ public class PriceCouponConfig {
|
||||
*/
|
||||
private Integer usedQuantity;
|
||||
|
||||
/**
|
||||
* 已领取数量(区分于已使用数量)
|
||||
*/
|
||||
private Integer claimedQuantity;
|
||||
|
||||
/**
|
||||
* 每个用户可领取数量限制(NULL表示不限制)
|
||||
*/
|
||||
private Integer userClaimLimit;
|
||||
|
||||
/**
|
||||
* 生效时间
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 更新优惠券使用状态
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
/**
|
||||
* 插入优惠券配置
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
// 6. 检查用户领取数量限制
|
||||
if (coupon.getUserClaimLimit() != null && coupon.getUserClaimLimit() > 0) {
|
||||
int userClaimCount = couponClaimRecordMapper.countUserCouponClaims(
|
||||
request.getUserId(), request.getCouponId());
|
||||
if (existingRecord != null) {
|
||||
return CouponClaimResult.failure(CouponClaimResult.ERROR_ALREADY_CLAIMED, "您已经领取过该优惠券");
|
||||
// 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事务上下文,跳过回滚标记");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ public class OnePricePurchaseDiscountProvider implements IDiscountProvider {
|
||||
discountInfo.setDiscountAmount(onePriceInfo.getActualDiscountAmount());
|
||||
discountInfo.setDiscountDescription("景区一口价购买,价格更优惠");
|
||||
discountInfo.setOnePriceInfo(onePriceInfo);
|
||||
discountInfo.setPriority(getPriority());
|
||||
|
||||
discounts.add(discountInfo);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -220,20 +220,52 @@ 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) {
|
||||
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(),
|
||||
@@ -261,17 +293,25 @@ public class AppScenicServiceImpl implements AppScenicService {
|
||||
scenicAppVO.setArea(scenicEntity.getArea());
|
||||
scenicAppVO.setAddress(scenicEntity.getAddress());
|
||||
scenicAppVO.setDistance(distance);
|
||||
scenicAppVO.setDeviceNum(deviceRepository.getAllDeviceByScenicId(scenicEntity.getId()).size());
|
||||
|
||||
// 获取设备数量
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +586,16 @@ public class OrderServiceImpl implements OrderService {
|
||||
if (orderAppRespVO != null && orderAppRespVO.getOrderItemList() != null && !orderAppRespVO.getOrderItemList().isEmpty()) {
|
||||
orderAppRespVO.getOrderItemList().forEach(orderItem -> {
|
||||
if (orderItem.getGoodsType() == 3) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,15 +557,15 @@ 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);
|
||||
printer = printerMapper.getById(printerId);
|
||||
if (printer == null) {
|
||||
throw new BaseException("打印机不存在");
|
||||
}
|
||||
@@ -412,16 +575,40 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
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,14 +737,25 @@ 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();
|
||||
if (item.getSourceId() != null) {
|
||||
try {
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(item.getScenicId());
|
||||
String printWatermarkType = scenicConfig.getString("print_watermark_type");
|
||||
@@ -572,8 +805,12 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
|
||||
// 处理水印
|
||||
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);
|
||||
|
||||
@@ -619,6 +856,7 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
} catch (Exception e) {
|
||||
log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据数量创建多个打印任务
|
||||
Integer quantity = item.getQuantity();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取下一个要使用的打印机名称(轮询逻辑)
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
g2d.setTransform(transform);
|
||||
g2d.drawImage(source, 0, 0, null);
|
||||
} else {
|
||||
throw new IllegalArgumentException("仅支持90度和270度旋转");
|
||||
}
|
||||
|
||||
return rotated;
|
||||
} finally {
|
||||
|
||||
BIN
src/main/resources/PingFang_SC_t.ttf
Normal file
BIN
src/main/resources/PingFang_SC_t.ttf
Normal file
Binary file not shown.
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user