You've already forked FrameTour-BE
Compare commits
34 Commits
e887fd47f2
...
78a2a74fa6
| Author | SHA1 | Date | |
|---|---|---|---|
| 78a2a74fa6 | |||
| 222f974ad5 | |||
| 96a4d3ffeb | |||
| e99d75ba1b | |||
| 12cd9bd275 | |||
| 7c42c5c462 | |||
| 631d5c175f | |||
| 785de52780 | |||
| 2ee7e93201 | |||
| 65ae23a956 | |||
| b9ade63e8e | |||
| cdeb2e4d5a | |||
| cc38d6e095 | |||
| 82d86c8c3c | |||
| 5979b1a275 | |||
| a7fe0d715d | |||
| ef8a913636 | |||
| 73791a92d3 | |||
| f0ad0f58a9 | |||
| 73825cd1d6 | |||
| 745943fc23 | |||
| b6bde4ad62 | |||
| 07ebccad3c | |||
| 028178605e | |||
| 03162dec44 | |||
| 85cdfe9ea1 | |||
| 5e2fe0329d | |||
| 6f8b3c8cdf | |||
| 1efe4a1439 | |||
| e27f092f85 | |||
| 215a7e87ae | |||
| 636ab96e96 | |||
| cc68a8dbbd | |||
| 1b312313b2 |
33
AGENTS.md
33
AGENTS.md
@@ -1,38 +1,25 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Application code: `src/main/java/com/ycwl/basic/**` (controllers, services, mapper/repository, dto/model, config, util).
|
||||
- Resources: `src/main/resources/**` (Spring configs, `mapper/*.xml`, static assets, logging).
|
||||
- Tests: `src/test/java/**` mirrors main packages.
|
||||
- Build output: `target/` (never commit).
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Build artifact: `mvn clean package` (tests are skipped by default via `pom.xml`).
|
||||
- Run locally (dev): `mvn spring-boot:run -Dspring-boot.run.profiles=dev`.
|
||||
- Run jar: `java -jar target/basic21-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev`.
|
||||
- Execute tests: `mvn -DskipTests=false test` (note: `pom.xml` excludes `**/*Test.java` from test-compile; temporarily remove/override that config if you need to compile and run tests).
|
||||
- Execute all tests: `mvn -DskipTests=false test` (note: `pom.xml` excludes `**/*Test.java` from test-compile; temporarily remove/override that config if you need to compile and run tests).
|
||||
- Run single test: `mvn -DskipTests=false test -Dtest=ClassNameTest` (after removing testExcludes from maven-compiler-plugin).
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
## Code Style Guidelines
|
||||
- Java 21. Use 4-space indentation; UTF-8; no wildcard imports.
|
||||
- Packages: `com.ycwl.basic.*`; classes PascalCase; methods/fields camelCase; constants UPPER_SNAKE_CASE.
|
||||
- Controllers in `controller`, business logic in `service`, persistence in `mapper` + `resources/mapper/*.xml`.
|
||||
- Prefer Lombok for boilerplate and constructor injection where applicable.
|
||||
- Error handling: Use custom exceptions in `exception` package; proper logging with SLF4J.
|
||||
- Testing: Spring Boot testing + JUnit; test names end with `Test` or `Tests` and mirror package structure.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Spring Boot testing + JUnit (see `spring-boot-starter-test`).
|
||||
- Test names end with `Test` or `Tests` and mirror package structure.
|
||||
- Aim to cover service/util layers and critical controllers. No enforced coverage target.
|
||||
- To enable tests locally, remove/override the `maven-compiler-plugin` `testExcludes` in `pom.xml` and run `mvn -DskipTests=false test`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow Conventional Commits: `feat(scope): summary`, `fix(scope): summary`, `refactor: ...`.
|
||||
- Reference issues (e.g., `#123`) and include brief rationale and screenshots for UI-facing changes.
|
||||
- Keep PRs focused; include run/build instructions and any config changes.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Profiles: `application.yml` and `bootstrap.yml` with `-dev`/`-prod` variants. Select via `--spring.profiles.active`.
|
||||
- Do not commit secrets. Provide Nacos, Redis, MySQL, OSS/S3, and 3rd‑party keys via environment or secure config.
|
||||
- Review `logback-spring*.xml` before raising log levels in production.
|
||||
## Project Structure
|
||||
- Application code: `src/main/java/com/ycwl/basic/**` (controllers, services, mapper/repository, dto/model, config, util).
|
||||
- Resources: `src/main/resources/**` (Spring configs, `mapper/*.xml`, static assets, logging).
|
||||
- Tests: `src/test/java/**` mirrors main packages.
|
||||
- Build output: `target/` (never commit).
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Keep changes minimal and within existing package boundaries.
|
||||
|
||||
33
CLAUDE.md
33
CLAUDE.md
@@ -1,38 +1,25 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Application code: `src/main/java/com/ycwl/basic/**` (controllers, services, mapper/repository, dto/model, config, util).
|
||||
- Resources: `src/main/resources/**` (Spring configs, `mapper/*.xml`, static assets, logging).
|
||||
- Tests: `src/test/java/**` mirrors main packages.
|
||||
- Build output: `target/` (never commit).
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Build artifact: `mvn clean package` (tests are skipped by default via `pom.xml`).
|
||||
- Run locally (dev): `mvn spring-boot:run -Dspring-boot.run.profiles=dev`.
|
||||
- Run jar: `java -jar target/basic21-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev`.
|
||||
- Execute tests: `mvn -DskipTests=false test` (note: `pom.xml` excludes `**/*Test.java` from test-compile; temporarily remove/override that config if you need to compile and run tests).
|
||||
- Execute all tests: `mvn -DskipTests=false test` (note: `pom.xml` excludes `**/*Test.java` from test-compile; temporarily remove/override that config if you need to compile and run tests).
|
||||
- Run single test: `mvn -DskipTests=false test -Dtest=ClassNameTest` (after removing testExcludes from maven-compiler-plugin).
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
## Code Style Guidelines
|
||||
- Java 21. Use 4-space indentation; UTF-8; no wildcard imports.
|
||||
- Packages: `com.ycwl.basic.*`; classes PascalCase; methods/fields camelCase; constants UPPER_SNAKE_CASE.
|
||||
- Controllers in `controller`, business logic in `service`, persistence in `mapper` + `resources/mapper/*.xml`.
|
||||
- Prefer Lombok for boilerplate and constructor injection where applicable.
|
||||
- Error handling: Use custom exceptions in `exception` package; proper logging with SLF4J.
|
||||
- Testing: Spring Boot testing + JUnit; test names end with `Test` or `Tests` and mirror package structure.
|
||||
|
||||
## Testing Guidelines
|
||||
- Framework: Spring Boot testing + JUnit (see `spring-boot-starter-test`).
|
||||
- Test names end with `Test` or `Tests` and mirror package structure.
|
||||
- Aim to cover service/util layers and critical controllers. No enforced coverage target.
|
||||
- To enable tests locally, remove/override the `maven-compiler-plugin` `testExcludes` in `pom.xml` and run `mvn -DskipTests=false test`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow Conventional Commits: `feat(scope): summary`, `fix(scope): summary`, `refactor: ...`.
|
||||
- Reference issues (e.g., `#123`) and include brief rationale and screenshots for UI-facing changes.
|
||||
- Keep PRs focused; include run/build instructions and any config changes.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Profiles: `application.yml` and `bootstrap.yml` with `-dev`/`-prod` variants. Select via `--spring.profiles.active`.
|
||||
- Do not commit secrets. Provide Nacos, Redis, MySQL, OSS/S3, and 3rd‑party keys via environment or secure config.
|
||||
- Review `logback-spring*.xml` before raising log levels in production.
|
||||
## Project Structure
|
||||
- Application code: `src/main/java/com/ycwl/basic/**` (controllers, services, mapper/repository, dto/model, config, util).
|
||||
- Resources: `src/main/resources/**` (Spring configs, `mapper/*.xml`, static assets, logging).
|
||||
- Tests: `src/test/java/**` mirrors main packages.
|
||||
- Build output: `target/` (never commit).
|
||||
|
||||
## Agent-Specific Notes
|
||||
- Keep changes minimal and within existing package boundaries.
|
||||
|
||||
@@ -16,7 +16,6 @@ import com.ycwl.basic.model.pc.order.entity.OrderItemEntity;
|
||||
import com.ycwl.basic.model.pc.order.resp.OrderAppRespVO;
|
||||
import com.ycwl.basic.model.pc.order.resp.OrderItemVO;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
|
||||
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
|
||||
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
||||
@@ -87,7 +86,6 @@ public class OrderBiz {
|
||||
PriceObj priceObj = new PriceObj();
|
||||
priceObj.setGoodsType(goodsType);
|
||||
priceObj.setGoodsId(goodsId);
|
||||
ScenicEntity scenic = scenicRepository.getScenic(scenicId);
|
||||
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(scenicId);
|
||||
if (scenicConfig != null) {
|
||||
if (Boolean.TRUE.equals(scenicConfig.getAllFree())) {
|
||||
@@ -117,7 +115,7 @@ public class OrderBiz {
|
||||
vlogProductItem.setProductType(ProductType.VLOG_VIDEO);
|
||||
vlogProductItem.setProductId(template.getId().toString());
|
||||
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
|
||||
vlogProductItem.setScenicId(scenic.getId().toString());
|
||||
vlogProductItem.setScenicId(scenicId.toString());
|
||||
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
|
||||
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
|
||||
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
|
||||
@@ -132,9 +130,9 @@ public class OrderBiz {
|
||||
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
|
||||
ProductItem productItem = new ProductItem();
|
||||
productItem.setProductType(goodsType == 1 ? ProductType.RECORDING_SET : ProductType.PHOTO_SET);
|
||||
productItem.setProductId(scenic.getId().toString());
|
||||
productItem.setProductId(scenicId.toString());
|
||||
productItem.setPurchaseCount(1);
|
||||
productItem.setScenicId(scenic.getId().toString());
|
||||
productItem.setScenicId(scenicId.toString());
|
||||
calculationRequest.setProducts(Collections.singletonList(productItem));
|
||||
if (face != null) {
|
||||
calculationRequest.setUserId(face.getMemberId());
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.ycwl.basic.constant;
|
||||
public class StorageConstant {
|
||||
public static final String VLOG_PATH = "vlog";
|
||||
public static final String VIDEO_PIECE_PATH = "source_video";
|
||||
public static final String PHOTO_PATH = "source_photo";
|
||||
public static final String PHOTO_PATH = "viid";
|
||||
public static final String PHOTO_WATERMARKED_PATH = "photo_w";
|
||||
public static final String USER_FACE = "user_face";
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import com.ycwl.basic.model.jwt.JwtInfo;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
||||
import com.ycwl.basic.model.mobile.face.FaceStatusResp;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionUpdateReq;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionDetailVO;
|
||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||
import com.ycwl.basic.model.pc.faceSample.resp.FaceSampleRespVO;
|
||||
import com.ycwl.basic.service.pc.FaceService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||
@@ -108,4 +109,19 @@ public class AppFaceController {
|
||||
faceService.matchCustomFaceId(faceId, faceIds);
|
||||
return ApiResponse.success("OK");
|
||||
}
|
||||
|
||||
@PutMapping("/{faceId}/recognition")
|
||||
public ApiResponse<?> updateRecognition(@PathVariable Long faceId,
|
||||
@RequestBody FaceRecognitionUpdateReq req) {
|
||||
req.setFaceId(faceId);
|
||||
faceService.updateRecognition(req);
|
||||
return ApiResponse.success("OK");
|
||||
}
|
||||
|
||||
@GetMapping("/{faceId}/recognition/detail")
|
||||
public ApiResponse<FaceRecognitionDetailVO> recognitionDetail(@PathVariable Long faceId) {
|
||||
return ApiResponse.success(faceService.getRecognitionDetail(faceId));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@ public class AppScenicController {
|
||||
resp.setImageSourcePackHint(scenicConfig.getString("image_source_pack_hint"));
|
||||
resp.setVideoSourcePackHint(scenicConfig.getString("video_source_pack_hint"));
|
||||
resp.setShareBeforeBuy(scenicConfig.getBoolean("share_before_buy"));
|
||||
resp.setFaceSelectFirst(scenicConfig.getBoolean("face_select_first", false));
|
||||
resp.setPrintEnableSource(scenicConfig.getBoolean("print_enable_source", true));
|
||||
resp.setPrintForceFaceUpload(scenicConfig.getBoolean("print_force_face_upload", false));
|
||||
resp.setPrintEnableManual(scenicConfig.getBoolean("print_enable_manual", true));
|
||||
resp.setSceneMode(scenicConfig.getInteger("scene_mode", 0));
|
||||
return ApiResponse.success(resp);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package com.ycwl.basic.controller.mobile;
|
||||
|
||||
|
||||
import com.ycwl.basic.annotation.IgnoreToken;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Author: songmingsong
|
||||
* @CreateTime: 2024-12-06
|
||||
* @Description: 微信消息模板通知
|
||||
* @Version: 1.0
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/wx/notify/v1")
|
||||
// 微信消息模板通知
|
||||
public class AppWxNotifyController {
|
||||
@Autowired
|
||||
private ScenicRepository scenicRepository;
|
||||
//
|
||||
// @ApiOperation(value = "通知", notes = "通知")
|
||||
// @PostMapping("/pushMessage")
|
||||
// @IgnoreToken
|
||||
// public ApiResponse<?> pushMessage(@RequestBody WechatMessageSubscribeForm req) {
|
||||
// JSONObject resJson = wxNotifyService.pushMessage(req);
|
||||
// return ApiResponse.success(resJson);
|
||||
// }
|
||||
|
||||
@GetMapping({"/getIds", "/"})
|
||||
@IgnoreToken
|
||||
public ApiResponse<List<String>> getIds() {
|
||||
return ApiResponse.success(new ArrayList<>() {{
|
||||
add("5b8vTm7kvwYubqDxb3dxBs0BqxMsgVgGw573aahTEd8");
|
||||
add("vPIzbkA0x4mMj-vdbWx6_45e8juWXzs3FGYnDsIPv3A");
|
||||
add("HB1vp-0BXc2WyYeoYN3a3GuZV9HtPLXUTT7blCBq9eY");
|
||||
}});
|
||||
}
|
||||
|
||||
@GetMapping("/{scenicId}")
|
||||
@IgnoreToken
|
||||
public ApiResponse<List<String>> getIds(@PathVariable("scenicId") Long scenicId) {
|
||||
return ApiResponse.success(new ArrayList<>() {{
|
||||
String videoGeneratedTemplateId = scenicRepository.getVideoGeneratedTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoGeneratedTemplateId)) {
|
||||
add(videoGeneratedTemplateId);
|
||||
}
|
||||
String videoDownloadTemplateId = scenicRepository.getVideoDownloadTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoDownloadTemplateId)) {
|
||||
add(videoDownloadTemplateId);
|
||||
}
|
||||
String videoPreExpireTemplateId = scenicRepository.getVideoPreExpireTemplateId(scenicId);
|
||||
if (StringUtils.isNotBlank(videoPreExpireTemplateId)) {
|
||||
add(videoPreExpireTemplateId);
|
||||
}
|
||||
}});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -56,12 +56,4 @@ public class AppStatisticsController {
|
||||
return statisticsService.userConversionFunnel(query);
|
||||
}
|
||||
|
||||
// 统计数据记录
|
||||
@PostMapping("/addStatistics")
|
||||
@IgnoreToken
|
||||
public ApiResponse<String> addStatistics(@RequestBody StatisticsRecordAddReq req) {
|
||||
|
||||
return statisticsService.addStatistics(req);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -53,5 +53,4 @@ public class FaceController {
|
||||
return faceService.deleteByIds(ids);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package com.ycwl.basic.controller.pc;
|
||||
import com.ycwl.basic.integration.kafka.service.FaceProcessingKafkaService;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||
@@ -15,13 +16,14 @@ import java.util.List;
|
||||
* @Author:longbinbin
|
||||
* @Date:2024/12/2 16:33
|
||||
*/
|
||||
@Deprecated
|
||||
@RestController
|
||||
@RequestMapping("/api/faceSample/v1")
|
||||
// 人脸样本管理
|
||||
public class FaceSampleController {
|
||||
@Autowired
|
||||
private FaceSampleService FaceSampleService;
|
||||
@Autowired(required = false)
|
||||
private FaceProcessingKafkaService faceProcessingKafkaService;
|
||||
|
||||
// 分页查询人脸样本
|
||||
@PostMapping("/page")
|
||||
@@ -39,4 +41,25 @@ public class FaceSampleController {
|
||||
return FaceSampleService.getById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试失败的人脸识别
|
||||
* 用于手动重试状态为-1的人脸样本
|
||||
*
|
||||
* @param id 人脸样本ID
|
||||
* @return 重试结果
|
||||
*/
|
||||
@PostMapping("/retry/{id}")
|
||||
public ApiResponse<String> retryFaceRecognition(@PathVariable("id") Long id) {
|
||||
if (faceProcessingKafkaService == null) {
|
||||
return ApiResponse.fail("Kafka服务未启用,无法重试人脸识别");
|
||||
}
|
||||
|
||||
boolean success = faceProcessingKafkaService.retryFaceRecognition(id);
|
||||
if (success) {
|
||||
return ApiResponse.success("人脸识别重试任务已提交");
|
||||
} else {
|
||||
return ApiResponse.fail("提交重试任务失败,请检查人脸样本状态");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ 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;
|
||||
@@ -273,18 +277,71 @@ 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);
|
||||
if (response.getInt("error_code") == 0) {
|
||||
int errorCode = response.getInt("error_code");
|
||||
|
||||
if (errorCode == 0) {
|
||||
resp.setOriginalFaceScore(100f);
|
||||
JSONObject resultObj = response.getJSONObject("result");
|
||||
if (resultObj == null) {
|
||||
@@ -308,12 +365,18 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
||||
}
|
||||
return resp;
|
||||
} else {
|
||||
resp.setOriginalFaceScore(0f);
|
||||
return resp;
|
||||
// 使用错误码分类器创建相应的异常
|
||||
String errorMsg = response.optString("error_msg", "未知错误");
|
||||
throw BceErrorCodeClassifier.createException(errorCode,
|
||||
"人脸搜索失败[" + errorCode + "]: " + errorMsg);
|
||||
}
|
||||
} catch (FaceBodyException e) {
|
||||
// 重新抛出 FaceBodyException
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("搜索人脸失败!", e);
|
||||
return null;
|
||||
// 其他异常(如网络异常)包装为可重试异常
|
||||
log.error("搜索人脸网络异常", e);
|
||||
throw new RetryableFaceBodyException("搜索人脸网络异常: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
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,7 +1,24 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -191,4 +191,53 @@ public class FaceProcessingKafkaService {
|
||||
log.error("更新人脸样本状态失败, faceSampleId: {}", faceSampleId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试失败的人脸识别
|
||||
* 用于手动重试状态为-1的人脸样本
|
||||
*
|
||||
* @param faceSampleId 人脸样本ID
|
||||
* @return 是否成功提交重试任务
|
||||
*/
|
||||
public boolean retryFaceRecognition(Long faceSampleId) {
|
||||
try {
|
||||
// 查询人脸样本信息
|
||||
FaceSampleEntity faceSample = faceSampleMapper.getEntity(faceSampleId);
|
||||
if (faceSample == null) {
|
||||
log.error("人脸样本不存在, faceSampleId: {}", faceSampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查状态是否为失败状态(-1)
|
||||
if (faceSample.getStatus() != -1) {
|
||||
log.warn("人脸样本状态不是失败状态, 无需重试, faceSampleId: {}, status: {}",
|
||||
faceSampleId, faceSample.getStatus());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 构造人脸处理消息
|
||||
FaceProcessingMessage message = FaceProcessingMessage.builder()
|
||||
.faceSampleId(faceSample.getId())
|
||||
.scenicId(faceSample.getScenicId())
|
||||
.deviceId(faceSample.getDeviceId())
|
||||
.faceUrl(faceSample.getFaceUrl())
|
||||
.shotTime(faceSample.getCreateAt())
|
||||
.createTime(new Date())
|
||||
.source("retry-manual")
|
||||
.build();
|
||||
|
||||
// 提交到线程池进行异步处理
|
||||
faceRecognitionExecutor.execute(() -> processFaceRecognitionAsync(message));
|
||||
|
||||
log.info("人脸识别重试任务已提交, faceSampleId: {}, 活跃线程: {}, 队列大小: {}",
|
||||
faceSampleId, faceRecognitionExecutor.getActiveCount(),
|
||||
faceRecognitionExecutor.getQueue().size());
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("提交人脸识别重试任务失败, faceSampleId: {}", faceSampleId, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public interface FaceMapper {
|
||||
int forceDeleteById(Long id);
|
||||
int deleteByIds(@Param("list") List<Long> ids);
|
||||
int update(FaceEntity face);
|
||||
int updateManualFlag(@Param("id") Long id, @Param("isManual") Integer isManual);
|
||||
|
||||
FaceRespVO getLatestByMemberId(@Param("userId") Long userId, @Param("scenicId") Long scenicId);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ycwl.basic.model.mobile.face;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 人脸识别详情,包含系统采纳及被过滤的样本。
|
||||
*/
|
||||
@Data
|
||||
public class FaceRecognitionDetailVO {
|
||||
private Long faceId;
|
||||
private Long memberId;
|
||||
private Long scenicId;
|
||||
private String faceUrl;
|
||||
|
||||
private Float score;
|
||||
private Float firstMatchRate;
|
||||
private Boolean lowThreshold;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date lastMatchedAt;
|
||||
|
||||
/**
|
||||
* 系统采纳的样本信息。
|
||||
*/
|
||||
private List<FaceRecognitionSampleVO> acceptedSamples;
|
||||
|
||||
/**
|
||||
* 被系统过滤的样本信息。
|
||||
*/
|
||||
private List<FaceRecognitionSampleVO> filteredSamples;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ycwl.basic.model.mobile.face;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单个人脸样本识别结果的信息描述。
|
||||
*/
|
||||
@Data
|
||||
public class FaceRecognitionSampleVO {
|
||||
private Long sampleId;
|
||||
private Float score;
|
||||
private Boolean accepted;
|
||||
|
||||
private Long sourceId;
|
||||
private String sourceUrl;
|
||||
|
||||
private Long deviceId;
|
||||
private String deviceName;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date shotAt;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ycwl.basic.model.mobile.face;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 人工调整人脸识别结果的请求体。
|
||||
*/
|
||||
@Data
|
||||
public class FaceRecognitionUpdateReq {
|
||||
/**
|
||||
* 指定需要操作的人脸ID。
|
||||
*/
|
||||
private Long faceId;
|
||||
|
||||
/**
|
||||
* 用户人工选中希望保留的样本ID列表。
|
||||
*/
|
||||
private List<Long> manualAcceptedSampleIds;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 是否强制重新走一次识别流程。
|
||||
*/
|
||||
private Boolean forceRematch = false;
|
||||
|
||||
/**
|
||||
* 前端传回的备注信息。
|
||||
*/
|
||||
private String remark;
|
||||
}
|
||||
@@ -41,6 +41,10 @@ public class FaceEntity {
|
||||
* 匹配的结果,JSON字符串
|
||||
*/
|
||||
private String matchResult;
|
||||
/**
|
||||
* 是否人工调整过,0-否,1-是
|
||||
*/
|
||||
private Integer isManual;
|
||||
private Date createAt;
|
||||
private Date updateAt;
|
||||
private int isDelete;
|
||||
|
||||
@@ -44,6 +44,21 @@ public class ScenicConfigResp {
|
||||
*/
|
||||
private Boolean showPhotoWhenWaiting;
|
||||
|
||||
/**
|
||||
* 智能抓拍打印开关
|
||||
*/
|
||||
private Boolean printEnableSource;
|
||||
|
||||
/**
|
||||
* 打印必须录入人脸开关
|
||||
*/
|
||||
private Boolean printForceFaceUpload;
|
||||
|
||||
/**
|
||||
* 手机照片打印开关
|
||||
*/
|
||||
private Boolean printEnableManual;
|
||||
|
||||
// ========== 提示文案 ==========
|
||||
|
||||
/**
|
||||
@@ -56,4 +71,6 @@ public class ScenicConfigResp {
|
||||
*/
|
||||
private String videoSourcePackHint = "";
|
||||
private Boolean shareBeforeBuy = true;
|
||||
private Boolean faceSelectFirst = false;
|
||||
private Integer sceneMode = 0;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class ScenicRepository {
|
||||
return scenicEntity;
|
||||
}
|
||||
|
||||
@Deprecated
|
||||
public ScenicConfigEntity getScenicConfig(Long scenicId) {
|
||||
ScenicConfigManager scenicConfigManager = getScenicConfigManager(scenicId);
|
||||
ScenicConfigEntity config = new ScenicConfigEntity();
|
||||
|
||||
@@ -22,7 +22,5 @@ public interface AppStatisticsService {
|
||||
|
||||
ApiResponse<AppStatisticsFunnelVO> userConversionFunnel(CommonQueryReq query);
|
||||
|
||||
ApiResponse<String> addStatistics(StatisticsRecordAddReq req);
|
||||
|
||||
ApiResponse orderChart(CommonQueryReq query);
|
||||
}
|
||||
|
||||
@@ -288,34 +288,6 @@ public class AppStatisticsServiceImpl implements AppStatisticsService {
|
||||
return int1 == null ? 0 : int1 + (int2 == null ? 0 : int2);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<String> addStatistics(StatisticsRecordAddReq req) {
|
||||
// req.setId(SnowFlakeUtil.getLongId());
|
||||
if (req.getMemberId() == null) {
|
||||
try {
|
||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||
Long userId = worker.getUserId();
|
||||
req.setMemberId(userId);
|
||||
} catch (Exception ignored) {
|
||||
|
||||
}
|
||||
}
|
||||
Integer type = req.getType();
|
||||
if(type==null){
|
||||
return ApiResponse.fail("类型不能为空");
|
||||
}
|
||||
Map<Integer, StatisticEnum> valueMap = StatisticEnum.cacheMap;
|
||||
if(!valueMap.containsKey(type)){
|
||||
return ApiResponse.fail("添加失败,类型不存在");
|
||||
}
|
||||
int i=statisticsMapper.addStatisticsRecord(req);
|
||||
if(i==0){
|
||||
return ApiResponse.fail("添加失败");
|
||||
}else{
|
||||
return ApiResponse.success("添加成功");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse orderChart(CommonQueryReq query) {
|
||||
if(query.getEndTime()==null && query.getStartTime()==null){
|
||||
|
||||
@@ -30,6 +30,7 @@ import com.ycwl.basic.service.pc.ScenicService;
|
||||
import com.ycwl.basic.utils.SnowFlakeUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -60,6 +61,8 @@ public class WxPayServiceImpl implements WxPayService {
|
||||
private OrderMapper orderMapper;
|
||||
@Autowired
|
||||
private ScenicService scenicService;
|
||||
@Autowired
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
@Override
|
||||
public Map<String, Object> createOrder(Long scenicId, WXPayOrderReqVO req) {
|
||||
@@ -100,10 +103,14 @@ public class WxPayServiceImpl implements WxPayService {
|
||||
long orderId = Long.parseLong(callbackResponse.getOrderNo());
|
||||
if (callbackResponse.isPay()) {
|
||||
orderBiz.paidOrder(orderId);
|
||||
// 支付成功后删除统计缓存,确保下次查询获取最新数据
|
||||
invalidateStatisticsCache(scenicId);
|
||||
} else if (callbackResponse.isCancel()) {
|
||||
orderBiz.cancelOrder(orderId);
|
||||
} else if (callbackResponse.isRefund()) {
|
||||
orderBiz.refundOrder(orderId);
|
||||
// 退款后删除统计缓存
|
||||
invalidateStatisticsCache(scenicId);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
@@ -165,6 +172,10 @@ public class WxPayServiceImpl implements WxPayService {
|
||||
statisticsRecordAddReq.setScenicId(order.getScenicId());
|
||||
statisticsRecordAddReq.setMorphId(orderId);
|
||||
statisticsMapper.addStatisticsRecord(statisticsRecordAddReq);
|
||||
|
||||
// 退款成功后删除统计缓存,确保下次查询获取最新数据
|
||||
invalidateStatisticsCache(scenicId);
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("微信退款回调失败!", e);
|
||||
@@ -180,4 +191,15 @@ public class WxPayServiceImpl implements WxPayService {
|
||||
.setOrderNo(orderId);
|
||||
scenicPayAdapter.cancelOrder(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除景区统计缓存
|
||||
* 在支付或退款回调后调用,确保下次查询时重新计算统计数据
|
||||
* @param scenicId 景区ID
|
||||
*/
|
||||
private void invalidateStatisticsCache(Long scenicId) {
|
||||
String redisKey = "statistics:tmp_cache:" + scenicId;
|
||||
Boolean deleted = redisTemplate.delete(redisKey);
|
||||
log.info("[缓存删除] 景区 {} 的统计缓存删除结果: {}", scenicId, deleted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
||||
import com.ycwl.basic.model.mobile.face.FaceStatusResp;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionUpdateReq;
|
||||
import com.ycwl.basic.model.pc.face.req.FaceReqQuery;
|
||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionDetailVO;
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||
import com.ycwl.basic.model.pc.faceSample.resp.FaceSampleRespVO;
|
||||
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -51,4 +52,8 @@ public interface FaceService {
|
||||
List<FaceSampleEntity> getLowMatchedFaceSamples(Long faceId);
|
||||
|
||||
void matchCustomFaceId(Long faceId, List<Long> faceSampleIds);
|
||||
|
||||
void updateRecognition(FaceRecognitionUpdateReq req);
|
||||
|
||||
FaceRecognitionDetailVO getRecognitionDetail(Long faceId);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.ycwl.basic.service.pc.impl;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.ycwl.basic.mapper.ScenicDeviceStatsMapper;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
|
||||
import com.ycwl.basic.model.pc.scenicDeviceStats.resp.ScenicDeviceStatsListResp;
|
||||
import com.ycwl.basic.model.pc.scenicDeviceStats.resp.ScenicDeviceStatsResp;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
@@ -39,7 +39,7 @@ public class DeviceStatsServiceImpl implements DeviceStatsService {
|
||||
resp.setRealtime(true);
|
||||
List<ScenicDeviceStatsResp> data = mapper.countRealtimeStatsByScenicId(scenicId, start, end);
|
||||
data.forEach(item -> {
|
||||
ScenicEntity scenic = scenicRepository.getScenic(item.getScenicId());
|
||||
ScenicV2DTO scenic = scenicRepository.getScenicBasic(item.getScenicId());
|
||||
if (scenic != null) {
|
||||
item.setScenicName(scenic.getName());
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public class DeviceStatsServiceImpl implements DeviceStatsService {
|
||||
resp.setRealtime(false);
|
||||
List<ScenicDeviceStatsResp> data = mapper.countCachedStatsByScenicId(scenicId, start, end);
|
||||
data.forEach(item -> {
|
||||
ScenicEntity scenic = scenicRepository.getScenic(item.getScenicId());
|
||||
ScenicV2DTO scenic = scenicRepository.getScenicBasic(item.getScenicId());
|
||||
if (scenic != null) {
|
||||
item.setScenicName(scenic.getName());
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||
import com.ycwl.basic.model.pc.faceSample.req.FaceSampleReqQuery;
|
||||
import com.ycwl.basic.model.pc.faceSample.resp.FaceSampleRespVO;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.pc.FaceSampleService;
|
||||
@@ -36,7 +36,7 @@ public class FaceSampleServiceImpl implements FaceSampleService {
|
||||
PageHelper.startPage(faceSampleReqQuery.getPageNum(),faceSampleReqQuery.getPageSize());
|
||||
List<FaceSampleRespVO> list = faceSampleMapper.list(faceSampleReqQuery);
|
||||
list.forEach(item -> {
|
||||
ScenicEntity scenic = scenicRepository.getScenic(item.getScenicId());
|
||||
ScenicV2DTO scenic = scenicRepository.getScenicBasic(item.getScenicId());
|
||||
if (scenic != null) {
|
||||
item.setScenicName(scenic.getName());
|
||||
}
|
||||
|
||||
@@ -25,8 +25,12 @@ import com.ycwl.basic.model.mobile.goods.VideoTaskStatusVO;
|
||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionUpdateReq;
|
||||
import com.ycwl.basic.model.pc.face.req.FaceReqQuery;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionDetailVO;
|
||||
import com.ycwl.basic.model.mobile.face.FaceRecognitionSampleVO;
|
||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||
@@ -42,6 +46,7 @@ import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
||||
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
|
||||
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
|
||||
import com.ycwl.basic.model.repository.TaskUpdateResult;
|
||||
|
||||
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
@@ -53,6 +58,7 @@ import com.ycwl.basic.repository.VideoTaskRepository;
|
||||
import com.ycwl.basic.service.mobile.GoodsService;
|
||||
import com.ycwl.basic.service.pc.FaceService;
|
||||
import com.ycwl.basic.service.pc.ScenicService;
|
||||
import com.ycwl.basic.service.printer.PrinterService;
|
||||
import com.ycwl.basic.service.task.TaskFaceService;
|
||||
import com.ycwl.basic.service.task.TaskService;
|
||||
import com.ycwl.basic.storage.StorageFactory;
|
||||
@@ -74,12 +80,18 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -141,6 +153,8 @@ public class FaceServiceImpl implements FaceService {
|
||||
private MemberRelationRepository memberRelationRepository;
|
||||
@Autowired
|
||||
private TemplateRepository templateRepository;
|
||||
@Autowired
|
||||
private PrinterService printerService;
|
||||
|
||||
@Override
|
||||
public ApiResponse<PageInfo<FaceRespVO>> pageQuery(FaceReqQuery faceReqQuery) {
|
||||
@@ -204,16 +218,16 @@ public class FaceServiceImpl implements FaceService {
|
||||
SearchFaceRespVo userDbSearchResult = faceService.searchFace(faceBodyAdapter, USER_FACE_DB_NAME+scenicId, faceUrl, "判断是否为用户上传过的人脸");
|
||||
float strictScore = 0.6F;
|
||||
if (userDbSearchResult == null) {
|
||||
// 都是null了,那得是新的
|
||||
// 都是null了,那得是新的
|
||||
faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString());
|
||||
} else if (userDbSearchResult.getSampleListIds() == null || userDbSearchResult.getSampleListIds().isEmpty()) {
|
||||
// 没有匹配到过,也得是新的
|
||||
// 没有匹配到过,也得是新的
|
||||
faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString());
|
||||
} else if (userDbSearchResult.getFirstMatchRate() < strictScore) {
|
||||
// 有匹配结果,但是不匹配旧的
|
||||
// 有匹配结果,但是不匹配旧的
|
||||
faceBodyAdapter.addFace(USER_FACE_DB_NAME+scenicId, newFaceId.toString(), faceUrl, newFaceId.toString());
|
||||
} else {
|
||||
// 有匹配结果,且能匹配旧的数据
|
||||
// 有匹配结果,且能匹配旧的数据
|
||||
Optional<Long> faceAny = userDbSearchResult.getSampleListIds().stream().filter(_faceId -> {
|
||||
FaceEntity face = faceRepository.getFace(_faceId);
|
||||
if (face == null) {
|
||||
@@ -250,16 +264,15 @@ public class FaceServiceImpl implements FaceService {
|
||||
faceMapper.update(faceEntity);
|
||||
faceRepository.clearFaceCache(oldFaceId);
|
||||
}
|
||||
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
|
||||
statisticsRecordAddReq.setMemberId(userId);
|
||||
statisticsRecordAddReq.setType(StatisticEnum.UPLOAD_FACE.code);
|
||||
statisticsRecordAddReq.setScenicId(scenicId);
|
||||
statisticsRecordAddReq.setMorphId(newFaceId);
|
||||
statisticsMapper.addStatisticsRecord(statisticsRecordAddReq);
|
||||
FaceRecognizeResp resp = new FaceRecognizeResp();
|
||||
resp.setUrl(faceUrl);
|
||||
resp.setFaceId(newFaceId);
|
||||
matchFaceId(newFaceId, oldFaceId == null);
|
||||
|
||||
// 异步执行自动添加打印
|
||||
Long finalFaceId = newFaceId;
|
||||
new Thread(() -> autoAddPhotosToPreferPrint(finalFaceId), "auto-add-print-" + newFaceId).start();
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
@@ -279,19 +292,23 @@ public class FaceServiceImpl implements FaceService {
|
||||
if (faceId == null) {
|
||||
throw new IllegalArgumentException("faceId 不能为空");
|
||||
}
|
||||
|
||||
// 1. 数据准备:获取人脸信息、景区配置、适配器等
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("人脸不存在,faceId: {}", faceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isNew && Integer.valueOf(1).equals(face.getIsManual())) {
|
||||
log.info("人工选择的,无需匹配,faceId: {}", faceId);
|
||||
return null;
|
||||
}
|
||||
log.debug("开始人脸匹配:faceId={}, isNew={}", faceId, isNew);
|
||||
|
||||
// 记录识别次数到Redis,设置2天过期时间
|
||||
recordFaceRecognitionCount(faceId);
|
||||
|
||||
try {
|
||||
// 1. 数据准备:获取人脸信息、景区配置、适配器等
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("人脸不存在,faceId: {}", faceId);
|
||||
return null;
|
||||
}
|
||||
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
|
||||
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId());
|
||||
@@ -357,7 +374,14 @@ public class FaceServiceImpl implements FaceService {
|
||||
log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}", faceId, memberSourceEntityList.size());
|
||||
}
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
taskTaskService.autoCreateTaskByFaceId(faceId);
|
||||
|
||||
// 检查景区配置中的 face_select_first,如果为 true 则不自动创建任务
|
||||
Boolean faceSelectFirst = scenicConfig != null ? scenicConfig.getBoolean("face_select_first") : null;
|
||||
if (!Boolean.TRUE.equals(faceSelectFirst)) {
|
||||
taskTaskService.autoCreateTaskByFaceId(faceId);
|
||||
} else {
|
||||
log.debug("景区配置 face_select_first=true,跳过自动创建任务:faceId={}", faceId);
|
||||
}
|
||||
|
||||
log.info("人脸匹配完成:faceId={}, 匹配样本数={}, 关联源文件数={}, 免费数={}",
|
||||
faceId, sampleListIds.size(), memberSourceEntityList.size(), freeSourceIds.size());
|
||||
@@ -1098,21 +1122,23 @@ public class FaceServiceImpl implements FaceService {
|
||||
|
||||
@Override
|
||||
public void matchCustomFaceId(Long faceId, List<Long> faceSampleIds) {
|
||||
// 参数验证
|
||||
handleCustomFaceMatching(faceId, faceSampleIds);
|
||||
}
|
||||
|
||||
private SearchFaceRespVo handleCustomFaceMatching(Long faceId, List<Long> faceSampleIds) {
|
||||
if (faceId == null) {
|
||||
throw new IllegalArgumentException("faceId 不能为空");
|
||||
}
|
||||
if (faceSampleIds == null || faceSampleIds.isEmpty()) {
|
||||
throw new IllegalArgumentException("faceSampleIds 不能为空");
|
||||
}
|
||||
|
||||
|
||||
log.debug("开始自定义人脸匹配:faceId={}, faceSampleIds={}", faceId, faceSampleIds);
|
||||
|
||||
// 记录自定义匹配调用次数,便于监控调用频率
|
||||
recordCustomMatchCount(faceId);
|
||||
|
||||
|
||||
try {
|
||||
// 1. 获取基础数据
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
log.warn("人脸不存在,faceId: {}", faceId);
|
||||
@@ -1127,7 +1153,7 @@ public class FaceServiceImpl implements FaceService {
|
||||
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
|
||||
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId());
|
||||
|
||||
|
||||
if (faceBodyAdapter == null) {
|
||||
log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId());
|
||||
throw new BaseException("人脸识别服务不可用,请稍后再试");
|
||||
@@ -1138,27 +1164,28 @@ public class FaceServiceImpl implements FaceService {
|
||||
log.debug("face_select_post_mode配置值: {}", faceSelectPostMode);
|
||||
|
||||
SearchFaceRespVo mergedResult;
|
||||
|
||||
|
||||
// 2. 根据face_select_post_mode决定搜索策略
|
||||
if (Integer.valueOf(2).equals(faceSelectPostMode)) {
|
||||
// 模式2:不搜索,直接使用用户选择的faceSampleIds
|
||||
log.debug("使用模式2:直接使用用户选择的人脸样本,不进行搜索");
|
||||
mergedResult = createDirectResult(faceSampleIds);
|
||||
mergedResult.setSearchResultJson(face.getMatchResult()); // 没有检索
|
||||
} else {
|
||||
// 模式0(并集)和模式1(交集):需要进行搜索
|
||||
// 2.1 对每个faceSample进行人脸搜索
|
||||
List<SearchFaceRespVo> searchResults = new ArrayList<>();
|
||||
for (FaceSampleEntity faceSample : faceSamples) {
|
||||
try {
|
||||
SearchFaceRespVo result = faceService.searchFace(faceBodyAdapter,
|
||||
String.valueOf(face.getScenicId()),
|
||||
faceSample.getFaceUrl(),
|
||||
SearchFaceRespVo result = faceService.searchFace(faceBodyAdapter,
|
||||
String.valueOf(face.getScenicId()),
|
||||
faceSample.getFaceUrl(),
|
||||
"自定义人脸匹配");
|
||||
if (result != null) {
|
||||
searchResults.add(result);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}",
|
||||
log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}",
|
||||
faceSample.getId(), faceSample.getFaceUrl(), e);
|
||||
// 继续处理其他样本,不中断整个流程
|
||||
}
|
||||
@@ -1172,56 +1199,63 @@ public class FaceServiceImpl implements FaceService {
|
||||
// 2.2 根据模式整合多个搜索结果
|
||||
mergedResult = mergeSearchResults(searchResults, faceSelectPostMode);
|
||||
}
|
||||
|
||||
|
||||
// 3. 应用后置筛选逻辑
|
||||
if (mergedResult.getSampleListIds() != null && !mergedResult.getSampleListIds().isEmpty()) {
|
||||
List<FaceSampleEntity> allFaceSampleList = faceSampleMapper.listByIds(mergedResult.getSampleListIds());
|
||||
List<Long> filteredSampleIds = faceService.applySampleFilters(mergedResult.getSampleListIds(), allFaceSampleList, scenicConfig);
|
||||
List<Long> filteredSampleIds = faceService.applySampleFilters(
|
||||
mergedResult.getSampleListIds(), allFaceSampleList, scenicConfig);
|
||||
mergedResult.setSampleListIds(filteredSampleIds);
|
||||
log.debug("应用后置筛选:原始样本数={}, 筛选后样本数={}", allFaceSampleList.size(), filteredSampleIds.size());
|
||||
log.debug("应用后置筛选:原始样本数={}, 筛选后样本数={}",
|
||||
allFaceSampleList.size(), filteredSampleIds.size());
|
||||
}
|
||||
|
||||
// 5. 更新人脸实体结果
|
||||
updateFaceEntityResult(face, mergedResult, faceId);
|
||||
|
||||
// 6. 执行后续业务逻辑
|
||||
|
||||
List<Long> sampleListIds = mergedResult.getSampleListIds();
|
||||
if (sampleListIds != null && !sampleListIds.isEmpty()) {
|
||||
try {
|
||||
// 在保存新关系前,删除该人脸的旧数据关系(member_source 和 member_video)
|
||||
log.debug("删除人脸旧关系数据:faceId={}, memberId={}", faceId, face.getMemberId());
|
||||
sourceMapper.deleteNotBuyFaceRelation(face.getMemberId(), faceId);
|
||||
videoMapper.deleteNotBuyFaceRelations(face.getMemberId(), faceId);
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
log.debug("人脸旧关系数据删除完成:faceId={}", faceId);
|
||||
|
||||
List<MemberSourceEntity> memberSourceEntityList = processMemberSources(sampleListIds, face);
|
||||
|
||||
|
||||
if (!memberSourceEntityList.isEmpty()) {
|
||||
List<Long> freeSourceIds = processFreeSourceLogic(memberSourceEntityList, scenicConfig, false);
|
||||
processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(),
|
||||
face.getScenicId(), faceId);
|
||||
|
||||
handleVideoRecreation(scenicConfig, memberSourceEntityList, faceId,
|
||||
face.getMemberId(), sampleListIds, false);
|
||||
|
||||
// 过滤已存在的关联关系和无效的source引用,防止数据不一致
|
||||
processBuyStatus(memberSourceEntityList, freeSourceIds, face.getMemberId(),
|
||||
face.getScenicId(), faceId);
|
||||
|
||||
handleVideoRecreation(scenicConfig, memberSourceEntityList, faceId,
|
||||
face.getMemberId(), sampleListIds, false);
|
||||
|
||||
List<MemberSourceEntity> existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList);
|
||||
List<MemberSourceEntity> validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered);
|
||||
if (!validFiltered.isEmpty()) {
|
||||
sourceMapper.addRelations(validFiltered);
|
||||
log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}",
|
||||
faceId, memberSourceEntityList.size(), validFiltered.size());
|
||||
log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}",
|
||||
faceId, memberSourceEntityList.size(), validFiltered.size());
|
||||
} else {
|
||||
log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}", faceId, memberSourceEntityList.size());
|
||||
}
|
||||
memberRelationRepository.clearSCacheByFace(faceId);
|
||||
taskTaskService.autoCreateTaskByFaceId(faceId);
|
||||
|
||||
|
||||
log.info("自定义人脸匹配完成:faceId={}, 匹配样本数={}, 关联源文件数={}, 免费数={}",
|
||||
faceId, sampleListIds.size(), memberSourceEntityList.size(), freeSourceIds.size());
|
||||
faceId, sampleListIds.size(), memberSourceEntityList.size(), freeSourceIds.size());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("处理源文件关联失败,faceId={}", faceId, e);
|
||||
// 源文件关联失败不影响主流程
|
||||
}
|
||||
} else {
|
||||
log.warn("自定义人脸匹配无结果:faceId={}, faceSampleIds={}", faceId, faceSampleIds);
|
||||
}
|
||||
|
||||
|
||||
return mergedResult;
|
||||
|
||||
} catch (BaseException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
@@ -1237,9 +1271,272 @@ public class FaceServiceImpl implements FaceService {
|
||||
return mergeSearchResults(searchResults, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateRecognition(FaceRecognitionUpdateReq req) {
|
||||
if (req == null || req.getFaceId() == null) {
|
||||
throw new IllegalArgumentException("faceId 不能为空");
|
||||
}
|
||||
Long faceId = req.getFaceId();
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
throw new BaseException("人脸不存在");
|
||||
}
|
||||
|
||||
boolean forceRematch = Boolean.TRUE.equals(req.getForceRematch());
|
||||
if (forceRematch) {
|
||||
matchFaceId(faceId, false);
|
||||
face = faceRepository.getFace(faceId);
|
||||
}
|
||||
|
||||
List<Long> currentAccepted = parseMatchSampleIds(face.getMatchSampleIds());
|
||||
List<Long> manualAccepted = Optional.ofNullable(req.getManualAcceptedSampleIds()).orElse(Collections.emptyList());
|
||||
|
||||
// 直接使用上传的样本ID列表,不与旧数据合并
|
||||
List<Long> finalSampleList = manualAccepted.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
|
||||
boolean hasManualChange = !manualAccepted.isEmpty();
|
||||
boolean needsUpdate = hasManualChange && !finalSampleList.equals(currentAccepted);
|
||||
|
||||
if (needsUpdate) {
|
||||
if (finalSampleList.isEmpty()) {
|
||||
throw new BaseException("至少需要保留一个样本");
|
||||
}
|
||||
|
||||
// 设置人工调整标记
|
||||
faceMapper.updateManualFlag(faceId, 1);
|
||||
faceRepository.clearFaceCache(faceId);
|
||||
|
||||
handleCustomFaceMatching(faceId, finalSampleList);
|
||||
}
|
||||
|
||||
if (Strings.isNotBlank(req.getRemark())) {
|
||||
log.info("人脸识别人工调整备注:faceId={}, remark={}", faceId, req.getRemark());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public FaceRecognitionDetailVO getRecognitionDetail(Long faceId) {
|
||||
if (faceId == null) {
|
||||
throw new IllegalArgumentException("faceId 不能为空");
|
||||
}
|
||||
FaceEntity face = faceRepository.getFace(faceId);
|
||||
if (face == null) {
|
||||
throw new BaseException("人脸不存在");
|
||||
}
|
||||
|
||||
FaceRecognitionDetailVO detail = new FaceRecognitionDetailVO();
|
||||
detail.setFaceId(faceId);
|
||||
detail.setMemberId(face.getMemberId());
|
||||
detail.setScenicId(face.getScenicId());
|
||||
detail.setFaceUrl(face.getFaceUrl());
|
||||
detail.setScore(face.getScore());
|
||||
detail.setFirstMatchRate(face.getFirstMatchRate() != null ? face.getFirstMatchRate().floatValue() : null);
|
||||
detail.setLowThreshold(redisTemplate.hasKey(FACE_LOW_THRESHOLD_PFX + faceId));
|
||||
detail.setLastMatchedAt(face.getUpdateAt() != null ? face.getUpdateAt() : face.getCreateAt());
|
||||
|
||||
String matchResultJson = face.getMatchResult();
|
||||
if (Strings.isBlank(matchResultJson)) {
|
||||
detail.setAcceptedSamples(Collections.emptyList());
|
||||
detail.setFilteredSamples(Collections.emptyList());
|
||||
return detail;
|
||||
}
|
||||
|
||||
List<SearchFaceResultItem> resultItems = JacksonUtil.fromJsonToList(matchResultJson, SearchFaceResultItem.class);
|
||||
if (resultItems == null) {
|
||||
resultItems = Collections.emptyList();
|
||||
}
|
||||
|
||||
// 获取景区配置的得分阈值
|
||||
Float scoreThreshold = null;
|
||||
try {
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
|
||||
if (scenicConfig != null) {
|
||||
BigDecimal thresholdConfig = scenicConfig.getBigDecimal("face_select_score_threshold");
|
||||
if (thresholdConfig != null) {
|
||||
// 配置值是0~100的小数,需要转换为0~1的阈值
|
||||
scoreThreshold = thresholdConfig.floatValue() / 100.0f;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("获取景区人脸得分阈值配置失败, scenicId: {}, 将不进行得分筛选", face.getScenicId(), e);
|
||||
}
|
||||
|
||||
List<Long> persistedAcceptedIds = parseMatchSampleIds(face.getMatchSampleIds());
|
||||
LinkedHashSet<Long> sampleUniverse = new LinkedHashSet<>();
|
||||
Map<Long, SearchFaceResultItem> itemBySampleId = new LinkedHashMap<>();
|
||||
for (SearchFaceResultItem item : resultItems) {
|
||||
Long sampleId = parseLongSilently(item.getExtData());
|
||||
if (sampleId != null) {
|
||||
// 根据得分阈值筛选样本
|
||||
if (scoreThreshold != null && item.getScore() != null && item.getScore() < scoreThreshold) {
|
||||
// 得分低于阈值,跳过此样本
|
||||
continue;
|
||||
}
|
||||
sampleUniverse.add(sampleId);
|
||||
itemBySampleId.putIfAbsent(sampleId, item);
|
||||
}
|
||||
}
|
||||
sampleUniverse.addAll(persistedAcceptedIds);
|
||||
|
||||
List<Long> allSampleIds = new ArrayList<>(sampleUniverse);
|
||||
if (allSampleIds.isEmpty()) {
|
||||
detail.setAcceptedSamples(Collections.emptyList());
|
||||
detail.setFilteredSamples(Collections.emptyList());
|
||||
return detail;
|
||||
}
|
||||
|
||||
List<SourceEntity> sourceEntities = sourceMapper.listBySampleIds(allSampleIds);
|
||||
Map<Long, SourceEntity> sourceBySampleId = sourceEntities.stream()
|
||||
.collect(Collectors.toMap(SourceEntity::getFaceSampleId, Function.identity(), (a, b) -> a, LinkedHashMap::new));
|
||||
Map<Long, SourceEntity> sourceById = sourceEntities.stream()
|
||||
.collect(Collectors.toMap(SourceEntity::getId, Function.identity(), (a, b) -> a));
|
||||
|
||||
List<MemberSourceEntity> relations = new ArrayList<>();
|
||||
List<MemberSourceEntity> videoRelations = memberRelationRepository.listSourceByFaceRelation(faceId, 1);
|
||||
if (videoRelations != null) {
|
||||
relations.addAll(videoRelations);
|
||||
}
|
||||
List<MemberSourceEntity> imageRelations = memberRelationRepository.listSourceByFaceRelation(faceId, 2);
|
||||
if (imageRelations != null) {
|
||||
relations.addAll(imageRelations);
|
||||
}
|
||||
Map<Long, MemberSourceEntity> relationBySampleId = new HashMap<>();
|
||||
for (MemberSourceEntity relation : relations) {
|
||||
SourceEntity source = sourceById.get(relation.getSourceId());
|
||||
if (source != null && source.getFaceSampleId() != null) {
|
||||
relationBySampleId.putIfAbsent(source.getFaceSampleId(), relation);
|
||||
}
|
||||
}
|
||||
|
||||
Map<Long, DeviceEntity> deviceCache = new HashMap<>();
|
||||
|
||||
Set<Long> persistedAcceptedSet = new LinkedHashSet<>(persistedAcceptedIds);
|
||||
List<Long> acceptedOrdered = new ArrayList<>();
|
||||
for (Long sampleId : allSampleIds) {
|
||||
if (persistedAcceptedSet.contains(sampleId)) {
|
||||
acceptedOrdered.add(sampleId);
|
||||
}
|
||||
}
|
||||
for (Long sampleId : persistedAcceptedIds) {
|
||||
if (!acceptedOrdered.contains(sampleId)) {
|
||||
acceptedOrdered.add(sampleId);
|
||||
}
|
||||
}
|
||||
|
||||
List<FaceRecognitionSampleVO> acceptedSamples = acceptedOrdered.stream()
|
||||
.map(sampleId -> buildSampleVO(
|
||||
sampleId,
|
||||
true,
|
||||
itemBySampleId.get(sampleId),
|
||||
sourceBySampleId.get(sampleId),
|
||||
deviceCache))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
Set<Long> acceptedSet = new LinkedHashSet<>(acceptedOrdered);
|
||||
List<FaceRecognitionSampleVO> filteredSamples = new ArrayList<>();
|
||||
for (Long sampleId : allSampleIds) {
|
||||
if (acceptedSet.contains(sampleId)) {
|
||||
continue;
|
||||
}
|
||||
FaceRecognitionSampleVO sampleVO = buildSampleVO(
|
||||
sampleId,
|
||||
false,
|
||||
itemBySampleId.get(sampleId),
|
||||
sourceBySampleId.get(sampleId),
|
||||
deviceCache);
|
||||
if (sampleVO.getSourceId() != null) {
|
||||
filteredSamples.add(sampleVO);
|
||||
}
|
||||
}
|
||||
|
||||
detail.setAcceptedSamples(acceptedSamples);
|
||||
detail.setFilteredSamples(filteredSamples);
|
||||
return detail;
|
||||
}
|
||||
|
||||
private List<Long> parseMatchSampleIds(String matchSampleIds) {
|
||||
if (Strings.isBlank(matchSampleIds)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String[] segments = matchSampleIds.split(",");
|
||||
List<Long> result = new ArrayList<>(segments.length);
|
||||
for (String segment : segments) {
|
||||
Long id = parseLongSilently(segment);
|
||||
if (id != null) {
|
||||
result.add(id);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Long parseLongSilently(String value) {
|
||||
if (Strings.isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private FaceRecognitionSampleVO buildSampleVO(Long sampleId,
|
||||
boolean accepted,
|
||||
SearchFaceResultItem resultItem,
|
||||
SourceEntity sourceEntity,
|
||||
Map<Long, DeviceEntity> deviceCache) {
|
||||
FaceRecognitionSampleVO vo = new FaceRecognitionSampleVO();
|
||||
vo.setSampleId(sampleId);
|
||||
vo.setAccepted(accepted);
|
||||
if (resultItem != null) {
|
||||
vo.setScore(resultItem.getScore());
|
||||
}
|
||||
if (sourceEntity != null) {
|
||||
if (sourceEntity.getDeviceId() != null) {
|
||||
vo.setDeviceId(sourceEntity.getDeviceId());
|
||||
vo.setShotAt(sourceEntity.getCreateTime());
|
||||
DeviceEntity device = getDeviceCached(sourceEntity.getDeviceId(), deviceCache);
|
||||
if (device != null) {
|
||||
vo.setDeviceName(device.getName());
|
||||
}
|
||||
}
|
||||
vo.setSourceId(sourceEntity.getId());
|
||||
vo.setSourceUrl(resolveSourceUrl(sourceEntity));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private DeviceEntity getDeviceCached(Long deviceId, Map<Long, DeviceEntity> cache) {
|
||||
if (deviceId == null) {
|
||||
return null;
|
||||
}
|
||||
if (cache.containsKey(deviceId)) {
|
||||
return cache.get(deviceId);
|
||||
}
|
||||
DeviceEntity device = deviceRepository.getDevice(deviceId);
|
||||
cache.put(deviceId, device);
|
||||
return device;
|
||||
}
|
||||
|
||||
private String resolveSourceUrl(SourceEntity sourceEntity) {
|
||||
if (sourceEntity == null) {
|
||||
return null;
|
||||
}
|
||||
if (!Strings.isBlank(sourceEntity.getThumbUrl())) {
|
||||
return sourceEntity.getThumbUrl();
|
||||
}
|
||||
if (!Strings.isBlank(sourceEntity.getUrl())) {
|
||||
return sourceEntity.getUrl();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个搜索结果
|
||||
*
|
||||
*
|
||||
* @param searchResults 搜索结果列表
|
||||
* @param mergeMode 合并模式:0-并集,1-交集
|
||||
* @return 合并后的结果
|
||||
@@ -1250,7 +1547,7 @@ public class FaceServiceImpl implements FaceService {
|
||||
if (searchResults == null || searchResults.isEmpty()) {
|
||||
return mergedResult;
|
||||
}
|
||||
|
||||
|
||||
List<String> allSearchJsons = new ArrayList<>();
|
||||
float maxScore = 0f;
|
||||
float maxFirstMatchRate = 0f;
|
||||
@@ -1289,7 +1586,7 @@ public class FaceServiceImpl implements FaceService {
|
||||
finalSampleIds = new ArrayList<>(allSampleIds);
|
||||
log.debug("使用并集模式合并搜索结果,并集样本数: {}", finalSampleIds.size());
|
||||
}
|
||||
|
||||
|
||||
mergedResult.setSampleListIds(finalSampleIds);
|
||||
mergedResult.setSearchResultJson(String.join("|", allSearchJsons));
|
||||
mergedResult.setScore(maxScore);
|
||||
@@ -1309,52 +1606,52 @@ public class FaceServiceImpl implements FaceService {
|
||||
if (searchResults == null || searchResults.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
|
||||
// 过滤掉空结果
|
||||
List<List<Long>> validSampleLists = searchResults.stream()
|
||||
.filter(result -> result.getSampleListIds() != null && !result.getSampleListIds().isEmpty())
|
||||
.map(SearchFaceRespVo::getSampleListIds)
|
||||
.toList();
|
||||
|
||||
|
||||
if (validSampleLists.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
|
||||
// 如果只有一个有效结果,直接返回
|
||||
if (validSampleLists.size() == 1) {
|
||||
return new ArrayList<>(validSampleLists.getFirst());
|
||||
}
|
||||
|
||||
|
||||
// 计算交集:从第一个列表开始,保留在所有其他列表中都出现的ID
|
||||
Set<Long> intersection = new LinkedHashSet<>(validSampleLists.getFirst());
|
||||
|
||||
|
||||
for (int i = 1; i < validSampleLists.size(); i++) {
|
||||
intersection.retainAll(validSampleLists.get(i));
|
||||
}
|
||||
|
||||
|
||||
return new ArrayList<>(intersection);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建直接结果(模式2:不搜索,直接使用用户选择的faceSampleIds)
|
||||
*
|
||||
*
|
||||
* @param faceSampleIds 用户选择的人脸样本ID列表
|
||||
* @return 搜索结果对象
|
||||
*/
|
||||
private SearchFaceRespVo createDirectResult(List<Long> faceSampleIds) {
|
||||
SearchFaceRespVo result = new SearchFaceRespVo();
|
||||
|
||||
|
||||
// 直接使用用户选择的faceSampleIds作为结果
|
||||
result.setSampleListIds(new ArrayList<>(faceSampleIds));
|
||||
|
||||
|
||||
// 设置默认值
|
||||
result.setScore(1.0f);
|
||||
result.setFirstMatchRate(1.0f);
|
||||
result.setLowThreshold(false);
|
||||
result.setSearchResultJson("");
|
||||
|
||||
|
||||
log.debug("创建直接结果,样本数: {}", faceSampleIds.size());
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1430,4 +1727,114 @@ public class FaceServiceImpl implements FaceService {
|
||||
log.error("记录低阈值检测人脸失败:faceId={}", faceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动将人脸关联的照片添加到优先打印列表
|
||||
* 根据景区和设备配置自动添加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());
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,11 +113,21 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
|
||||
@Override
|
||||
public ApiResponse<Integer> add(PrinterEntity entity) {
|
||||
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(entity.getScenicId());
|
||||
Boolean printEnable = scenicConfig.getBoolean("print_enable");
|
||||
if (!Boolean.TRUE.equals(printEnable)) {
|
||||
return ApiResponse.fail("景区没有开启打印功能!");
|
||||
}
|
||||
return ApiResponse.success(printerMapper.add(entity));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse<Integer> update(PrinterEntity entity) {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -148,7 +158,7 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> printers = req.getPrinters();
|
||||
String printersStr = StringUtils.join(printers, ",");
|
||||
String printersStr = StringUtils.join(printers, "|");
|
||||
if (!Strings.CS.equals(printersStr, printer.getPrinters())) {
|
||||
printer.setPrinters(printersStr);
|
||||
printerMapper.update(printer);
|
||||
@@ -231,7 +241,58 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
entity.setMemberId(memberId);
|
||||
entity.setScenicId(scenicId);
|
||||
entity.setOrigUrl(url);
|
||||
entity.setCropUrl(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 {
|
||||
// 上传裁剪后的图片(使用File版本的uploadFile方法)
|
||||
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={}, 原图={}, 裁剪后={}, 尺寸={}x{}",
|
||||
memberId, scenicId, url, cropUrl, printWidth, printHeight);
|
||||
} finally {
|
||||
// 清理临时文件
|
||||
if (croppedFile != null && croppedFile.exists()) {
|
||||
croppedFile.delete();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("照片裁剪失败,使用原图: memberId={}, scenicId={}, url={}", memberId, scenicId, url, e);
|
||||
// 出现异常则使用原图
|
||||
cropUrl = url;
|
||||
}
|
||||
|
||||
entity.setCropUrl(cropUrl);
|
||||
entity.setStatus(0);
|
||||
printerMapper.addUserPhoto(entity);
|
||||
return entity.getId();
|
||||
@@ -444,6 +505,8 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
@Autowired
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
private static final String USER_PHOTO_LIST_TO_PRINTER = "USER_PHOTO_LIST_TO_PRINTER:";
|
||||
private static final String PRINTER_INDEX_KEY_PREFIX = "PRINTER_INDEX:";
|
||||
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 final Lock syncTaskLock = new ReentrantLock();
|
||||
@@ -559,8 +622,12 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
log.error("获取景区配置失败,使用原始照片进行打印。景区ID: {}, 照片ID: {}", item.getScenicId(), item.getId(), e);
|
||||
}
|
||||
|
||||
// 获取打印机名称(支持轮询)
|
||||
String selectedPrinter = getNextPrinter(printer);
|
||||
|
||||
PrintTaskEntity task = new PrintTaskEntity();
|
||||
task.setPrinterId(printer.getId());
|
||||
task.setPrinterName(selectedPrinter);
|
||||
task.setMpId(item.getId());
|
||||
task.setPaper(printer.getPreferPaper());
|
||||
task.setStatus(0);
|
||||
@@ -572,4 +639,52 @@ public class PrinterServiceImpl implements PrinterService {
|
||||
printTaskMapper.insertTask(task);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下一个要使用的打印机名称(轮询逻辑)
|
||||
*
|
||||
* @param printer 打印机实体
|
||||
* @return 选中的打印机名称
|
||||
*/
|
||||
private String getNextPrinter(PrinterEntity printer) {
|
||||
String usePrinter = printer.getUsePrinter();
|
||||
if (StringUtils.isBlank(usePrinter)) {
|
||||
log.warn("打印机 {} 没有配置 usePrinter", printer.getId());
|
||||
return "";
|
||||
}
|
||||
|
||||
// 分割打印机列表
|
||||
String[] printers = usePrinter.split("\\|");
|
||||
if (printers.length == 0) {
|
||||
log.warn("打印机 {} 的 usePrinter 配置为空", printer.getId());
|
||||
return "";
|
||||
}
|
||||
|
||||
// 如果只有一个打印机,直接返回
|
||||
if (printers.length == 1) {
|
||||
return printers[0].trim();
|
||||
}
|
||||
|
||||
// 从 Redis 原子递增获取索引
|
||||
String redisKey = PRINTER_INDEX_KEY_PREFIX + printer.getId();
|
||||
Long incrementedValue = redisTemplate.opsForValue().increment(redisKey);
|
||||
|
||||
if (incrementedValue == null) {
|
||||
log.warn("Redis increment 操作失败,打印机ID: {}", printer.getId());
|
||||
incrementedValue = 1L;
|
||||
}
|
||||
|
||||
// 每次递增后都重新设置过期时间为 5 分钟
|
||||
redisTemplate.expire(redisKey, PRINTER_INDEX_EXPIRE_MINUTES, TimeUnit.MINUTES);
|
||||
|
||||
// 对打印机数量取模得到当前索引
|
||||
int currentIndex = (int) ((incrementedValue - 1) % printers.length);
|
||||
|
||||
// 获取当前打印机
|
||||
String selectedPrinter = printers[currentIndex].trim();
|
||||
|
||||
log.debug("打印机 {} 选择了第 {} 个打印机: {} (递增值: {})", printer.getId(), currentIndex, selectedPrinter, incrementedValue);
|
||||
|
||||
return selectedPrinter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public interface TaskFaceService {
|
||||
* @param scenicConfig 景区配置管理器
|
||||
* @return 筛选后的样本ID列表
|
||||
*/
|
||||
List<Long> applySampleFilters(List<Long> acceptedSampleIds,
|
||||
List<FaceSampleEntity> allFaceSampleList,
|
||||
List<Long> applySampleFilters(List<Long> acceptedSampleIds,
|
||||
List<FaceSampleEntity> allFaceSampleList,
|
||||
ScenicConfigManager scenicConfig);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||
|
||||
import com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLog;
|
||||
import com.ycwl.basic.model.pc.faceDetectLog.resp.MatchLocalRecord;
|
||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||
@@ -30,6 +31,7 @@ import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
|
||||
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
|
||||
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||
|
||||
import com.ycwl.basic.repository.DeviceRepository;
|
||||
import com.ycwl.basic.repository.FaceRepository;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
@@ -50,9 +52,14 @@ import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -264,7 +271,8 @@ public class TaskFaceServiceImpl implements TaskFaceService {
|
||||
idIndexMap.put(allFaceSampleIds.get(i), i);
|
||||
}
|
||||
allFaceSampleList.sort(Comparator.comparing(sample -> idIndexMap.get(sample.getId())));
|
||||
acceptFaceSampleIds = applySampleFilters(acceptFaceSampleIds, allFaceSampleList, scenicConfig);
|
||||
List<Long> finalAcceptedSampleIds = applySampleFilters(acceptFaceSampleIds, allFaceSampleList, scenicConfig);
|
||||
|
||||
List<MatchLocalRecord> collect = new ArrayList<>();
|
||||
for (SearchFaceResultItem item : records) {
|
||||
MatchLocalRecord record = new MatchLocalRecord();
|
||||
@@ -277,7 +285,7 @@ public class TaskFaceServiceImpl implements TaskFaceService {
|
||||
if (device != null) {
|
||||
record.setDeviceName(device.getName());
|
||||
}
|
||||
record.setAccept(acceptFaceSampleIds.contains(optionalFse.get().getId()));
|
||||
record.setAccept(finalAcceptedSampleIds.contains(optionalFse.get().getId()));
|
||||
record.setFaceUrl(optionalFse.get().getFaceUrl());
|
||||
record.setShotDate(optionalFse.get().getCreateAt());
|
||||
}
|
||||
@@ -289,13 +297,13 @@ public class TaskFaceServiceImpl implements TaskFaceService {
|
||||
collect.add(record);
|
||||
}
|
||||
logEntity.setMatchLocalRecord(JacksonUtil.toJSONString(collect));
|
||||
if (acceptFaceSampleIds.isEmpty()) {
|
||||
if (finalAcceptedSampleIds.isEmpty()) {
|
||||
respVo.setFirstMatchRate(0f);
|
||||
respVo.setSampleListIds(Collections.emptyList());
|
||||
return respVo;
|
||||
}
|
||||
respVo.setFirstMatchRate(response.getFirstMatchRate());
|
||||
respVo.setSampleListIds(acceptFaceSampleIds);
|
||||
respVo.setSampleListIds(finalAcceptedSampleIds);
|
||||
return respVo;
|
||||
} catch (Exception e) {
|
||||
logEntity.setMatchRawResult("识别错误,错误为:["+e.getLocalizedMessage()+"]");
|
||||
@@ -389,171 +397,143 @@ public class TaskFaceServiceImpl implements TaskFaceService {
|
||||
*/
|
||||
@Override
|
||||
public List<Long> applySampleFilters(List<Long> acceptedSampleIds,
|
||||
List<FaceSampleEntity> allFaceSampleList,
|
||||
ScenicConfigManager scenicConfig) {
|
||||
List<FaceSampleEntity> allFaceSampleList,
|
||||
ScenicConfigManager scenicConfig) {
|
||||
if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) {
|
||||
return acceptedSampleIds;
|
||||
return acceptedSampleIds == null ? Collections.emptyList() : new ArrayList<>(acceptedSampleIds);
|
||||
}
|
||||
if (scenicConfig == null) {
|
||||
// 没有配置,不管
|
||||
return acceptedSampleIds;
|
||||
if (allFaceSampleList == null || allFaceSampleList.isEmpty()) {
|
||||
return new ArrayList<>(acceptedSampleIds);
|
||||
}
|
||||
|
||||
// 1. 找到最高匹配的样本(按创建时间倒序排列的第一个)
|
||||
Optional<FaceSampleEntity> firstFaceSample = allFaceSampleList.stream()
|
||||
.filter(faceSample -> acceptedSampleIds.contains(faceSample.getId())).max(Comparator.comparing(FaceSampleEntity::getCreateAt));
|
||||
Map<Long, FaceSampleEntity> sampleMap = allFaceSampleList.stream()
|
||||
.collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a));
|
||||
|
||||
if (firstFaceSample.isEmpty()) {
|
||||
log.warn("样本筛选逻辑:未找到匹配的人脸样本,acceptedIds: {}", acceptedSampleIds);
|
||||
return acceptedSampleIds;
|
||||
List<Long> workingList = acceptedSampleIds.stream()
|
||||
.filter(sampleMap::containsKey)
|
||||
.collect(Collectors.toCollection(ArrayList::new));
|
||||
|
||||
if (workingList.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
FaceSampleEntity topMatchSample = firstFaceSample.get();
|
||||
log.debug("样本筛选逻辑:找到最高匹配样本 ID={}, 创建时间={}",
|
||||
topMatchSample.getId(), topMatchSample.getCreateAt());
|
||||
|
||||
List<Long> filteredIds = acceptedSampleIds;
|
||||
|
||||
// 2. 应用时间范围筛选(基于景区配置)
|
||||
if (scenicConfig.getInteger("tour_time", 0) > 0) {
|
||||
filteredIds = filterSampleIdsByTimeRange(filteredIds, topMatchSample, scenicConfig.getInteger("tour_time"));
|
||||
log.debug("应用时间范围筛选:游览时间限制={}分钟", scenicConfig.getInteger("tour_time"));
|
||||
Integer tourMinutes = scenicConfig != null ? scenicConfig.getInteger("tour_time") : null;
|
||||
if (tourMinutes != null && tourMinutes > 0) {
|
||||
workingList = filterSampleIdsByTimeRange(workingList, sampleMap, tourMinutes);
|
||||
log.debug("应用时间范围筛选:游览时间限制={}分钟,过滤后数量={}", tourMinutes, workingList.size());
|
||||
} else {
|
||||
log.debug("时间范围逻辑:景区未设置游览时间限制");
|
||||
}
|
||||
|
||||
workingList = applyDevicePhotoLimit(workingList, sampleMap);
|
||||
|
||||
|
||||
// 3. 应用设备照片数量限制筛选
|
||||
filteredIds = applyDevicePhotoLimit(filteredIds, allFaceSampleList);
|
||||
log.debug("应用设备照片数量限制筛选完成");
|
||||
|
||||
// 4. TODO: 基于景区配置的其他筛选策略
|
||||
// 可以根据 scenicConfig 中的配置来决定是否启用特定筛选
|
||||
// 示例:未来可能的筛选策略
|
||||
// if (scenicConfig.getEnableLocationFilter() != null && scenicConfig.getEnableLocationFilter()) {
|
||||
// filteredIds = applyLocationFilter(filteredIds, allFaceSampleList, scenicConfig);
|
||||
// }
|
||||
// if (scenicConfig.getEnableQualityFilter() != null && scenicConfig.getEnableQualityFilter()) {
|
||||
// filteredIds = applyQualityFilter(filteredIds, allFaceSampleList, scenicConfig);
|
||||
// }
|
||||
// if (scenicConfig.getMaxSampleCount() != null) {
|
||||
// filteredIds = applySampleCountLimit(filteredIds, scenicConfig.getMaxSampleCount());
|
||||
// }
|
||||
|
||||
log.debug("样本筛选完成:原始数量={}, 最终数量={}",
|
||||
acceptedSampleIds.size(), filteredIds.size());
|
||||
return filteredIds;
|
||||
log.debug("样本筛选完成:原始数量={}, 最终数量={}", acceptedSampleIds.size(), workingList.size());
|
||||
return new ArrayList<>(workingList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据时间范围过滤人脸样本ID
|
||||
* 基于最高匹配样本的时间,过滤出指定时间范围内的样本ID
|
||||
*/
|
||||
private List<Long> filterSampleIdsByTimeRange(List<Long> acceptedSampleIds,
|
||||
FaceSampleEntity firstMatch,
|
||||
int tourMinutes) {
|
||||
if (acceptedSampleIds == null || acceptedSampleIds.isEmpty() ||
|
||||
firstMatch == null || tourMinutes <= 0) {
|
||||
return acceptedSampleIds;
|
||||
}
|
||||
|
||||
List<FaceSampleEntity> acceptFaceSampleList = faceSampleMapper.listByIds(acceptedSampleIds);
|
||||
if (acceptFaceSampleList.isEmpty()) {
|
||||
return acceptedSampleIds;
|
||||
}
|
||||
|
||||
Date startDate = DateUtil.offsetMinute(firstMatch.getCreateAt(), -tourMinutes);
|
||||
Date endDate = DateUtil.offsetMinute(firstMatch.getCreateAt(), 1);
|
||||
|
||||
List<Long> filteredIds = acceptFaceSampleList.stream()
|
||||
.filter(faceSample -> faceSample.getCreateAt().after(startDate) &&
|
||||
faceSample.getCreateAt().before(endDate))
|
||||
.map(FaceSampleEntity::getId)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
log.info("时间范围逻辑:最高匹配:{},时间范围:{}~{},原样本数:{},过滤后样本数:{}",
|
||||
firstMatch.getId(), startDate, endDate, acceptedSampleIds.size(), filteredIds.size());
|
||||
|
||||
return filteredIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设备配置的limit_photo值限制每个设备的照片数量
|
||||
*
|
||||
* @param acceptedSampleIds 已接受的样本ID列表
|
||||
* @param allFaceSampleList 所有人脸样本实体列表
|
||||
* @return 应用设备照片数量限制后的样本ID列表
|
||||
*/
|
||||
private List<Long> applyDevicePhotoLimit(List<Long> acceptedSampleIds,
|
||||
List<FaceSampleEntity> allFaceSampleList) {
|
||||
private List<Long> filterSampleIdsByTimeRange(List<Long> acceptedSampleIds,
|
||||
Map<Long, FaceSampleEntity> sampleMap,
|
||||
int tourMinutes) {
|
||||
if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
FaceSampleEntity topMatchSample = acceptedSampleIds.stream()
|
||||
.map(sampleMap::get)
|
||||
.filter(Objects::nonNull)
|
||||
.max(Comparator.comparing(FaceSampleEntity::getCreateAt))
|
||||
.orElse(null);
|
||||
if (topMatchSample == null || topMatchSample.getCreateAt() == null) {
|
||||
log.warn("样本筛选逻辑:未找到匹配的人脸样本,acceptedIds: {}", acceptedSampleIds);
|
||||
return acceptedSampleIds;
|
||||
}
|
||||
|
||||
// 获取过滤后的样本列表
|
||||
List<FaceSampleEntity> filteredSamples = allFaceSampleList.stream()
|
||||
.filter(sample -> acceptedSampleIds.contains(sample.getId()))
|
||||
.collect(Collectors.toList());
|
||||
Date startDate = DateUtil.offsetMinute(topMatchSample.getCreateAt(), -tourMinutes);
|
||||
Date endDate = DateUtil.offsetMinute(topMatchSample.getCreateAt(), 1);
|
||||
|
||||
// 按设备ID分组
|
||||
Map<Long, List<FaceSampleEntity>> samplesByDevice = filteredSamples.stream()
|
||||
.collect(Collectors.groupingBy(FaceSampleEntity::getDeviceId));
|
||||
List<Long> result = new ArrayList<>();
|
||||
for (Long sampleId : acceptedSampleIds) {
|
||||
FaceSampleEntity sample = sampleMap.get(sampleId);
|
||||
if (sample == null || sample.getCreateAt() == null) {
|
||||
result.add(sampleId);
|
||||
continue;
|
||||
}
|
||||
Date createAt = sample.getCreateAt();
|
||||
if (createAt.after(startDate) && createAt.before(endDate)) {
|
||||
result.add(sampleId);
|
||||
}
|
||||
}
|
||||
|
||||
List<Long> resultIds = new ArrayList<>();
|
||||
log.info("时间范围逻辑:最高匹配:{},时间范围:{}~{},原样本数:{},过滤后样本数:{}",
|
||||
topMatchSample.getId(), startDate, endDate, acceptedSampleIds.size(), result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 处理每个设备的样本
|
||||
for (Map.Entry<Long, List<FaceSampleEntity>> entry : samplesByDevice.entrySet()) {
|
||||
private List<Long> applyDevicePhotoLimit(List<Long> acceptedSampleIds,
|
||||
Map<Long, FaceSampleEntity> sampleMap) {
|
||||
if (acceptedSampleIds == null || acceptedSampleIds.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
Map<Long, List<FaceSampleEntity>> deviceSamplesMap = new LinkedHashMap<>();
|
||||
Set<Long> passthroughSampleIds = new LinkedHashSet<>();
|
||||
for (Long sampleId : acceptedSampleIds) {
|
||||
FaceSampleEntity sample = sampleMap.get(sampleId);
|
||||
if (sample == null || sample.getDeviceId() == null) {
|
||||
passthroughSampleIds.add(sampleId);
|
||||
continue;
|
||||
}
|
||||
deviceSamplesMap
|
||||
.computeIfAbsent(sample.getDeviceId(), key -> new ArrayList<>())
|
||||
.add(sample);
|
||||
}
|
||||
|
||||
Map<Long, Integer> limitCache = new HashMap<>();
|
||||
Set<Long> retainedSampleIds = new LinkedHashSet<>(passthroughSampleIds);
|
||||
|
||||
for (Map.Entry<Long, List<FaceSampleEntity>> entry : deviceSamplesMap.entrySet()) {
|
||||
Long deviceId = entry.getKey();
|
||||
List<FaceSampleEntity> deviceSamples = entry.getValue();
|
||||
List<Long> deviceSampleIds = deviceSamples.stream()
|
||||
.map(FaceSampleEntity::getId)
|
||||
.toList();
|
||||
|
||||
// 获取设备配置
|
||||
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(deviceId);
|
||||
Integer limitPhoto = null;
|
||||
if (deviceConfig != null) {
|
||||
limitPhoto = deviceConfig.getInteger("limit_photo");
|
||||
}
|
||||
Integer limitPhoto = limitCache.computeIfAbsent(deviceId, id -> {
|
||||
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(id);
|
||||
return deviceConfig != null ? deviceConfig.getInteger("limit_photo") : null;
|
||||
});
|
||||
|
||||
// 如果没有配置或配置为0,不限制
|
||||
List<Long> retainedForDevice;
|
||||
if (limitPhoto == null || limitPhoto <= 0) {
|
||||
List<Long> deviceSampleIds = deviceSamples.stream()
|
||||
retainedForDevice = deviceSampleIds;
|
||||
log.debug("设备照片限制:设备ID={}, 无限制,保留{}张照片", deviceId, deviceSampleIds.size());
|
||||
} else if (deviceSamples.size() > (limitPhoto + 2)) {
|
||||
retainedForDevice = processDeviceSamples(deviceSamples, limitPhoto, true);
|
||||
log.debug("设备照片限制:设备ID={}, 限制={}张, 原始{}张,去首尾后最终{}张",
|
||||
deviceId, limitPhoto, deviceSamples.size(), retainedForDevice.size());
|
||||
} else if (deviceSamples.size() > (limitPhoto + 1)) {
|
||||
retainedForDevice = processDeviceSamples(deviceSamples, limitPhoto, false);
|
||||
log.debug("设备照片限制:设备ID={}, 限制={}张, 原始{}张,去尾部后最终{}张",
|
||||
deviceId, limitPhoto, deviceSamples.size(), retainedForDevice.size());
|
||||
} else if (deviceSamples.size() > limitPhoto) {
|
||||
retainedForDevice = deviceSamples.stream()
|
||||
.limit(limitPhoto)
|
||||
.map(FaceSampleEntity::getId)
|
||||
.toList();
|
||||
resultIds.addAll(deviceSampleIds);
|
||||
log.debug("设备照片限制:设备ID={}, 无限制,保留{}张照片",
|
||||
deviceId, deviceSampleIds.size());
|
||||
log.debug("设备照片限制:设备ID={}, 限制={}张, 原始{}张,取前{}张",
|
||||
deviceId, limitPhoto, deviceSamples.size(), retainedForDevice.size());
|
||||
} else {
|
||||
// 根据样本数量与限制的关系进行不同处理
|
||||
if (deviceSamples.size() > (limitPhoto + 2)) {
|
||||
// 样本数大于(limit_photo+2)时,去掉首尾
|
||||
List<Long> limitedIds = processDeviceSamples(deviceSamples, limitPhoto, true);
|
||||
resultIds.addAll(limitedIds);
|
||||
log.debug("设备照片限制:设备ID={}, 限制={}张, 原始{}张,去首尾后最终{}张",
|
||||
deviceId, limitPhoto, deviceSamples.size(), limitedIds.size());
|
||||
} else if (deviceSamples.size() > (limitPhoto + 1)) {
|
||||
// 样本数大于(limit_photo+1)时,去掉尾部
|
||||
List<Long> limitedIds = processDeviceSamples(deviceSamples, limitPhoto, false);
|
||||
resultIds.addAll(limitedIds);
|
||||
log.debug("设备照片限制:设备ID={}, 限制={}张, 原始{}张,去尾部后最终{}张",
|
||||
deviceId, limitPhoto, deviceSamples.size(), limitedIds.size());
|
||||
} else if (deviceSamples.size() > limitPhoto) {
|
||||
// 样本数大于limit_photo但小于等于(limit_photo+1),取前N张
|
||||
List<Long> limitedIds = deviceSamples.stream()
|
||||
.limit(limitPhoto)
|
||||
.map(FaceSampleEntity::getId)
|
||||
.toList();
|
||||
resultIds.addAll(limitedIds);
|
||||
log.debug("设备照片限制:设备ID={}, 限制={}张, 原始{}张,取前{}张",
|
||||
deviceId, limitPhoto, deviceSamples.size(), limitedIds.size());
|
||||
} else {
|
||||
// 样本数小于等于limit_photo,不做操作
|
||||
List<Long> limitedIds = deviceSamples.stream()
|
||||
.map(FaceSampleEntity::getId)
|
||||
.toList();
|
||||
resultIds.addAll(limitedIds);
|
||||
log.debug("设备照片限制:设备ID={}, 限制={}张, 原始{}张,无需筛选,保留全部",
|
||||
deviceId, limitPhoto, deviceSamples.size());
|
||||
}
|
||||
retainedForDevice = deviceSampleIds;
|
||||
log.debug("设备照片限制:设备ID={}, 限制={}张, 原始{}张,无需筛选,保留全部",
|
||||
deviceId, limitPhoto, deviceSamples.size());
|
||||
}
|
||||
|
||||
retainedSampleIds.addAll(retainedForDevice);
|
||||
}
|
||||
|
||||
List<Long> resultIds = new ArrayList<>(acceptedSampleIds.size());
|
||||
for (Long sampleId : acceptedSampleIds) {
|
||||
if (retainedSampleIds.contains(sampleId)) {
|
||||
resultIds.add(sampleId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
|
||||
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||
import com.ycwl.basic.model.pc.renderWorker.entity.RenderWorkerEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
|
||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicEntity;
|
||||
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
|
||||
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
|
||||
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
|
||||
import com.ycwl.basic.model.pc.task.req.TaskReqQuery;
|
||||
@@ -643,7 +643,7 @@ public class TaskTaskServiceImpl implements TaskService {
|
||||
return;
|
||||
}
|
||||
|
||||
ScenicEntity scenic = scenicRepository.getScenic(item.getScenicId());
|
||||
ScenicV2DTO scenic = scenicRepository.getScenicBasic(item.getScenicId());
|
||||
ScenicConfigManager configManager = scenicRepository.getScenicConfigManager(item.getScenicId());
|
||||
String configTitle = configManager.getString("first_notification_title");
|
||||
String configContent = configManager.getString("first_notification_content");
|
||||
|
||||
@@ -85,7 +85,7 @@ public class DownloadNotificationTasker {
|
||||
return;
|
||||
}
|
||||
|
||||
ScenicEntity scenic = scenicRepository.getScenic(item.getScenicId());
|
||||
ScenicV2DTO scenic = scenicRepository.getScenicBasic(item.getScenicId());
|
||||
ScenicConfigManager configManager = scenicRepository.getScenicConfigManager(item.getScenicId());
|
||||
String configTitle = configManager.getString("second_notification_title");
|
||||
String configContent = configManager.getString("second_notification_content");
|
||||
@@ -164,7 +164,7 @@ public class DownloadNotificationTasker {
|
||||
return;
|
||||
}
|
||||
|
||||
ScenicEntity scenic = scenicRepository.getScenic(item.getScenicId());
|
||||
ScenicV2DTO scenic = scenicRepository.getScenicBasic(item.getScenicId());
|
||||
ScenicConfigManager configManager = scenicRepository.getScenicConfigManager(item.getScenicId());
|
||||
String configTitle = configManager.getString("third_notification_title");
|
||||
String configContent = configManager.getString("third_notification_content");
|
||||
|
||||
@@ -220,17 +220,9 @@ public class FaceCleaner {
|
||||
|
||||
@Scheduled(cron = "0 0 2 * * ?")
|
||||
public void clearOss(){
|
||||
cleanFaceSampleOss();
|
||||
cleanSourceOss();
|
||||
cleanVideoOss();
|
||||
}
|
||||
private void cleanFaceSampleOss() {
|
||||
log.info("开始清理人脸文件");
|
||||
List<FaceSampleRespVO> list = faceSampleMapper.list(new FaceSampleReqQuery());
|
||||
IStorageAdapter adapter = StorageFactory.use("faces");
|
||||
// VIID相关功能已移除,不再清理VIID_FACE目录
|
||||
log.info("VIID人脸文件清理功能已移除");
|
||||
}
|
||||
public void cleanSourceOss() {
|
||||
log.info("开始清理源视频素材文件");
|
||||
List<SourceRespVO> list = sourceMapper.list(new SourceReqQuery());
|
||||
|
||||
@@ -11,8 +11,10 @@ import com.ycwl.basic.model.pc.scenicDeviceStats.entity.ScenicDeviceStatsEntity;
|
||||
import com.ycwl.basic.repository.ScenicRepository;
|
||||
import com.ycwl.basic.service.mobile.AppStatisticsService;
|
||||
import com.ycwl.basic.utils.ApiResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -20,6 +22,7 @@ import org.springframework.stereotype.Component;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@EnableScheduling
|
||||
@Profile("prod")
|
||||
@@ -32,6 +35,8 @@ public class ScenicStatsTask {
|
||||
private AppStatisticsService statisticsService;
|
||||
@Autowired
|
||||
private ScenicRepository scenicRepository;
|
||||
@Autowired
|
||||
private RedisTemplate<String, String> redisTemplate;
|
||||
|
||||
@Scheduled(cron = "0 1 0 * * *")
|
||||
public void countDeviceStats() {
|
||||
@@ -55,23 +60,58 @@ public class ScenicStatsTask {
|
||||
});
|
||||
}
|
||||
}
|
||||
@Scheduled(cron = "0 20 0 * * *")
|
||||
@Scheduled(cron = "0 0 2 * * *")
|
||||
public void countScenicStats() {
|
||||
Date yesterdayStart = DateUtil.beginOfDay(DateUtil.yesterday());
|
||||
Date yesterdayEnd = DateUtil.endOfDay(yesterdayStart);
|
||||
log.info("开始执行景区统计任务,统计前7天至昨天的数据");
|
||||
|
||||
// 获取所有景区列表
|
||||
ScenicReqQuery query = new ScenicReqQuery();
|
||||
query.setPageSize(1000);
|
||||
List<ScenicV2DTO> scenicList = scenicRepository.list(query);
|
||||
scenicList.forEach((scenic) -> {
|
||||
CommonQueryReq commonQueryReq = new CommonQueryReq();
|
||||
Long scenicId = Long.valueOf(scenic.getId());
|
||||
commonQueryReq.setScenicId(scenicId);
|
||||
commonQueryReq.setStartTime(yesterdayStart);
|
||||
commonQueryReq.setEndTime(yesterdayEnd);
|
||||
commonQueryReq.setRealtime(true);
|
||||
ApiResponse<AppStatisticsFunnelVO> resp = statisticsService.userConversionFunnel(commonQueryReq);
|
||||
AppStatisticsFunnelVO data = resp.getData();
|
||||
statisticsMapper.insertStat(scenicId, yesterdayStart, data);
|
||||
});
|
||||
|
||||
// 循环统计前7天(至昨天)的数据
|
||||
for (int daysAgo = 1; daysAgo <= 7; daysAgo++) {
|
||||
Date targetDate = DateUtil.offsetDay(new Date(), -daysAgo);
|
||||
Date startTime = DateUtil.beginOfDay(targetDate);
|
||||
Date endTime = DateUtil.endOfDay(targetDate);
|
||||
|
||||
log.info("正在统计第{}天前的数据,日期: {}", daysAgo, DateUtil.formatDate(startTime));
|
||||
|
||||
scenicList.forEach((scenic) -> {
|
||||
try {
|
||||
CommonQueryReq commonQueryReq = new CommonQueryReq();
|
||||
Long scenicId = Long.valueOf(scenic.getId());
|
||||
commonQueryReq.setScenicId(scenicId);
|
||||
commonQueryReq.setStartTime(startTime);
|
||||
commonQueryReq.setEndTime(endTime);
|
||||
commonQueryReq.setRealtime(true);
|
||||
|
||||
// 执行统计查询
|
||||
ApiResponse<AppStatisticsFunnelVO> resp = statisticsService.userConversionFunnel(commonQueryReq);
|
||||
AppStatisticsFunnelVO data = resp.getData();
|
||||
|
||||
// 写入数据库(REPLACE INTO 会自动更新已存在的记录)
|
||||
statisticsMapper.insertStat(scenicId, startTime, data);
|
||||
|
||||
// 删除该景区的缓存,确保下次查询时获取最新数据
|
||||
invalidateStatisticsCache(scenicId);
|
||||
} catch (Exception e) {
|
||||
log.error("统计景区 {} 在日期 {} 的数据时发生错误", scenic.getId(), DateUtil.formatDate(startTime), e);
|
||||
}
|
||||
});
|
||||
|
||||
log.info("第{}天前的数据统计完成,日期: {}", daysAgo, DateUtil.formatDate(startTime));
|
||||
}
|
||||
|
||||
log.info("景区统计任务执行完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除景区统计缓存
|
||||
* @param scenicId 景区ID
|
||||
*/
|
||||
private void invalidateStatisticsCache(Long scenicId) {
|
||||
String redisKey = "statistics:tmp_cache:" + scenicId;
|
||||
redisTemplate.delete(redisKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ public class VideoPieceGetter {
|
||||
templatePlaceholder.forEach(deviceId -> {
|
||||
currentUnFinPlaceholder.computeIfAbsent(deviceId, k -> new AtomicInteger(0)).incrementAndGet();
|
||||
});
|
||||
log.info("[Placeholder初始化] 有templateId,初始化完成:placeholder总数={}, 不同设备数={}, 详细计数={}",
|
||||
log.debug("[Placeholder初始化] 有templateId,初始化完成:placeholder总数={}, 不同设备数={}, 详细计数={}",
|
||||
templatePlaceholder.size(),
|
||||
currentUnFinPlaceholder.size(),
|
||||
currentUnFinPlaceholder.entrySet().stream()
|
||||
@@ -183,7 +183,7 @@ public class VideoPieceGetter {
|
||||
collection.keySet().forEach(deviceId -> {
|
||||
currentUnFinPlaceholder.put(deviceId.toString(), new AtomicInteger(1));
|
||||
});
|
||||
log.info("[Placeholder初始化] 无templateId,初始化完成:设备数={}",
|
||||
log.debug("[Placeholder初始化] 无templateId,初始化完成:设备数={}",
|
||||
currentUnFinPlaceholder.size());
|
||||
}
|
||||
collection.values().forEach(faceSampleList -> {
|
||||
@@ -213,7 +213,7 @@ public class VideoPieceGetter {
|
||||
pairDeviceId, remaining, currentUnFinPlaceholder.size());
|
||||
if (remaining <= 0) {
|
||||
currentUnFinPlaceholder.remove(pairDeviceId.toString());
|
||||
log.info("[Placeholder完成] 设备 {} 的placeholder已满足并移除,剩余设备数={}",
|
||||
log.debug("[Placeholder完成] 设备 {} 的placeholder已满足并移除,剩余设备数={}",
|
||||
pairDeviceId, currentUnFinPlaceholder.size());
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,7 @@ public class VideoPieceGetter {
|
||||
faceSample.getDeviceId(), remaining, currentUnFinPlaceholder.size());
|
||||
if (remaining <= 0) {
|
||||
currentUnFinPlaceholder.remove(faceSample.getDeviceId().toString());
|
||||
log.info("[Placeholder完成] 设备 {} 的placeholder已满足并移除,剩余设备数={}",
|
||||
log.debug("[Placeholder完成] 设备 {} 的placeholder已满足并移除,剩余设备数={}",
|
||||
faceSample.getDeviceId(), currentUnFinPlaceholder.size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ public class VideoTaskGenerator {
|
||||
}
|
||||
}
|
||||
try {
|
||||
Thread.sleep(5000L);
|
||||
Thread.sleep(2000L);
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
package com.ycwl.basic.template;
|
||||
|
||||
public class TemplateFactory {
|
||||
}
|
||||
@@ -193,6 +193,235 @@ public class ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能裁切图片以填充目标尺寸,支持自动旋转以减少裁切损失
|
||||
*
|
||||
* @param imageSource 图片源,可以是URL字符串或File对象
|
||||
* @param targetWidth 目标宽度
|
||||
* @param targetHeight 目标高度
|
||||
* @return 处理后的临时文件
|
||||
* @throws IOException 读取或处理图片失败
|
||||
* @throws IllegalArgumentException 参数无效
|
||||
*/
|
||||
public static File smartCropAndFill(Object imageSource, int targetWidth, int targetHeight) throws IOException {
|
||||
if (targetWidth <= 0 || targetHeight <= 0) {
|
||||
throw new IllegalArgumentException("目标宽高必须大于0");
|
||||
}
|
||||
|
||||
BufferedImage originalImage = loadImage(imageSource);
|
||||
if (originalImage == null) {
|
||||
throw new IOException("无法加载图片: " + imageSource);
|
||||
}
|
||||
|
||||
File resultFile = null;
|
||||
BufferedImage processedImage = null;
|
||||
try {
|
||||
// 计算最优方案(是否需要旋转以及如何裁切)
|
||||
CropStrategy strategy = calculateOptimalCropStrategy(
|
||||
originalImage.getWidth(),
|
||||
originalImage.getHeight(),
|
||||
targetWidth,
|
||||
targetHeight
|
||||
);
|
||||
|
||||
log.info("图片处理策略: 原始尺寸={}x{}, 目标尺寸={}x{}, 旋转={}, 裁切损失={}像素",
|
||||
originalImage.getWidth(), originalImage.getHeight(),
|
||||
targetWidth, targetHeight,
|
||||
strategy.rotationDegrees, strategy.pixelsLost);
|
||||
|
||||
// 如果需要旋转,先旋转图片
|
||||
BufferedImage workingImage = originalImage;
|
||||
if (strategy.rotationDegrees != 0) {
|
||||
workingImage = rotateImage(originalImage, strategy.rotationDegrees);
|
||||
}
|
||||
|
||||
// 执行居中裁切并缩放
|
||||
processedImage = cropAndResize(workingImage, targetWidth, targetHeight);
|
||||
|
||||
// 保存到临时文件
|
||||
resultFile = File.createTempFile("smartcrop_", ".jpg");
|
||||
ImageIO.write(processedImage, "jpg", resultFile);
|
||||
|
||||
log.info("图片处理完成,输出文件: {}", resultFile.getAbsolutePath());
|
||||
return resultFile;
|
||||
|
||||
} finally {
|
||||
if (originalImage != null) {
|
||||
originalImage.flush();
|
||||
}
|
||||
if (processedImage != null) {
|
||||
processedImage.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从URL或File加载图片
|
||||
*/
|
||||
private static BufferedImage loadImage(Object imageSource) throws IOException {
|
||||
if (imageSource instanceof String) {
|
||||
String urlStr = (String) imageSource;
|
||||
if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) {
|
||||
// 从URL加载
|
||||
java.net.URL url = new java.net.URL(urlStr);
|
||||
return ImageIO.read(url);
|
||||
} else {
|
||||
// 作为文件路径处理
|
||||
return ImageIO.read(new File(urlStr));
|
||||
}
|
||||
} else if (imageSource instanceof File) {
|
||||
return ImageIO.read((File) imageSource);
|
||||
} else {
|
||||
throw new IllegalArgumentException("图片源必须是String(URL或路径)或File对象,实际类型: " +
|
||||
(imageSource != null ? imageSource.getClass().getName() : "null"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算最优裁切策略
|
||||
*/
|
||||
private static CropStrategy calculateOptimalCropStrategy(
|
||||
int srcWidth, int srcHeight, int targetWidth, int targetHeight) {
|
||||
|
||||
CropStrategy best = new CropStrategy();
|
||||
best.rotationDegrees = 0;
|
||||
best.pixelsLost = Integer.MAX_VALUE;
|
||||
|
||||
// 测试三种情况: 不旋转、旋转90度、旋转270度
|
||||
int[][] scenarios = {
|
||||
{0, srcWidth, srcHeight}, // 不旋转
|
||||
{90, srcHeight, srcWidth}, // 旋转90度
|
||||
{270, srcHeight, srcWidth} // 旋转270度
|
||||
};
|
||||
|
||||
for (int[] scenario : scenarios) {
|
||||
int rotation = scenario[0];
|
||||
int currentWidth = scenario[1];
|
||||
int currentHeight = scenario[2];
|
||||
|
||||
// 计算裁切损失
|
||||
double srcRatio = (double) currentWidth / currentHeight;
|
||||
double targetRatio = (double) targetWidth / targetHeight;
|
||||
|
||||
int pixelsLost;
|
||||
if (srcRatio > targetRatio) {
|
||||
// 源图更宽,需要裁掉左右两边
|
||||
int neededWidth = (int) Math.ceil(currentHeight * targetRatio);
|
||||
pixelsLost = (currentWidth - neededWidth) * currentHeight;
|
||||
} else {
|
||||
// 源图更高,需要裁掉上下两边
|
||||
int neededHeight = (int) Math.ceil(currentWidth / targetRatio);
|
||||
pixelsLost = (currentHeight - neededHeight) * currentWidth;
|
||||
}
|
||||
|
||||
if (pixelsLost < best.pixelsLost) {
|
||||
best.rotationDegrees = rotation;
|
||||
best.pixelsLost = pixelsLost;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 旋转图片
|
||||
*/
|
||||
private static BufferedImage rotateImage(BufferedImage source, int degrees) {
|
||||
if (degrees == 0) {
|
||||
return source;
|
||||
}
|
||||
|
||||
int width = source.getWidth();
|
||||
int height = source.getHeight();
|
||||
|
||||
// 90度和270度会交换宽高
|
||||
BufferedImage rotated;
|
||||
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 {
|
||||
if (g2d != null) {
|
||||
g2d.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 居中裁切并缩放到目标尺寸
|
||||
*/
|
||||
private static BufferedImage cropAndResize(BufferedImage source, int targetWidth, int targetHeight) {
|
||||
int srcWidth = source.getWidth();
|
||||
int srcHeight = source.getHeight();
|
||||
|
||||
// 计算裁切区域(居中)
|
||||
double srcRatio = (double) srcWidth / srcHeight;
|
||||
double targetRatio = (double) targetWidth / targetHeight;
|
||||
|
||||
int cropX, cropY, cropWidth, cropHeight;
|
||||
|
||||
if (srcRatio > targetRatio) {
|
||||
// 源图更宽,裁掉左右
|
||||
cropHeight = srcHeight;
|
||||
cropWidth = (int) Math.round(srcHeight * targetRatio);
|
||||
cropX = (srcWidth - cropWidth) / 2;
|
||||
cropY = 0;
|
||||
} else {
|
||||
// 源图更高,裁掉上下
|
||||
cropWidth = srcWidth;
|
||||
cropHeight = (int) Math.round(srcWidth / targetRatio);
|
||||
cropX = 0;
|
||||
cropY = (srcHeight - cropHeight) / 2;
|
||||
}
|
||||
|
||||
// 裁切
|
||||
BufferedImage cropped = source.getSubimage(cropX, cropY, cropWidth, cropHeight);
|
||||
|
||||
// 如果裁切后的尺寸与目标尺寸不完全一致,进行缩放
|
||||
if (cropWidth != targetWidth || cropHeight != targetHeight) {
|
||||
BufferedImage resized = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g2d = resized.createGraphics();
|
||||
try {
|
||||
g2d.drawImage(cropped, 0, 0, targetWidth, targetHeight, null);
|
||||
return resized;
|
||||
} finally {
|
||||
g2d.dispose();
|
||||
cropped.flush();
|
||||
}
|
||||
}
|
||||
|
||||
return cropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 裁切策略内部类
|
||||
*/
|
||||
private static class CropStrategy {
|
||||
int rotationDegrees; // 0, 90, 或 270
|
||||
int pixelsLost; // 损失的像素数
|
||||
}
|
||||
|
||||
public static class Base64DecodedMultipartFile implements MultipartFile {
|
||||
|
||||
private final byte[] imgContent;
|
||||
|
||||
@@ -20,12 +20,20 @@
|
||||
<if test="faceUrl!= null and faceUrl!= ''">
|
||||
face_url = #{faceUrl},
|
||||
</if>
|
||||
<if test="isManual!= null ">
|
||||
is_manual = #{isManual},
|
||||
</if>
|
||||
match_sample_ids = #{matchSampleIds},
|
||||
first_match_rate = #{firstMatchRate},
|
||||
match_result = #{matchResult},
|
||||
</set>
|
||||
where id = #{id}
|
||||
</update>
|
||||
<update id="updateManualFlag">
|
||||
update face
|
||||
set is_manual = #{isManual}
|
||||
where id = #{id}
|
||||
</update>
|
||||
<update id="finishedJourney">
|
||||
update face set finished_journey = 1 where id = #{id}
|
||||
</update>
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
<if test="payPrice!= null ">
|
||||
pay_price = #{payPrice},
|
||||
</if>
|
||||
<if test="remark!= null and remark!= ''">
|
||||
<if test="remark!= null">
|
||||
remark = #{remark},
|
||||
</if>
|
||||
<if test="refundReason!= null and refundReason!= ''">
|
||||
|
||||
Reference in New Issue
Block a user