Compare commits

...

34 Commits

Author SHA1 Message Date
78a2a74fa6 feat(print): 实现照片自动裁剪与优先打印功能
- 人脸上传后自动将关联照片添加到优先打印列表
- 根据景区和设备配置自动处理type=2的照片
- 支持按设备分组处理并限制打印数量
- 实现智能图片裁剪功能,支持自动旋转以减少裁切损失
- 添加图片尺寸配置读取和默认值处理
- 完善异常处理确保不影响主流程执行
-优化打印服务中照片上传和裁剪逻辑
- 增加详细的日志记录便于问题追踪
2025-11-02 09:13:10 +08:00
222f974ad5 feat(face): 添加人脸识别重试功能并优化得分筛选逻辑
- 在FaceSampleController中新增retryFaceRecognition接口用于手动重试失败的人脸识别任务- 集成人脸识别Kafka服务,支持异步处理重试请求- 在FaceServiceImpl中增加从景区配置读取人脸得分阈值的功能
- 根据配置的得分阈值对人脸识别结果进行筛选,过滤低分样本
- 添加详细的日志记录和异常处理机制- 优化线程池使用,确保重试任务能够正确提交和执行
2025-11-01 20:12:25 +08:00
96a4d3ffeb feat(storage): 更新照片存储路径常量
- 将 PHOTO_PATH 常量值从 "source_photo" 更改为 "viid"
- 保持其他存储路径常量不变- 确保与现有存储结构兼容
2025-11-01 19:56:27 +08:00
e99d75ba1b feat(app): 添加场景模式配置字段
- 在AppScenicController中新增scene_mode配置项
- 默认值设置为0
- 支持从scenicConfig获取场景模式配置
2025-11-01 19:55:27 +08:00
12cd9bd275 1 2025-10-31 16:41:15 +08:00
7c42c5c462 feat(facebody): 实现人脸识别搜索的重试机制
- 添加可重试和不可重试异常分类
- 集成百度云错误码分类器
- 实现搜索人脸接口的自动重试逻辑
- 支持根据错误码动态调整重试次数和延迟
- 添加详细的异常日志记录
- 保持与原有逻辑一致的空结果返回行为
2025-10-31 15:01:06 +08:00
631d5c175f feat(payment): 支付与退款后清除景区统计缓存
- 在支付成功、取消、退款回调后增加缓存删除逻辑
- 新增 `invalidateStatisticsCache` 方法用于删除 Redis 缓存
- 定时任务中统计景区数据后也调用缓存清除方法
- 调整景区统计任务时间并扩展统计周期为近7天
- 增强定时任务日志记录和异常处理机制
2025-10-31 13:46:17 +08:00
785de52780 feat(scenic): 添加打印相关配置项
- 新增智能抓拍打印开关配置
- 新增打印必须录入人脸开关配置
- 新增手机照片打印开关配置
- 在AppScenicController中设置打印相关配置项
- 在ScenicConfigResp中添加打印配置字段
2025-10-31 13:03:44 +08:00
2ee7e93201 refactor(order):优化订单业务逻辑中的景区信息获取方式
- 移除了对 ScenicEntity 的直接依赖
- 使用 scenicId 替代 scenic 对象以减少数据库查询
- 更新了 VLOG 和普通商品的价格计算逻辑
- 在下载通知任务中使用 ScenicV2DTO 替代 ScenicEntity
- 统一通过 scenicRepository 获取景区基本信息的方法调用
2025-10-31 11:29:48 +08:00
65ae23a956 refactor(scenic): 替换ScenicEntity为ScenicV2DTO以优化数据访问
- 将DeviceStatsServiceImpl中的ScenicEntity替换为ScenicV2DTO
- 将FaceSampleServiceImpl中的ScenicEntity替换为ScenicV2DTO
- 将TaskTaskServiceImpl中的ScenicEntity替换为ScenicV2DTO
- 更新相关方法调用以使用新的DTO结构
- 确保景点名称获取逻辑保持一致
-优化数据访问层以提高性能和可维护性
2025-10-31 11:12:38 +08:00
b9ade63e8e feat(wx): 移除微信消息模板通知控制器
- 删除了 AppWxNotifyController 类及相关接口实现
- 移除了 /api/mobile/wx/notify/v1 路径下的所有端点
- 清理了相关依赖注入和业务逻辑代码
2025-10-31 11:05:01 +08:00
cdeb2e4d5a refactor(statistics): 移除统计记录添加功能
- 删除 AppStatisticsController 中的 addStatistics 接口
- 移除 AppStatisticsServiceImpl 中 addStatistics 方法的实现
- 从 AppStatisticsService 接口中移除 addStatistics 方法声明- 清理 FaceServiceImpl 中调用统计记录添加的代码逻辑
2025-10-31 11:00:38 +08:00
cc38d6e095 feat(printer): 添加打印功能开关检查
- 在新增打印机接口中增加景区打印功能开关检查
- 在更新打印机接口中增加景区打印功能开关检查
- 打印功能未开启时返回失败响应及提示信息
2025-10-31 11:00:06 +08:00
82d86c8c3c fix(face):修复人脸匹配样本ID处理逻辑
- 移除旧数据合并逻辑,直接使用上传的样本ID列表
- 使用stream过滤和去重处理样本ID
- 简化样本列表变更检测逻辑
- 移除不必要的LinkedHashSet转换
- 优化最终样本列表的生成方式
2025-10-30 11:43:02 +08:00
5979b1a275 refactor(face): 调整人脸关系数据删除逻辑
- 将删除人脸旧关系数据的逻辑从匹配前移至保存新关系前- 确保在更新人脸关系时正确清理旧数据- 避免重复删除操作,优化数据处理流程
2025-10-30 10:40:33 +08:00
a7fe0d715d feat(face): 添加人工调整标记更新功能
- 在 FaceMapper 接口中新增 updateManualFlag 方法
- 实现根据 ID 更新 is_manual 字段的 SQL 语句
- 优化 FaceServiceImpl 中设置人工调整标记的逻辑
- 使用专门的更新方法替代原有的通用更新方式
- 清理相关缓存以确保数据一致性
2025-10-30 10:16:24 +08:00
ef8a913636 fix(face):修复人脸样本直接使用模式下搜索结果未设置的问题
- 在模式2下直接使用用户选择的人脸样本时,补充设置搜索结果JSON
- 保证检索结果在所有模式下都能正确返回
- 避免前端因缺少搜索结果数据而出现异常
2025-10-30 09:54:29 +08:00
73791a92d3 feat(face):重新匹配前删除人脸旧关系数据
- 在重新匹配前删除该人脸的旧数据关系
- 清理 member_source 和 member_video 中的关联记录
- 更新缓存清理逻辑以确保数据一致性
- 添加详细的日志记录以便追踪操作过程
2025-10-30 09:47:29 +08:00
f0ad0f58a9 fix(order):修复订单备注更新逻辑
- 移除了订单备注更新时的空字符串检查
- 允许将订单备注更新为空值
-保持了退款原因的非空检查逻辑不变
2025-10-30 09:24:28 +08:00
73825cd1d6 feat(face): 添加人工调整标记字段并优化匹配逻辑
- 在 FaceEntity 中新增 isManual 字段,用于标识是否经过人工调整
- 优化人脸识别匹配流程,若已人工调整则跳过自动匹配
- 更新 FaceMapper.xml,支持 isManual 字段的更新操作
- 在处理自定义人脸匹配时,设置人工调整标记并清除缓存
2025-10-30 00:18:03 +08:00
745943fc23 refactor(face): 移除样本筛选轨迹功能及相关枚举
- 删除 FaceRecognitionFilterReason 枚举类
- 移除 SampleFilterTrace 类及其相关逻辑
- 简化样本筛选方法,去除轨迹记录功能- 更新 FaceServiceImpl 和 TaskFaceServiceImpl 中的调用逻辑
- 移除 SearchFaceRespVo 中的 filterTrace 字段- 清理无用的 import语句和相关代码引用
2025-10-29 19:26:35 +08:00
b6bde4ad62 refactor(face):优化人脸识别更新接口及样本展示逻辑
- 修改 updateRecognition 接口返回类型为 void,简化响应内容
- 移除 FaceRecognitionSampleVO 中冗余的字段(sourceType、faceUrl 等)- 删除与过滤原因相关的属性和处理逻辑
- 简化 buildSampleVO 方法参数及内部实现- 调整 resolveSourceUrl 方法中 URL 获取优先级
- 优化样本列表构建逻辑,提升性能与可读性
2025-10-29 15:21:15 +08:00
07ebccad3c fix(video): 减少视频任务生成器的等待时间
- 将线程睡眠时间从5000毫秒减少到2000毫秒
- 提高视频任务处理效率
- 减少系统资源占用
2025-10-28 18:04:35 +08:00
028178605e fix(printer): 修改打印机列表分隔符
- 将打印机列表的分隔符从逗号(,)更改为竖线(|)
- 避免打印机名称中包含逗号导致解析错误
- 更新打印机信息时使用新的分隔符格式
2025-10-28 17:44:09 +08:00
03162dec44 feat(face): 移动人脸识别接口到移动端并优化请求参数
- 将人脸识别相关接口从PC端控制器迁移至移动端控制器
- 更新人脸识别详情和样本VO类的包路径至mobile.face
- 修改人脸识别更新请求参数默认值
- 删除PC端冗余的人脸识别接口实现
- 调整服务层依赖引用至新的mobile.face包路径
- 移除过时的FaceSampleRespVO引用依赖
2025-10-28 17:42:18 +08:00
85cdfe9ea1 feat(printer): 实现打印机轮询选择功能
- 新增 getNextPrinter 方法实现打印机轮询逻辑
- 添加 Redis 键前缀 PRINTER_INDEX_KEY_PREFIX 和过期时间常量
- 在创建打印任务时设置选中的打印机名称- 支持多个打印机按顺序轮流使用
- 使用 Redis 原子递增确保并发安全的索引获取
- 自动为 Redis 键设置 5 分钟过期时间以避免内存泄漏
2025-10-28 17:31:08 +08:00
5e2fe0329d refactor(task):优化设备照片限制筛选逻辑
- 使用LinkedHashMap和LinkedHashSet保持插入顺序
-重构筛选逻辑,提高代码可读性
- 优化设备样本分组处理流程
- 添加筛选原因追踪功能-保持原有筛选规则和日志记录- 提升代码执行效率和内存使用
2025-10-28 16:21:30 +08:00
6f8b3c8cdf chore(template): 删除空的模板工厂类
- 移除了无用的 TemplateFactory 类定义
- 清理了包声明和空的类结构
- 减少了代码库中的冗余文件
2025-10-28 15:52:08 +08:00
1efe4a1439 refactor(task): 移除过时的人脸清理功能
- 删除了 `cleanFaceSampleOss` 方法及相关调用
- 注释说明 VIID 相关功能已移除
-保留并继续使用 `cleanSourceOss` 和 `cleanVideoOss` 方法
2025-10-28 15:51:53 +08:00
e27f092f85 refactor(logging): 将部分info级别日志调整为debug级别- 将Placeholder初始化相关日志从info 2025-10-28 15:51:32 +08:00
215a7e87ae feat(face): 添加景区配置控制人脸任务自动创建
- 新增对景区配置中 face_select_first 参数的检查
- 当 face_select_first为 true 时跳过自动创建任务
- 添加相关日志记录以方便调试和追踪
-保留原有自动创建任务逻辑作为默认行为
2025-10-28 15:41:55 +08:00
636ab96e96 feat(scenic): 添加景区配置人脸优先选择功能
- 在AppScenicController中新增faceSelectFirst字段返回
- 在ScenicConfigResp中增加faceSelectFirst属性默认值为false- 支持景区配置中设置人脸识别优先级开关
2025-10-28 15:41:46 +08:00
cc68a8dbbd Merge branch 'refs/heads/result_edit_2'
# Conflicts:
#	src/main/java/com/ycwl/basic/service/pc/impl/FaceServiceImpl.java
#	src/main/java/com/ycwl/basic/service/task/impl/TaskFaceServiceImpl.java
2025-10-28 15:36:32 +08:00
1b312313b2 feat(face): 增加人脸识别详情与人工调整功能
- 新增人脸识别详情接口,返回系统采纳与被过滤的样本信息
- 新增人工调整识别结果接口,支持用户手动选择或排除样本
- 引入样本过滤原因枚举,用于记录和展示过滤原因
- 重构样本过滤逻辑,增加过滤轨迹追踪功能
- 优化时间范围与设备照片数量限制的过滤实现
- 在搜索结果中增加过滤轨迹信息,便于前端展示
- 添加人脸识别详情VO和样本VO,丰富返回数据结构
- 完善人脸识别相关的请求与响应模型定义
2025-10-21 21:35:06 +08:00
43 changed files with 2093 additions and 413 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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());

View File

@@ -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";
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}});
}
}

View File

@@ -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);
}
}

View File

@@ -53,5 +53,4 @@ public class FaceController {
return faceService.deleteByIds(ids);
}
}

View File

@@ -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("提交重试任务失败,请检查人脸样本状态");
}
}
}

View File

@@ -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);
}
}

View File

@@ -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...
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -59,6 +59,7 @@ public class ScenicRepository {
return scenicEntity;
}
@Deprecated
public ScenicConfigEntity getScenicConfig(Long scenicId) {
ScenicConfigManager scenicConfigManager = getScenicConfigManager(scenicId);
ScenicConfigEntity config = new ScenicConfigEntity();

View File

@@ -22,7 +22,5 @@ public interface AppStatisticsService {
ApiResponse<AppStatisticsFunnelVO> userConversionFunnel(CommonQueryReq query);
ApiResponse<String> addStatistics(StatisticsRecordAddReq req);
ApiResponse orderChart(CommonQueryReq query);
}

View File

@@ -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){

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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());
}

View File

@@ -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());
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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");

View File

@@ -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");

View File

@@ -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());

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -106,7 +106,7 @@ public class VideoTaskGenerator {
}
}
try {
Thread.sleep(5000L);
Thread.sleep(2000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

View File

@@ -1,4 +0,0 @@
package com.ycwl.basic.template;
public class TemplateFactory {
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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!= ''">