You've already forked FrameTour-BE
Compare commits
313 Commits
aa7330000f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cc32ddf61 | |||
| 07987835ec | |||
| 0a3f4119d7 | |||
| 51c7de2474 | |||
| 773d7f2254 | |||
| af131131ed | |||
| 3f6f1508c5 | |||
| dbee1d9709 | |||
| 83d1096fdb | |||
| 82925d203c | |||
| 3b11ddef6a | |||
| 6e7b4729a8 | |||
| 917cb37ccf | |||
| 7c0a3a63bb | |||
| 478467e124 | |||
| d5befd75e1 | |||
| b2c55c9feb | |||
| fef616c837 | |||
| a5fe00052d | |||
| 349b702fc3 | |||
| 9f5a61247b | |||
| 9321422e56 | |||
| 1834fe3ddd | |||
| fa8f92d38b | |||
| df33e7929f | |||
| 554f55a7c1 | |||
| f71149fd06 | |||
| e8eb8d816b | |||
| 576d87d113 | |||
| a2378053a8 | |||
| c92ea20575 | |||
| bb71cf9458 | |||
| 7749faf807 | |||
| c42b055d5f | |||
| fe3bda28b4 | |||
| 66775ea48b | |||
| 125fadd6c5 | |||
| 1f4a16f0e6 | |||
| e9916d6aca | |||
| b71452b3ed | |||
| 4a82ee6c4d | |||
| 24bbb63bf7 | |||
| ee13ef09f7 | |||
| 33c3a194ca | |||
| 71a8d3b539 | |||
| 82626f615b | |||
| de2eadf764 | |||
| fd143830d3 | |||
| 68916f3f53 | |||
| e27ed7d971 | |||
| 7a19f18962 | |||
| eade5f8092 | |||
| 42540e2dc4 | |||
| 15dda645b9 | |||
| 17419d83e7 | |||
| ae92ba10a7 | |||
| af60cc1540 | |||
| 60b4473279 | |||
| ecd5378b26 | |||
| 8c08c8947e | |||
| b165840176 | |||
| 71d6400a1e | |||
| b3fa10e8fd | |||
| 96e75a458f | |||
| d2ad14175d | |||
| 06c0ade9b4 | |||
| 36f85dbb63 | |||
| 9becd6bfa7 | |||
| 788184fc04 | |||
| 3cf7c81162 | |||
| 88d9463e25 | |||
| 590a7c6191 | |||
| d590286b13 | |||
| b92568b842 | |||
| 1de760fc87 | |||
| 4e9aac4cf3 | |||
| aa43d14316 | |||
| a2d87e7fdc | |||
| 57be6aa983 | |||
| cacb22a7bd | |||
| 300edbe582 | |||
| 9219ea4ab0 | |||
| e292a0798d | |||
| 4244b42d4b | |||
| 8058bc21f5 | |||
| 6dd08ac4e7 | |||
| 610a183be1 | |||
| e9a59cd466 | |||
| d60d7d9ad8 | |||
| d483c222d0 | |||
| a7ef2cb35a | |||
| cbc0584706 | |||
| 67932c374b | |||
| 8a88c74df2 | |||
| 3ce3972875 | |||
| 1945639f90 | |||
| 40d5874560 | |||
| 95419fee66 | |||
| 333c4d3ca7 | |||
| 90efc908c5 | |||
| d2846e6d8e | |||
| 7b18d7c2af | |||
| bcebe5defe | |||
| 4a86849372 | |||
| e418a5ccdb | |||
| 4360ef1313 | |||
| 9278d4479f | |||
| 18bf51487d | |||
| 447e8799e8 | |||
| fd130c471f | |||
| c47c24a39a | |||
| 97e3ab19a0 | |||
| 5b27cac6b0 | |||
| 91f3632e2b | |||
| cd8ae491e2 | |||
| d0d238d31d | |||
| 2be30c6eb4 | |||
| fb82329a88 | |||
| 4f0d6dc44f | |||
| 302b6811c4 | |||
| c0daa4d3b2 | |||
| 83cfbc67e1 | |||
| 8f918570d9 | |||
| f4a3dc9cae | |||
| cd5ba23d59 | |||
| 038b2e6f08 | |||
| caad0c2cf0 | |||
| 259d99bde7 | |||
| 0e2122910f | |||
| e1a77a1614 | |||
| 8791cf5910 | |||
| a860319ea1 | |||
| d5fc5c2565 | |||
| 0db713b4a8 | |||
| 6ef710201c | |||
| 9123a1f6db | |||
| d458f918ed | |||
| 27e58d36d0 | |||
| 8c76c85ae2 | |||
| 8991d68673 | |||
| 3b93e07a66 | |||
| c8054c60ab | |||
| 2fd852c5c6 | |||
| aaa8d8310a | |||
| 8d2d0901fd | |||
| d1381c93b0 | |||
| 536f2866f6 | |||
| 4cbd0dc255 | |||
| 90cf0d44c9 | |||
| d387f11173 | |||
| f6d6a63977 | |||
| 67aebd5770 | |||
| 6d18a770b8 | |||
| b6cbb18a7f | |||
| cfb3625ac0 | |||
| cb17ea527b | |||
| 625ad910c9 | |||
| 778afaaa83 | |||
| de421cf0d5 | |||
| 3ddf7bd0e9 | |||
| 208202ba41 | |||
| 6e84a5fd43 | |||
| 8e48bd92cc | |||
| 23181e9f08 | |||
| 42e806df76 | |||
| a49e581915 | |||
| af60e95529 | |||
| bb2367c5a6 | |||
| 3d361200b0 | |||
| 5c49a5af9e | |||
| a5ffb86790 | |||
| 755ba1153e | |||
| ebf05ab189 | |||
| e2b450682b | |||
| 443f92ff92 | |||
| 630d344b5a | |||
| 9eb3fd3e58 | |||
| 3463dcc9ae | |||
| 1b9bebf7e4 | |||
| fa287f36ae | |||
| 0c3ada97f9 | |||
| a7a7e30364 | |||
| 17a33ada9f | |||
| d408c47963 | |||
| 8efd16ba56 | |||
| 9d708ae20c | |||
| 7c906d5529 | |||
| 88ad6d6b6f | |||
| 7835283f0f | |||
| 9ee466bd5e | |||
| 1a25848102 | |||
| c319398c58 | |||
| afc589bb39 | |||
| 4ccb563557 | |||
| 11face7935 | |||
| 515f68a6f4 | |||
| 3f396b4cb8 | |||
| a1b0687526 | |||
| 932081abf0 | |||
| 6462037dcd | |||
| 9b9e69cf52 | |||
| 6246d6ef46 | |||
| 19fae4bd00 | |||
| 661aa4567f | |||
| ec34437e9d | |||
| dde9f5d542 | |||
| 72e60c95e0 | |||
| 9df30a84e0 | |||
| acfaebfffa | |||
| 72e215c552 | |||
| ee5cc81864 | |||
| 1bbfe8d092 | |||
| 88c31d4fdc | |||
| fb75cbf230 | |||
| eda4ed2955 | |||
| c41611e5d0 | |||
| 747081901f | |||
| 616ab217e4 | |||
| 00db16e7bd | |||
| 1821ba9f58 | |||
| ea48f03bbc | |||
| 6b2089a9bc | |||
| 25c0e629c6 | |||
| 48c8518ae6 | |||
| 96d001dfc0 | |||
| fb3a08fdcf | |||
| dcc8cdeb6a | |||
| 054958ebf5 | |||
| fa0c3a1a43 | |||
| 779334a09e | |||
| 3a3bdee296 | |||
| 546ddfbb62 | |||
| 58b642f356 | |||
| fa8a8ed711 | |||
| 6886f87fe9 | |||
| 78a2a74fa6 | |||
| 222f974ad5 | |||
| 96a4d3ffeb | |||
| e99d75ba1b | |||
| c1b9a42c73 | |||
| 4c10c1d939 | |||
| 3000e18cb7 | |||
| bf014db7ff | |||
| 12cd9bd275 | |||
| 7c42c5c462 | |||
| 631d5c175f | |||
| 785de52780 | |||
| 2ee7e93201 | |||
| 65ae23a956 | |||
| b9ade63e8e | |||
| cdeb2e4d5a | |||
| cc38d6e095 | |||
| 82d86c8c3c | |||
| 5979b1a275 | |||
| a7fe0d715d | |||
| ef8a913636 | |||
| 73791a92d3 | |||
| f0ad0f58a9 | |||
| 73825cd1d6 | |||
| 745943fc23 | |||
| b6bde4ad62 | |||
| 07ebccad3c | |||
| 028178605e | |||
| 03162dec44 | |||
| 85cdfe9ea1 | |||
| 5e2fe0329d | |||
| 6f8b3c8cdf | |||
| 1efe4a1439 | |||
| e27f092f85 | |||
| 215a7e87ae | |||
| 636ab96e96 | |||
| cc68a8dbbd | |||
| e887fd47f2 | |||
| f07d808f3d | |||
| 2a662ae86d | |||
| e805fdac9a | |||
| 0db411c2e4 | |||
| 27930b1dca | |||
| b3e2601758 | |||
| d9049b8a29 | |||
| 0f0601e5eb | |||
| 0a57eeaeef | |||
| fb637bc7db | |||
| ca2b812574 | |||
| 841c89af04 | |||
| bed3a4e3c9 | |||
| 3f8b911e6f | |||
| ea4adcdeb7 | |||
| 6242a346ce | |||
| 2d2ed6fc1b | |||
| 1b312313b2 | |||
| 37033f1b16 | |||
| 7ea78e4a17 | |||
| 9fcb472717 | |||
| 70ef4eb09c | |||
| 58224a03c5 | |||
| 927c4955f8 | |||
| 27f356519e | |||
| c80086ba69 | |||
| 86d5f8ceb1 | |||
| ff708a3fc3 | |||
| 44b20890d5 | |||
| 19ca91778f | |||
| e5a58ae757 | |||
| 602eb84f9c | |||
| 8d24e7f9e5 | |||
| 8eed685373 | |||
| 2b79302874 | |||
| 56e1081304 | |||
| 658e741611 | |||
| d5cd1924f5 | |||
| 645afbaf0c | |||
| a7ede3303d |
33
AGENTS.md
33
AGENTS.md
@@ -1,38 +1,25 @@
|
|||||||
# Repository Guidelines
|
# 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, Test, and Development Commands
|
||||||
- Build artifact: `mvn clean package` (tests are skipped by default via `pom.xml`).
|
- 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 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`.
|
- 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.
|
- Java 21. Use 4-space indentation; UTF-8; no wildcard imports.
|
||||||
- Packages: `com.ycwl.basic.*`; classes PascalCase; methods/fields camelCase; constants UPPER_SNAKE_CASE.
|
- 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`.
|
- Controllers in `controller`, business logic in `service`, persistence in `mapper` + `resources/mapper/*.xml`.
|
||||||
- Prefer Lombok for boilerplate and constructor injection where applicable.
|
- 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
|
## Project Structure
|
||||||
- Framework: Spring Boot testing + JUnit (see `spring-boot-starter-test`).
|
- Application code: `src/main/java/com/ycwl/basic/**` (controllers, services, mapper/repository, dto/model, config, util).
|
||||||
- Test names end with `Test` or `Tests` and mirror package structure.
|
- Resources: `src/main/resources/**` (Spring configs, `mapper/*.xml`, static assets, logging).
|
||||||
- Aim to cover service/util layers and critical controllers. No enforced coverage target.
|
- Tests: `src/test/java/**` mirrors main packages.
|
||||||
- To enable tests locally, remove/override the `maven-compiler-plugin` `testExcludes` in `pom.xml` and run `mvn -DskipTests=false test`.
|
- Build output: `target/` (never commit).
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Keep changes minimal and within existing package boundaries.
|
- Keep changes minimal and within existing package boundaries.
|
||||||
|
|||||||
33
CLAUDE.md
33
CLAUDE.md
@@ -1,38 +1,25 @@
|
|||||||
# Repository Guidelines
|
# 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, Test, and Development Commands
|
||||||
- Build artifact: `mvn clean package` (tests are skipped by default via `pom.xml`).
|
- 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 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`.
|
- 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.
|
- Java 21. Use 4-space indentation; UTF-8; no wildcard imports.
|
||||||
- Packages: `com.ycwl.basic.*`; classes PascalCase; methods/fields camelCase; constants UPPER_SNAKE_CASE.
|
- 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`.
|
- Controllers in `controller`, business logic in `service`, persistence in `mapper` + `resources/mapper/*.xml`.
|
||||||
- Prefer Lombok for boilerplate and constructor injection where applicable.
|
- 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
|
## Project Structure
|
||||||
- Framework: Spring Boot testing + JUnit (see `spring-boot-starter-test`).
|
- Application code: `src/main/java/com/ycwl/basic/**` (controllers, services, mapper/repository, dto/model, config, util).
|
||||||
- Test names end with `Test` or `Tests` and mirror package structure.
|
- Resources: `src/main/resources/**` (Spring configs, `mapper/*.xml`, static assets, logging).
|
||||||
- Aim to cover service/util layers and critical controllers. No enforced coverage target.
|
- Tests: `src/test/java/**` mirrors main packages.
|
||||||
- To enable tests locally, remove/override the `maven-compiler-plugin` `testExcludes` in `pom.xml` and run `mvn -DskipTests=false test`.
|
- Build output: `target/` (never commit).
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Keep changes minimal and within existing package boundaries.
|
- Keep changes minimal and within existing package boundaries.
|
||||||
|
|||||||
35
pom.xml
35
pom.xml
@@ -75,6 +75,12 @@
|
|||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebSocket -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Nacos服务发现 -->
|
<!-- Nacos服务发现 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.alibaba.cloud</groupId>
|
<groupId>com.alibaba.cloud</groupId>
|
||||||
@@ -267,11 +273,30 @@
|
|||||||
<version>5.0.0</version>
|
<version>5.0.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 智谱AI SDK -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>ai.z.openapi</groupId>
|
||||||
|
<artifactId>zai-sdk</artifactId>
|
||||||
|
<version>0.1.3</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Spring Kafka -->
|
<!-- Spring Kafka -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.kafka</groupId>
|
<groupId>org.springframework.kafka</groupId>
|
||||||
<artifactId>spring-kafka</artifactId>
|
<artifactId>spring-kafka</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Apache POI - 处理Excel文件 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi</artifactId>
|
||||||
|
<version>5.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.poi</groupId>
|
||||||
|
<artifactId>poi-ooxml</artifactId>
|
||||||
|
<version>5.4.0</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -299,16 +324,6 @@
|
|||||||
<skip>${skipTests}</skip>
|
<skip>${skipTests}</skip>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
<!-- 跳过测试编译 -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
|
||||||
<configuration>
|
|
||||||
<testExcludes>
|
|
||||||
<testExclude>**/*Test.java</testExclude>
|
|
||||||
</testExcludes>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.ycwl.basic;
|
package com.ycwl.basic;
|
||||||
|
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
|
||||||
@@ -9,8 +8,6 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
|
|||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableDiscoveryClient
|
@EnableDiscoveryClient
|
||||||
@EnableFeignClients
|
@EnableFeignClients
|
||||||
@MapperScan(basePackages = "com.ycwl.basic.mapper")
|
|
||||||
@MapperScan(basePackages = "com.ycwl.basic.*.mapper")
|
|
||||||
public class Application {
|
public class Application {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.ycwl.basic.biz;
|
package com.ycwl.basic.biz;
|
||||||
|
|
||||||
import com.ycwl.basic.enums.StatisticEnum;
|
import com.ycwl.basic.enums.StatisticEnum;
|
||||||
|
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||||
import com.ycwl.basic.mapper.OrderMapper;
|
import com.ycwl.basic.mapper.OrderMapper;
|
||||||
import com.ycwl.basic.mapper.SourceMapper;
|
import com.ycwl.basic.mapper.SourceMapper;
|
||||||
import com.ycwl.basic.mapper.StatisticsMapper;
|
import com.ycwl.basic.mapper.StatisticsMapper;
|
||||||
import com.ycwl.basic.mapper.VideoMapper;
|
import com.ycwl.basic.mapper.VideoMapper;
|
||||||
|
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
|
||||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||||
import com.ycwl.basic.model.mobile.order.PriceObj;
|
import com.ycwl.basic.model.mobile.order.PriceObj;
|
||||||
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
|
import com.ycwl.basic.model.mobile.statistic.req.StatisticsRecordAddReq;
|
||||||
@@ -16,7 +18,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.OrderAppRespVO;
|
||||||
import com.ycwl.basic.model.pc.order.resp.OrderItemVO;
|
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.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.source.entity.SourceEntity;
|
||||||
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
|
import com.ycwl.basic.model.pc.task.entity.TaskEntity;
|
||||||
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
||||||
@@ -87,7 +88,6 @@ public class OrderBiz {
|
|||||||
PriceObj priceObj = new PriceObj();
|
PriceObj priceObj = new PriceObj();
|
||||||
priceObj.setGoodsType(goodsType);
|
priceObj.setGoodsType(goodsType);
|
||||||
priceObj.setGoodsId(goodsId);
|
priceObj.setGoodsId(goodsId);
|
||||||
ScenicEntity scenic = scenicRepository.getScenic(scenicId);
|
|
||||||
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(scenicId);
|
ScenicConfigEntity scenicConfig = scenicRepository.getScenicConfig(scenicId);
|
||||||
if (scenicConfig != null) {
|
if (scenicConfig != null) {
|
||||||
if (Boolean.TRUE.equals(scenicConfig.getAllFree())) {
|
if (Boolean.TRUE.equals(scenicConfig.getAllFree())) {
|
||||||
@@ -117,9 +117,10 @@ public class OrderBiz {
|
|||||||
vlogProductItem.setProductType(ProductType.VLOG_VIDEO);
|
vlogProductItem.setProductType(ProductType.VLOG_VIDEO);
|
||||||
vlogProductItem.setProductId(template.getId().toString());
|
vlogProductItem.setProductId(template.getId().toString());
|
||||||
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
|
vlogProductItem.setQuantity(videoTaskRepository.getTaskLensNum(video.getTaskId()));
|
||||||
vlogProductItem.setScenicId(scenic.getId().toString());
|
vlogProductItem.setScenicId(scenicId.toString());
|
||||||
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
|
vlogCalculationRequest.setProducts(Collections.singletonList(vlogProductItem));
|
||||||
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
|
vlogCalculationRequest.setFaceId(priceObj.getFaceId());
|
||||||
|
vlogCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||||
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
|
PriceCalculationResult vlogCalculationResult = iPriceCalculationService.calculatePrice(vlogCalculationRequest);
|
||||||
priceObj.setPrice(vlogCalculationResult.getFinalAmount());
|
priceObj.setPrice(vlogCalculationResult.getFinalAmount());
|
||||||
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
|
priceObj.setSlashPrice(vlogCalculationResult.getOriginalAmount());
|
||||||
@@ -132,19 +133,35 @@ public class OrderBiz {
|
|||||||
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
|
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
|
||||||
ProductItem productItem = new ProductItem();
|
ProductItem productItem = new ProductItem();
|
||||||
productItem.setProductType(goodsType == 1 ? ProductType.RECORDING_SET : ProductType.PHOTO_SET);
|
productItem.setProductType(goodsType == 1 ? ProductType.RECORDING_SET : ProductType.PHOTO_SET);
|
||||||
productItem.setProductId(scenic.getId().toString());
|
productItem.setProductId(scenicId.toString());
|
||||||
productItem.setPurchaseCount(1);
|
productItem.setPurchaseCount(1);
|
||||||
productItem.setScenicId(scenic.getId().toString());
|
productItem.setScenicId(scenicId.toString());
|
||||||
calculationRequest.setProducts(Collections.singletonList(productItem));
|
calculationRequest.setProducts(Collections.singletonList(productItem));
|
||||||
if (face != null) {
|
if (face != null) {
|
||||||
calculationRequest.setUserId(face.getMemberId());
|
calculationRequest.setUserId(face.getMemberId());
|
||||||
}
|
}
|
||||||
calculationRequest.setFaceId(goodsId);
|
calculationRequest.setFaceId(goodsId);
|
||||||
|
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||||
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
PriceCalculationResult priceCalculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||||
priceObj.setPrice(priceCalculationResult.getFinalAmount());
|
priceObj.setPrice(priceCalculationResult.getFinalAmount());
|
||||||
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
|
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
|
||||||
priceObj.setFaceId(goodsId);
|
priceObj.setFaceId(goodsId);
|
||||||
break;
|
break;
|
||||||
|
case 13:
|
||||||
|
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
|
||||||
|
ProductItem aiCamProductItem = new ProductItem();
|
||||||
|
aiCamProductItem.setProductType(ProductType.AI_CAM_PHOTO_SET);
|
||||||
|
aiCamProductItem.setProductId(scenicId.toString());
|
||||||
|
aiCamProductItem.setPurchaseCount(1);
|
||||||
|
aiCamProductItem.setScenicId(scenicId.toString());
|
||||||
|
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
|
||||||
|
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||||
|
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
|
||||||
|
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
|
||||||
|
priceObj.setSlashPrice(aiCamPriceCalculationResult.getOriginalAmount());
|
||||||
|
priceObj.setFaceId(goodsId);
|
||||||
|
priceObj.setScenicId(scenicId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return priceObj;
|
return priceObj;
|
||||||
}
|
}
|
||||||
@@ -160,90 +177,46 @@ public class OrderBiz {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public IsBuyRespVO isBuy(Long scenicId, Long memberId, Long faceId, int goodsType, Long goodsId) {
|
||||||
public IsBuyRespVO isBuy(Long userId, Long scenicId, int goodsType, Long goodsId) {
|
|
||||||
IsBuyRespVO respVO = new IsBuyRespVO();
|
IsBuyRespVO respVO = new IsBuyRespVO();
|
||||||
boolean isBuy = orderRepository.checkUserBuyItem(userId, goodsType, goodsId);
|
respVO.setGoodsType(goodsType);
|
||||||
// 模板购买逻辑
|
respVO.setGoodsId(goodsId);
|
||||||
if (!isBuy) {
|
OrderEntity orderEntity = orderMapper.getUserBuyFaceItem(memberId, faceId, goodsType, goodsId);
|
||||||
if (goodsType == 0) {
|
if (orderEntity != null) {
|
||||||
VideoEntity video = videoRepository.getVideo(goodsId);
|
respVO.setOrderId(orderEntity.getId());
|
||||||
if (video == null) {
|
respVO.setBuy(true);
|
||||||
|
respVO.setFree(false);
|
||||||
|
return respVO;
|
||||||
|
}
|
||||||
|
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(scenicId);
|
||||||
|
if (Boolean.TRUE.equals(scenicConfig.getBoolean("all_free"))) {
|
||||||
|
// 景区全免
|
||||||
|
respVO.setFree(true);
|
||||||
|
respVO.setOrigPrice(BigDecimal.ZERO);
|
||||||
|
respVO.setSlashPrice(BigDecimal.ZERO);
|
||||||
|
return respVO;
|
||||||
|
}
|
||||||
|
// 未来模板一口价
|
||||||
|
if (goodsType == 0) {
|
||||||
|
// 视频,可以买断模板
|
||||||
|
VideoEntity video = videoRepository.getVideo(goodsId);
|
||||||
|
if (video != null && video.getTemplateId() != null) {
|
||||||
|
OrderEntity templateBuy = orderMapper.getUserBuyFaceItem(memberId, faceId, -1, video.getTemplateId());
|
||||||
|
if (templateBuy != null) {
|
||||||
|
respVO.setOrderId(templateBuy.getId());
|
||||||
|
respVO.setBuy(true);
|
||||||
|
respVO.setFree(false);
|
||||||
return respVO;
|
return respVO;
|
||||||
}
|
}
|
||||||
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId());
|
|
||||||
Long templateId = video.getTemplateId();
|
|
||||||
// -1为整个模板购买
|
|
||||||
OrderEntity orderEntity = orderRepository.getUserBuyItem(userId, -1, templateId);
|
|
||||||
if (orderEntity != null && task != null) {
|
|
||||||
respVO.setOrderId(orderEntity.getId());
|
|
||||||
if (orderEntity.getFaceId() != null && task.getFaceId() != null) {
|
|
||||||
isBuy = orderEntity.getFaceId().equals(task.getFaceId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 免费送逻辑,之前已经赠送了的
|
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
|
||||||
if (!isBuy) {
|
if (priceObj == null) {
|
||||||
isBuy = switch (goodsType) {
|
return respVO;
|
||||||
case 0 -> videoRepository.getUserIsBuy(userId, goodsId);
|
|
||||||
case 1, 2 -> sourceRepository.getUserIsBuy(userId, goodsType, goodsId);
|
|
||||||
default -> false;
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
OrderEntity orderEntity = orderRepository.getUserBuyItem(userId, goodsType, goodsId);
|
|
||||||
if (orderEntity != null) {
|
|
||||||
respVO.setOrderId(orderEntity.getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
respVO.setBuy(isBuy);
|
|
||||||
// 还是没买
|
|
||||||
if (!isBuy) {
|
|
||||||
PriceObj priceObj = queryPrice(scenicId, goodsType, goodsId);
|
|
||||||
if (priceObj == null) {
|
|
||||||
return respVO;
|
|
||||||
}
|
|
||||||
FaceEntity face = faceRepository.getFace(priceObj.getFaceId());
|
|
||||||
respVO.setShare(true);
|
|
||||||
if (face != null && face.getMemberId().equals(userId)) {
|
|
||||||
respVO.setShare(false);
|
|
||||||
}
|
|
||||||
respVO.setFree(priceObj.isFree());
|
|
||||||
respVO.setGoodsType(goodsType);
|
|
||||||
respVO.setGoodsId(goodsId);
|
|
||||||
respVO.setOrigPrice(priceObj.getPrice());
|
|
||||||
respVO.setSlashPrice(priceObj.getSlashPrice());
|
|
||||||
switch (goodsType) {
|
|
||||||
case 0: // vlog
|
|
||||||
VideoEntity video = videoRepository.getVideo(goodsId);
|
|
||||||
TaskEntity taskById = videoTaskRepository.getTaskById(video.getTaskId());
|
|
||||||
if (taskById != null) {
|
|
||||||
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, taskById.getFaceId(), taskById.getTemplateId().toString());
|
|
||||||
if (recordQueryResp.isUsable()) {
|
|
||||||
respVO.setCouponId(recordQueryResp.getCouponId());
|
|
||||||
respVO.setCouponRecordId(recordQueryResp.getId());
|
|
||||||
CouponEntity coupon = recordQueryResp.getCoupon();
|
|
||||||
if (coupon != null) {
|
|
||||||
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceObj.getPrice()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
case 2:
|
|
||||||
CouponRecordQueryResp recordQueryResp = couponBiz.queryUserCouponRecord(scenicId, userId, goodsId, String.valueOf(goodsType));
|
|
||||||
if (recordQueryResp.isUsable()) {
|
|
||||||
respVO.setCouponId(recordQueryResp.getCouponId());
|
|
||||||
respVO.setCouponRecordId(recordQueryResp.getId());
|
|
||||||
CouponEntity coupon = recordQueryResp.getCoupon();
|
|
||||||
if (coupon != null) {
|
|
||||||
respVO.setCouponPrice(coupon.calculateDiscountPrice(priceObj.getPrice()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
respVO.setBuy(false);
|
||||||
|
respVO.setOrigPrice(priceObj.getPrice());
|
||||||
|
respVO.setSlashPrice(priceObj.getSlashPrice());
|
||||||
return respVO;
|
return respVO;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,13 +230,20 @@ public class OrderBiz {
|
|||||||
orderRepository.updateOrder(orderId, orderUpdate);
|
orderRepository.updateOrder(orderId, orderUpdate);
|
||||||
orderItems.forEach(item -> {
|
orderItems.forEach(item -> {
|
||||||
switch (item.getGoodsType()) {
|
switch (item.getGoodsType()) {
|
||||||
|
case -1: // vlog视频模板
|
||||||
|
videoRepository.setUserIsBuyTemplate(order.getMemberId(), item.getGoodsId(), order.getId(), order.getFaceId());
|
||||||
|
break;
|
||||||
case 0: // vlog视频
|
case 0: // vlog视频
|
||||||
videoRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
|
videoRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
|
||||||
|
break;
|
||||||
case 1: // 视频原素材
|
case 1: // 视频原素材
|
||||||
case 2: // 照片原素材
|
case 2: // 照片原素材
|
||||||
|
case 13: // AI微单
|
||||||
sourceRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId(), order.getId());
|
sourceRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId(), order.getId());
|
||||||
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
printerService.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
|
printerService.setUserIsBuyItem(order.getMemberId(), item.getGoodsId(), order.getId());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||||
@@ -271,38 +251,6 @@ public class OrderBiz {
|
|||||||
if (couponRecordId != null) {
|
if (couponRecordId != null) {
|
||||||
couponBiz.userUseCoupon(order.getMemberId(), order.getFaceId(), couponRecordId, orderId);
|
couponBiz.userUseCoupon(order.getMemberId(), order.getFaceId(), couponRecordId, orderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
//支付时间
|
|
||||||
OrderAppRespVO orderDetail = orderMapper.appDetail(orderId);
|
|
||||||
Date payAt = orderDetail.getPayAt();
|
|
||||||
//商品创建时间
|
|
||||||
Date goodsCreateTime = new Date();
|
|
||||||
if (!orderDetail.getOrderItemList().isEmpty()) {
|
|
||||||
OrderItemVO orderItemVO = orderDetail.getOrderItemList().getFirst();
|
|
||||||
switch (orderItemVO.getGoodsType()) {
|
|
||||||
case 0:
|
|
||||||
VideoEntity video = videoRepository.getVideo(orderItemVO.getGoodsId());
|
|
||||||
if (video != null) {
|
|
||||||
goodsCreateTime = video.getCreateTime();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
List<SourceEntity> imageSource = sourceMapper.listImageByFaceRelation(order.getMemberId(), orderItemVO.getGoodsId());
|
|
||||||
Optional<SourceEntity> min = imageSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
|
|
||||||
if (min.isPresent()) {
|
|
||||||
goodsCreateTime = min.get().getCreateTime();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
List<SourceEntity> videoSource = sourceMapper.listImageByFaceRelation(order.getMemberId(), orderItemVO.getGoodsId());
|
|
||||||
Optional<SourceEntity> minTime = videoSource.stream().min(Comparator.comparing(SourceEntity::getCreateTime));
|
|
||||||
if (minTime.isPresent()) {
|
|
||||||
goodsCreateTime = minTime.get().getCreateTime();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
|
StatisticsRecordAddReq statisticsRecordAddReq = new StatisticsRecordAddReq();
|
||||||
statisticsRecordAddReq.setMemberId(order.getMemberId());
|
statisticsRecordAddReq.setMemberId(order.getMemberId());
|
||||||
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
|
Long enterType = statisticsMapper.getUserRecentEnterType(order.getMemberId(), order.getCreateAt());
|
||||||
@@ -330,9 +278,11 @@ public class OrderBiz {
|
|||||||
switch (item.getGoodsType()) {
|
switch (item.getGoodsType()) {
|
||||||
case 0: // vlog视频
|
case 0: // vlog视频
|
||||||
videoRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsId());
|
videoRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsId());
|
||||||
|
break;
|
||||||
case 1: // 视频原素材
|
case 1: // 视频原素材
|
||||||
case 2: // 照片原素材
|
case 2: // 照片原素材
|
||||||
sourceRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId());
|
sourceRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||||
@@ -352,9 +302,11 @@ public class OrderBiz {
|
|||||||
switch (item.getGoodsType()) {
|
switch (item.getGoodsType()) {
|
||||||
case 0: // vlog视频
|
case 0: // vlog视频
|
||||||
videoRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsId());
|
videoRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsId());
|
||||||
|
break;
|
||||||
case 1: // 视频原素材
|
case 1: // 视频原素材
|
||||||
case 2: // 照片原素材
|
case 2: // 照片原素材
|
||||||
sourceRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId());
|
sourceRepository.setUserNotBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId());
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
orderRepository.clearOrderCache(orderId); // 更新完了,清理下
|
||||||
@@ -362,10 +314,14 @@ public class OrderBiz {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查用户是否购买了指定商品
|
* 检查用户是否购买了指定商品,并额外校验订单的faceId是否匹配
|
||||||
* 提供给PriceBiz使用,避免循环调用
|
* @param userId 用户ID
|
||||||
|
* @param faceId 人脸ID
|
||||||
|
* @param goodsType 商品类型
|
||||||
|
* @param goodsId 商品ID
|
||||||
|
* @return 是否已购买且faceId匹配
|
||||||
*/
|
*/
|
||||||
public boolean checkUserBuyItem(Long userId, int goodsType, Long goodsId) {
|
public boolean checkUserBuyFaceItem(Long userId, Long faceId, int goodsType, Long goodsId) {
|
||||||
return orderRepository.checkUserBuyItem(userId, goodsType, goodsId);
|
return orderRepository.checkUserBuyFaceItem(userId, faceId, goodsType, goodsId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
package com.ycwl.basic.biz;
|
package com.ycwl.basic.biz;
|
||||||
|
|
||||||
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
|
import com.ycwl.basic.model.mobile.order.IsBuyBatchRespVO;
|
||||||
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
|
||||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
|
||||||
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
import com.ycwl.basic.model.pc.coupon.entity.CouponEntity;
|
||||||
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
|
import com.ycwl.basic.model.pc.couponRecord.resp.CouponRecordQueryResp;
|
||||||
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||||
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
|
import com.ycwl.basic.model.pc.order.entity.OrderEntity;
|
||||||
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;
|
import com.ycwl.basic.model.pc.price.entity.PriceConfigEntity;
|
||||||
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
|
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
|
||||||
|
import com.ycwl.basic.model.pc.price.resp.SimpleGoodsRespVO;
|
||||||
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
|
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
|
||||||
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
import com.ycwl.basic.model.pc.template.resp.TemplateRespVO;
|
||||||
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
|
import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity;
|
||||||
import com.ycwl.basic.pricing.entity.PriceOnePriceConfig;
|
import com.ycwl.basic.product.capability.ProductTypeCapability;
|
||||||
import com.ycwl.basic.pricing.service.IOnePricePurchaseService;
|
import com.ycwl.basic.product.service.IProductTypeCapabilityManagementService;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper;
|
||||||
import com.ycwl.basic.repository.FaceRepository;
|
import com.ycwl.basic.repository.FaceRepository;
|
||||||
import com.ycwl.basic.repository.MemberRelationRepository;
|
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||||
import com.ycwl.basic.repository.PriceRepository;
|
import com.ycwl.basic.repository.PriceRepository;
|
||||||
@@ -29,7 +30,6 @@ import java.math.BigDecimal;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -51,6 +51,10 @@ public class PriceBiz {
|
|||||||
private CouponBiz couponBiz;
|
private CouponBiz couponBiz;
|
||||||
@Autowired
|
@Autowired
|
||||||
private MemberRelationRepository memberRelationRepository;
|
private MemberRelationRepository memberRelationRepository;
|
||||||
|
@Autowired
|
||||||
|
private PuzzleTemplateMapper puzzleTemplateMapper;
|
||||||
|
@Autowired
|
||||||
|
private IProductTypeCapabilityManagementService productTypeCapabilityManagementService;
|
||||||
|
|
||||||
public List<GoodsListRespVO> listGoodsByScenic(Long scenicId) {
|
public List<GoodsListRespVO> listGoodsByScenic(Long scenicId) {
|
||||||
List<GoodsListRespVO> goodsList = new ArrayList<>();
|
List<GoodsListRespVO> goodsList = new ArrayList<>();
|
||||||
@@ -72,6 +76,117 @@ public class PriceBiz {
|
|||||||
goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
|
goodsList.add(new GoodsListRespVO(2L, "照片集", 2));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 拼图
|
||||||
|
puzzleTemplateMapper.list(scenicId, null, 1).forEach(puzzleTemplate -> {
|
||||||
|
GoodsListRespVO goods = new GoodsListRespVO();
|
||||||
|
goods.setGoodsId(puzzleTemplate.getId());
|
||||||
|
goods.setGoodsName(puzzleTemplate.getName());
|
||||||
|
goods.setGoodsType(5);
|
||||||
|
goodsList.add(goods);
|
||||||
|
});
|
||||||
|
return goodsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据景区ID和商品类型查询简化的商品列表
|
||||||
|
*
|
||||||
|
* @param scenicId 景区ID
|
||||||
|
* @param productType 商品类型(可选,为空时返回所有商品)
|
||||||
|
* @return 简化的商品列表
|
||||||
|
*/
|
||||||
|
public List<SimpleGoodsRespVO> listSimpleGoodsByScenic(Long scenicId, String productType) {
|
||||||
|
List<SimpleGoodsRespVO> goodsList = new ArrayList<>();
|
||||||
|
|
||||||
|
// 如果 productType 为空,兼容旧逻辑
|
||||||
|
if (productType == null || productType.isEmpty()) {
|
||||||
|
return listAllSimpleGoods(scenicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 productType 查询不同数据源
|
||||||
|
switch (productType) {
|
||||||
|
case "VLOG_VIDEO":
|
||||||
|
// 从 template 表查询视频模板
|
||||||
|
List<TemplateRespVO> templateList = templateRepository.getTemplateListByScenicId(scenicId);
|
||||||
|
templateList.stream()
|
||||||
|
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
|
||||||
|
.forEach(goodsList::add);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PHOTO_VLOG":
|
||||||
|
// TODO
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "【待实现】pLog视频", productType));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PHOTO":
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "单张照片", productType));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PHOTO_SET":
|
||||||
|
// 返回固定的照片集条目
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "照片集", productType));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "AI_CAM_PHOTO_SET":
|
||||||
|
// 返回固定的照片集条目
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "AI微单照片集", productType));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PHOTO_LOG":
|
||||||
|
// 从 template 表查询pLog模板
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "pLog图<景区打包>", productType));
|
||||||
|
List<PuzzleTemplateEntity> puzzleList = puzzleTemplateMapper.list(scenicId, null, null);
|
||||||
|
puzzleList.stream()
|
||||||
|
.map(template -> new SimpleGoodsRespVO(template.getId(), template.getName(), productType))
|
||||||
|
.forEach(goodsList::add);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "RECORDING_SET":
|
||||||
|
// 返回固定的录像集条目
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "录像集", productType));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "PHOTO_PRINT":
|
||||||
|
// 打印类返回单一通用条目
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "照片打印", productType));
|
||||||
|
break;
|
||||||
|
case "PHOTO_PRINT_MU":
|
||||||
|
// 打印类返回单一通用条目
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "手机照片打印", productType));
|
||||||
|
break;
|
||||||
|
case "PHOTO_PRINT_FX":
|
||||||
|
// 打印类返回单一通用条目
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "效果图片打印", productType));
|
||||||
|
break;
|
||||||
|
case "MACHINE_PRINT":
|
||||||
|
// 打印类返回单一通用条目
|
||||||
|
goodsList.add(new SimpleGoodsRespVO(scenicId, "一体机打印", productType));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 不支持的 productType,返回空列表
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return goodsList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容旧逻辑:返回所有商品
|
||||||
|
* 通过查询系统中所有已知的 productType,将结果综合到一起
|
||||||
|
*/
|
||||||
|
private List<SimpleGoodsRespVO> listAllSimpleGoods(Long scenicId) {
|
||||||
|
List<SimpleGoodsRespVO> goodsList = new ArrayList<>();
|
||||||
|
|
||||||
|
// 从 ProductTypeCapability 服务查询所有已知的商品类型(仅包含启用的)
|
||||||
|
List<ProductTypeCapability> capabilities = productTypeCapabilityManagementService.queryAll(false);
|
||||||
|
|
||||||
|
// 轮询每个商品类型,获取对应的商品列表
|
||||||
|
for (ProductTypeCapability capability : capabilities) {
|
||||||
|
String productType = capability.getProductType();
|
||||||
|
List<SimpleGoodsRespVO> typeGoodsList = listSimpleGoodsByScenic(scenicId, productType);
|
||||||
|
goodsList.addAll(typeGoodsList);
|
||||||
|
}
|
||||||
|
|
||||||
return goodsList;
|
return goodsList;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +210,7 @@ public class PriceBiz {
|
|||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public IsBuyBatchRespVO isBuy(Long userId, Long faceId, Long scenicId, Integer type, String goodsIds) {
|
public IsBuyBatchRespVO isOnePriceBuy(Long userId, Long faceId, Long scenicId, Integer type, String goodsIds) {
|
||||||
IsBuyBatchRespVO respVO = new IsBuyBatchRespVO();
|
IsBuyBatchRespVO respVO = new IsBuyBatchRespVO();
|
||||||
PriceConfigEntity priceConfig = priceRepository.getPriceConfigByScenicTypeGoods(scenicId, type, goodsIds);
|
PriceConfigEntity priceConfig = priceRepository.getPriceConfigByScenicTypeGoods(scenicId, type, goodsIds);
|
||||||
if (priceConfig == null) {
|
if (priceConfig == null) {
|
||||||
@@ -166,7 +281,7 @@ public class PriceBiz {
|
|||||||
allContentsPurchased = false;
|
allContentsPurchased = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
boolean hasPurchasedTemplate = orderBiz.checkUserBuyItem(userId, -1, videoEntities.getFirst().getVideoId());
|
boolean hasPurchasedTemplate = orderBiz.checkUserBuyFaceItem(userId, faceId, -1, videoEntities.getFirst().getVideoId());
|
||||||
if (!hasPurchasedTemplate) {
|
if (!hasPurchasedTemplate) {
|
||||||
allContentsPurchased = false;
|
allContentsPurchased = false;
|
||||||
break;
|
break;
|
||||||
@@ -178,7 +293,7 @@ public class PriceBiz {
|
|||||||
if (scenicConfig != null) {
|
if (scenicConfig != null) {
|
||||||
// 检查录像集
|
// 检查录像集
|
||||||
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceVideo())) {
|
if (!Boolean.TRUE.equals(scenicConfig.getDisableSourceVideo())) {
|
||||||
boolean hasPurchasedRecording = orderBiz.checkUserBuyItem(userId, 1, faceId);
|
boolean hasPurchasedRecording = orderBiz.checkUserBuyFaceItem(userId, faceId, 1, faceId);
|
||||||
if (!hasPurchasedRecording) {
|
if (!hasPurchasedRecording) {
|
||||||
allContentsPurchased = false;
|
allContentsPurchased = false;
|
||||||
}
|
}
|
||||||
@@ -186,7 +301,7 @@ public class PriceBiz {
|
|||||||
|
|
||||||
// 检查照片集
|
// 检查照片集
|
||||||
if (allContentsPurchased && !Boolean.TRUE.equals(scenicConfig.getDisableSourceImage())) {
|
if (allContentsPurchased && !Boolean.TRUE.equals(scenicConfig.getDisableSourceImage())) {
|
||||||
boolean hasPurchasedPhoto = orderBiz.checkUserBuyItem(userId, 2, faceId);
|
boolean hasPurchasedPhoto = orderBiz.checkUserBuyFaceItem(userId, faceId, 2, faceId);
|
||||||
if (!hasPurchasedPhoto) {
|
if (!hasPurchasedPhoto) {
|
||||||
allContentsPurchased = false;
|
allContentsPurchased = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ public class TemplateBiz {
|
|||||||
if (scanSource) {
|
if (scanSource) {
|
||||||
List<SourceEntity> sourceEntities = sourceMapper.listVideoByScenicFaceRelation(face.getScenicId(), faceId);
|
List<SourceEntity> sourceEntities = sourceMapper.listVideoByScenicFaceRelation(face.getScenicId(), faceId);
|
||||||
if (sourceEntities == null || sourceEntities.isEmpty()) {
|
if (sourceEntities == null || sourceEntities.isEmpty()) {
|
||||||
|
log.info("faceId:{} has no source", faceId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
count = sourceEntities.stream()
|
count = sourceEntities.stream()
|
||||||
@@ -65,6 +66,7 @@ public class TemplateBiz {
|
|||||||
} else {
|
} else {
|
||||||
List<FaceSampleEntity> faceSampleList = faceRepository.getFaceSampleList(faceId);
|
List<FaceSampleEntity> faceSampleList = faceRepository.getFaceSampleList(faceId);
|
||||||
if (faceSampleList == null || faceSampleList.isEmpty()) {
|
if (faceSampleList == null || faceSampleList.isEmpty()) {
|
||||||
|
log.info("faceId:{} has no faceSample", faceId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
count = faceSampleList.stream()
|
count = faceSampleList.stream()
|
||||||
|
|||||||
@@ -1,34 +1,26 @@
|
|||||||
package com.ycwl.basic.config;
|
package com.ycwl.basic.config;
|
||||||
|
|
||||||
import org.springframework.cache.annotation.CachingConfigurerSupport;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.springframework.cache.annotation.EnableCaching;
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
|
||||||
|
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
|
||||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
|
||||||
import org.springframework.data.redis.serializer.RedisSerializationContext;
|
|
||||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||||
|
|
||||||
import java.time.Duration;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author wenshijia
|
* @author wenshijia
|
||||||
* @date 2021年07月05日 18:34
|
* @date 2021年07月05日 18:34
|
||||||
* 修改redis缓存序列化器
|
* 修改redis缓存序列化器
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableCaching
|
public class CustomRedisCacheManager {
|
||||||
public class CustomRedisCacheManager extends CachingConfigurerSupport {
|
@Autowired
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
@Bean
|
|
||||||
public RedisCacheConfiguration redisCacheConfiguration() {
|
|
||||||
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
|
|
||||||
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig();
|
|
||||||
configuration = configuration.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer)).entryTtl(Duration.ofMinutes(1));
|
|
||||||
return configuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理redis连接工具显示redis key值显示乱码问题,value值没处理
|
* 处理redis连接工具显示redis key值显示乱码问题,value值没处理
|
||||||
@@ -45,10 +37,23 @@ public class CustomRedisCacheManager extends CachingConfigurerSupport {
|
|||||||
|
|
||||||
final StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
|
final StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
|
||||||
redisTemplate.setKeySerializer(stringRedisSerializer);
|
redisTemplate.setKeySerializer(stringRedisSerializer);
|
||||||
redisTemplate.setValueSerializer(stringRedisSerializer);
|
|
||||||
|
|
||||||
redisTemplate.setHashKeySerializer(stringRedisSerializer);
|
redisTemplate.setHashKeySerializer(stringRedisSerializer);
|
||||||
|
|
||||||
|
// Configure Jackson2JsonRedisSerializer with JavaTimeModule for value serialization
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
objectMapper.registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
|
// Configure type handling to prevent ClassCastException
|
||||||
|
PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
|
||||||
|
.allowIfBaseType(Object.class)
|
||||||
|
.build();
|
||||||
|
objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
|
||||||
|
|
||||||
|
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
|
||||||
|
|
||||||
|
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
|
||||||
|
redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
|
||||||
|
|
||||||
redisTemplate.afterPropertiesSet();
|
redisTemplate.afterPropertiesSet();
|
||||||
return redisTemplate;
|
return redisTemplate;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.ycwl.basic.config;
|
|||||||
import com.baomidou.mybatisplus.annotation.DbType;
|
import com.baomidou.mybatisplus.annotation.DbType;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@@ -11,6 +12,15 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
* @date 2021年06月04日 9:42
|
* @date 2021年06月04日 9:42
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@MapperScan(basePackages = {
|
||||||
|
"com.ycwl.basic.mapper",
|
||||||
|
"com.ycwl.basic.order.mapper",
|
||||||
|
"com.ycwl.basic.pricing.mapper",
|
||||||
|
"com.ycwl.basic.product.mapper",
|
||||||
|
"com.ycwl.basic.profitsharing.mapper",
|
||||||
|
"com.ycwl.basic.puzzle.mapper",
|
||||||
|
"com.ycwl.basic.stats.mapper"
|
||||||
|
})
|
||||||
public class MybatisPlusPageConfig {
|
public class MybatisPlusPageConfig {
|
||||||
|
|
||||||
/* 旧版本配置
|
/* 旧版本配置
|
||||||
|
|||||||
71
src/main/java/com/ycwl/basic/constant/BuyStatus.java
Normal file
71
src/main/java/com/ycwl/basic/constant/BuyStatus.java
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ycwl.basic.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 购买状态枚举
|
||||||
|
* 定义源文件的已购买和未购买两种状态
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-10-31
|
||||||
|
*/
|
||||||
|
public enum BuyStatus {
|
||||||
|
/**
|
||||||
|
* 未购买状态
|
||||||
|
*/
|
||||||
|
NOT_BOUGHT(0, "未购买"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已购买状态
|
||||||
|
*/
|
||||||
|
BOUGHT(1, "已购买");
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
BuyStatus(int code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据代码值获取枚举
|
||||||
|
*
|
||||||
|
* @param code 状态代码
|
||||||
|
* @return 对应的枚举值,如果不存在返回 null
|
||||||
|
*/
|
||||||
|
public static BuyStatus fromCode(int code) {
|
||||||
|
for (BuyStatus status : values()) {
|
||||||
|
if (status.code == code) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断给定的代码是否为已购买状态
|
||||||
|
*
|
||||||
|
* @param code 状态代码
|
||||||
|
* @return true-已购买,false-未购买
|
||||||
|
*/
|
||||||
|
public static boolean isBought(Integer code) {
|
||||||
|
return code != null && code == BOUGHT.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断给定的代码是否为未购买状态
|
||||||
|
*
|
||||||
|
* @param code 状态代码
|
||||||
|
* @return true-未购买,false-已购买
|
||||||
|
*/
|
||||||
|
public static boolean isNotBought(Integer code) {
|
||||||
|
return code != null && code == NOT_BOUGHT.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,5 +5,6 @@ public class FaceConstant {
|
|||||||
public static final String USER_FACE_DB_NAME="userFace";
|
public static final String USER_FACE_DB_NAME="userFace";
|
||||||
public static final String FACE_USER_URL_PFX="face:user:url:";
|
public static final String FACE_USER_URL_PFX="face:user:url:";
|
||||||
public static final String FACE_RECOGNITION_COUNT_PFX="face:recognition:count:";
|
public static final String FACE_RECOGNITION_COUNT_PFX="face:recognition:count:";
|
||||||
|
public static final String FACE_CUSTOM_MATCH_COUNT_PFX="face:custom:match:count:";
|
||||||
public static final String FACE_LOW_THRESHOLD_PFX="face:low:threshold:";
|
public static final String FACE_LOW_THRESHOLD_PFX="face:low:threshold:";
|
||||||
}
|
}
|
||||||
|
|||||||
71
src/main/java/com/ycwl/basic/constant/FreeStatus.java
Normal file
71
src/main/java/com/ycwl/basic/constant/FreeStatus.java
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ycwl.basic.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 免费状态枚举
|
||||||
|
* 定义源文件的收费和免费两种状态
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-10-31
|
||||||
|
*/
|
||||||
|
public enum FreeStatus {
|
||||||
|
/**
|
||||||
|
* 收费状态
|
||||||
|
*/
|
||||||
|
PAID(0, "收费"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 免费状态
|
||||||
|
*/
|
||||||
|
FREE(1, "免费");
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
FreeStatus(int code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据代码值获取枚举
|
||||||
|
*
|
||||||
|
* @param code 状态代码
|
||||||
|
* @return 对应的枚举值,如果不存在返回 null
|
||||||
|
*/
|
||||||
|
public static FreeStatus fromCode(int code) {
|
||||||
|
for (FreeStatus status : values()) {
|
||||||
|
if (status.code == code) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断给定的代码是否为免费状态
|
||||||
|
*
|
||||||
|
* @param code 状态代码
|
||||||
|
* @return true-免费,false-收费
|
||||||
|
*/
|
||||||
|
public static boolean isFree(Integer code) {
|
||||||
|
return code != null && code == FREE.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断给定的代码是否为收费状态
|
||||||
|
*
|
||||||
|
* @param code 状态代码
|
||||||
|
* @return true-收费,false-免费
|
||||||
|
*/
|
||||||
|
public static boolean isPaid(Integer code) {
|
||||||
|
return code != null && code == PAID.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/main/java/com/ycwl/basic/constant/SourceType.java
Normal file
86
src/main/java/com/ycwl/basic/constant/SourceType.java
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package com.ycwl.basic.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 源文件类型枚举
|
||||||
|
* 定义视频和图片两种源文件类型
|
||||||
|
*
|
||||||
|
* @author Claude
|
||||||
|
* @since 2025-10-31
|
||||||
|
*/
|
||||||
|
public enum SourceType {
|
||||||
|
/**
|
||||||
|
* 视频类型
|
||||||
|
*/
|
||||||
|
VIDEO(1, "视频"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片类型
|
||||||
|
*/
|
||||||
|
IMAGE(2, "图片"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI微单类型
|
||||||
|
*/
|
||||||
|
AI_CAM(3, "AI微单");
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
SourceType(int code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据代码值获取枚举
|
||||||
|
*
|
||||||
|
* @param code 类型代码
|
||||||
|
* @return 对应的枚举值,如果不存在返回 null
|
||||||
|
*/
|
||||||
|
public static SourceType fromCode(int code) {
|
||||||
|
for (SourceType type : values()) {
|
||||||
|
if (type.code == code) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断给定的代码是否为视频类型
|
||||||
|
*
|
||||||
|
* @param code 类型代码
|
||||||
|
* @return true-是视频,false-不是视频
|
||||||
|
*/
|
||||||
|
public static boolean isVideo(Integer code) {
|
||||||
|
return code != null && code == VIDEO.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断给定的代码是否为图片类型
|
||||||
|
*
|
||||||
|
* @param code 类型代码
|
||||||
|
* @return true-是图片,false-不是图片
|
||||||
|
*/
|
||||||
|
public static boolean isImage(Integer code) {
|
||||||
|
return code != null && code == IMAGE.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断给定的代码是否为AI微单类型
|
||||||
|
*
|
||||||
|
* @param code 类型代码
|
||||||
|
* @return true-是AI微单,false-不是AI微单
|
||||||
|
*/
|
||||||
|
public static boolean isAiCam(Integer code) {
|
||||||
|
return code != null && code == AI_CAM.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,7 @@ package com.ycwl.basic.constant;
|
|||||||
public class StorageConstant {
|
public class StorageConstant {
|
||||||
public static final String VLOG_PATH = "vlog";
|
public static final String VLOG_PATH = "vlog";
|
||||||
public static final String VIDEO_PIECE_PATH = "source_video";
|
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 PHOTO_WATERMARKED_PATH = "photo_w";
|
||||||
public static final String VIID_FACE = "viid_face";
|
public static final String USER_FACE = "user_face";
|
||||||
public static final String USER_FACE = "user_face";
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.ycwl.basic.controller;
|
||||||
|
|
||||||
|
import com.github.pagehelper.PageInfo;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewAddReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewListReqDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewRespDTO;
|
||||||
|
import com.ycwl.basic.model.pc.videoreview.dto.VideoReviewStatisticsRespDTO;
|
||||||
|
import com.ycwl.basic.service.VideoReviewService;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频评价Controller
|
||||||
|
* 管理端使用,通过token角色控制权限
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/video-review/v1")
|
||||||
|
public class VideoReviewController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private VideoReviewService videoReviewService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增视频评价
|
||||||
|
*
|
||||||
|
* @param reqDTO 评价信息
|
||||||
|
* @return 评价ID
|
||||||
|
*/
|
||||||
|
@PostMapping("/add")
|
||||||
|
public ApiResponse<Long> addReview(@RequestBody VideoReviewAddReqDTO reqDTO) {
|
||||||
|
log.info("新增视频评价,videoId: {}", reqDTO.getVideoId());
|
||||||
|
Long reviewId = videoReviewService.addReview(reqDTO);
|
||||||
|
return ApiResponse.success(reviewId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询评价列表
|
||||||
|
*
|
||||||
|
* @param reqDTO 查询条件
|
||||||
|
* @return 分页结果
|
||||||
|
*/
|
||||||
|
@GetMapping("/list")
|
||||||
|
public ApiResponse<PageInfo<VideoReviewRespDTO>> getReviewList(VideoReviewListReqDTO reqDTO) {
|
||||||
|
log.info("查询视频评价列表,pageNum: {}, pageSize: {}", reqDTO.getPageNum(), reqDTO.getPageSize());
|
||||||
|
PageInfo<VideoReviewRespDTO> pageInfo = videoReviewService.getReviewList(reqDTO);
|
||||||
|
return ApiResponse.success(pageInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取评价统计数据
|
||||||
|
*
|
||||||
|
* @return 统计结果
|
||||||
|
*/
|
||||||
|
@GetMapping("/statistics")
|
||||||
|
public ApiResponse<VideoReviewStatisticsRespDTO> getStatistics() {
|
||||||
|
log.info("获取视频评价统计数据");
|
||||||
|
VideoReviewStatisticsRespDTO statistics = videoReviewService.getStatistics();
|
||||||
|
return ApiResponse.success(statistics);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出评价数据到Excel
|
||||||
|
*
|
||||||
|
* @param reqDTO 查询条件
|
||||||
|
* @param response HTTP响应
|
||||||
|
*/
|
||||||
|
@GetMapping("/export")
|
||||||
|
public void exportReviews(VideoReviewListReqDTO reqDTO, HttpServletResponse response) {
|
||||||
|
log.info("导出视频评价数据");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 设置响应头
|
||||||
|
String fileName = "video_reviews_" + System.currentTimeMillis() + ".xlsx";
|
||||||
|
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));
|
||||||
|
|
||||||
|
// 导出数据
|
||||||
|
videoReviewService.exportReviews(reqDTO, response.getOutputStream());
|
||||||
|
|
||||||
|
response.getOutputStream().flush();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("导出视频评价数据失败", e);
|
||||||
|
throw new RuntimeException("导出失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,14 @@ import cn.hutool.http.HttpUtil;
|
|||||||
import com.ycwl.basic.annotation.IgnoreToken;
|
import com.ycwl.basic.annotation.IgnoreToken;
|
||||||
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
|
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
|
||||||
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
|
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.image.pipeline.stages.DownloadStage;
|
||||||
|
import com.ycwl.basic.image.pipeline.stages.ImageEnhanceStage;
|
||||||
|
import com.ycwl.basic.image.pipeline.stages.ImageSRStage;
|
||||||
|
import com.ycwl.basic.image.pipeline.stages.SourcePhotoUpdateStage;
|
||||||
|
import com.ycwl.basic.image.pipeline.stages.CleanupStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.Pipeline;
|
||||||
|
import com.ycwl.basic.pipeline.core.PipelineBuilder;
|
||||||
import com.ycwl.basic.mapper.AioDeviceMapper;
|
import com.ycwl.basic.mapper.AioDeviceMapper;
|
||||||
import com.ycwl.basic.mapper.MemberMapper;
|
import com.ycwl.basic.mapper.MemberMapper;
|
||||||
import com.ycwl.basic.model.aio.entity.AioDeviceBannerEntity;
|
import com.ycwl.basic.model.aio.entity.AioDeviceBannerEntity;
|
||||||
@@ -12,29 +20,22 @@ import com.ycwl.basic.model.aio.entity.AioDevicePriceConfigEntity;
|
|||||||
import com.ycwl.basic.model.aio.req.AioDeviceCreateOrderReq;
|
import com.ycwl.basic.model.aio.req.AioDeviceCreateOrderReq;
|
||||||
import com.ycwl.basic.model.aio.resp.AioDeviceCreateOrderResp;
|
import com.ycwl.basic.model.aio.resp.AioDeviceCreateOrderResp;
|
||||||
import com.ycwl.basic.model.aio.resp.AioDeviceInfoResp;
|
import com.ycwl.basic.model.aio.resp.AioDeviceInfoResp;
|
||||||
import com.ycwl.basic.model.jwt.JwtInfo;
|
|
||||||
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
||||||
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
|
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
|
||||||
import com.ycwl.basic.model.mobile.goods.GoodsReqQuery;
|
import com.ycwl.basic.model.mobile.goods.GoodsReqQuery;
|
||||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||||
import com.ycwl.basic.model.pc.member.entity.MemberEntity;
|
import com.ycwl.basic.model.pc.member.entity.MemberEntity;
|
||||||
import com.ycwl.basic.model.pc.member.resp.MemberRespVO;
|
|
||||||
import com.ycwl.basic.pay.entity.PayResponse;
|
import com.ycwl.basic.pay.entity.PayResponse;
|
||||||
import com.ycwl.basic.service.aio.AioDeviceService;
|
import com.ycwl.basic.service.aio.AioDeviceService;
|
||||||
import com.ycwl.basic.service.mobile.GoodsService;
|
import com.ycwl.basic.service.mobile.GoodsService;
|
||||||
import com.ycwl.basic.service.pc.FaceService;
|
import com.ycwl.basic.service.pc.FaceService;
|
||||||
import com.ycwl.basic.service.pc.OrderService;
|
import com.ycwl.basic.service.pc.OrderService;
|
||||||
import com.ycwl.basic.service.pc.ScenicService;
|
|
||||||
import com.ycwl.basic.service.pc.SourceService;
|
import com.ycwl.basic.service.pc.SourceService;
|
||||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
|
||||||
import com.ycwl.basic.utils.SnowFlakeUtil;
|
import com.ycwl.basic.utils.SnowFlakeUtil;
|
||||||
import jakarta.servlet.ServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.apache.commons.lang3.Strings;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.data.redis.core.RedisTemplate;
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -48,7 +49,9 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -119,7 +122,7 @@ public class AioDeviceController {
|
|||||||
memberEntity.setId(SnowFlakeUtil.getLongId());
|
memberEntity.setId(SnowFlakeUtil.getLongId());
|
||||||
memberEntity.setNickname("用户");
|
memberEntity.setNickname("用户");
|
||||||
memberMapper.add(memberEntity);
|
memberMapper.add(memberEntity);
|
||||||
FaceRecognizeResp resp = faceService.faceUpload(file, aioDevice.getScenicId(), memberEntity.getId());
|
FaceRecognizeResp resp = faceService.faceUpload(file, aioDevice.getScenicId(), memberEntity.getId(), "");
|
||||||
// 尝试超分
|
// 尝试超分
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
@@ -136,27 +139,38 @@ public class AioDeviceController {
|
|||||||
redisTemplate.opsForValue().set("aio:faceId:"+resp.getFaceId().toString()+":pass", "1", 1, TimeUnit.DAYS);
|
redisTemplate.opsForValue().set("aio:faceId:"+resp.getFaceId().toString()+":pass", "1", 1, TimeUnit.DAYS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.info("超分开始!");
|
log.info("超分开始!共{}张图片待处理", sourcePhotoList.size());
|
||||||
|
|
||||||
sourcePhotoList.forEach(photo -> {
|
sourcePhotoList.forEach(photo -> {
|
||||||
if (StringUtils.contains(photo.getUrl(), "_q_")) {
|
if (StringUtils.contains(photo.getUrl(), "_q_")) {
|
||||||
|
log.debug("跳过已增强的图片: {}", photo.getUrl());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
File dstFile = new File(photo.getGoodsId()+".jpg");
|
// 创建超分Pipeline
|
||||||
long fileSize = HttpUtil.downloadFile(photo.getUrl(), dstFile);
|
Pipeline<PhotoProcessContext> superResolutionPipeline = createSuperResolutionPipeline(photo.getGoodsId());
|
||||||
log.info("超分开始:{}", fileSize);
|
|
||||||
BceImageEnhancer enhancer = getEnhancer();
|
// 使用静态工厂方法创建Context
|
||||||
MultipartFile enhancedFile = enhancer.enhance(dstFile.getName());
|
PhotoProcessContext context = PhotoProcessContext.forSuperResolution(
|
||||||
log.info("超分结束:{}", photo.getUrl());
|
photo.getGoodsId(), photo.getUrl(), photo.getScenicId()
|
||||||
String url = sourceService.uploadAndUpdateUrl(photo.getGoodsId(), enhancedFile);
|
);
|
||||||
log.info("上传结束:->{}", url);
|
|
||||||
|
// 启用图像增强和超分的Stage
|
||||||
|
context.enableStage("image_enhance");
|
||||||
|
context.enableStage("image_sr");
|
||||||
|
|
||||||
|
// 执行Pipeline
|
||||||
|
boolean success = superResolutionPipeline.execute(context);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
log.info("超分成功: {} -> {}", photo.getUrl(), context.getResultUrl());
|
||||||
|
} else {
|
||||||
|
log.error("超分失败: {}", photo.getGoodsId());
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("超分失败:{}", photo.getGoodsId(), e);
|
log.error("超分失败:{}", photo.getGoodsId(), e);
|
||||||
} finally {
|
|
||||||
File _file = new File(photo.getGoodsId()+".jpg");
|
|
||||||
if (_file.exists()) {
|
|
||||||
_file.delete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
redisTemplate.opsForValue().set("aio:faceId:"+sourcePhotoList.getFirst().getFaceId().toString()+":pass", "1", 1, TimeUnit.DAYS);
|
redisTemplate.opsForValue().set("aio:faceId:"+sourcePhotoList.getFirst().getFaceId().toString()+":pass", "1", 1, TimeUnit.DAYS);
|
||||||
@@ -213,6 +227,28 @@ public class AioDeviceController {
|
|||||||
return ApiResponse.success(orderService.queryOrder(orderId));
|
return ApiResponse.success(orderService.queryOrder(orderId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建源图片超分辨率增强Pipeline
|
||||||
|
*
|
||||||
|
* @param sourceId 源图片ID
|
||||||
|
* @return 超分Pipeline
|
||||||
|
*/
|
||||||
|
private Pipeline<PhotoProcessContext> createSuperResolutionPipeline(Long sourceId) {
|
||||||
|
// 创建带有百度云配置的ImageEnhanceStage
|
||||||
|
BceEnhancerConfig config = new BceEnhancerConfig();
|
||||||
|
config.setQps(1);
|
||||||
|
config.setAppId("119554288");
|
||||||
|
config.setApiKey("OX6QoijgKio3eVtA0PiUVf7f");
|
||||||
|
config.setSecretKey("dYatXReVriPeiktTjUblhfubpcmYfuMk");
|
||||||
|
|
||||||
|
return new PipelineBuilder<PhotoProcessContext>("SourcePhotoSuperResolutionPipeline")
|
||||||
|
.addStage(new DownloadStage()) // 1. 下载图片
|
||||||
|
.addStage(new ImageEnhanceStage(config)).addStage(new ImageSRStage(config)) // 2. 图像增强(超分)
|
||||||
|
.addStage(new SourcePhotoUpdateStage(sourceService, sourceId)) // 3. 上传并更新数据库
|
||||||
|
.addStage(new CleanupStage()) // 4. 清理临时文件
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private BceImageEnhancer getEnhancer() {
|
private BceImageEnhancer getEnhancer() {
|
||||||
BceImageEnhancer enhancer = new BceImageEnhancer();
|
BceImageEnhancer enhancer = new BceImageEnhancer();
|
||||||
BceEnhancerConfig config = new BceEnhancerConfig();
|
BceEnhancerConfig config = new BceEnhancerConfig();
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import com.ycwl.basic.model.pc.task.entity.TaskEntity;
|
|||||||
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
|
import com.ycwl.basic.model.pc.video.entity.VideoEntity;
|
||||||
import com.ycwl.basic.repository.VideoRepository;
|
import com.ycwl.basic.repository.VideoRepository;
|
||||||
import com.ycwl.basic.repository.VideoTaskRepository;
|
import com.ycwl.basic.repository.VideoTaskRepository;
|
||||||
import com.ycwl.basic.service.mobile.AppScenicService;
|
|
||||||
import com.ycwl.basic.service.mobile.GoodsService;
|
import com.ycwl.basic.service.mobile.GoodsService;
|
||||||
import com.ycwl.basic.service.pc.FaceService;
|
import com.ycwl.basic.service.pc.FaceService;
|
||||||
import com.ycwl.basic.service.task.impl.TaskTaskServiceImpl;
|
import com.ycwl.basic.service.task.impl.TaskTaskServiceImpl;
|
||||||
@@ -114,7 +113,7 @@ public class LyCompatibleController {
|
|||||||
}
|
}
|
||||||
FaceRecognizeResp resp;
|
FaceRecognizeResp resp;
|
||||||
try {
|
try {
|
||||||
resp = faceService.faceUpload(file, scenicId, member.getId());
|
resp = faceService.faceUpload(file, scenicId, member.getId(), "");
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return R.error("上传失败!报错:"+e.getMessage());
|
return R.error("上传失败!报错:"+e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.ycwl.basic.controller.mobile;
|
||||||
|
|
||||||
|
import com.ycwl.basic.model.jwt.JwtInfo;
|
||||||
|
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
||||||
|
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
|
||||||
|
import com.ycwl.basic.service.mobile.AppAiCamService;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI相机相关接口
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mobile/ai_cam/v1")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppAiCamController {
|
||||||
|
|
||||||
|
private final AppAiCamService appAiCamService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据faceId获取AI相机识别到的商品列表
|
||||||
|
* @param faceId 人脸ID
|
||||||
|
* @return 商品详情列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/{faceId}/content")
|
||||||
|
public ApiResponse<List<GoodsDetailVO>> getAiCamGoods(@PathVariable Long faceId) {
|
||||||
|
try {
|
||||||
|
List<GoodsDetailVO> goods = appAiCamService.getAiCamGoodsByFaceId(faceId);
|
||||||
|
return ApiResponse.success(goods);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取AI相机商品失败: faceId={}", faceId, e);
|
||||||
|
return ApiResponse.fail("获取商品列表失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量添加会员与source的关联关系
|
||||||
|
* @param faceId 人脸ID
|
||||||
|
* @param sourceIds source ID列表
|
||||||
|
* @return 添加结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/{faceId}/relations")
|
||||||
|
public ApiResponse<String> addMemberSourceRelations(
|
||||||
|
@PathVariable Long faceId,
|
||||||
|
@RequestBody List<Long> sourceIds
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
int count = appAiCamService.addMemberSourceRelations(faceId, sourceIds);
|
||||||
|
return ApiResponse.success("成功添加" + count + "条关联记录");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("添加关联失败: faceId={}, error={}", faceId, e.getMessage());
|
||||||
|
return ApiResponse.fail(e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("添加关联失败: faceId={}", faceId, e);
|
||||||
|
return ApiResponse.fail("添加关联失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用人脸样本创建或获取Face记录
|
||||||
|
* @param faceSampleId 人脸样本ID
|
||||||
|
* @return 人脸识别响应
|
||||||
|
*/
|
||||||
|
@GetMapping("/useSample/{faceSampleId}")
|
||||||
|
public ApiResponse<FaceRecognizeResp> useSample(@PathVariable Long faceSampleId) {
|
||||||
|
try {
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
FaceRecognizeResp resp = appAiCamService.useSample(worker.getUserId(), faceSampleId);
|
||||||
|
return ApiResponse.success(resp);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("使用人脸样本失败: faceSampleId={}", faceSampleId, e);
|
||||||
|
return ApiResponse.fail("使用人脸样本失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.ycwl.basic.controller.mobile;
|
||||||
|
|
||||||
|
import com.ycwl.basic.model.jwt.JwtInfo;
|
||||||
|
import com.ycwl.basic.model.mobile.chat.*;
|
||||||
|
import com.ycwl.basic.service.mobile.FaceChatService;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序人脸智能聊天接口。
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mobile/chat/v1")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppChatController {
|
||||||
|
|
||||||
|
private final FaceChatService faceChatService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取或创建会话(同一人脸只保留一条)。
|
||||||
|
*/
|
||||||
|
@PostMapping("/faces/{faceId}/conversation")
|
||||||
|
public ApiResponse<ChatConversationVO> createConversation(@PathVariable Long faceId) {
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
ChatConversationVO vo = faceChatService.getOrCreateConversation(faceId, worker.getUserId());
|
||||||
|
return ApiResponse.success(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步发送消息,适用于短回复或前端自行轮询。
|
||||||
|
*/
|
||||||
|
@PostMapping("/conversations/{conversationId}/messages")
|
||||||
|
public ApiResponse<ChatSendMessageResp> sendMessage(@PathVariable Long conversationId,
|
||||||
|
@RequestBody ChatSendMessageReq req) {
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
ChatSendMessageResp resp = faceChatService.sendMessage(conversationId, worker.getUserId(),
|
||||||
|
req.getContent(), req.getTraceId());
|
||||||
|
return ApiResponse.success(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流式返回,使用 HTTP chunked。小程序侧用 wx.request 的 onChunkReceived 消费。
|
||||||
|
*/
|
||||||
|
@PostMapping(value = "/conversations/{conversationId}/messages/stream", produces = "text/plain;charset=UTF-8")
|
||||||
|
public ResponseBodyEmitter streamMessage(@PathVariable Long conversationId,
|
||||||
|
@RequestBody ChatSendMessageReq req) {
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
ResponseBodyEmitter emitter = new ResponseBodyEmitter(30_000L);
|
||||||
|
CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
faceChatService.sendMessageStream(
|
||||||
|
conversationId,
|
||||||
|
worker.getUserId(),
|
||||||
|
req.getContent(),
|
||||||
|
req.getTraceId(),
|
||||||
|
chunk -> {
|
||||||
|
try {
|
||||||
|
emitter.send(chunk, new MediaType("text", "plain", java.nio.charset.StandardCharsets.UTF_8));
|
||||||
|
} catch (Exception e) {
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
emitter.complete();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("streamMessage error", e);
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询历史消息,cursor 为最后一条 seq,limit 为条数。
|
||||||
|
*/
|
||||||
|
@GetMapping("/conversations/{conversationId}/messages")
|
||||||
|
public ApiResponse<ChatMessagePageResp> listMessages(@PathVariable Long conversationId,
|
||||||
|
@RequestParam(value = "cursor", required = false) Integer cursor,
|
||||||
|
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
ChatMessagePageResp resp = faceChatService.listMessages(conversationId, cursor, limit, worker.getUserId());
|
||||||
|
return ApiResponse.success(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/conversations/{conversationId}/close")
|
||||||
|
public ApiResponse<String> closeConversation(@PathVariable Long conversationId) {
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
faceChatService.closeConversation(conversationId, worker.getUserId());
|
||||||
|
return ApiResponse.success("OK");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.ycwl.basic.controller.mobile;
|
package com.ycwl.basic.controller.mobile;
|
||||||
|
|
||||||
|
import com.ycwl.basic.exception.BaseException;
|
||||||
import com.ycwl.basic.model.jwt.JwtInfo;
|
import com.ycwl.basic.model.jwt.JwtInfo;
|
||||||
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
||||||
import com.ycwl.basic.model.mobile.face.FaceStatusResp;
|
import com.ycwl.basic.model.mobile.face.FaceStatusResp;
|
||||||
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
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.entity.FaceEntity;
|
||||||
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
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.entity.FaceSampleEntity;
|
||||||
import com.ycwl.basic.model.pc.faceSample.resp.FaceSampleRespVO;
|
import com.ycwl.basic.repository.FaceRepository;
|
||||||
import com.ycwl.basic.service.pc.FaceService;
|
import com.ycwl.basic.service.pc.FaceService;
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||||
@@ -27,6 +31,8 @@ public class AppFaceController {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private FaceService faceService;
|
private FaceService faceService;
|
||||||
|
@Autowired
|
||||||
|
private FaceRepository faceRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1、上传人脸照片
|
* 1、上传人脸照片
|
||||||
@@ -39,11 +45,13 @@ public class AppFaceController {
|
|||||||
*/
|
*/
|
||||||
// 人脸照片上传
|
// 人脸照片上传
|
||||||
@PostMapping("/faceUPload")
|
@PostMapping("/faceUPload")
|
||||||
public ApiResponse<FaceRecognizeResp> faceUpload(@RequestParam("file")MultipartFile file, @RequestParam("scenicId") Long scenicId) {
|
public ApiResponse<FaceRecognizeResp> faceUpload(@RequestParam("file")MultipartFile file,
|
||||||
|
@RequestParam(value = "scene", defaultValue = "", required = false) String scene,
|
||||||
|
@RequestParam("scenicId") Long scenicId) {
|
||||||
//获取用户id
|
//获取用户id
|
||||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
Long userId = worker.getUserId();
|
Long userId = worker.getUserId();
|
||||||
FaceRecognizeResp resp = faceService.faceUpload(file, scenicId, userId);
|
FaceRecognizeResp resp = faceService.faceUpload(file, scenicId, userId, scene);
|
||||||
return ApiResponse.success(resp);
|
return ApiResponse.success(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +59,7 @@ public class AppFaceController {
|
|||||||
public ApiResponse<List<FaceRespVO>> list(@PathVariable("scenicId") String scenicId) {
|
public ApiResponse<List<FaceRespVO>> list(@PathVariable("scenicId") String scenicId) {
|
||||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
Long userId = worker.getUserId();
|
Long userId = worker.getUserId();
|
||||||
List<FaceRespVO> list = faceService.listByUser(userId, scenicId);
|
List<FaceRespVO> list = faceService.listByUser(userId, Long.parseLong(scenicId));
|
||||||
return ApiResponse.success(list);
|
return ApiResponse.success(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +70,18 @@ public class AppFaceController {
|
|||||||
|
|
||||||
@DeleteMapping("/{faceId}")
|
@DeleteMapping("/{faceId}")
|
||||||
public ApiResponse<String> deleteFace(@PathVariable("faceId") Long faceId) {
|
public ApiResponse<String> deleteFace(@PathVariable("faceId") Long faceId) {
|
||||||
|
// 添加权限检查:验证当前用户是否拥有该 face
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
Long userId = worker.getUserId();
|
||||||
|
|
||||||
|
FaceEntity face = faceRepository.getFace(faceId);
|
||||||
|
if (face == null) {
|
||||||
|
throw new BaseException("人脸数据不存在");
|
||||||
|
}
|
||||||
|
if (!face.getMemberId().equals(userId)) {
|
||||||
|
throw new BaseException("无权删除此人脸");
|
||||||
|
}
|
||||||
|
|
||||||
return faceService.deleteFace(faceId);
|
return faceService.deleteFace(faceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,4 +128,19 @@ public class AppFaceController {
|
|||||||
faceService.matchCustomFaceId(faceId, faceIds);
|
faceService.matchCustomFaceId(faceId, faceIds);
|
||||||
return ApiResponse.success("OK");
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import com.ycwl.basic.annotation.IgnoreToken;
|
|||||||
import com.ycwl.basic.exception.CheckTokenException;
|
import com.ycwl.basic.exception.CheckTokenException;
|
||||||
import com.ycwl.basic.model.jwt.JwtInfo;
|
import com.ycwl.basic.model.jwt.JwtInfo;
|
||||||
import com.ycwl.basic.model.mobile.goods.*;
|
import com.ycwl.basic.model.mobile.goods.*;
|
||||||
|
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
|
||||||
import com.ycwl.basic.service.mobile.GoodsService;
|
import com.ycwl.basic.service.mobile.GoodsService;
|
||||||
|
import com.ycwl.basic.service.pc.FaceService;
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import com.ycwl.basic.utils.JwtTokenUtil;
|
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -24,11 +26,17 @@ public class AppGoodsController {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private GoodsService goodsService;
|
private GoodsService goodsService;
|
||||||
|
@Autowired
|
||||||
|
private FaceService faceService;
|
||||||
|
|
||||||
// 商品列表
|
// 商品列表
|
||||||
@PostMapping("/goodsList")
|
@PostMapping("/goodsList")
|
||||||
public ApiResponse<List<GoodsPageVO>> goodsList(@RequestBody GoodsReqQuery query) {
|
public ApiResponse<List<GoodsPageVO>> goodsList(@RequestBody GoodsReqQuery query) {
|
||||||
return goodsService.goodsList(query);
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
Long userId = worker.getUserId();
|
||||||
|
List<FaceRespVO> faceRespVOS = faceService.listByUser(userId, query.getScenicId());
|
||||||
|
List<Long> faceIds = faceRespVOS.stream().map(FaceRespVO::getId).toList();
|
||||||
|
return goodsService.listGoodsByFaceIdList(faceIds, query.getIsBuy(), query.getScenicId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 源素材(原片/照片)商品列表
|
// 源素材(原片/照片)商品列表
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.ycwl.basic.controller.mobile;
|
package com.ycwl.basic.controller.mobile;
|
||||||
|
|
||||||
import com.github.pagehelper.PageInfo;
|
import com.github.pagehelper.PageInfo;
|
||||||
|
import com.ycwl.basic.annotation.IgnoreToken;
|
||||||
import com.ycwl.basic.biz.OrderBiz;
|
import com.ycwl.basic.biz.OrderBiz;
|
||||||
import com.ycwl.basic.biz.PriceBiz;
|
import com.ycwl.basic.biz.PriceBiz;
|
||||||
import com.ycwl.basic.constant.BaseContextHandler;
|
import com.ycwl.basic.constant.BaseContextHandler;
|
||||||
@@ -51,6 +52,7 @@ public class AppOrderController {
|
|||||||
|
|
||||||
// 用户端订单详情查询
|
// 用户端订单详情查询
|
||||||
@GetMapping("getOrderDetails/{id}")
|
@GetMapping("getOrderDetails/{id}")
|
||||||
|
@IgnoreToken
|
||||||
public ApiResponse<OrderAppRespVO> getOrderDetails(@PathVariable("id") Long id) {
|
public ApiResponse<OrderAppRespVO> getOrderDetails(@PathVariable("id") Long id) {
|
||||||
return orderService.appDetail(id);
|
return orderService.appDetail(id);
|
||||||
}
|
}
|
||||||
@@ -91,9 +93,9 @@ public class AppOrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/scenic/{scenicId}/query")
|
@GetMapping("/scenic/{scenicId}/query")
|
||||||
public ApiResponse<IsBuyRespVO> isBuy(@PathVariable("scenicId") Long scenicId, @RequestParam("type") Integer type, @RequestParam("goodsId") Long goodsId) {
|
public ApiResponse<IsBuyRespVO> isBuy(@PathVariable("scenicId") Long scenicId, @RequestParam("type") Integer type, @RequestParam("goodsId") Long goodsId, @RequestParam(value = "faceId", required = false) Long faceId) {
|
||||||
Long userId = Long.parseLong(BaseContextHandler.getUserId());
|
Long userId = Long.parseLong(BaseContextHandler.getUserId());
|
||||||
return ApiResponse.success(orderBiz.isBuy(userId, scenicId, type, goodsId));
|
return ApiResponse.success(orderBiz.isBuy(scenicId, userId, faceId, type, goodsId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/scenic/{scenicId}/queryBatchPrice")
|
@GetMapping("/scenic/{scenicId}/queryBatchPrice")
|
||||||
@@ -106,7 +108,7 @@ public class AppOrderController {
|
|||||||
}
|
}
|
||||||
faceId = lastFaceByUserId.getId();
|
faceId = lastFaceByUserId.getId();
|
||||||
}
|
}
|
||||||
IsBuyBatchRespVO buy = priceBiz.isBuy(userId, faceId, scenicId, type, goodsIds);
|
IsBuyBatchRespVO buy = priceBiz.isOnePriceBuy(userId, faceId, scenicId, type, goodsIds);
|
||||||
if (buy == null) {
|
if (buy == null) {
|
||||||
return ApiResponse.fail("该套餐暂未开放购买");
|
return ApiResponse.fail("该套餐暂未开放购买");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import com.ycwl.basic.order.dto.PaymentParamsResponse;
|
|||||||
import com.ycwl.basic.order.dto.PaymentCallbackResponse;
|
import com.ycwl.basic.order.dto.PaymentCallbackResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
@@ -55,6 +56,7 @@ public class AppOrderV2Controller {
|
|||||||
private final VideoTaskRepository videoTaskRepository;
|
private final VideoTaskRepository videoTaskRepository;
|
||||||
private final TemplateRepository templateRepository;
|
private final TemplateRepository templateRepository;
|
||||||
private final VideoRepository videoRepository;
|
private final VideoRepository videoRepository;
|
||||||
|
private final RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移动端价格计算
|
* 移动端价格计算
|
||||||
@@ -86,7 +88,7 @@ public class AppOrderV2Controller {
|
|||||||
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId());
|
TaskEntity task = videoTaskRepository.getTaskById(video.getTaskId());
|
||||||
request.setFaceId(task.getFaceId());
|
request.setFaceId(task.getFaceId());
|
||||||
}
|
}
|
||||||
case RECORDING_SET, PHOTO_SET -> request.setFaceId(Long.valueOf(productItem.getProductId()));
|
case RECORDING_SET, PHOTO_SET, AI_CAM_PHOTO_SET -> request.setFaceId(Long.valueOf(productItem.getProductId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +121,14 @@ public class AppOrderV2Controller {
|
|||||||
Integer count = sourceMapper.countUser(sourceReqQuery);
|
Integer count = sourceMapper.countUser(sourceReqQuery);
|
||||||
product.setQuantity(count);
|
product.setQuantity(count);
|
||||||
break;
|
break;
|
||||||
|
case AI_CAM_PHOTO_SET:
|
||||||
|
SourceReqQuery aiPhotoSetReqQuery = new SourceReqQuery();
|
||||||
|
aiPhotoSetReqQuery.setMemberId(currentUserId);
|
||||||
|
aiPhotoSetReqQuery.setType(13);
|
||||||
|
aiPhotoSetReqQuery.setFaceId(face.getId());
|
||||||
|
Integer _count = sourceMapper.countUser(aiPhotoSetReqQuery);
|
||||||
|
product.setQuantity(_count);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType());
|
log.warn("未知的商品类型,跳过重复购买检查: productType={}", product.getProductType());
|
||||||
break;
|
break;
|
||||||
@@ -262,36 +272,21 @@ public class AppOrderV2Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户查询自己的订单详情
|
* 查询订单详情
|
||||||
*/
|
*/
|
||||||
@GetMapping("/detail/{orderId}")
|
@GetMapping("/detail/{orderId}")
|
||||||
public ApiResponse<OrderV2DetailResponse> getUserOrderDetail(@PathVariable("orderId") Long orderId) {
|
public ApiResponse<OrderV2DetailResponse> getUserOrderDetail(@PathVariable("orderId") Long orderId) {
|
||||||
String currentUserIdStr = BaseContextHandler.getUserId();
|
log.info("查询订单详情: orderId={}", orderId);
|
||||||
if (currentUserIdStr == null) {
|
|
||||||
log.warn("用户未登录");
|
|
||||||
return ApiResponse.fail("用户未登录");
|
|
||||||
}
|
|
||||||
|
|
||||||
Long currentUserId = Long.valueOf(currentUserIdStr);
|
|
||||||
|
|
||||||
log.info("用户查询订单详情: userId={}, orderId={}", currentUserId, orderId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
OrderV2DetailResponse detail = orderService.getOrderDetail(orderId);
|
OrderV2DetailResponse detail = orderService.getOrderDetail(orderId);
|
||||||
if (detail == null) {
|
if (detail == null) {
|
||||||
return ApiResponse.fail("订单不存在");
|
return ApiResponse.fail("订单不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证订单是否属于当前用户
|
|
||||||
if (!currentUserId.equals(detail.getMemberId())) {
|
|
||||||
log.warn("用户尝试访问他人订单: userId={}, orderId={}, orderOwner={}",
|
|
||||||
currentUserId, orderId, detail.getMemberId());
|
|
||||||
return ApiResponse.fail("无权访问该订单");
|
|
||||||
}
|
|
||||||
|
|
||||||
return ApiResponse.success(detail);
|
return ApiResponse.success(detail);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("查询用户订单详情失败: userId={}, orderId={}", currentUserId, orderId, e);
|
log.error("查询订单详情失败: orderId={}", orderId, e);
|
||||||
return ApiResponse.fail("查询失败:" + e.getMessage());
|
return ApiResponse.fail("查询失败:" + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,4 +351,9 @@ public class AppOrderV2Controller {
|
|||||||
return "FAIL";
|
return "FAIL";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/downloadable/{orderId}")
|
||||||
|
public ApiResponse<Boolean> getDownloadableOrder(@PathVariable("orderId") Long orderId) {
|
||||||
|
return ApiResponse.success(!redisTemplate.hasKey("order_content_not_downloadable_" + orderId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ycwl.basic.controller.mobile;
|
|||||||
|
|
||||||
import com.ycwl.basic.annotation.IgnoreToken;
|
import com.ycwl.basic.annotation.IgnoreToken;
|
||||||
import com.ycwl.basic.model.jwt.JwtInfo;
|
import com.ycwl.basic.model.jwt.JwtInfo;
|
||||||
|
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
|
||||||
import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp;
|
import com.ycwl.basic.model.pc.printer.resp.MemberPrintResp;
|
||||||
import com.ycwl.basic.model.pc.printer.resp.PrinterResp;
|
import com.ycwl.basic.model.pc.printer.resp.PrinterResp;
|
||||||
import com.ycwl.basic.model.printer.req.FromSourceReq;
|
import com.ycwl.basic.model.printer.req.FromSourceReq;
|
||||||
@@ -16,6 +17,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@@ -35,10 +37,16 @@ public class AppPrinterController {
|
|||||||
return ApiResponse.success(printerService.listByScenicId(scenicId));
|
return ApiResponse.success(printerService.listByScenicId(scenicId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/getListFor/{scenicId}")
|
@GetMapping("/useSample/{sampleId}")
|
||||||
public ApiResponse<List<MemberPrintResp>> getListFor(@PathVariable("scenicId") Long scenicId) {
|
public ApiResponse<FaceRecognizeResp> useSample(@PathVariable("sampleId") Long sampleId) throws IOException {
|
||||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
return ApiResponse.success(printerService.getUserPhotoList(worker.getUserId(), scenicId));
|
return ApiResponse.success(printerService.useSample(worker.getUserId(), sampleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/getListFor/{scenicId}")
|
||||||
|
public ApiResponse<List<MemberPrintResp>> getListFor(@PathVariable("scenicId") Long scenicId, @RequestParam(required = false) String faceId) {
|
||||||
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
|
return ApiResponse.success(printerService.getUserPhotoList(worker.getUserId(), scenicId, parseFaceId(faceId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/getItem/{scenicId}/{id}")
|
@GetMapping("/getItem/{scenicId}/{id}")
|
||||||
@@ -52,30 +60,37 @@ public class AppPrinterController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/deleteFrom/{scenicId}/{id}")
|
@PostMapping("/deleteFrom/{scenicId}/{id}")
|
||||||
public ApiResponse<?> deleteFrom(@PathVariable("scenicId") Long scenicId, @PathVariable("id") Long id) throws IOException {
|
public ApiResponse<?> deleteFrom(@PathVariable("scenicId") Long scenicId, @PathVariable("id") Long id) {
|
||||||
JwtInfo worker = JwtTokenUtil.getWorker();
|
JwtInfo worker = JwtTokenUtil.getWorker();
|
||||||
printerService.deleteUserPhoto(worker.getUserId(), scenicId, id);
|
printerService.deleteUserPhoto(worker.getUserId(), scenicId, id);
|
||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
@PostMapping("/uploadTo/{scenicId}")
|
@PostMapping("/uploadTo/{scenicId}")
|
||||||
public ApiResponse<?> upload(@PathVariable("scenicId") Long scenicId, @RequestParam(value = "file") MultipartFile file) throws IOException {
|
public ApiResponse<?> upload(@PathVariable("scenicId") Long scenicId,
|
||||||
|
@RequestParam(value = "file") MultipartFile file,
|
||||||
|
@RequestParam(value = "faceId", required = false) String faceId) {
|
||||||
String[] split = file.getOriginalFilename().split("\\.");
|
String[] split = file.getOriginalFilename().split("\\.");
|
||||||
String ext = split[split.length - 1];
|
String ext = split[split.length - 1];
|
||||||
String url = StorageFactory.use().uploadFile(file, "printer", UUID.randomUUID() + "." + ext);
|
String url = StorageFactory.use().uploadFile(file, "printer", UUID.randomUUID() + "." + ext);
|
||||||
printerService.addUserPhoto(JwtTokenUtil.getWorker().getUserId(), scenicId, url);
|
Integer id = printerService.addUserPhoto(JwtTokenUtil.getWorker().getUserId(), scenicId, url, parseFaceId(faceId), null);
|
||||||
return ApiResponse.success(url);
|
return ApiResponse.success(id);
|
||||||
}
|
}
|
||||||
@PostMapping("/uploadTo/{scenicId}/cropped/{id}")
|
@PostMapping(value = "/uploadTo/{scenicId}/cropped/{id}", consumes = "multipart/form-data")
|
||||||
public ApiResponse<?> uploadReplace(@PathVariable("scenicId") Long scenicId, @PathVariable("id") Long id, @RequestParam(value = "file") MultipartFile file) throws IOException {
|
public ApiResponse<?> uploadReplace(@PathVariable("scenicId") Long scenicId,
|
||||||
|
@PathVariable("id") Long id,
|
||||||
|
@RequestPart(value = "crop", required = false) String crop,
|
||||||
|
@RequestPart(value = "file") MultipartFile file) {
|
||||||
String[] split = file.getOriginalFilename().split("\\.");
|
String[] split = file.getOriginalFilename().split("\\.");
|
||||||
String ext = split[split.length - 1];
|
String ext = split[split.length - 1];
|
||||||
String url = StorageFactory.use().uploadFile(file, "printer", UUID.randomUUID() + "." + ext);
|
String url = StorageFactory.use().uploadFile(file, "printer", UUID.randomUUID() + "." + ext);
|
||||||
printerService.setPhotoCropped(JwtTokenUtil.getWorker().getUserId(), scenicId, id, url);
|
printerService.setPhotoCropped(JwtTokenUtil.getWorker().getUserId(), scenicId, id, url, crop);
|
||||||
return ApiResponse.success(url);
|
return ApiResponse.success(url);
|
||||||
}
|
}
|
||||||
@PostMapping("/uploadTo/{scenicId}/formSource")
|
@PostMapping("/uploadTo/{scenicId}/formSource")
|
||||||
public ApiResponse<?> uploadFromSource(@PathVariable("scenicId") Long scenicId, @RequestBody FromSourceReq req) throws IOException {
|
public ApiResponse<?> uploadFromSource(@PathVariable("scenicId") Long scenicId,
|
||||||
List<Integer> list = printerService.addUserPhotoFromSource(JwtTokenUtil.getWorker().getUserId(), scenicId, req);
|
@RequestBody FromSourceReq req,
|
||||||
|
@RequestParam(value = "faceId", required = false) String faceId) {
|
||||||
|
List<Integer> list = printerService.addUserPhotoFromSource(JwtTokenUtil.getWorker().getUserId(), scenicId, req, parseFaceId(faceId));
|
||||||
return ApiResponse.success(list);
|
return ApiResponse.success(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,16 +106,35 @@ public class AppPrinterController {
|
|||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
@GetMapping("/price/{scenicId}")
|
@GetMapping("/price/{scenicId}")
|
||||||
public ApiResponse<?> queryPrice(@PathVariable("scenicId") Long scenicId) {
|
public ApiResponse<?> queryPrice(@PathVariable("scenicId") Long scenicId,
|
||||||
return ApiResponse.success(printerService.queryPrice(JwtTokenUtil.getWorker().getUserId(), scenicId));
|
@RequestParam(value = "faceId", required = false) String faceId) {
|
||||||
|
return ApiResponse.success(printerService.queryPrice(JwtTokenUtil.getWorker().getUserId(), scenicId, parseFaceId(faceId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/order/{scenicId}")
|
@PostMapping("/order/{scenicId}")
|
||||||
public ApiResponse<Map<String, Object>> createOrder(@PathVariable("scenicId") Long scenicId) {
|
public ApiResponse<Map<String, Object>> createOrder(@PathVariable("scenicId") Long scenicId,
|
||||||
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, null));
|
@RequestParam(value = "faceId", required = false) String faceId) {
|
||||||
|
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, null, parseFaceId(faceId)));
|
||||||
}
|
}
|
||||||
@PostMapping("/order/{scenicId}/toPrinter/{printerId}")
|
@PostMapping("/order/{scenicId}/toPrinter/{printerId}")
|
||||||
public ApiResponse<Map<String, Object>> createOrderToPrinter(@PathVariable("scenicId") Long scenicId, @PathVariable("printerId") Integer printerId) {
|
public ApiResponse<Map<String, Object>> createOrderToPrinter(@PathVariable("scenicId") Long scenicId,
|
||||||
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, printerId));
|
@PathVariable("printerId") Integer printerId,
|
||||||
|
@RequestParam(value = "faceId", required = false) String faceId) {
|
||||||
|
return ApiResponse.success(printerService.createOrder(JwtTokenUtil.getWorker().getUserId(), scenicId, printerId, parseFaceId(faceId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 faceId 字符串为 Long 类型
|
||||||
|
* 如果字符串不是有效数字,则返回 null
|
||||||
|
*/
|
||||||
|
private Long parseFaceId(String faceId) {
|
||||||
|
if (faceId == null || faceId.trim().isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(faceId.trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package com.ycwl.basic.controller.mobile;
|
||||||
|
|
||||||
|
import com.ycwl.basic.biz.OrderBiz;
|
||||||
|
import com.ycwl.basic.constant.SourceType;
|
||||||
|
import com.ycwl.basic.model.mobile.order.IsBuyRespVO;
|
||||||
|
import com.ycwl.basic.model.mobile.scenic.content.ContentPageVO;
|
||||||
|
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||||
|
import com.ycwl.basic.pricing.dto.PriceCalculationRequest;
|
||||||
|
import com.ycwl.basic.pricing.dto.PriceCalculationResult;
|
||||||
|
import com.ycwl.basic.pricing.dto.ProductItem;
|
||||||
|
import com.ycwl.basic.pricing.enums.ProductType;
|
||||||
|
import com.ycwl.basic.pricing.service.IPriceCalculationService;
|
||||||
|
import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity;
|
||||||
|
import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper;
|
||||||
|
import com.ycwl.basic.repository.FaceRepository;
|
||||||
|
import com.ycwl.basic.service.printer.PrinterService;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mobile/puzzle/v1")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AppPuzzleController {
|
||||||
|
|
||||||
|
private final PuzzleGenerationRecordMapper recordMapper;
|
||||||
|
private final FaceRepository faceRepository;
|
||||||
|
private final IPriceCalculationService iPriceCalculationService;
|
||||||
|
private final PrinterService printerService;
|
||||||
|
private final OrderBiz orderBiz;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据faceId查询三拼图数量
|
||||||
|
*/
|
||||||
|
@GetMapping("/count/{faceId}")
|
||||||
|
public ApiResponse<Integer> countByFaceId(@PathVariable("faceId") Long faceId) {
|
||||||
|
if (faceId == null) {
|
||||||
|
return ApiResponse.fail("faceId不能为空");
|
||||||
|
}
|
||||||
|
int count = recordMapper.countByFaceId(faceId);
|
||||||
|
return ApiResponse.success(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据faceId查询所有三拼图记录
|
||||||
|
*/
|
||||||
|
@GetMapping("/list/{faceId}")
|
||||||
|
public ApiResponse<List<ContentPageVO>> listByFaceId(@PathVariable("faceId") Long faceId) {
|
||||||
|
if (faceId == null) {
|
||||||
|
return ApiResponse.fail("faceId不能为空");
|
||||||
|
}
|
||||||
|
List<PuzzleGenerationRecordEntity> records = recordMapper.listByFaceId(faceId);
|
||||||
|
List<ContentPageVO> result = records.stream()
|
||||||
|
.map(this::convertToContentPageVO)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据recordId查询单个三拼图记录
|
||||||
|
*/
|
||||||
|
@GetMapping("/detail/{recordId}")
|
||||||
|
public ApiResponse<ContentPageVO> getByRecordId(@PathVariable("recordId") Long recordId) {
|
||||||
|
if (recordId == null) {
|
||||||
|
return ApiResponse.fail("recordId不能为空");
|
||||||
|
}
|
||||||
|
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||||
|
if (record == null) {
|
||||||
|
return ApiResponse.fail("未找到对应的拼图记录");
|
||||||
|
}
|
||||||
|
ContentPageVO result = convertToContentPageVO(record);
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据recordId下载拼图资源
|
||||||
|
*/
|
||||||
|
@GetMapping("/download/{recordId}")
|
||||||
|
public ApiResponse<List<String>> download(@PathVariable("recordId") Long recordId) {
|
||||||
|
if (recordId == null) {
|
||||||
|
return ApiResponse.fail("recordId不能为空");
|
||||||
|
}
|
||||||
|
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||||
|
if (record == null) {
|
||||||
|
return ApiResponse.fail("未找到对应的拼图记录");
|
||||||
|
}
|
||||||
|
String resultImageUrl = record.getResultImageUrl();
|
||||||
|
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||||
|
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||||
|
}
|
||||||
|
return ApiResponse.success(Collections.singletonList(resultImageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据recordId查询拼图价格
|
||||||
|
*/
|
||||||
|
@GetMapping("/price/{recordId}")
|
||||||
|
public ApiResponse<PriceCalculationResult> getPriceByRecordId(@PathVariable("recordId") Long recordId) {
|
||||||
|
if (recordId == null) {
|
||||||
|
return ApiResponse.fail("recordId不能为空");
|
||||||
|
}
|
||||||
|
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||||
|
if (record == null) {
|
||||||
|
return ApiResponse.fail("未找到对应的拼图记录");
|
||||||
|
}
|
||||||
|
FaceEntity face = faceRepository.getFace(record.getFaceId());
|
||||||
|
if (face == null) {
|
||||||
|
return ApiResponse.fail("未找到对应的人脸信息");
|
||||||
|
}
|
||||||
|
|
||||||
|
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
|
||||||
|
ProductItem productItem = new ProductItem();
|
||||||
|
productItem.setProductType(ProductType.PHOTO_LOG);
|
||||||
|
productItem.setProductId(record.getTemplateId().toString());
|
||||||
|
productItem.setPurchaseCount(1);
|
||||||
|
productItem.setScenicId(face.getScenicId().toString());
|
||||||
|
calculationRequest.setProducts(Collections.singletonList(productItem));
|
||||||
|
calculationRequest.setUserId(face.getMemberId());
|
||||||
|
calculationRequest.setFaceId(record.getFaceId());
|
||||||
|
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||||
|
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||||
|
|
||||||
|
return ApiResponse.success(calculationResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将拼图导入到打印列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/import-to-print/{recordId}")
|
||||||
|
public ApiResponse<Integer> importToPrint(@PathVariable("recordId") Long recordId) {
|
||||||
|
if (recordId == null) {
|
||||||
|
return ApiResponse.fail("recordId不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询拼图记录
|
||||||
|
PuzzleGenerationRecordEntity record = recordMapper.getById(recordId);
|
||||||
|
if (record == null) {
|
||||||
|
return ApiResponse.fail("未找到对应的拼图记录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有图片URL
|
||||||
|
String resultImageUrl = record.getResultImageUrl();
|
||||||
|
if (resultImageUrl == null || resultImageUrl.isEmpty()) {
|
||||||
|
return ApiResponse.fail("该拼图记录没有可用的图片URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取人脸信息
|
||||||
|
FaceEntity face = faceRepository.getFace(record.getFaceId());
|
||||||
|
if (face == null) {
|
||||||
|
return ApiResponse.fail("未找到对应的人脸信息");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用服务添加到打印列表
|
||||||
|
Integer memberPrintId = printerService.addUserPhotoFromPuzzle(
|
||||||
|
face.getMemberId(),
|
||||||
|
face.getScenicId(),
|
||||||
|
record.getFaceId(),
|
||||||
|
resultImageUrl,
|
||||||
|
0L // 打印特有
|
||||||
|
);
|
||||||
|
|
||||||
|
if (memberPrintId == null) {
|
||||||
|
return ApiResponse.fail("添加到打印列表失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.success(memberPrintId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将PuzzleGenerationRecordEntity转换为ContentPageVO
|
||||||
|
*/
|
||||||
|
private ContentPageVO convertToContentPageVO(PuzzleGenerationRecordEntity record) {
|
||||||
|
ContentPageVO vo = new ContentPageVO();
|
||||||
|
|
||||||
|
// 内容类型为3(拼图)
|
||||||
|
vo.setContentType(3);
|
||||||
|
|
||||||
|
// 源素材类型为3(拼图)
|
||||||
|
vo.setSourceType(3);
|
||||||
|
vo.setGroup("拼图");
|
||||||
|
|
||||||
|
// 只要存在记录,lockType不为0(设置为-1表示已生成)
|
||||||
|
vo.setLockType(-1);
|
||||||
|
|
||||||
|
// 通过faceId填充scenicId的信息
|
||||||
|
FaceEntity face = faceRepository.getFace(record.getFaceId());
|
||||||
|
if (record.getFaceId() != null) {
|
||||||
|
vo.setScenicId(face.getScenicId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// contentId为生成记录id
|
||||||
|
vo.setContentId(record.getId());
|
||||||
|
|
||||||
|
// templateCoverUrl和生成的图是一致的
|
||||||
|
vo.setTemplateCoverUrl(record.getResultImageUrl());
|
||||||
|
|
||||||
|
// 设置模板ID
|
||||||
|
vo.setTemplateId(record.getTemplateId());
|
||||||
|
IsBuyRespVO isBuyScenic = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), 5, face.getScenicId());
|
||||||
|
if (isBuyScenic.isBuy()) {
|
||||||
|
vo.setIsBuy(1);
|
||||||
|
} else {
|
||||||
|
IsBuyRespVO isBuyRespVO = orderBiz.isBuy(face.getScenicId(), face.getMemberId(), face.getId(), 5, record.getTemplateId());
|
||||||
|
if (isBuyRespVO.isBuy()) {
|
||||||
|
vo.setIsBuy(1);
|
||||||
|
} else {
|
||||||
|
vo.setIsBuy(0);
|
||||||
|
PriceCalculationRequest calculationRequest = new PriceCalculationRequest();
|
||||||
|
ProductItem productItem = new ProductItem();
|
||||||
|
productItem.setProductType(ProductType.PHOTO_LOG);
|
||||||
|
productItem.setProductId(record.getTemplateId().toString());
|
||||||
|
productItem.setPurchaseCount(1);
|
||||||
|
productItem.setScenicId(face.getScenicId().toString());
|
||||||
|
calculationRequest.setProducts(Collections.singletonList(productItem));
|
||||||
|
calculationRequest.setUserId(face.getMemberId());
|
||||||
|
calculationRequest.setFaceId(record.getFaceId());
|
||||||
|
calculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
|
||||||
|
PriceCalculationResult calculationResult = iPriceCalculationService.calculatePrice(calculationRequest);
|
||||||
|
if (calculationResult.getFinalAmount().compareTo(BigDecimal.ZERO) > 0) {
|
||||||
|
vo.setFreeCount(0);
|
||||||
|
} else {
|
||||||
|
vo.setFreeCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ public class AppScenicController {
|
|||||||
add("3932535453961555968");
|
add("3932535453961555968");
|
||||||
add("3936121342868459520");
|
add("3936121342868459520");
|
||||||
add("3936940597855784960");
|
add("3936940597855784960");
|
||||||
|
add("4049850382325780480");
|
||||||
}};
|
}};
|
||||||
|
|
||||||
// 分页查询景区列表
|
// 分页查询景区列表
|
||||||
@@ -80,6 +81,15 @@ public class AppScenicController {
|
|||||||
resp.setShowPhotoWhenWaiting(scenicConfig.getBoolean("show_photo_when_waiting", false));
|
resp.setShowPhotoWhenWaiting(scenicConfig.getBoolean("show_photo_when_waiting", false));
|
||||||
resp.setImageSourcePackHint(scenicConfig.getString("image_source_pack_hint"));
|
resp.setImageSourcePackHint(scenicConfig.getString("image_source_pack_hint"));
|
||||||
resp.setVideoSourcePackHint(scenicConfig.getString("video_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));
|
||||||
|
resp.setPrintEnable(scenicConfig.getBoolean("print_enable", false));
|
||||||
|
resp.setShowMyPagePaid(scenicConfig.getBoolean("show_my_page_paid", true));
|
||||||
|
resp.setShowMyPageUnpaid(scenicConfig.getBoolean("show_my_page_unpaid", true));
|
||||||
return ApiResponse.success(resp);
|
return ApiResponse.success(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class AppTaskController {
|
|||||||
|
|
||||||
@PostMapping("/submit")
|
@PostMapping("/submit")
|
||||||
public ApiResponse<String> submitVideoTask(@RequestBody VideoTaskReq videoTaskReq) {
|
public ApiResponse<String> submitVideoTask(@RequestBody VideoTaskReq videoTaskReq) {
|
||||||
taskService.createTaskByFaceIdAndTemplateId(videoTaskReq.getFaceId(),videoTaskReq.getTemplateId(),0);
|
taskService.createTaskByFaceIdAndTemplateId(videoTaskReq.getFaceId(),videoTaskReq.getTemplateId(),false);
|
||||||
return ApiResponse.success("成功");
|
return ApiResponse.success("成功");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,9 +64,6 @@ public class AppVoucherController {
|
|||||||
if (face == null) {
|
if (face == null) {
|
||||||
throw new BaseException("请选择人脸");
|
throw new BaseException("请选择人脸");
|
||||||
}
|
}
|
||||||
if (!face.getMemberId().equals(Long.valueOf(BaseContextHandler.getUserId()))) {
|
|
||||||
throw new BaseException("自动领取失败");
|
|
||||||
}
|
|
||||||
req.setScenicId(face.getScenicId());
|
req.setScenicId(face.getScenicId());
|
||||||
VoucherCodeResp result = voucherCodeService.claimVoucher(req);
|
VoucherCodeResp result = voucherCodeService.claimVoucher(req);
|
||||||
return ApiResponse.success(result);
|
return ApiResponse.success(result);
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package com.ycwl.basic.controller.mobile;
|
|
||||||
|
|
||||||
|
|
||||||
import com.ycwl.basic.annotation.IgnoreToken;
|
|
||||||
import com.ycwl.basic.repository.ScenicRepository;
|
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @Author: songmingsong
|
|
||||||
* @CreateTime: 2024-12-06
|
|
||||||
* @Description: 微信消息模板通知
|
|
||||||
* @Version: 1.0
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/mobile/wx/notify/v1")
|
|
||||||
// 微信消息模板通知
|
|
||||||
public class AppWxNotifyController {
|
|
||||||
@Autowired
|
|
||||||
private ScenicRepository scenicRepository;
|
|
||||||
//
|
|
||||||
// @ApiOperation(value = "通知", notes = "通知")
|
|
||||||
// @PostMapping("/pushMessage")
|
|
||||||
// @IgnoreToken
|
|
||||||
// public ApiResponse<?> pushMessage(@RequestBody WechatMessageSubscribeForm req) {
|
|
||||||
// JSONObject resJson = wxNotifyService.pushMessage(req);
|
|
||||||
// return ApiResponse.success(resJson);
|
|
||||||
// }
|
|
||||||
|
|
||||||
@GetMapping({"/getIds", "/"})
|
|
||||||
@IgnoreToken
|
|
||||||
public ApiResponse<List<String>> getIds() {
|
|
||||||
return ApiResponse.success(new ArrayList<>() {{
|
|
||||||
add("5b8vTm7kvwYubqDxb3dxBs0BqxMsgVgGw573aahTEd8");
|
|
||||||
add("vPIzbkA0x4mMj-vdbWx6_45e8juWXzs3FGYnDsIPv3A");
|
|
||||||
add("HB1vp-0BXc2WyYeoYN3a3GuZV9HtPLXUTT7blCBq9eY");
|
|
||||||
}});
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{scenicId}")
|
|
||||||
@IgnoreToken
|
|
||||||
public ApiResponse<List<String>> getIds(@PathVariable("scenicId") Long scenicId) {
|
|
||||||
return ApiResponse.success(new ArrayList<>() {{
|
|
||||||
String videoGeneratedTemplateId = scenicRepository.getVideoGeneratedTemplateId(scenicId);
|
|
||||||
if (StringUtils.isNotBlank(videoGeneratedTemplateId)) {
|
|
||||||
add(videoGeneratedTemplateId);
|
|
||||||
}
|
|
||||||
String videoDownloadTemplateId = scenicRepository.getVideoDownloadTemplateId(scenicId);
|
|
||||||
if (StringUtils.isNotBlank(videoDownloadTemplateId)) {
|
|
||||||
add(videoDownloadTemplateId);
|
|
||||||
}
|
|
||||||
String videoPreExpireTemplateId = scenicRepository.getVideoPreExpireTemplateId(scenicId);
|
|
||||||
if (StringUtils.isNotBlank(videoPreExpireTemplateId)) {
|
|
||||||
add(videoPreExpireTemplateId);
|
|
||||||
}
|
|
||||||
}});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -56,12 +56,4 @@ public class AppStatisticsController {
|
|||||||
return statisticsService.userConversionFunnel(query);
|
return statisticsService.userConversionFunnel(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计数据记录
|
|
||||||
@PostMapping("/addStatistics")
|
|
||||||
@IgnoreToken
|
|
||||||
public ApiResponse<String> addStatistics(@RequestBody StatisticsRecordAddReq req) {
|
|
||||||
|
|
||||||
return statisticsService.addStatistics(req);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.ycwl.basic.controller.mobile.notify;
|
||||||
|
|
||||||
|
import com.ycwl.basic.model.mobile.notify.req.NotificationAuthRecordReq;
|
||||||
|
import com.ycwl.basic.model.mobile.notify.resp.NotificationAuthRecordResp;
|
||||||
|
import com.ycwl.basic.model.mobile.notify.resp.ScenicTemplateAuthResp;
|
||||||
|
import com.ycwl.basic.service.UserNotificationAuthorizationService;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import com.ycwl.basic.utils.JwtTokenUtil;
|
||||||
|
import com.ycwl.basic.repository.ScenicRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户通知授权记录Controller (移动端API)
|
||||||
|
* 只提供用户主动授权记录功能,支持批量授权,其他检查和消费功能由系统内部调用
|
||||||
|
*
|
||||||
|
* @Author: System
|
||||||
|
* @Date: 2024/12/28
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mobile/notify/auth")
|
||||||
|
@Slf4j
|
||||||
|
public class UserNotificationAuthController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserNotificationAuthorizationService userNotificationAuthorizationService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ScenicRepository scenicRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录用户通知授权 - 支持批量授权
|
||||||
|
* 用户主动同意通知授权时调用
|
||||||
|
*/
|
||||||
|
@PostMapping("/record")
|
||||||
|
public ApiResponse<NotificationAuthRecordResp> recordAuthorization(
|
||||||
|
@RequestBody NotificationAuthRecordReq req) {
|
||||||
|
log.debug("记录用户通知授权: templateIds={}, scenicId={}", req.getTemplateIds(), req.getScenicId());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前用户ID
|
||||||
|
Long memberId = JwtTokenUtil.getWorker().getUserId();
|
||||||
|
|
||||||
|
// 调用批量授权记录方法
|
||||||
|
List<UserNotificationAuthorizationService.AuthorizationRecord> records =
|
||||||
|
userNotificationAuthorizationService.batchRecordAuthorization(
|
||||||
|
memberId, req.getTemplateIds(), req.getScenicId());
|
||||||
|
|
||||||
|
NotificationAuthRecordResp resp = new NotificationAuthRecordResp();
|
||||||
|
|
||||||
|
// 转换响应数据
|
||||||
|
List<NotificationAuthRecordResp.AuthorizationRecord> successRecords = new ArrayList<>();
|
||||||
|
List<String> failedTemplateIds = new ArrayList<>();
|
||||||
|
List<String> failureReasons = new ArrayList<>();
|
||||||
|
|
||||||
|
for (UserNotificationAuthorizationService.AuthorizationRecord record : records) {
|
||||||
|
if (record.isSuccess()) {
|
||||||
|
NotificationAuthRecordResp.AuthorizationRecord successRecord =
|
||||||
|
new NotificationAuthRecordResp.AuthorizationRecord();
|
||||||
|
successRecord.setId(record.getId());
|
||||||
|
successRecord.setTemplateId(record.getTemplateId());
|
||||||
|
successRecord.setScenicId(record.getScenicId());
|
||||||
|
successRecord.setAuthorizationCount(record.getAuthorizationCount());
|
||||||
|
successRecord.setConsumedCount(record.getConsumedCount());
|
||||||
|
successRecord.setRemainingCount(record.getRemainingCount());
|
||||||
|
successRecord.setLastAuthorizedTime(record.getLastAuthorizedTime());
|
||||||
|
successRecord.setLastConsumedTime(record.getLastConsumedTime());
|
||||||
|
successRecord.setStatus(record.getStatus());
|
||||||
|
successRecord.setCreateTime(record.getCreateTime());
|
||||||
|
successRecords.add(successRecord);
|
||||||
|
} else {
|
||||||
|
failedTemplateIds.add(record.getTemplateId());
|
||||||
|
failureReasons.add(record.getFailureReason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.setAllSuccess(CollectionUtils.isEmpty(failedTemplateIds));
|
||||||
|
resp.setSuccessRecords(successRecords);
|
||||||
|
resp.setFailedTemplateIds(failedTemplateIds);
|
||||||
|
resp.setFailureReasons(failureReasons);
|
||||||
|
|
||||||
|
return ApiResponse.success(resp);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("记录用户通知授权失败", e);
|
||||||
|
return ApiResponse.fail("记录授权失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取景区通知模板ID及用户授权余额
|
||||||
|
* 复制AppWxNotifyController中的逻辑,并额外返回用户对应的授权余额
|
||||||
|
*/
|
||||||
|
@GetMapping("/{scenicId}/templates")
|
||||||
|
public ApiResponse<ScenicTemplateAuthResp> getScenicTemplatesWithAuth(@PathVariable("scenicId") Long scenicId) {
|
||||||
|
log.debug("获取景区通知模板ID及用户授权余额: scenicId={}", scenicId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前用户ID
|
||||||
|
Long memberId = JwtTokenUtil.getWorker().getUserId();
|
||||||
|
|
||||||
|
// 获取景区的所有模板ID(复制自AppWxNotifyController的逻辑)
|
||||||
|
List<String> templateIds = 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);
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
|
||||||
|
// 构建响应对象
|
||||||
|
ScenicTemplateAuthResp resp = new ScenicTemplateAuthResp();
|
||||||
|
resp.setScenicId(scenicId);
|
||||||
|
|
||||||
|
// 查询每个模板的授权余额信息
|
||||||
|
List<ScenicTemplateAuthResp.TemplateAuthInfo> templateAuthInfos = new ArrayList<>();
|
||||||
|
for (String templateId : templateIds) {
|
||||||
|
ScenicTemplateAuthResp.TemplateAuthInfo templateAuthInfo =
|
||||||
|
new ScenicTemplateAuthResp.TemplateAuthInfo();
|
||||||
|
templateAuthInfo.setTemplateId(templateId);
|
||||||
|
|
||||||
|
if (templateId.equals(scenicRepository.getVideoGeneratedTemplateId(scenicId))) {
|
||||||
|
templateAuthInfo.setTitle("视频生成通知");
|
||||||
|
templateAuthInfo.setDescription("当视频生成完成时,我们将提醒您");
|
||||||
|
} else if (templateId.equals(scenicRepository.getVideoDownloadTemplateId(scenicId))) {
|
||||||
|
templateAuthInfo.setTitle("视频下载通知");
|
||||||
|
templateAuthInfo.setDescription("当您的视频未购买时,我们将提醒您");
|
||||||
|
} else if (templateId.equals(scenicRepository.getVideoPreExpireTemplateId(scenicId))) {
|
||||||
|
templateAuthInfo.setTitle("视频即将过期通知");
|
||||||
|
templateAuthInfo.setDescription("当您的视频即将过期时,我们将提醒您及时下载");
|
||||||
|
} else {
|
||||||
|
templateAuthInfo.setTitle("未知模板类型");
|
||||||
|
templateAuthInfo.setDescription("未知的模板类型");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取授权详情
|
||||||
|
try {
|
||||||
|
com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationEntity authEntity =
|
||||||
|
userNotificationAuthorizationService.checkAuthorization(memberId, templateId, scenicId);
|
||||||
|
|
||||||
|
if (authEntity != null) {
|
||||||
|
templateAuthInfo.setAuthorizationCount(authEntity.getAuthorizationCount());
|
||||||
|
templateAuthInfo.setConsumedCount(authEntity.getConsumedCount());
|
||||||
|
templateAuthInfo.setRemainingCount(authEntity.getRemainingCount());
|
||||||
|
templateAuthInfo.setHasAuthorization(authEntity.getRemainingCount() != null && authEntity.getRemainingCount() > 0);
|
||||||
|
} else {
|
||||||
|
// 没有授权记录
|
||||||
|
templateAuthInfo.setAuthorizationCount(0);
|
||||||
|
templateAuthInfo.setConsumedCount(0);
|
||||||
|
templateAuthInfo.setRemainingCount(0);
|
||||||
|
templateAuthInfo.setHasAuthorization(false);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("获取模板授权信息失败: templateId={}, scenicId={}, memberId={}, error={}",
|
||||||
|
templateId, scenicId, memberId, e.getMessage());
|
||||||
|
|
||||||
|
// 获取失败时设置为无授权
|
||||||
|
templateAuthInfo.setAuthorizationCount(0);
|
||||||
|
templateAuthInfo.setConsumedCount(0);
|
||||||
|
templateAuthInfo.setRemainingCount(0);
|
||||||
|
templateAuthInfo.setHasAuthorization(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
templateAuthInfos.add(templateAuthInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.setTemplates(templateAuthInfos);
|
||||||
|
|
||||||
|
log.debug("成功获取景区通知模板ID及用户授权余额: scenicId={}, templateCount={}, memberId={}",
|
||||||
|
scenicId, templateIds.size(), memberId);
|
||||||
|
|
||||||
|
return ApiResponse.success(resp);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取景区通知模板ID及用户授权余额失败: scenicId={}", scenicId, e);
|
||||||
|
return ApiResponse.fail("获取授权信息失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.ycwl.basic.controller.monitor;
|
||||||
|
|
||||||
|
import com.ycwl.basic.integration.kafka.scheduler.AccountFaceSchedulerManager;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸识别监控接口
|
||||||
|
* 提供调度器状态查询功能
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/monitor/face-recognition")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FaceRecognitionMonitorController {
|
||||||
|
|
||||||
|
private final AccountFaceSchedulerManager schedulerManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有账号的调度器统计信息
|
||||||
|
* <p>
|
||||||
|
* 示例返回:
|
||||||
|
* {
|
||||||
|
* "LTAI5xxx": {
|
||||||
|
* "accountKey": "LTAI5xxx",
|
||||||
|
* "cloudType": "ALI",
|
||||||
|
* "activeThreads": 3,
|
||||||
|
* "executorQueueSize": 12,
|
||||||
|
* "schedulerQueueSize": 45
|
||||||
|
* },
|
||||||
|
* "245xxx": {
|
||||||
|
* "accountKey": "245xxx",
|
||||||
|
* "cloudType": "BAIDU",
|
||||||
|
* "activeThreads": 8,
|
||||||
|
* "executorQueueSize": 5,
|
||||||
|
* "schedulerQueueSize": 20
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @return 所有账号的调度器状态
|
||||||
|
*/
|
||||||
|
@GetMapping("/schedulers")
|
||||||
|
public ApiResponse<Map<String, AccountFaceSchedulerManager.AccountSchedulerStats>> getAllSchedulerStats() {
|
||||||
|
return ApiResponse.success(schedulerManager.getAllStats());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,9 +53,9 @@ public class DeviceV2Controller {
|
|||||||
if (pageSize > 100) {
|
if (pageSize > 100) {
|
||||||
pageSize = 100;
|
pageSize = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, name, no, type, isActive, scenicId);
|
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, name, no, type, isActive, scenicId, null);
|
||||||
return ApiResponse.success(response);
|
return ApiResponse.success(response);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("分页查询设备核心信息列表失败", e);
|
log.error("分页查询设备核心信息列表失败", e);
|
||||||
@@ -368,50 +368,19 @@ public class DeviceV2Controller {
|
|||||||
|
|
||||||
// ========== 景区设备管理操作 ==========
|
// ========== 景区设备管理操作 ==========
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取景区IPC设备列表
|
|
||||||
*/
|
|
||||||
@GetMapping("/scenic/{scenicId}/ipc")
|
|
||||||
public ApiResponse<PageResponse<DeviceV2DTO>> getScenicIpcDevices(@PathVariable Long scenicId,
|
|
||||||
@RequestParam(defaultValue = "1") Integer page,
|
|
||||||
@RequestParam(defaultValue = "10") Integer pageSize) {
|
|
||||||
log.info("获取景区IPC设备列表, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
|
|
||||||
try {
|
|
||||||
PageResponse<DeviceV2DTO> response = deviceIntegrationService.getScenicIpcDevices(scenicId, page, pageSize);
|
|
||||||
return ApiResponse.success(response);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("获取景区IPC设备列表失败, scenicId: {}", scenicId, e);
|
|
||||||
return ApiResponse.fail("获取景区IPC设备列表失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取景区激活设备列表
|
|
||||||
*/
|
|
||||||
@GetMapping("/scenic/{scenicId}/active")
|
|
||||||
public ApiResponse<PageResponse<DeviceV2DTO>> getScenicActiveDevices(@PathVariable Long scenicId,
|
|
||||||
@RequestParam(defaultValue = "1") Integer page,
|
|
||||||
@RequestParam(defaultValue = "10") Integer pageSize) {
|
|
||||||
log.info("获取景区激活设备列表, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
|
|
||||||
try {
|
|
||||||
PageResponse<DeviceV2DTO> response = deviceIntegrationService.getScenicActiveDevices(scenicId, page, pageSize);
|
|
||||||
return ApiResponse.success(response);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("获取景区激活设备列表失败, scenicId: {}", scenicId, e);
|
|
||||||
return ApiResponse.fail("获取景区激活设备列表失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取景区所有设备列表
|
* 获取景区所有设备列表
|
||||||
*/
|
*/
|
||||||
@GetMapping("/scenic/{scenicId}/all")
|
@GetMapping("/scenic/{scenicId}")
|
||||||
public ApiResponse<PageResponse<DeviceV2DTO>> getScenicAllDevices(@PathVariable Long scenicId,
|
public ApiResponse<PageResponse<DeviceV2DTO>> getScenicAllDevices(@PathVariable Long scenicId,
|
||||||
|
@RequestParam(required = false) String name,
|
||||||
|
@RequestParam(required = false) String type,
|
||||||
|
@RequestParam(required = false) String no,
|
||||||
@RequestParam(defaultValue = "1") Integer page,
|
@RequestParam(defaultValue = "1") Integer page,
|
||||||
@RequestParam(defaultValue = "10") Integer pageSize) {
|
@RequestParam(defaultValue = "10") Integer pageSize) {
|
||||||
log.info("获取景区所有设备列表, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
|
log.info("获取景区所有设备列表, scenicId: {}, page: {}, pageSize: {}", scenicId, page, pageSize);
|
||||||
try {
|
try {
|
||||||
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, null, null, null, null, scenicId);
|
PageResponse<DeviceV2DTO> response = deviceIntegrationService.listDevices(page, pageSize, name, no, type, null, scenicId, null);
|
||||||
return ApiResponse.success(response);
|
return ApiResponse.success(response);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("获取景区所有设备列表失败, scenicId: {}", scenicId, e);
|
log.error("获取景区所有设备列表失败, scenicId: {}", scenicId, e);
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.ycwl.basic.controller.pc;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ycwl.basic.device.entity.common.DeviceVideoContinuityCache;
|
||||||
|
import com.ycwl.basic.task.DeviceVideoContinuityCheckTask;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备视频连续性检查控制器
|
||||||
|
* 提供查询设备视频连续性检查结果的接口
|
||||||
|
*
|
||||||
|
* @author Claude Code
|
||||||
|
* @date 2025-09-01
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/device/video-continuity")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DeviceVideoContinuityController {
|
||||||
|
|
||||||
|
private static final String REDIS_KEY_PREFIX = "device:video:continuity:";
|
||||||
|
|
||||||
|
private final RedisTemplate<String, String> redisTemplate;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final DeviceVideoContinuityCheckTask checkTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询设备最近的视频连续性检查结果
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @return 检查结果
|
||||||
|
*/
|
||||||
|
@GetMapping("/{deviceId}")
|
||||||
|
public ApiResponse<DeviceVideoContinuityCache> getDeviceContinuityResult(@PathVariable Long deviceId) {
|
||||||
|
log.info("查询设备 {} 的视频连续性检查结果", deviceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String redisKey = REDIS_KEY_PREFIX + deviceId;
|
||||||
|
String cacheJson = redisTemplate.opsForValue().get(redisKey);
|
||||||
|
|
||||||
|
if (cacheJson == null) {
|
||||||
|
log.warn("未找到设备 {} 的视频连续性检查结果", deviceId);
|
||||||
|
return ApiResponse.buildResponse(404, null, "未找到该设备的检查结果,可能设备未配置存储或尚未执行检查");
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceVideoContinuityCache cache = objectMapper.readValue(cacheJson, DeviceVideoContinuityCache.class);
|
||||||
|
return ApiResponse.success(cache);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("查询设备 {} 视频连续性检查结果失败", deviceId, e);
|
||||||
|
return ApiResponse.buildResponse(500, null, "查询失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发设备视频连续性检查
|
||||||
|
* 注意:仅用于测试和紧急情况,正常情况下由定时任务自动执行
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @return 检查结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/{deviceId}/check")
|
||||||
|
public ApiResponse<DeviceVideoContinuityCache> manualCheck(@PathVariable Long deviceId) {
|
||||||
|
log.info("手动触发设备 {} 的视频连续性检查", deviceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DeviceVideoContinuityCache result = checkTask.manualCheck(deviceId);
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("手动检查设备 {} 视频连续性失败", deviceId, e);
|
||||||
|
return ApiResponse.buildResponse(500, null, "检查失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除设备的视频连续性检查缓存
|
||||||
|
* 用于清理过期或错误的缓存数据
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @return 删除结果
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{deviceId}")
|
||||||
|
public ApiResponse<String> deleteContinuityCache(@PathVariable Long deviceId) {
|
||||||
|
log.info("删除设备 {} 的视频连续性检查缓存", deviceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String redisKey = REDIS_KEY_PREFIX + deviceId;
|
||||||
|
Boolean deleted = redisTemplate.delete(redisKey);
|
||||||
|
|
||||||
|
if (deleted != null && deleted) {
|
||||||
|
return ApiResponse.success("缓存删除成功");
|
||||||
|
} else {
|
||||||
|
return ApiResponse.buildResponse(404, null, "未找到该设备的缓存数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("删除设备 {} 视频连续性检查缓存失败", deviceId, e);
|
||||||
|
return ApiResponse.buildResponse(500, null, "删除失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,5 +53,4 @@ public class FaceController {
|
|||||||
return faceService.deleteByIds(ids);
|
return faceService.deleteByIds(ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
package com.ycwl.basic.controller.pc;
|
package com.ycwl.basic.controller.pc;
|
||||||
|
import com.ycwl.basic.integration.kafka.service.FaceProcessingKafkaService;
|
||||||
|
|
||||||
import com.github.pagehelper.PageInfo;
|
import com.github.pagehelper.PageInfo;
|
||||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
@@ -15,13 +16,14 @@ import java.util.List;
|
|||||||
* @Author:longbinbin
|
* @Author:longbinbin
|
||||||
* @Date:2024/12/2 16:33
|
* @Date:2024/12/2 16:33
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/faceSample/v1")
|
@RequestMapping("/api/faceSample/v1")
|
||||||
// 人脸样本管理
|
// 人脸样本管理
|
||||||
public class FaceSampleController {
|
public class FaceSampleController {
|
||||||
@Autowired
|
@Autowired
|
||||||
private FaceSampleService FaceSampleService;
|
private FaceSampleService FaceSampleService;
|
||||||
|
@Autowired(required = false)
|
||||||
|
private FaceProcessingKafkaService faceProcessingKafkaService;
|
||||||
|
|
||||||
// 分页查询人脸样本
|
// 分页查询人脸样本
|
||||||
@PostMapping("/page")
|
@PostMapping("/page")
|
||||||
@@ -39,4 +41,25 @@ public class FaceSampleController {
|
|||||||
return FaceSampleService.getById(id);
|
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("提交重试任务失败,请检查人脸样本状态");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ycwl.basic.controller.pc;
|
|||||||
|
|
||||||
import com.ycwl.basic.biz.PriceBiz;
|
import com.ycwl.basic.biz.PriceBiz;
|
||||||
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
|
import com.ycwl.basic.model.pc.price.resp.GoodsListRespVO;
|
||||||
|
import com.ycwl.basic.model.pc.price.resp.SimpleGoodsRespVO;
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -16,8 +17,10 @@ public class PriceConfigController {
|
|||||||
private PriceBiz priceBiz;
|
private PriceBiz priceBiz;
|
||||||
|
|
||||||
@GetMapping("/goodsList")
|
@GetMapping("/goodsList")
|
||||||
public ApiResponse<List<GoodsListRespVO>> goodsList(@RequestParam Long scenicId) {
|
public ApiResponse<List<SimpleGoodsRespVO>> goodsList(
|
||||||
return ApiResponse.success(priceBiz.listGoodsByScenic(scenicId));
|
@RequestParam Long scenicId,
|
||||||
|
@RequestParam(required = false) String productType) {
|
||||||
|
return ApiResponse.success(priceBiz.listSimpleGoodsByScenic(scenicId, productType));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
package com.ycwl.basic.controller.pc;
|
package com.ycwl.basic.controller.pc;
|
||||||
|
|
||||||
|
import com.github.pagehelper.PageHelper;
|
||||||
|
import com.github.pagehelper.PageInfo;
|
||||||
|
import com.ycwl.basic.mapper.PrintTaskMapper;
|
||||||
|
import com.ycwl.basic.model.pc.printer.entity.PrintTaskEntity;
|
||||||
import com.ycwl.basic.model.pc.printer.entity.PrinterEntity;
|
import com.ycwl.basic.model.pc.printer.entity.PrinterEntity;
|
||||||
|
import com.ycwl.basic.model.pc.printer.req.PrintTaskReqQuery;
|
||||||
|
import com.ycwl.basic.model.pc.printer.req.ReprintRequest;
|
||||||
import com.ycwl.basic.service.printer.PrinterService;
|
import com.ycwl.basic.service.printer.PrinterService;
|
||||||
import com.ycwl.basic.utils.ApiResponse;
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -20,6 +26,9 @@ public class PrinterController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PrinterService printerService;
|
private PrinterService printerService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PrintTaskMapper printTaskMapper;
|
||||||
|
|
||||||
// 查询列表
|
// 查询列表
|
||||||
@PostMapping("/list")
|
@PostMapping("/list")
|
||||||
public ApiResponse<List<PrinterEntity>> list(@RequestBody PrinterEntity condition) {
|
public ApiResponse<List<PrinterEntity>> list(@RequestBody PrinterEntity condition) {
|
||||||
@@ -49,4 +58,65 @@ public class PrinterController {
|
|||||||
public ApiResponse<Integer> delete(@PathVariable("id") Integer id) {
|
public ApiResponse<Integer> delete(@PathVariable("id") Integer id) {
|
||||||
return printerService.delete(id);
|
return printerService.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分页查询打印任务
|
||||||
|
@PostMapping("/task/page")
|
||||||
|
public ApiResponse<PageInfo<PrintTaskEntity>> taskPage(@RequestBody PrintTaskReqQuery req) {
|
||||||
|
PageHelper.startPage(req.getPageNum(), req.getPageSize());
|
||||||
|
List<PrintTaskEntity> list = printTaskMapper.queryByCondition(req.getPrinterId(), req.getStatus());
|
||||||
|
PageInfo<PrintTaskEntity> pageInfo = new PageInfo<>(list);
|
||||||
|
return ApiResponse.success(pageInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新打印(将状态设置为0-未开始,并更新打印机名称)
|
||||||
|
@PostMapping("/task/reprint/{id}")
|
||||||
|
public ApiResponse<Integer> reprint(@PathVariable("id") Integer id, @RequestBody ReprintRequest request) {
|
||||||
|
int result = printerService.handleReprint(id, request);
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询待审核的打印任务
|
||||||
|
* @param printerId 打印机ID(可选)
|
||||||
|
* @return 待审核任务列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/task/pending-review")
|
||||||
|
public ApiResponse<List<PrintTaskEntity>> getPendingReviewTasks(Integer printerId) {
|
||||||
|
List<PrintTaskEntity> tasks = printerService.getPendingReviewTasks(printerId);
|
||||||
|
return ApiResponse.success(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新待审核任务的URL(重新处理水印等)
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @param url 新的打印URL
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/task/{taskId}/url")
|
||||||
|
public ApiResponse<Boolean> updateTaskUrl(@PathVariable("taskId") Integer taskId, @RequestBody String url) {
|
||||||
|
boolean success = printerService.updatePendingReviewTaskUrl(taskId, url);
|
||||||
|
return ApiResponse.success(success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批准待审核任务,下发到打印队列
|
||||||
|
* @param taskIds 任务ID列表
|
||||||
|
* @return 成功数量
|
||||||
|
*/
|
||||||
|
@PostMapping("/task/approve")
|
||||||
|
public ApiResponse<Integer> approveTasks(@RequestBody List<Integer> taskIds) {
|
||||||
|
int count = printerService.approvePrintTasks(taskIds);
|
||||||
|
return ApiResponse.success(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拒绝待审核任务
|
||||||
|
* @param taskIds 任务ID列表
|
||||||
|
* @return 成功数量
|
||||||
|
*/
|
||||||
|
@PostMapping("/task/reject")
|
||||||
|
public ApiResponse<Integer> rejectTasks(@RequestBody List<Integer> taskIds) {
|
||||||
|
int count = printerService.rejectPrintTasks(taskIds);
|
||||||
|
return ApiResponse.success(count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.ycwl.basic.controller.pc;
|
package com.ycwl.basic.controller.pc;
|
||||||
|
|
||||||
import com.ycwl.basic.controller.dto.RenderWorkerWithStatusDTO;
|
import com.ycwl.basic.dto.RenderWorkerWithStatusDTO;
|
||||||
import com.ycwl.basic.integration.common.response.PageResponse;
|
import com.ycwl.basic.integration.common.response.PageResponse;
|
||||||
import com.ycwl.basic.integration.render.dto.worker.CreateRenderWorkerRequest;
|
import com.ycwl.basic.integration.render.dto.worker.CreateRenderWorkerRequest;
|
||||||
import com.ycwl.basic.integration.render.dto.worker.RenderWorkerV2DTO;
|
import com.ycwl.basic.integration.render.dto.worker.RenderWorkerV2DTO;
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ public class ScenicV2Controller {
|
|||||||
if (pageSize > 100) {
|
if (pageSize > 100) {
|
||||||
pageSize = 100;
|
pageSize = 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PageResponse<ScenicV2DTO> response = scenicIntegrationService.listScenics(page, pageSize, status, name);
|
PageResponse<ScenicV2DTO> response = scenicIntegrationService.listScenics(page, pageSize, status, name, null);
|
||||||
return ApiResponse.success(response);
|
return ApiResponse.success(response);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("分页查询景区核心信息列表失败", e);
|
log.error("分页查询景区核心信息列表失败", e);
|
||||||
@@ -156,7 +156,7 @@ public class ScenicV2Controller {
|
|||||||
log.info("查询景区列表, status: {}", status);
|
log.info("查询景区列表, status: {}", status);
|
||||||
try {
|
try {
|
||||||
// 默认查询1000条数据,第1页
|
// 默认查询1000条数据,第1页
|
||||||
PageResponse<ScenicV2DTO> scenics = scenicIntegrationService.listScenics(1, 1000, status, null);
|
PageResponse<ScenicV2DTO> scenics = scenicIntegrationService.listScenics(1, 1000, status, null, null);
|
||||||
return ApiResponse.success(scenics);
|
return ApiResponse.success(scenics);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("查询景区列表失败, status: {}", status, e);
|
log.error("查询景区列表失败, status: {}", status, e);
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ public class SourceController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private SourceService sourceService;
|
private SourceService sourceService;
|
||||||
|
|
||||||
@Deprecated
|
|
||||||
// 分页查询视频源
|
// 分页查询视频源
|
||||||
@PostMapping("/page")
|
@PostMapping("/page")
|
||||||
public ApiResponse pageQuery(@RequestBody SourceReqQuery sourceReqQuery) {
|
public ApiResponse pageQuery(@RequestBody SourceReqQuery sourceReqQuery) {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/task/v1")
|
@RequestMapping("/api/task/v1")
|
||||||
@Deprecated
|
|
||||||
// 任务列表管理
|
// 任务列表管理
|
||||||
public class TaskController {
|
public class TaskController {
|
||||||
|
|
||||||
|
|||||||
@@ -40,4 +40,16 @@ public class VideoController {
|
|||||||
return videoService.getById(id);
|
return videoService.getById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询视频是否被购买
|
||||||
|
*
|
||||||
|
* @param videoId 视频ID
|
||||||
|
* @return 是否已购买
|
||||||
|
*/
|
||||||
|
@GetMapping("/checkBuyStatus")
|
||||||
|
public ApiResponse<Boolean> checkBuyStatus(@RequestParam("videoId") Long videoId) {
|
||||||
|
Boolean isBuy = videoService.checkVideoBuyStatus(videoId);
|
||||||
|
return ApiResponse.success(isBuy);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package com.ycwl.basic.controller.printer;
|
||||||
|
|
||||||
|
|
||||||
|
import cn.hutool.core.date.DateUtil;
|
||||||
|
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
|
||||||
|
import com.ycwl.basic.mapper.FaceMapper;
|
||||||
|
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||||
|
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||||
|
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||||
|
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||||
|
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||||
|
import com.ycwl.basic.repository.SourceRepository;
|
||||||
|
import com.ycwl.basic.service.printer.PrinterService;
|
||||||
|
import com.ycwl.basic.service.task.TaskFaceService;
|
||||||
|
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||||
|
import com.ycwl.basic.annotation.IgnoreToken;
|
||||||
|
import com.ycwl.basic.storage.StorageFactory;
|
||||||
|
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
|
||||||
|
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
|
||||||
|
import com.ycwl.basic.mapper.SourceMapper;
|
||||||
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
|
import com.ycwl.basic.model.pc.mp.MpConfigEntity;
|
||||||
|
import com.ycwl.basic.model.pc.scenic.req.ScenicReqQuery;
|
||||||
|
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
|
||||||
|
import com.ycwl.basic.model.printer.FaceRecognizeWithSourcesResp;
|
||||||
|
import com.ycwl.basic.repository.DeviceRepository;
|
||||||
|
import com.ycwl.basic.repository.FaceRepository;
|
||||||
|
import com.ycwl.basic.repository.ScenicRepository;
|
||||||
|
import com.ycwl.basic.service.pc.FaceService;
|
||||||
|
import com.ycwl.basic.service.pc.ScenicService;
|
||||||
|
import com.ycwl.basic.storage.utils.StorageUtil;
|
||||||
|
import com.ycwl.basic.utils.ApiResponse;
|
||||||
|
import com.ycwl.basic.utils.SnowFlakeUtil;
|
||||||
|
import com.ycwl.basic.utils.WxMpUtil;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static com.ycwl.basic.constant.StorageConstant.USER_FACE;
|
||||||
|
|
||||||
|
@IgnoreToken
|
||||||
|
// 打印机大屏对接接口
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/printer/v1/tv")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PrinterTvController {
|
||||||
|
|
||||||
|
private final DeviceRepository deviceRepository;
|
||||||
|
private final ScenicRepository scenicRepository;
|
||||||
|
private final FaceRepository faceRepository;
|
||||||
|
private final TaskFaceService faceService;
|
||||||
|
private final FaceService pcFaceService;
|
||||||
|
private final ScenicService scenicService;
|
||||||
|
private final SourceMapper sourceMapper;
|
||||||
|
private final FaceMapper faceMapper;
|
||||||
|
private final MemberRelationRepository memberRelationRepository;
|
||||||
|
private final SourceRepository sourceRepository;
|
||||||
|
private final PrinterService printerService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取景区列表
|
||||||
|
*
|
||||||
|
* @return 景区列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/scenic/list")
|
||||||
|
public ApiResponse<List<ScenicV2DTO>> getScenicList() {
|
||||||
|
ScenicReqQuery query = new ScenicReqQuery();
|
||||||
|
query.setStatus("1"); // 只查询启用状态的景区
|
||||||
|
query.setPageNum(1);
|
||||||
|
query.setPageSize(1000);
|
||||||
|
return ApiResponse.success(scenicRepository.list(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据景区ID查询设备列表
|
||||||
|
*
|
||||||
|
* @param scenicId 景区ID
|
||||||
|
* @return 设备列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/device/list")
|
||||||
|
public ApiResponse<List<DeviceV2DTO>> getDeviceListByScenicId(@RequestParam Long scenicId) {
|
||||||
|
List<DeviceV2DTO> result = deviceRepository.getAllDeviceByScenicId(scenicId);
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{sampleId}/qrcode")
|
||||||
|
public void getQrcode(@PathVariable("sampleId") Long sampleId, HttpServletResponse response) throws Exception {
|
||||||
|
File qrcode = new File("qrcode_"+sampleId+".jpg");
|
||||||
|
FaceSampleEntity faceSample = faceRepository.getFaceSample(sampleId);
|
||||||
|
if (faceSample == null) {
|
||||||
|
response.setStatus(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String targetPath = "pages/printer/from_sample";
|
||||||
|
DeviceV2DTO device = deviceRepository.getDeviceBasic(faceSample.getDeviceId());
|
||||||
|
if (device.getType().equals("AI_CAM")) {
|
||||||
|
// AI_CAM,需要修改path
|
||||||
|
targetPath = "pages/ai-cam/from_sample";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(faceSample.getScenicId());
|
||||||
|
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), targetPath, sampleId.toString(), qrcode);
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
response.setContentType("image/jpeg");
|
||||||
|
response.setHeader("Content-Disposition", "inline; filename=\"" + qrcode.getName() + "\"");
|
||||||
|
|
||||||
|
// 将二维码文件写入响应输出流
|
||||||
|
try (FileInputStream fis = new FileInputStream(qrcode);
|
||||||
|
OutputStream os = response.getOutputStream()) {
|
||||||
|
byte[] buffer = new byte[1024];
|
||||||
|
int bytesRead;
|
||||||
|
while ((bytesRead = fis.read(buffer)) != -1) {
|
||||||
|
os.write(buffer, 0, bytesRead);
|
||||||
|
}
|
||||||
|
os.flush();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 删除临时文件
|
||||||
|
if (qrcode.exists()) {
|
||||||
|
qrcode.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取人脸绑定二维码
|
||||||
|
* 生成小程序二维码,用于绑定人脸到用户账号
|
||||||
|
*
|
||||||
|
* @param faceId 人脸ID
|
||||||
|
* @param response HTTP响应
|
||||||
|
*/
|
||||||
|
@GetMapping("/face/{faceId}/qrcode")
|
||||||
|
public void getFaceQrcode(@PathVariable("faceId") Long faceId, HttpServletResponse response) throws Exception {
|
||||||
|
File qrcode = new File("qrcode_face_" + faceId + ".jpg");
|
||||||
|
try {
|
||||||
|
FaceEntity face = faceRepository.getFace(faceId);
|
||||||
|
if (face == null) {
|
||||||
|
response.setStatus(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(face.getScenicId());
|
||||||
|
if (scenicMpConfig == null) {
|
||||||
|
response.setStatus(500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
WxMpUtil.generateUnlimitedWXAQRCode(
|
||||||
|
scenicMpConfig.getAppId(),
|
||||||
|
scenicMpConfig.getAppSecret(),
|
||||||
|
"pages/videoSynthesis/bind_face",
|
||||||
|
faceId.toString(),
|
||||||
|
qrcode
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
response.setContentType("image/jpeg");
|
||||||
|
response.setHeader("Content-Disposition", "inline; filename=\"" + qrcode.getName() + "\"");
|
||||||
|
|
||||||
|
// 将二维码文件写入响应输出流
|
||||||
|
try (FileInputStream fis = new FileInputStream(qrcode);
|
||||||
|
OutputStream os = response.getOutputStream()) {
|
||||||
|
byte[] buffer = new byte[1024];
|
||||||
|
int bytesRead;
|
||||||
|
while ((bytesRead = fis.read(buffer)) != -1) {
|
||||||
|
os.write(buffer, 0, bytesRead);
|
||||||
|
}
|
||||||
|
os.flush();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 删除临时文件
|
||||||
|
if (qrcode.exists()) {
|
||||||
|
qrcode.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据人脸样本ID查询图像素材
|
||||||
|
*
|
||||||
|
* @param faceSampleId 人脸样本ID
|
||||||
|
* @return type=2且face_sample_id匹配的source记录
|
||||||
|
*/
|
||||||
|
@GetMapping("/{faceSampleId}/source")
|
||||||
|
public ApiResponse<SourceEntity> getSourceByFaceSampleId(@PathVariable Long faceSampleId) {
|
||||||
|
SourceEntity source = sourceMapper.getBySampleIdAndType(faceSampleId, 2);
|
||||||
|
if (source == null) {
|
||||||
|
return ApiResponse.fail("未找到对应的图像素材");
|
||||||
|
}
|
||||||
|
return ApiResponse.success(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打印机大屏人脸识别
|
||||||
|
* 上传照片,在景区人脸库中搜索匹配的人脸样本,返回识别结果和匹配到的图像素材
|
||||||
|
*
|
||||||
|
* @param file 人脸照片文件
|
||||||
|
* @param scenicId 景区ID
|
||||||
|
* @return 人脸识别结果和匹配的source列表
|
||||||
|
*/
|
||||||
|
@PostMapping("/{scenicId}/faceRecognize")
|
||||||
|
public ApiResponse<FaceRecognizeWithSourcesResp> faceRecognize(
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@PathVariable Long scenicId) throws Exception {
|
||||||
|
|
||||||
|
// 1. 上传人脸照片到存储
|
||||||
|
IStorageAdapter adapter = StorageFactory.use("faces");
|
||||||
|
String filePath = StorageUtil.joinPath(USER_FACE, DateUtil.format(new Date(), "yyyy-MM-dd"));
|
||||||
|
String originalFilename = file.getOriginalFilename();
|
||||||
|
String suffix = originalFilename.split("\\.", 2)[1];
|
||||||
|
String fileName = UUID.randomUUID() + "." + suffix;
|
||||||
|
String faceUrl = adapter.uploadFile(file, filePath, fileName);
|
||||||
|
|
||||||
|
// 2. 保存人脸数据到数据库
|
||||||
|
Long faceId = SnowFlakeUtil.getLongId();
|
||||||
|
FaceEntity faceEntity = new FaceEntity();
|
||||||
|
faceEntity.setId(faceId);
|
||||||
|
faceEntity.setScenicId(scenicId);
|
||||||
|
faceEntity.setFaceUrl(faceUrl);
|
||||||
|
faceEntity.setCreateAt(new Date());
|
||||||
|
faceEntity.setMemberId(0L); // 打印机大屏端没有用户ID
|
||||||
|
faceMapper.add(faceEntity);
|
||||||
|
|
||||||
|
// 3. 在景区人脸库中搜索(注意:这里使用scenicId作为数据库名,搜索的是景区内的人脸样本)
|
||||||
|
pcFaceService.matchFaceId(faceId);
|
||||||
|
|
||||||
|
// 4. 自动添加照片到打印列表,并获取添加成功的照片列表
|
||||||
|
List<SourceEntity> addedSources = printerService.autoAddPhotosToPreferPrint(faceId);
|
||||||
|
|
||||||
|
// 5. 根据自动添加结果决定返回的sources
|
||||||
|
List<SourceEntity> sources;
|
||||||
|
if (addedSources != null && !addedSources.isEmpty()) {
|
||||||
|
// 如果自动添加成功,返回添加的照片列表
|
||||||
|
sources = addedSources;
|
||||||
|
} else {
|
||||||
|
// 如果自动添加为空,按原逻辑查询匹配到的图像素材(type=2)
|
||||||
|
sources = new ArrayList<>();
|
||||||
|
List<MemberSourceEntity> memberSourceEntities = memberRelationRepository.listSourceByFaceRelation(faceId, 2);
|
||||||
|
for (MemberSourceEntity memberSourceEntity : memberSourceEntities) {
|
||||||
|
SourceEntity source = sourceRepository.getSource(memberSourceEntity.getSourceId());
|
||||||
|
if (source != null) {
|
||||||
|
sources.add(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 构造响应
|
||||||
|
FaceRecognizeWithSourcesResp resp = new FaceRecognizeWithSourcesResp();
|
||||||
|
resp.setUrl(faceUrl);
|
||||||
|
resp.setFaceId(faceId);
|
||||||
|
resp.setScenicId(scenicId);
|
||||||
|
resp.setSources(sources);
|
||||||
|
// 只有当添加了照片时才返回二维码URL
|
||||||
|
if (addedSources != null && !addedSources.isEmpty()) {
|
||||||
|
resp.setQrcodeUrl("https://zhentuai.com/printer/v1/tv/face/" + faceId + "/qrcode");
|
||||||
|
} else {
|
||||||
|
resp.setQrcodeUrl(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.success(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
package com.ycwl.basic.controller.viid;
|
|
||||||
|
|
||||||
import cn.hutool.core.collection.CollUtil;
|
|
||||||
import cn.hutool.core.date.DateUtil;
|
|
||||||
import cn.hutool.core.thread.ThreadFactoryBuilder;
|
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
|
||||||
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
|
|
||||||
import com.ycwl.basic.utils.JacksonUtil;
|
|
||||||
import com.ycwl.basic.annotation.IgnoreLogReq;
|
|
||||||
import com.ycwl.basic.annotation.IgnoreToken;
|
|
||||||
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
|
|
||||||
import com.ycwl.basic.facebody.entity.AddFaceResp;
|
|
||||||
import com.ycwl.basic.integration.device.service.DeviceIntegrationService;
|
|
||||||
import com.ycwl.basic.integration.device.dto.device.CreateDeviceRequest;
|
|
||||||
import com.ycwl.basic.integration.device.dto.device.UpdateDeviceRequest;
|
|
||||||
import com.ycwl.basic.integration.device.dto.device.DeviceV2DTO;
|
|
||||||
import com.ycwl.basic.mapper.FaceSampleMapper;
|
|
||||||
import com.ycwl.basic.mapper.SourceMapper;
|
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceCropConfig;
|
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
|
||||||
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
|
||||||
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
|
|
||||||
import com.ycwl.basic.model.viid.entity.DeviceIdObject;
|
|
||||||
import com.ycwl.basic.model.viid.entity.FaceListObject;
|
|
||||||
import com.ycwl.basic.model.viid.entity.FaceObject;
|
|
||||||
import com.ycwl.basic.model.viid.entity.FacePositionObject;
|
|
||||||
import com.ycwl.basic.model.viid.entity.ResponseStatusObject;
|
|
||||||
import com.ycwl.basic.model.viid.entity.SubImageInfoObject;
|
|
||||||
import com.ycwl.basic.model.viid.entity.SubImageList;
|
|
||||||
import com.ycwl.basic.model.viid.entity.SystemTimeObject;
|
|
||||||
import com.ycwl.basic.model.viid.req.FaceUploadReq;
|
|
||||||
import com.ycwl.basic.model.viid.req.ImageUploadReq;
|
|
||||||
import com.ycwl.basic.model.viid.req.KeepaliveReq;
|
|
||||||
import com.ycwl.basic.model.viid.req.RegisterReq;
|
|
||||||
import com.ycwl.basic.model.viid.req.UnRegisterReq;
|
|
||||||
import com.ycwl.basic.model.viid.resp.SystemTimeResp;
|
|
||||||
import com.ycwl.basic.model.viid.resp.VIIDBaseResp;
|
|
||||||
import com.ycwl.basic.repository.DeviceRepository;
|
|
||||||
import com.ycwl.basic.service.pc.ScenicService;
|
|
||||||
import com.ycwl.basic.service.task.TaskFaceService;
|
|
||||||
import com.ycwl.basic.storage.StorageFactory;
|
|
||||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
|
||||||
import com.ycwl.basic.storage.enums.StorageAcl;
|
|
||||||
import com.ycwl.basic.storage.utils.StorageUtil;
|
|
||||||
import com.ycwl.basic.task.DynamicTaskGenerator;
|
|
||||||
import com.ycwl.basic.utils.ImageUtils;
|
|
||||||
import com.ycwl.basic.utils.IpUtils;
|
|
||||||
import com.ycwl.basic.utils.SnowFlakeUtil;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMethod;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
|
|
||||||
import java.awt.image.RasterFormatException;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.text.ParseException;
|
|
||||||
import java.text.SimpleDateFormat;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.TimeZone;
|
|
||||||
import java.util.UUID;
|
|
||||||
import java.util.concurrent.ArrayBlockingQueue;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.ThreadFactory;
|
|
||||||
import java.util.concurrent.ThreadPoolExecutor;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import static com.ycwl.basic.constant.StorageConstant.PHOTO_PATH;
|
|
||||||
import static com.ycwl.basic.constant.StorageConstant.VIID_FACE;
|
|
||||||
|
|
||||||
@IgnoreToken
|
|
||||||
@RestController
|
|
||||||
// 摄像头对接接口
|
|
||||||
@RequestMapping("/VIID")
|
|
||||||
@Slf4j
|
|
||||||
public class ViidController {
|
|
||||||
@Autowired
|
|
||||||
private DeviceIntegrationService deviceIntegrationService;
|
|
||||||
private static final String serverId = "00000000000000000001";
|
|
||||||
@Autowired
|
|
||||||
private SourceMapper sourceMapper;
|
|
||||||
@Autowired
|
|
||||||
private DeviceRepository deviceRepository;
|
|
||||||
@Autowired
|
|
||||||
private TaskFaceService taskFaceService;
|
|
||||||
private final Map<Long, ThreadPoolExecutor> executors = new ConcurrentHashMap<>();
|
|
||||||
@Autowired
|
|
||||||
private ScenicService scenicService;
|
|
||||||
|
|
||||||
private ThreadPoolExecutor getExecutor(Long scenicId) {
|
|
||||||
return executors.computeIfAbsent(scenicId, k -> {
|
|
||||||
ThreadFactory threadFactory = new ThreadFactoryBuilder()
|
|
||||||
.setNamePrefix("VIID-" + scenicId + "-t")
|
|
||||||
.build();
|
|
||||||
return new ThreadPoolExecutor(
|
|
||||||
8, 32, 10L, TimeUnit.SECONDS, // 核心2个线程,最大20个线程,空闲60秒回收
|
|
||||||
new ArrayBlockingQueue<>(1024), // 队列大小从1024降至100
|
|
||||||
threadFactory,
|
|
||||||
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时由调用线程执行,提供背压控制
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// region 注册注销基础接口
|
|
||||||
/**
|
|
||||||
* 注册接口
|
|
||||||
*
|
|
||||||
* @param req 注册的信息
|
|
||||||
* @param request 请求
|
|
||||||
* @return 返回
|
|
||||||
*/
|
|
||||||
@RequestMapping(value = "/System/Register", method = RequestMethod.POST)
|
|
||||||
public VIIDBaseResp register(@RequestBody RegisterReq req, HttpServletRequest request) {
|
|
||||||
DeviceIdObject deviceIdObject = req.getRegisterObject();
|
|
||||||
log.info("注册的设备信息:{}", deviceIdObject);
|
|
||||||
// 保存设备注册时间
|
|
||||||
String deviceId = deviceIdObject.getDeviceId();
|
|
||||||
DeviceEntity device = deviceRepository.getDeviceByDeviceNo(deviceId);
|
|
||||||
if (device == null) {
|
|
||||||
device = new DeviceEntity();
|
|
||||||
device.setName("未配置设备");
|
|
||||||
device.setNo(deviceId);
|
|
||||||
device.setOnline(1);
|
|
||||||
}
|
|
||||||
device.setKeepaliveAt(new Date());
|
|
||||||
device.setIpAddr(IpUtils.getIpAddr(request));
|
|
||||||
if (device.getId() == null) {
|
|
||||||
// 通过zt-device服务创建新设备
|
|
||||||
CreateDeviceRequest createRequest = new CreateDeviceRequest();
|
|
||||||
createRequest.setName(device.getName());
|
|
||||||
createRequest.setNo(device.getNo());
|
|
||||||
createRequest.setType("IPC"); // 默认类型为IPC
|
|
||||||
createRequest.setIsActive(0);
|
|
||||||
createRequest.setScenicId(0L);
|
|
||||||
createRequest.setSort(0);
|
|
||||||
try {
|
|
||||||
DeviceV2DTO createdDevice = deviceIntegrationService.createDevice(createRequest);
|
|
||||||
device.setId(createdDevice.getId());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("创建设备失败,设备编号: {}, 错误: {}", deviceId, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new VIIDBaseResp(
|
|
||||||
new ResponseStatusObject(serverId, "/VIID/System/Register", "0", "注册成功", sdfTime.format(new Date()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保活接口
|
|
||||||
*
|
|
||||||
* @param req 保活的设备信息
|
|
||||||
* @param request 请求
|
|
||||||
* @return 返回
|
|
||||||
*/
|
|
||||||
@IgnoreLogReq
|
|
||||||
@RequestMapping(value = "/System/Keepalive", method = RequestMethod.POST)
|
|
||||||
public VIIDBaseResp keepalive(@RequestBody KeepaliveReq req, HttpServletRequest request) {
|
|
||||||
DeviceIdObject keepaliveObject = req.getKeepaliveObject();
|
|
||||||
// log.info("对方发送的心跳的信息:{}", keepaliveObject);
|
|
||||||
|
|
||||||
String deviceId = keepaliveObject.getDeviceId();
|
|
||||||
DeviceEntity device = deviceRepository.getDeviceByDeviceNo(deviceId);
|
|
||||||
|
|
||||||
// 判断设备状态
|
|
||||||
if (device == null) {
|
|
||||||
// 不存在设备就注册
|
|
||||||
device = new DeviceEntity();
|
|
||||||
device.setName("未配置设备");
|
|
||||||
device.setNo(deviceId);
|
|
||||||
device.setOnline(1);
|
|
||||||
device.setKeepaliveAt(new Date());
|
|
||||||
device.setIpAddr(IpUtils.getIpAddr(request));
|
|
||||||
// 通过zt-device服务创建新设备
|
|
||||||
CreateDeviceRequest createRequest = new CreateDeviceRequest();
|
|
||||||
createRequest.setName(device.getName());
|
|
||||||
createRequest.setNo(device.getNo());
|
|
||||||
createRequest.setType("IPC"); // 默认类型为IPC
|
|
||||||
createRequest.setIsActive(0);
|
|
||||||
createRequest.setScenicId(0L);
|
|
||||||
createRequest.setSort(0);
|
|
||||||
try {
|
|
||||||
DeviceV2DTO createdDevice = deviceIntegrationService.createDevice(createRequest);
|
|
||||||
device.setId(createdDevice.getId());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("创建设备失败,设备编号: {}, 错误: {}", deviceId, e.getMessage());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
deviceRepository.updateOnlineStatus(device.getId(), IpUtils.getIpAddr(request), 1, new Date());
|
|
||||||
}
|
|
||||||
// log.info("已经解析过的心跳信息:{}", keepaliveObject);
|
|
||||||
|
|
||||||
return new VIIDBaseResp(
|
|
||||||
new ResponseStatusObject(deviceId, "/VIID/System/Keepalive", "0", "保活", sdfTime.format(new Date()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注销设备
|
|
||||||
*
|
|
||||||
* @param req 参数
|
|
||||||
* @return 返回
|
|
||||||
*/
|
|
||||||
@RequestMapping(value = "/System/UnRegister", method = RequestMethod.POST)
|
|
||||||
public VIIDBaseResp unRegister(@RequestBody UnRegisterReq req, HttpServletRequest request) {
|
|
||||||
// 获取设备id
|
|
||||||
DeviceIdObject unRegisterObject = req.getUnRegisterObject();
|
|
||||||
String deviceId = unRegisterObject.getDeviceId();
|
|
||||||
log.info("获取的注销的请求参数:{}", unRegisterObject);
|
|
||||||
|
|
||||||
// 首先查询该设备是否存在
|
|
||||||
DeviceEntity device = deviceRepository.getDeviceByDeviceNo(deviceId);
|
|
||||||
// 判断
|
|
||||||
if (device != null) {
|
|
||||||
deviceRepository.updateOnlineStatus(device.getId(), IpUtils.getIpAddr(request), 0, new Date());
|
|
||||||
}
|
|
||||||
return new VIIDBaseResp(
|
|
||||||
new ResponseStatusObject(deviceId, "/VIID/System/UnRegister", "0", "注销成功", sdfTime.format(new Date()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校时接口
|
|
||||||
*
|
|
||||||
* @return 返回
|
|
||||||
*/
|
|
||||||
@RequestMapping(value = "/System/Time", method = RequestMethod.GET)
|
|
||||||
public SystemTimeResp time() {
|
|
||||||
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss");
|
|
||||||
return new SystemTimeResp(
|
|
||||||
new SystemTimeObject(serverId, "2", sdf.format(new Date()), TimeZone.getTimeZone("Asia/Shanghai").toString())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// endregion
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private FaceSampleMapper faceSampleMapper;
|
|
||||||
|
|
||||||
private final SimpleDateFormat sdfTime = new SimpleDateFormat("yyyyMMddHHmmss");
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量新增人脸
|
|
||||||
*/
|
|
||||||
@RequestMapping(value = "/Faces", method = RequestMethod.POST)
|
|
||||||
@IgnoreLogReq
|
|
||||||
public VIIDBaseResp faces(@RequestBody FaceUploadReq req) {
|
|
||||||
FaceListObject faceListObject = req.getFaceListObject();
|
|
||||||
List<FaceObject> faceObject = faceListObject.getFaceObject();
|
|
||||||
String faceId = null;
|
|
||||||
// 遍历人脸列表
|
|
||||||
for (FaceObject face : faceObject) {
|
|
||||||
// 设置FaceId
|
|
||||||
faceId = face.getFaceID();
|
|
||||||
// 获取图片信息
|
|
||||||
SubImageList subImageList = face.getSubImageList();
|
|
||||||
// 判断人脸对象中的列表是否为空
|
|
||||||
String deviceID = face.getDeviceID();
|
|
||||||
DeviceEntity device = deviceRepository.getDeviceByDeviceNo(deviceID);
|
|
||||||
if (device == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(device.getId());
|
|
||||||
DeviceConfigEntity deviceConfigEntity = deviceRepository.getDeviceConfig(device.getId());
|
|
||||||
if (deviceConfig == null) {
|
|
||||||
log.warn("设备配置不存在:" + deviceID);
|
|
||||||
return new VIIDBaseResp(
|
|
||||||
new ResponseStatusObject(faceId, "/VIID/Faces", "0", "OK", sdfTime.format(new Date()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Integer viidMode = deviceConfig.getInteger("viid_mode", 0);
|
|
||||||
Date shotTime = null;
|
|
||||||
if (StringUtils.isNotBlank(face.getShotTime())) {
|
|
||||||
try {
|
|
||||||
shotTime = sdfTime.parse(face.getShotTime());
|
|
||||||
} catch (ParseException e) {
|
|
||||||
log.warn("拍摄时间时间转换失败,使用当前时间。错误entity:{}", face);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (shotTime == null) {
|
|
||||||
if (StringUtils.isNotBlank(face.getFaceAppearTime())) {
|
|
||||||
try {
|
|
||||||
shotTime = sdfTime.parse(face.getFaceAppearTime());
|
|
||||||
} catch (ParseException e) {
|
|
||||||
log.warn("拍摄时间时间转换失败,使用当前时间。错误entity:{}", face);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (shotTime == null) {
|
|
||||||
shotTime = new Date();
|
|
||||||
} else if (!DateUtil.isSameDay(shotTime, new Date())) {
|
|
||||||
log.warn("时间不是今天,使用当前时间。错误entity:{}", face);
|
|
||||||
shotTime = new Date();
|
|
||||||
}
|
|
||||||
if (Math.abs(shotTime.getTime() - System.currentTimeMillis()) > 3600 * 1000) {
|
|
||||||
String jsonString = JacksonUtil.toJSONStringCompat(req);
|
|
||||||
log.warn("时间差超过1小时。device:{},错误entity:{}", device, jsonString);
|
|
||||||
}
|
|
||||||
Long scenicId = device.getScenicId();
|
|
||||||
if (scenicId == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
IStorageAdapter scenicStorageAdapter = scenicService.getScenicStorageAdapter(scenicId);
|
|
||||||
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(scenicId);
|
|
||||||
if (faceBodyAdapter == null) {
|
|
||||||
log.warn("人脸上传适配器不存在:" + scenicId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
FacePositionObject facePosition = new FacePositionObject();
|
|
||||||
facePosition.setLtY(face.getLeftTopY());
|
|
||||||
facePosition.setLtX(face.getLeftTopX());
|
|
||||||
facePosition.setRbY(face.getRightBtmY());
|
|
||||||
facePosition.setRbX(face.getRightBtmX());
|
|
||||||
if (ObjectUtil.isNotEmpty(subImageList) && CollUtil.isNotEmpty(subImageList.getSubImageInfoObject())) {
|
|
||||||
if (viidMode == 0) {
|
|
||||||
// 遍历每个图片对象
|
|
||||||
// 先找到type14的图片
|
|
||||||
List<SubImageInfoObject> type14ImageList = subImageList.getSubImageInfoObject().stream().filter(subImage -> "14".equals(subImage.getType())).toList();
|
|
||||||
for (SubImageInfoObject subImage : subImageList.getSubImageInfoObject()) {
|
|
||||||
// base64转换成MultipartFIle
|
|
||||||
MultipartFile file = ImageUtils.base64ToMultipartFile(subImage.getData());
|
|
||||||
String ext;
|
|
||||||
if (subImage.getFileFormat().equalsIgnoreCase("jpeg")) {
|
|
||||||
ext = "jpg";
|
|
||||||
} else {
|
|
||||||
ext = subImage.getFileFormat();
|
|
||||||
}
|
|
||||||
IStorageAdapter adapter = StorageFactory.use("faces");
|
|
||||||
// Type=11 人脸
|
|
||||||
if (subImage.getType().equals("11")) {
|
|
||||||
// 上传oss
|
|
||||||
Long newFaceSampleId = SnowFlakeUtil.getLongId();
|
|
||||||
if (Integer.valueOf(1).equals(device.getStatus())) {
|
|
||||||
FaceSampleEntity faceSample = new FaceSampleEntity();
|
|
||||||
faceSample.setId(newFaceSampleId);
|
|
||||||
faceSample.setScenicId(scenicId);
|
|
||||||
faceSample.setDeviceId(device.getId());
|
|
||||||
faceSample.setStatus(0);
|
|
||||||
faceSample.setCreateAt(shotTime);
|
|
||||||
String url = adapter.uploadFile(file, VIID_FACE, UUID.randomUUID() + "." + ext);
|
|
||||||
faceSample.setFaceUrl(url);
|
|
||||||
faceSampleMapper.add(faceSample);
|
|
||||||
ThreadPoolExecutor executor = getExecutor(scenicId);
|
|
||||||
executor.execute(() -> {
|
|
||||||
taskFaceService.assureFaceDb(faceBodyAdapter, scenicId.toString());
|
|
||||||
AddFaceResp addFaceResp;
|
|
||||||
try {
|
|
||||||
addFaceResp = faceBodyAdapter.addFace(scenicId.toString(), faceSample.getId().toString(), url, newFaceSampleId.toString());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("人脸添加失败:{}", e.getMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (addFaceResp != null) {
|
|
||||||
faceSample.setScore(addFaceResp.getScore());
|
|
||||||
faceSampleMapper.updateScore(faceSample.getId(), addFaceResp.getScore());
|
|
||||||
|
|
||||||
}
|
|
||||||
if (Integer.valueOf(1).equals(deviceConfig.getInteger("enable_pre_book"))) {
|
|
||||||
DynamicTaskGenerator.addTask(faceSample.getId());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (SubImageInfoObject _subImage : type14ImageList) {
|
|
||||||
facePosition.setImgHeight(_subImage.getHeight());
|
|
||||||
facePosition.setImgWidth(_subImage.getWidth());
|
|
||||||
SourceEntity source = new SourceEntity();
|
|
||||||
source.setDeviceId(device.getId());
|
|
||||||
source.setScenicId(device.getScenicId());
|
|
||||||
source.setFaceSampleId(newFaceSampleId);
|
|
||||||
source.setCreateTime(shotTime);
|
|
||||||
source.setType(2);
|
|
||||||
// 上传oss
|
|
||||||
MultipartFile _file = ImageUtils.base64ToMultipartFile(_subImage.getData());
|
|
||||||
ThreadPoolExecutor executor = getExecutor(scenicId);
|
|
||||||
executor.execute(() -> {
|
|
||||||
List<DeviceCropConfig> cropConfigs = deviceConfigEntity._getCropConfig();
|
|
||||||
for (DeviceCropConfig cropConfig : cropConfigs) {
|
|
||||||
source.setId(SnowFlakeUtil.getLongId());
|
|
||||||
String filename = StorageUtil.joinPath(PHOTO_PATH, UUID.randomUUID() + "." + ext);
|
|
||||||
MultipartFile _finalFile = _file;
|
|
||||||
if (cropConfig.getCropType() == 1) {
|
|
||||||
// 按固定位置截图
|
|
||||||
try {
|
|
||||||
_finalFile = ImageUtils.cropImage(_file, cropConfig.getTargetX(), cropConfig.getTargetY(), cropConfig.getTargetWidth(), cropConfig.getTargetHeight());
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("裁切图片失败!", e);
|
|
||||||
} catch (RasterFormatException e) {
|
|
||||||
log.error("裁切图片出错!", e);
|
|
||||||
}
|
|
||||||
} else if (cropConfig.getCropType() == 2) {
|
|
||||||
// 按人脸位置
|
|
||||||
try {
|
|
||||||
int targetX = facePosition.getLtX() - (cropConfig.getTargetWidth() - facePosition.getWidth())/2;
|
|
||||||
int targetY = facePosition.getLtY() - (cropConfig.getTargetHeight() - facePosition.getHeight())/2;
|
|
||||||
_finalFile = ImageUtils.cropImage(_file, targetX, targetY, cropConfig.getTargetWidth(), cropConfig.getTargetHeight());
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.error("裁切图片失败!", e);
|
|
||||||
} catch (RasterFormatException e) {
|
|
||||||
log.error("裁切图片出错!", e);
|
|
||||||
}
|
|
||||||
facePosition.setImgHeight(cropConfig.getTargetHeight());
|
|
||||||
facePosition.setImgWidth(cropConfig.getTargetWidth());
|
|
||||||
}
|
|
||||||
String _sourceUrl = scenicStorageAdapter.uploadFile(_finalFile, filename);
|
|
||||||
scenicStorageAdapter.setAcl(StorageAcl.PUBLIC_READ, filename);
|
|
||||||
source.setUrl(_sourceUrl);
|
|
||||||
source.setPosJson(JacksonUtil.toJSONString(facePosition));
|
|
||||||
sourceMapper.add(source);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
log.info("人脸信息及原图{}张入库成功!设备ID:{}", type14ImageList.size(), deviceID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (viidMode == 1) {
|
|
||||||
for (SubImageInfoObject subImage : subImageList.getSubImageInfoObject()) {
|
|
||||||
// base64转换成MultipartFIle
|
|
||||||
MultipartFile file = ImageUtils.base64ToMultipartFile(subImage.getData());
|
|
||||||
String ext = subImage.getFileFormat();
|
|
||||||
if (ext.equalsIgnoreCase("jpeg")) {
|
|
||||||
ext = "jpg";
|
|
||||||
}
|
|
||||||
IStorageAdapter adapter = StorageFactory.use("faces");
|
|
||||||
// Type=14 人脸,传™的,有这么传的嘛
|
|
||||||
if (subImage.getType().equals("14")) {
|
|
||||||
// 上传oss
|
|
||||||
if (Integer.valueOf(1).equals(device.getStatus())) {
|
|
||||||
FaceSampleEntity faceSample = new FaceSampleEntity();
|
|
||||||
Long newFaceSampleId = SnowFlakeUtil.getLongId();
|
|
||||||
faceSample.setId(newFaceSampleId);
|
|
||||||
faceSample.setScenicId(scenicId);
|
|
||||||
faceSample.setDeviceId(device.getId());
|
|
||||||
faceSample.setStatus(0);
|
|
||||||
faceSample.setCreateAt(shotTime);
|
|
||||||
String url = adapter.uploadFile(file, VIID_FACE, UUID.randomUUID() + "." + ext);
|
|
||||||
faceSample.setFaceUrl(url);
|
|
||||||
faceSampleMapper.add(faceSample);
|
|
||||||
DynamicTaskGenerator.addTask(faceSample.getId());
|
|
||||||
ThreadPoolExecutor executor = getExecutor(scenicId);
|
|
||||||
executor.execute(() -> {
|
|
||||||
taskFaceService.assureFaceDb(faceBodyAdapter, scenicId.toString());
|
|
||||||
AddFaceResp addFaceResp;
|
|
||||||
try {
|
|
||||||
addFaceResp = faceBodyAdapter.addFace(scenicId.toString(), faceSample.getId().toString(), url, newFaceSampleId.toString());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("人脸添加失败:{}", e.getMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (addFaceResp != null) {
|
|
||||||
faceSample.setScore(addFaceResp.getScore());
|
|
||||||
faceSampleMapper.updateScore(faceSample.getId(), addFaceResp.getScore());
|
|
||||||
if (Integer.valueOf(1).equals(deviceConfig.getInteger("enable_pre_book"))) {
|
|
||||||
DynamicTaskGenerator.addTask(faceSample.getId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
log.info("模式1人脸信息入库成功!设备ID:{}", deviceID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new VIIDBaseResp(
|
|
||||||
new ResponseStatusObject(faceId, "/VIID/Faces", "0", "OK", sdfTime.format(new Date()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequestMapping(value = "/Images", method = RequestMethod.POST)
|
|
||||||
@IgnoreLogReq
|
|
||||||
public VIIDBaseResp images(HttpServletRequest request, @RequestBody ImageUploadReq req) throws IOException {
|
|
||||||
return new VIIDBaseResp(
|
|
||||||
new ResponseStatusObject("1", "/VIID/Images", "0", "OK", sdfTime.format(new Date()))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.ycwl.basic.device.entity.common;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备视频连续性检查缓存实体
|
||||||
|
* 用于存储在Redis中的检查结果
|
||||||
|
*
|
||||||
|
* @author Claude Code
|
||||||
|
* @date 2025-09-01
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class DeviceVideoContinuityCache implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备ID
|
||||||
|
*/
|
||||||
|
private Long deviceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查时间
|
||||||
|
*/
|
||||||
|
private Date checkTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查的开始时间
|
||||||
|
*/
|
||||||
|
private Date startTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查的结束时间
|
||||||
|
*/
|
||||||
|
private Date endTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否支持连续性检查
|
||||||
|
*/
|
||||||
|
private Boolean support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频是否连续
|
||||||
|
*/
|
||||||
|
private Boolean continuous;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频总数
|
||||||
|
*/
|
||||||
|
private Integer totalVideos;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总时长(毫秒)
|
||||||
|
*/
|
||||||
|
private Long totalDurationMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许的最大间隙(毫秒)
|
||||||
|
*/
|
||||||
|
private Long maxAllowedGapMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙数量
|
||||||
|
*/
|
||||||
|
private Integer gapCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙列表(简化版,只包含关键信息)
|
||||||
|
*/
|
||||||
|
private List<GapInfo> gaps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙信息简化类
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class GapInfo implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前一个文件名
|
||||||
|
*/
|
||||||
|
private String beforeFileName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后一个文件名
|
||||||
|
*/
|
||||||
|
private String afterFileName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙时长(毫秒)
|
||||||
|
*/
|
||||||
|
private Long gapMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙开始时间
|
||||||
|
*/
|
||||||
|
private Date gapStartTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙结束时间
|
||||||
|
*/
|
||||||
|
private Date gapEndTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从VideoContinuityResult创建缓存对象
|
||||||
|
*
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @param result 检查结果
|
||||||
|
* @param startTime 检查开始时间
|
||||||
|
* @param endTime 检查结束时间
|
||||||
|
* @return 缓存对象
|
||||||
|
*/
|
||||||
|
public static DeviceVideoContinuityCache fromResult(Long deviceId, VideoContinuityResult result,
|
||||||
|
Date startTime, Date endTime) {
|
||||||
|
DeviceVideoContinuityCache cache = new DeviceVideoContinuityCache();
|
||||||
|
cache.setDeviceId(deviceId);
|
||||||
|
cache.setCheckTime(new Date());
|
||||||
|
cache.setStartTime(startTime);
|
||||||
|
cache.setEndTime(endTime);
|
||||||
|
cache.setSupport(result.isSupport());
|
||||||
|
cache.setContinuous(result.isContinuous());
|
||||||
|
cache.setTotalVideos(result.getTotalVideos());
|
||||||
|
cache.setTotalDurationMs(result.getTotalDurationMs());
|
||||||
|
cache.setMaxAllowedGapMs(result.getMaxAllowedGapMs());
|
||||||
|
cache.setGapCount(result.getGapCount());
|
||||||
|
|
||||||
|
// 转换间隙列表
|
||||||
|
if (result.getGaps() != null && !result.getGaps().isEmpty()) {
|
||||||
|
List<GapInfo> gapInfos = result.getGaps().stream()
|
||||||
|
.map(gap -> new GapInfo(
|
||||||
|
gap.getBeforeFile() != null ? gap.getBeforeFile().getName() : null,
|
||||||
|
gap.getAfterFile() != null ? gap.getAfterFile().getName() : null,
|
||||||
|
gap.getGapMs(),
|
||||||
|
gap.getGapStartTime(),
|
||||||
|
gap.getGapEndTime()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
cache.setGaps(gapInfos);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ycwl.basic.device.entity.common;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频连续性检查中的间隙信息
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class VideoContinuityGap {
|
||||||
|
/**
|
||||||
|
* 间隙前的视频文件
|
||||||
|
*/
|
||||||
|
private FileObject beforeFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙后的视频文件
|
||||||
|
*/
|
||||||
|
private FileObject afterFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙时长(毫秒)
|
||||||
|
*/
|
||||||
|
private long gapMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙开始时间(前一个视频的endTime)
|
||||||
|
*/
|
||||||
|
private Date gapStartTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 间隙结束时间(后一个视频的createTime)
|
||||||
|
*/
|
||||||
|
private Date gapEndTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.ycwl.basic.device.entity.common;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频连续性检查结果
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class VideoContinuityResult {
|
||||||
|
/**
|
||||||
|
* 是否支持连续性检查功能
|
||||||
|
*/
|
||||||
|
private boolean support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频是否连续(所有间隙都在允许范围内)
|
||||||
|
*/
|
||||||
|
private boolean continuous;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测到的间隙列表
|
||||||
|
*/
|
||||||
|
private List<VideoContinuityGap> gaps = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频文件总数
|
||||||
|
*/
|
||||||
|
private int totalVideos;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总时长(毫秒)
|
||||||
|
*/
|
||||||
|
private long totalDurationMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 允许的最大间隙(毫秒)
|
||||||
|
*/
|
||||||
|
private long maxAllowedGapMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加一个间隙
|
||||||
|
*/
|
||||||
|
public void addGap(VideoContinuityGap gap) {
|
||||||
|
this.gaps.add(gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取间隙数量
|
||||||
|
*/
|
||||||
|
public int getGapCount() {
|
||||||
|
return gaps.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,50 @@
|
|||||||
package com.ycwl.basic.device.operator;
|
package com.ycwl.basic.device.operator;
|
||||||
|
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.Calendar;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
public abstract class ADeviceStorageOperator implements IDeviceStorageOperator {
|
public abstract class ADeviceStorageOperator implements IDeviceStorageOperator {
|
||||||
@Setter
|
@Setter
|
||||||
protected DeviceEntity device;
|
protected DeviceEntity device;
|
||||||
@Setter
|
@Setter
|
||||||
protected DeviceConfigEntity deviceConfig;
|
protected DeviceConfigEntity deviceConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认实现:不支持视频连续性检查
|
||||||
|
*
|
||||||
|
* @param startDate 开始时间
|
||||||
|
* @param endDate 结束时间
|
||||||
|
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||||
|
* @return support=false的结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||||
|
VideoContinuityResult result = new VideoContinuityResult();
|
||||||
|
result.setSupport(false);
|
||||||
|
result.setContinuous(false);
|
||||||
|
result.setTotalVideos(0);
|
||||||
|
result.setTotalDurationMs(0);
|
||||||
|
result.setMaxAllowedGapMs(maxGapMs);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认实现:不支持视频连续性检查
|
||||||
|
*
|
||||||
|
* @return support=false的结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
calendar.add(Calendar.MINUTE, -2);
|
||||||
|
Date endDate = calendar.getTime();
|
||||||
|
calendar.add(Calendar.MINUTE, -5);
|
||||||
|
Date startDate = calendar.getTime();
|
||||||
|
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.ycwl.basic.device.operator;
|
|||||||
import cn.hutool.core.date.DateUtil;
|
import cn.hutool.core.date.DateUtil;
|
||||||
import com.ycwl.basic.utils.JacksonUtil;
|
import com.ycwl.basic.utils.JacksonUtil;
|
||||||
import com.ycwl.basic.device.entity.common.FileObject;
|
import com.ycwl.basic.device.entity.common.FileObject;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityGap;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
import com.ycwl.basic.storage.StorageFactory;
|
import com.ycwl.basic.storage.StorageFactory;
|
||||||
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||||
import com.ycwl.basic.storage.entity.AliOssStorageConfig;
|
import com.ycwl.basic.storage.entity.AliOssStorageConfig;
|
||||||
@@ -98,4 +100,104 @@ public class AliOssStorageOperator extends ADeviceStorageOperator {
|
|||||||
String prefix = dateFormat.format(calendar.getTime());
|
String prefix = dateFormat.format(calendar.getTime());
|
||||||
return removeFilesByPrefix(prefix);
|
return removeFilesByPrefix(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查视频片段的连续性
|
||||||
|
*
|
||||||
|
* @param startDate 开始时间
|
||||||
|
* @param endDate 结束时间
|
||||||
|
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||||
|
* @return 包含缺口信息的验证结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||||
|
VideoContinuityResult result = new VideoContinuityResult();
|
||||||
|
result.setSupport(true);
|
||||||
|
result.setMaxAllowedGapMs(maxGapMs);
|
||||||
|
|
||||||
|
// 获取时间范围内的视频列表
|
||||||
|
List<FileObject> fileList = getFileListByDtRange(startDate, endDate);
|
||||||
|
|
||||||
|
if (fileList == null || fileList.isEmpty()) {
|
||||||
|
result.setContinuous(false);
|
||||||
|
result.setTotalVideos(0);
|
||||||
|
result.setTotalDurationMs(0);
|
||||||
|
log.warn("未找到指定时间范围内的视频文件: {} - {}", startDate, endDate);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.setTotalVideos(fileList.size());
|
||||||
|
|
||||||
|
// 只有一个视频文件时,认为是连续的
|
||||||
|
if (fileList.size() == 1) {
|
||||||
|
FileObject file = fileList.get(0);
|
||||||
|
long duration = file.getEndTime().getTime() - file.getCreateTime().getTime();
|
||||||
|
result.setContinuous(true);
|
||||||
|
result.setTotalDurationMs(duration);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查相邻视频之间的间隙
|
||||||
|
long totalDuration = 0;
|
||||||
|
for (int i = 0; i < fileList.size() - 1; i++) {
|
||||||
|
FileObject currentFile = fileList.get(i);
|
||||||
|
FileObject nextFile = fileList.get(i + 1);
|
||||||
|
|
||||||
|
// 计算当前视频的时长
|
||||||
|
totalDuration += currentFile.getEndTime().getTime() - currentFile.getCreateTime().getTime();
|
||||||
|
|
||||||
|
// 计算间隙: 后一个视频的开始时间 - 前一个视频的结束时间
|
||||||
|
long gapMs = nextFile.getCreateTime().getTime() - currentFile.getEndTime().getTime();
|
||||||
|
|
||||||
|
// 如果间隙超过允许值,记录该间隙
|
||||||
|
if (gapMs > maxGapMs) {
|
||||||
|
VideoContinuityGap gap = new VideoContinuityGap();
|
||||||
|
gap.setBeforeFile(currentFile);
|
||||||
|
gap.setAfterFile(nextFile);
|
||||||
|
gap.setGapMs(gapMs);
|
||||||
|
gap.setGapStartTime(currentFile.getEndTime());
|
||||||
|
gap.setGapEndTime(nextFile.getCreateTime());
|
||||||
|
result.addGap(gap);
|
||||||
|
log.debug("检测到视频间隙: {} -> {}, 间隙时长: {}ms",
|
||||||
|
currentFile.getName(), nextFile.getName(), gapMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加上最后一个视频的时长
|
||||||
|
FileObject lastFile = fileList.get(fileList.size() - 1);
|
||||||
|
totalDuration += lastFile.getEndTime().getTime() - lastFile.getCreateTime().getTime();
|
||||||
|
|
||||||
|
result.setTotalDurationMs(totalDuration);
|
||||||
|
result.setContinuous(result.getGapCount() == 0);
|
||||||
|
|
||||||
|
log.info("视频连续性检查完成: 总视频数={}, 总时长={}ms, 间隙数={}, 连续={}",
|
||||||
|
result.getTotalVideos(), result.getTotalDurationMs(), result.getGapCount(), result.isContinuous());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查近期视频的连续性(测试用)
|
||||||
|
* 时间范围: 当前时间向前2分钟后,再向前10分钟(即前12分钟到前2分钟)
|
||||||
|
* 允许的最大间隙: 2秒
|
||||||
|
*
|
||||||
|
* @return 包含缺口信息的验证结果
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
|
||||||
|
// 结束时间: 当前时间 - 2分钟
|
||||||
|
calendar.add(Calendar.MINUTE, -2);
|
||||||
|
Date endDate = calendar.getTime();
|
||||||
|
|
||||||
|
// 开始时间: 当前时间 - 12分钟 (再向前10分钟)
|
||||||
|
calendar.add(Calendar.MINUTE, -10);
|
||||||
|
Date startDate = calendar.getTime();
|
||||||
|
|
||||||
|
log.info("检查近期视频连续性: {} - {}", startDate, endDate);
|
||||||
|
|
||||||
|
// 允许的最大间隙为2秒(2000毫秒)
|
||||||
|
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.ycwl.basic.device.operator;
|
|||||||
|
|
||||||
import com.ycwl.basic.device.IDeviceCommon;
|
import com.ycwl.basic.device.IDeviceCommon;
|
||||||
import com.ycwl.basic.device.entity.common.FileObject;
|
import com.ycwl.basic.device.entity.common.FileObject;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -19,10 +20,29 @@ public interface IDeviceStorageOperator extends IDeviceCommon {
|
|||||||
List<FileObject> getFileListByDtRange(Date startDate, Date endDate);
|
List<FileObject> getFileListByDtRange(Date startDate, Date endDate);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除指定日期之前的文件,不包含指定的日期当天
|
* 删除指定日期之前的文件,不包含指定的日期当天
|
||||||
*
|
*
|
||||||
* @param date 指定日期,不包含指定日期当天
|
* @param date 指定日期,不包含指定日期当天
|
||||||
* @return
|
* @return
|
||||||
*/
|
*/
|
||||||
boolean removeFilesBeforeDate(Date date);
|
boolean removeFilesBeforeDate(Date date);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查视频片段的连续性
|
||||||
|
*
|
||||||
|
* @param startDate 开始时间
|
||||||
|
* @param endDate 结束时间
|
||||||
|
* @param maxGapMs 允许的最大间隔时间(毫秒)
|
||||||
|
* @return 包含缺口信息的验证结果
|
||||||
|
*/
|
||||||
|
VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查近期视频的连续性(便捷方法)
|
||||||
|
* 时间范围: 当前时间向前2分钟后,再向前5分钟(即前7分钟到前2分钟)
|
||||||
|
* 允许的最大间隙: 2秒
|
||||||
|
*
|
||||||
|
* @return 包含缺口信息的验证结果
|
||||||
|
*/
|
||||||
|
VideoContinuityResult checkRecentVideoContinuity();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.ycwl.basic.device.operator;
|
package com.ycwl.basic.device.operator;
|
||||||
|
|
||||||
import com.ycwl.basic.device.entity.common.FileObject;
|
import com.ycwl.basic.device.entity.common.FileObject;
|
||||||
|
import com.ycwl.basic.device.entity.common.VideoContinuityResult;
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
import com.ycwl.basic.model.pc.device.entity.DeviceConfigEntity;
|
||||||
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.Calendar;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -34,4 +36,24 @@ public class LocalStorageOperator implements IDeviceStorageOperator {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkVideoContinuity(Date startDate, Date endDate, long maxGapMs) {
|
||||||
|
VideoContinuityResult result = new VideoContinuityResult();
|
||||||
|
result.setSupport(false);
|
||||||
|
result.setContinuous(false);
|
||||||
|
result.setTotalVideos(0);
|
||||||
|
result.setTotalDurationMs(0);
|
||||||
|
result.setMaxAllowedGapMs(maxGapMs);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public VideoContinuityResult checkRecentVideoContinuity() {
|
||||||
|
Calendar calendar = Calendar.getInstance();
|
||||||
|
calendar.add(Calendar.MINUTE, -2);
|
||||||
|
Date endDate = calendar.getTime();
|
||||||
|
calendar.add(Calendar.MINUTE, -5);
|
||||||
|
Date startDate = calendar.getTime();
|
||||||
|
return checkVideoContinuity(startDate, endDate, 2000L);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.ycwl.basic.controller.dto;
|
package com.ycwl.basic.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
@@ -59,7 +59,7 @@ public class ZTSourceMessage {
|
|||||||
* 判断是否为照片
|
* 判断是否为照片
|
||||||
*/
|
*/
|
||||||
public boolean isPhoto() {
|
public boolean isPhoto() {
|
||||||
return sourceType != null && sourceType == 2;
|
return sourceType != null && (sourceType == 2 || sourceType == 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.core;
|
||||||
|
|
||||||
|
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
|
||||||
|
import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene;
|
||||||
|
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||||
|
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||||
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
|
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||||
|
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||||
|
import com.ycwl.basic.pipeline.core.PipelineContext;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸匹配管线上下文
|
||||||
|
* 在各个Stage之间传递状态和数据
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class FaceMatchingContext implements PipelineContext {
|
||||||
|
|
||||||
|
// ==================== 核心字段(构造时必填)====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸ID(必填)
|
||||||
|
*/
|
||||||
|
private final Long faceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否新用户
|
||||||
|
*/
|
||||||
|
private final boolean isNew;
|
||||||
|
|
||||||
|
// ==================== 场景标识 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景标识
|
||||||
|
*/
|
||||||
|
private FaceMatchingScene scene;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动选择的样本ID(自定义匹配场景)
|
||||||
|
*/
|
||||||
|
private List<Long> faceSampleIds;
|
||||||
|
|
||||||
|
// ==================== 中间状态 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸实体
|
||||||
|
*/
|
||||||
|
private FaceEntity face;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区配置管理器
|
||||||
|
*/
|
||||||
|
private ScenicConfigManager scenicConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸识别适配器
|
||||||
|
*/
|
||||||
|
private IFaceBodyAdapter faceBodyAdapter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸搜索结果
|
||||||
|
*/
|
||||||
|
private SearchFaceRespVo searchResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸样本列表(自定义匹配场景)
|
||||||
|
*/
|
||||||
|
private List<FaceSampleEntity> faceSamples;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配到的样本ID列表
|
||||||
|
*/
|
||||||
|
private List<Long> sampleListIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 源文件关联列表
|
||||||
|
*/
|
||||||
|
private List<MemberSourceEntity> memberSourceList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 免费源文件ID列表
|
||||||
|
*/
|
||||||
|
private List<Long> freeSourceIds;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸选择后置模式配置(自定义匹配场景)
|
||||||
|
* 0: 并集, 1: 交集, 2: 直接使用
|
||||||
|
*/
|
||||||
|
private Integer faceSelectPostMode;
|
||||||
|
|
||||||
|
// ==================== 输出结果 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最终结果
|
||||||
|
*/
|
||||||
|
private SearchFaceRespVo finalResult;
|
||||||
|
|
||||||
|
// ==================== Stage配置 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage开关配置表
|
||||||
|
* Key: stageId, Value: 是否启用
|
||||||
|
*/
|
||||||
|
private Map<String, Boolean> stageEnabledMap = new HashMap<>();
|
||||||
|
|
||||||
|
// ==================== 构造函数(私有)====================
|
||||||
|
|
||||||
|
private FaceMatchingContext(Builder builder) {
|
||||||
|
this.faceId = builder.faceId;
|
||||||
|
this.isNew = builder.isNew;
|
||||||
|
this.scene = builder.scene;
|
||||||
|
this.faceSampleIds = builder.faceSampleIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 静态工厂方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Builder
|
||||||
|
*/
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速创建自动匹配场景Context
|
||||||
|
*/
|
||||||
|
public static FaceMatchingContext forAutoMatching(Long faceId, boolean isNew) {
|
||||||
|
return FaceMatchingContext.builder()
|
||||||
|
.faceId(faceId)
|
||||||
|
.isNew(isNew)
|
||||||
|
.scene(FaceMatchingScene.AUTO_MATCHING)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速创建自定义匹配场景Context
|
||||||
|
*/
|
||||||
|
public static FaceMatchingContext forCustomMatching(Long faceId, List<Long> faceSampleIds) {
|
||||||
|
return FaceMatchingContext.builder()
|
||||||
|
.faceId(faceId)
|
||||||
|
.isNew(false)
|
||||||
|
.faceSampleIds(faceSampleIds)
|
||||||
|
.scene(FaceMatchingScene.CUSTOM_MATCHING)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 快速创建仅识别场景Context
|
||||||
|
*/
|
||||||
|
public static FaceMatchingContext forRecognitionOnly(Long faceId) {
|
||||||
|
return FaceMatchingContext.builder()
|
||||||
|
.faceId(faceId)
|
||||||
|
.isNew(false)
|
||||||
|
.scene(FaceMatchingScene.RECOGNITION_ONLY)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 业务方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指定Stage是否启用
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @param defaultEnabled 默认值(当配置未指定时使用)
|
||||||
|
* @return true-启用, false-禁用
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isStageEnabled(String stageId, boolean defaultEnabled) {
|
||||||
|
return stageEnabledMap.getOrDefault(stageId, defaultEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指定Stage是否启用(默认为false)
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @return true-启用, false-禁用
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isStageEnabled(String stageId) {
|
||||||
|
return stageEnabledMap.getOrDefault(stageId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置指定Stage的启用状态
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @param enabled 是否启用
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public FaceMatchingContext setStageState(String stageId, boolean enabled) {
|
||||||
|
stageEnabledMap.put(stageId, enabled);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用指定Stage
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public FaceMatchingContext enableStage(String stageId) {
|
||||||
|
stageEnabledMap.put(stageId, true);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用指定Stage
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public FaceMatchingContext disableStage(String stageId) {
|
||||||
|
stageEnabledMap.put(stageId, false);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置Stage启用状态
|
||||||
|
*
|
||||||
|
* @param stages Stage配置Map(stageId -> enabled)
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public FaceMatchingContext setStages(Map<String, Boolean> stages) {
|
||||||
|
if (stages != null) {
|
||||||
|
stageEnabledMap.putAll(stages);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有Stage配置
|
||||||
|
*
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public FaceMatchingContext clearStages() {
|
||||||
|
stageEnabledMap.clear();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Builder ====================
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private Long faceId;
|
||||||
|
private boolean isNew = false;
|
||||||
|
private FaceMatchingScene scene;
|
||||||
|
private List<Long> faceSampleIds;
|
||||||
|
|
||||||
|
public Builder faceId(Long faceId) {
|
||||||
|
this.faceId = faceId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder isNew(boolean isNew) {
|
||||||
|
this.isNew = isNew;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder scene(FaceMatchingScene scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder faceSampleIds(List<Long> faceSampleIds) {
|
||||||
|
this.faceSampleIds = faceSampleIds;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public FaceMatchingContext build() {
|
||||||
|
// 参数校验
|
||||||
|
if (faceId == null) {
|
||||||
|
throw new IllegalArgumentException("faceId is required");
|
||||||
|
}
|
||||||
|
if (scene == null) {
|
||||||
|
throw new IllegalArgumentException("scene is required");
|
||||||
|
}
|
||||||
|
// 自定义匹配场景必须提供faceSampleIds
|
||||||
|
if (scene == FaceMatchingScene.CUSTOM_MATCHING && (faceSampleIds == null || faceSampleIds.isEmpty())) {
|
||||||
|
throw new IllegalArgumentException("faceSampleIds is required for CUSTOM_MATCHING scene");
|
||||||
|
}
|
||||||
|
return new FaceMatchingContext(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸匹配场景枚举
|
||||||
|
*/
|
||||||
|
public enum FaceMatchingScene {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动人脸匹配
|
||||||
|
* 新用户上传人脸后自动执行匹配,或老用户重新匹配
|
||||||
|
*/
|
||||||
|
AUTO_MATCHING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义人脸匹配
|
||||||
|
* 用户手动选择人脸样本进行匹配
|
||||||
|
*/
|
||||||
|
CUSTOM_MATCHING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅识别
|
||||||
|
* 只执行人脸识别,不处理后续业务逻辑(源文件关联、任务创建等)
|
||||||
|
*/
|
||||||
|
RECOGNITION_ONLY
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage执行异常
|
||||||
|
*/
|
||||||
|
public class StageExecutionException extends RuntimeException {
|
||||||
|
|
||||||
|
private final String stageName;
|
||||||
|
|
||||||
|
public StageExecutionException(String stageName, String message) {
|
||||||
|
super(String.format("[%s] %s", stageName, message));
|
||||||
|
this.stageName = stageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StageExecutionException(String stageName, String message, Throwable cause) {
|
||||||
|
super(String.format("[%s] %s", stageName, message), cause);
|
||||||
|
this.stageName = stageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStageName() {
|
||||||
|
return stageName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.factory;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.core.Pipeline;
|
||||||
|
import com.ycwl.basic.pipeline.core.PipelineBuilder;
|
||||||
|
import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene;
|
||||||
|
import com.ycwl.basic.face.pipeline.stages.*;
|
||||||
|
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸匹配Pipeline工厂
|
||||||
|
* 负责为不同场景组装Pipeline
|
||||||
|
*
|
||||||
|
* 支持的场景:
|
||||||
|
* 1. 自动人脸匹配(新用户/老用户)
|
||||||
|
* 2. 自定义人脸匹配
|
||||||
|
* 3. 仅识别
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class FaceMatchingPipelineFactory {
|
||||||
|
|
||||||
|
// ==================== 通用 Stage(13个)====================
|
||||||
|
@Autowired
|
||||||
|
private PrepareContextStage prepareContextStage;
|
||||||
|
@Autowired
|
||||||
|
private RecordMetricsStage recordMetricsStage;
|
||||||
|
@Autowired
|
||||||
|
private FaceRecognitionStage faceRecognitionStage;
|
||||||
|
@Autowired
|
||||||
|
private FaceRecoveryStage faceRecoveryStage;
|
||||||
|
@Autowired
|
||||||
|
private UpdateFaceResultStage updateFaceResultStage;
|
||||||
|
@Autowired
|
||||||
|
private BuildSourceRelationStage buildSourceRelationStage;
|
||||||
|
@Autowired
|
||||||
|
private ProcessFreeSourceStage processFreeSourceStage;
|
||||||
|
@Autowired
|
||||||
|
private ProcessBuyStatusStage processBuyStatusStage;
|
||||||
|
@Autowired
|
||||||
|
private HandleVideoRecreationStage handleVideoRecreationStage;
|
||||||
|
@Autowired
|
||||||
|
private PersistRelationsStage persistRelationsStage;
|
||||||
|
@Autowired
|
||||||
|
private CreateTaskStage createTaskStage;
|
||||||
|
@Autowired
|
||||||
|
private SetTaskStatusStage setTaskStatusStage;
|
||||||
|
@Autowired
|
||||||
|
private GeneratePuzzleStage generatePuzzleStage;
|
||||||
|
|
||||||
|
// ==================== 自定义匹配专属 Stage(6个)====================
|
||||||
|
@Autowired
|
||||||
|
private RecordCustomMatchMetricsStage recordCustomMatchMetricsStage;
|
||||||
|
@Autowired
|
||||||
|
private LoadFaceSamplesStage loadFaceSamplesStage;
|
||||||
|
@Autowired
|
||||||
|
private CustomFaceSearchStage customFaceSearchStage;
|
||||||
|
@Autowired
|
||||||
|
private LoadMatchedSamplesStage loadMatchedSamplesStage;
|
||||||
|
@Autowired
|
||||||
|
private FilterByTimeRangeStage filterByTimeRangeStage;
|
||||||
|
@Autowired
|
||||||
|
private FilterByDevicePhotoLimitStage filterByDevicePhotoLimitStage;
|
||||||
|
@Autowired
|
||||||
|
private DeleteOldRelationsStage deleteOldRelationsStage;
|
||||||
|
|
||||||
|
// ==================== 辅助服务 ====================
|
||||||
|
@Autowired
|
||||||
|
private ScenicConfigFacade scenicConfigFacade;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建自动人脸匹配Pipeline
|
||||||
|
*
|
||||||
|
* @param isNew 是否新用户
|
||||||
|
* @return Pipeline
|
||||||
|
*/
|
||||||
|
public Pipeline<FaceMatchingContext> createAutoMatchingPipeline(boolean isNew) {
|
||||||
|
PipelineBuilder<FaceMatchingContext> builder = new PipelineBuilder<>("AutoMatching-" + (isNew ? "New" : "Old"));
|
||||||
|
|
||||||
|
// 1. 准备上下文
|
||||||
|
builder.addStage(prepareContextStage);
|
||||||
|
|
||||||
|
// 2. 新用户设置任务状态
|
||||||
|
if (isNew) {
|
||||||
|
builder.addStage(setTaskStatusStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 记录识别次数
|
||||||
|
builder.addStage(recordMetricsStage);
|
||||||
|
|
||||||
|
// 4. 执行人脸识别
|
||||||
|
builder.addStage(faceRecognitionStage);
|
||||||
|
|
||||||
|
// 5. 人脸识别补救
|
||||||
|
builder.addStage(faceRecoveryStage);
|
||||||
|
|
||||||
|
// 6. 更新人脸结果
|
||||||
|
builder.addStage(updateFaceResultStage);
|
||||||
|
|
||||||
|
// 7. 构建源文件关联
|
||||||
|
builder.addStage(buildSourceRelationStage);
|
||||||
|
|
||||||
|
// 8. 处理免费源文件逻辑
|
||||||
|
builder.addStage(processFreeSourceStage);
|
||||||
|
|
||||||
|
// 9. 处理购买状态
|
||||||
|
builder.addStage(processBuyStatusStage);
|
||||||
|
|
||||||
|
// 10. 处理视频重切
|
||||||
|
builder.addStage(handleVideoRecreationStage);
|
||||||
|
|
||||||
|
// 11. 持久化关联关系
|
||||||
|
builder.addStage(persistRelationsStage);
|
||||||
|
|
||||||
|
// 12. 创建任务
|
||||||
|
builder.addStage(createTaskStage);
|
||||||
|
|
||||||
|
// 13. 异步生成拼图模板
|
||||||
|
builder.addStage(generatePuzzleStage);
|
||||||
|
|
||||||
|
log.debug("创建自动人脸匹配Pipeline: isNew={}, stageCount={}", isNew, builder.build().getStageCount());
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建自定义人脸匹配Pipeline
|
||||||
|
*
|
||||||
|
* @return Pipeline
|
||||||
|
*/
|
||||||
|
public Pipeline<FaceMatchingContext> createCustomMatchingPipeline() {
|
||||||
|
PipelineBuilder<FaceMatchingContext> builder = new PipelineBuilder<>("CustomMatching");
|
||||||
|
|
||||||
|
// 1. 准备上下文
|
||||||
|
builder.addStage(prepareContextStage);
|
||||||
|
|
||||||
|
// 2. 记录自定义匹配次数
|
||||||
|
builder.addStage(recordCustomMatchMetricsStage);
|
||||||
|
|
||||||
|
// 3. 加载用户选择的人脸样本
|
||||||
|
builder.addStage(loadFaceSamplesStage);
|
||||||
|
|
||||||
|
// 4. 根据配置执行自定义人脸搜索
|
||||||
|
builder.addStage(customFaceSearchStage);
|
||||||
|
|
||||||
|
// 5. 加载匹配样本实体到缓存
|
||||||
|
builder.addStage(loadMatchedSamplesStage);
|
||||||
|
|
||||||
|
// 6. 应用时间范围筛选
|
||||||
|
builder.addStage(filterByTimeRangeStage);
|
||||||
|
|
||||||
|
// 7. 应用设备照片数量限制筛选
|
||||||
|
builder.addStage(filterByDevicePhotoLimitStage);
|
||||||
|
|
||||||
|
// 8. 更新人脸结果
|
||||||
|
builder.addStage(updateFaceResultStage);
|
||||||
|
|
||||||
|
// 9. 删除旧关系数据
|
||||||
|
builder.addStage(deleteOldRelationsStage);
|
||||||
|
|
||||||
|
// 10. 构建源文件关联
|
||||||
|
builder.addStage(buildSourceRelationStage);
|
||||||
|
|
||||||
|
// 11. 处理免费源文件逻辑
|
||||||
|
builder.addStage(processFreeSourceStage);
|
||||||
|
|
||||||
|
// 12. 处理购买状态
|
||||||
|
builder.addStage(processBuyStatusStage);
|
||||||
|
|
||||||
|
// 13. 处理视频重切
|
||||||
|
builder.addStage(handleVideoRecreationStage);
|
||||||
|
|
||||||
|
// 14. 持久化关联关系
|
||||||
|
builder.addStage(persistRelationsStage);
|
||||||
|
|
||||||
|
// 15. 创建任务
|
||||||
|
builder.addStage(createTaskStage);
|
||||||
|
|
||||||
|
log.debug("创建自定义人脸匹配Pipeline: stageCount={}", builder.build().getStageCount());
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建仅识别Pipeline
|
||||||
|
* 只执行人脸识别,不处理后续业务逻辑
|
||||||
|
*
|
||||||
|
* @return Pipeline
|
||||||
|
*/
|
||||||
|
public Pipeline<FaceMatchingContext> createRecognitionOnlyPipeline() {
|
||||||
|
PipelineBuilder<FaceMatchingContext> builder = new PipelineBuilder<>("RecognitionOnly");
|
||||||
|
|
||||||
|
// 1. 准备上下文
|
||||||
|
builder.addStage(prepareContextStage);
|
||||||
|
|
||||||
|
// 2. 执行人脸识别
|
||||||
|
builder.addStage(faceRecognitionStage);
|
||||||
|
|
||||||
|
// 3. 人脸识别补救
|
||||||
|
builder.addStage(faceRecoveryStage);
|
||||||
|
|
||||||
|
log.debug("创建仅识别Pipeline: stageCount={}", builder.build().getStageCount());
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据场景创建Pipeline
|
||||||
|
*
|
||||||
|
* @param scene 场景
|
||||||
|
* @param isNew 是否新用户(仅AUTO_MATCHING场景需要)
|
||||||
|
* @return Pipeline
|
||||||
|
*/
|
||||||
|
public Pipeline<FaceMatchingContext> createPipeline(FaceMatchingScene scene, boolean isNew) {
|
||||||
|
return switch (scene) {
|
||||||
|
case AUTO_MATCHING -> createAutoMatchingPipeline(isNew);
|
||||||
|
case CUSTOM_MATCHING -> createCustomMatchingPipeline();
|
||||||
|
case RECOGNITION_ONLY -> createRecognitionOnlyPipeline();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据Context创建Pipeline
|
||||||
|
*
|
||||||
|
* @param context 上下文
|
||||||
|
* @return Pipeline
|
||||||
|
*/
|
||||||
|
public Pipeline<FaceMatchingContext> createPipeline(FaceMatchingContext context) {
|
||||||
|
return createPipeline(context.getScene(), context.isNew());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.helper;
|
||||||
|
|
||||||
|
import cn.hutool.core.date.DateUtil;
|
||||||
|
import com.ycwl.basic.integration.scenic.dto.scenic.ScenicV2DTO;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateRequest;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse;
|
||||||
|
import com.ycwl.basic.puzzle.dto.PuzzleTemplateDTO;
|
||||||
|
import com.ycwl.basic.puzzle.service.IPuzzleGenerateService;
|
||||||
|
import com.ycwl.basic.puzzle.service.IPuzzleTemplateService;
|
||||||
|
import com.ycwl.basic.repository.ScenicRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图生成编排器
|
||||||
|
* 负责编排拼图模板的批量生成逻辑
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 查询景区的所有启用拼图模板
|
||||||
|
* 2. 构建动态数据
|
||||||
|
* 3. 逐个生成拼图图片
|
||||||
|
* 4. 记录统计信息
|
||||||
|
*
|
||||||
|
* 设计说明:
|
||||||
|
* - 从GeneratePuzzleStage中抽离出来,符合"薄Stage,厚Service"原则
|
||||||
|
* - Stage只负责触发异步任务,业务逻辑由此Orchestrator承担
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class PuzzleGenerationOrchestrator {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IPuzzleTemplateService puzzleTemplateService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private IPuzzleGenerateService puzzleGenerateService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ScenicRepository scenicRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步生成景区所有启用的拼图模板
|
||||||
|
*
|
||||||
|
* @param scenicId 景区ID
|
||||||
|
* @param faceId 人脸ID
|
||||||
|
* @param memberId 会员ID
|
||||||
|
* @param faceUrl 人脸URL
|
||||||
|
*/
|
||||||
|
public void generateAllTemplatesAsync(Long scenicId, Long faceId, Long memberId, String faceUrl) {
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
log.info("开始异步生成景区拼图模板: scenicId={}, faceId={}", scenicId, faceId);
|
||||||
|
|
||||||
|
// 1. 查询该景区所有启用状态的拼图模板
|
||||||
|
List<PuzzleTemplateDTO> templateList = puzzleTemplateService.listTemplates(
|
||||||
|
scenicId, null, 1); // 查询启用状态的模板
|
||||||
|
|
||||||
|
if (templateList == null || templateList.isEmpty()) {
|
||||||
|
log.info("景区不存在启用的拼图模板,跳过生成: scenicId={}", scenicId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("景区存在 {} 个启用的拼图模板,开始逐个生成: scenicId={}", templateList.size(), scenicId);
|
||||||
|
|
||||||
|
// 2. 获取景区信息用于动态数据
|
||||||
|
ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(scenicId);
|
||||||
|
|
||||||
|
// 3. 准备公共动态数据
|
||||||
|
Map<String, String> baseDynamicData = buildBaseDynamicData(faceId, faceUrl, scenicBasic);
|
||||||
|
|
||||||
|
// 4. 使用虚拟线程池并行生成所有模板
|
||||||
|
java.util.concurrent.atomic.AtomicInteger successCount = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||||
|
java.util.concurrent.atomic.AtomicInteger failCount = new java.util.concurrent.atomic.AtomicInteger(0);
|
||||||
|
|
||||||
|
try (java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
|
||||||
|
// 为每个模板创建一个异步任务
|
||||||
|
List<java.util.concurrent.CompletableFuture<Void>> futures = templateList.stream()
|
||||||
|
.map(template -> java.util.concurrent.CompletableFuture.runAsync(() -> {
|
||||||
|
try {
|
||||||
|
generateSingleTemplate(scenicId, faceId, memberId, template, baseDynamicData);
|
||||||
|
successCount.incrementAndGet();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("拼图生成失败: scenicId={}, templateCode={}, templateName={}",
|
||||||
|
scenicId, template.getCode(), template.getName(), e);
|
||||||
|
failCount.incrementAndGet();
|
||||||
|
}
|
||||||
|
}, executor))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 等待所有任务完成
|
||||||
|
java.util.concurrent.CompletableFuture.allOf(futures.toArray(new java.util.concurrent.CompletableFuture[0])).join();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("景区拼图模板批量生成完成: scenicId={}, 总数={}, 成功={}, 失败={}",
|
||||||
|
scenicId, templateList.size(), successCount.get(), failCount.get());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 异步任务失败不影响主流程,仅记录日志
|
||||||
|
log.error("异步生成拼图模板失败: scenicId={}, faceId={}", scenicId, faceId, e);
|
||||||
|
}
|
||||||
|
}, "PuzzleTemplateGenerator-" + scenicId + "-" + faceId).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建基础动态数据
|
||||||
|
*/
|
||||||
|
private Map<String, String> buildBaseDynamicData(Long faceId, String faceUrl, ScenicV2DTO scenicBasic) {
|
||||||
|
Map<String, String> baseDynamicData = new HashMap<>();
|
||||||
|
|
||||||
|
if (faceUrl != null) {
|
||||||
|
baseDynamicData.put("faceImage", faceUrl);
|
||||||
|
baseDynamicData.put("userAvatar", faceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
baseDynamicData.put("faceId", String.valueOf(faceId));
|
||||||
|
baseDynamicData.put("scenicName", scenicBasic.getName());
|
||||||
|
baseDynamicData.put("scenicText", scenicBasic.getName());
|
||||||
|
baseDynamicData.put("dateStr", DateUtil.format(new Date(), "yyyy.MM.dd"));
|
||||||
|
|
||||||
|
return baseDynamicData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成单个拼图模板
|
||||||
|
*/
|
||||||
|
private void generateSingleTemplate(Long scenicId, Long faceId, Long memberId,
|
||||||
|
PuzzleTemplateDTO template,
|
||||||
|
Map<String, String> baseDynamicData) {
|
||||||
|
log.info("开始生成拼图: scenicId={}, templateCode={}, templateName={}",
|
||||||
|
scenicId, template.getCode(), template.getName());
|
||||||
|
|
||||||
|
// 构建生成请求
|
||||||
|
PuzzleGenerateRequest generateRequest = new PuzzleGenerateRequest();
|
||||||
|
generateRequest.setScenicId(scenicId);
|
||||||
|
generateRequest.setUserId(memberId);
|
||||||
|
generateRequest.setFaceId(faceId);
|
||||||
|
generateRequest.setBusinessType("face_matching");
|
||||||
|
generateRequest.setTemplateCode(template.getCode());
|
||||||
|
generateRequest.setOutputFormat("PNG");
|
||||||
|
generateRequest.setQuality(90);
|
||||||
|
generateRequest.setDynamicData(new HashMap<>(baseDynamicData));
|
||||||
|
generateRequest.setRequireRuleMatch(true);
|
||||||
|
|
||||||
|
// 调用拼图生成服务
|
||||||
|
PuzzleGenerateResponse response = puzzleGenerateService.generate(generateRequest);
|
||||||
|
|
||||||
|
log.info("拼图生成成功: scenicId={}, templateCode={}, imageUrl={}",
|
||||||
|
scenicId, template.getCode(), response.getImageUrl());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||||
|
import com.ycwl.basic.service.pc.processor.SourceRelationProcessor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建源文件关联Stage
|
||||||
|
* 负责根据匹配到的样本ID构建member_source关联关系
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.sampleListIds读取匹配的样本ID列表
|
||||||
|
* 2. 调用sourceRelationProcessor.processMemberSources()生成MemberSourceEntity列表
|
||||||
|
* 3. 更新context.memberSourceList
|
||||||
|
*
|
||||||
|
* 前置条件: context.sampleListIds不为空
|
||||||
|
* 后置条件: context.memberSourceList已设置
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "build_source_relation",
|
||||||
|
optionalMode = StageOptionalMode.UNSUPPORT,
|
||||||
|
description = "构建源文件关联关系"
|
||||||
|
)
|
||||||
|
public class BuildSourceRelationStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SourceRelationProcessor sourceRelationProcessor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "BuildSourceRelation";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 只有当sampleListIds不为空时才执行
|
||||||
|
List<Long> sampleListIds = context.getSampleListIds();
|
||||||
|
if (sampleListIds == null || sampleListIds.isEmpty()) {
|
||||||
|
// 从searchResult中获取
|
||||||
|
if (context.getSearchResult() != null) {
|
||||||
|
sampleListIds = context.getSearchResult().getSampleListIds();
|
||||||
|
context.setSampleListIds(sampleListIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sampleListIds == null || sampleListIds.isEmpty()) {
|
||||||
|
log.debug("sampleListIds为空,跳过源文件关联,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
List<Long> sampleListIds = context.getSampleListIds();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:sampleListIds为空
|
||||||
|
if (sampleListIds == null || sampleListIds.isEmpty()) {
|
||||||
|
// 尝试从searchResult中获取
|
||||||
|
if (context.getSearchResult() != null) {
|
||||||
|
sampleListIds = context.getSearchResult().getSampleListIds();
|
||||||
|
if (sampleListIds != null && !sampleListIds.isEmpty()) {
|
||||||
|
context.setSampleListIds(sampleListIds);
|
||||||
|
} else {
|
||||||
|
log.debug("sampleListIds为空,跳过源文件关联,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("sampleListIds为空");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.debug("sampleListIds为空,跳过源文件关联,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("sampleListIds为空");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 处理源文件关联
|
||||||
|
List<MemberSourceEntity> memberSourceEntityList =
|
||||||
|
sourceRelationProcessor.processMemberSources(sampleListIds, context.getFace());
|
||||||
|
|
||||||
|
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
|
||||||
|
log.warn("未找到有效的源文件,faceId={}, sampleListIds={}", faceId, sampleListIds);
|
||||||
|
return StageResult.skipped("未找到有效的源文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setMemberSourceList(memberSourceEntityList);
|
||||||
|
|
||||||
|
log.info("构建源文件关联成功: faceId={}, 关联源文件数={}", faceId, memberSourceEntityList.size());
|
||||||
|
|
||||||
|
return StageResult.success(String.format("构建了%d个源文件关联", memberSourceEntityList.size()));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("构建源文件关联失败,faceId={}, sampleListIds={}", faceId, sampleListIds, e);
|
||||||
|
// 源文件关联失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("构建源文件关联失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.biz.TaskStatusBiz;
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.service.pc.helper.ScenicConfigFacade;
|
||||||
|
import com.ycwl.basic.service.task.TaskService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建任务Stage
|
||||||
|
* 负责根据配置决定是否自动创建任务
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 检查face_select_first配置
|
||||||
|
* 2. 如果配置为false,则调用taskService.autoCreateTaskByFaceId()
|
||||||
|
* 3. 如果配置为true,则设置任务状态为2(等待用户选择)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "create_task",
|
||||||
|
optionalMode = StageOptionalMode.UNSUPPORT,
|
||||||
|
description = "根据配置创建视频任务"
|
||||||
|
)
|
||||||
|
public class CreateTaskStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ScenicConfigFacade scenicConfigFacade;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TaskService taskService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TaskStatusBiz taskStatusBiz;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "CreateTask";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Long scenicId = context.getFace().getScenicId();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
boolean faceSelectFirst = scenicConfigFacade.isFaceSelectFirst(scenicId);
|
||||||
|
|
||||||
|
if (!faceSelectFirst) {
|
||||||
|
// 配置为自动创建任务
|
||||||
|
taskService.autoCreateTaskByFaceId(faceId);
|
||||||
|
log.info("自动创建任务成功: faceId={}", faceId);
|
||||||
|
return StageResult.success("自动创建任务成功");
|
||||||
|
} else {
|
||||||
|
// 配置为等待用户选择
|
||||||
|
taskStatusBiz.setFaceCutStatus(faceId, 2);
|
||||||
|
log.debug("景区配置 face_select_first=true,跳过自动创建任务: faceId={}", faceId);
|
||||||
|
return StageResult.skipped("等待用户手动选择");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("创建任务失败,faceId={}", faceId, e);
|
||||||
|
// 任务创建失败不影响主流程,返回降级而不是失败
|
||||||
|
return StageResult.degraded("任务创建失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.exception.BaseException;
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
|
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||||
|
import com.ycwl.basic.service.pc.helper.SearchResultMerger;
|
||||||
|
import com.ycwl.basic.service.task.TaskFaceService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义人脸搜索Stage
|
||||||
|
* 负责根据faceSelectPostMode执行不同的搜索策略
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.faceSelectPostMode读取配置
|
||||||
|
* 2. 模式2: 直接使用用户选择的样本,不搜索
|
||||||
|
* 3. 模式0/1: 对每个样本搜索,然后合并结果
|
||||||
|
* 4. 更新context.searchResult
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "custom_face_search",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "根据配置执行自定义人脸搜索"
|
||||||
|
)
|
||||||
|
public class CustomFaceSearchStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TaskFaceService taskFaceService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SearchResultMerger resultMerger;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "CustomFaceSearch";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Integer faceSelectPostMode = context.getFaceSelectPostMode();
|
||||||
|
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
|
||||||
|
List<Long> faceSampleIds = context.getFaceSampleIds();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
if (faceSelectPostMode == null) {
|
||||||
|
faceSelectPostMode = 0; // 默认为并集模式
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("face_select_post_mode配置值: {}, faceId={}", faceSelectPostMode, faceId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
SearchFaceRespVo mergedResult;
|
||||||
|
|
||||||
|
// 模式2:不搜索,直接使用用户选择的faceSampleIds
|
||||||
|
if (Integer.valueOf(2).equals(faceSelectPostMode)) {
|
||||||
|
log.debug("使用模式2:直接使用用户选择的人脸样本,不进行搜索,faceId={}", faceId);
|
||||||
|
mergedResult = resultMerger.createDirectResult(faceSampleIds);
|
||||||
|
// 保留原始matchResult
|
||||||
|
if (context.getFace().getMatchResult() != null) {
|
||||||
|
mergedResult.setSearchResultJson(context.getFace().getMatchResult());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 模式0(并集)和模式1(交集):需要进行搜索
|
||||||
|
List<SearchFaceRespVo> searchResults = new ArrayList<>();
|
||||||
|
|
||||||
|
for (FaceSampleEntity faceSample : faceSamples) {
|
||||||
|
try {
|
||||||
|
SearchFaceRespVo result = taskFaceService.searchFace(
|
||||||
|
context.getFaceBodyAdapter(),
|
||||||
|
String.valueOf(context.getFace().getScenicId()),
|
||||||
|
faceSample.getFaceUrl(),
|
||||||
|
"自定义人脸匹配");
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
searchResults.add(result);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("人脸样本搜索失败,faceSampleId={}, faceUrl={}, faceId={}",
|
||||||
|
faceSample.getId(), faceSample.getFaceUrl(), faceId, e);
|
||||||
|
// 继续处理其他样本,不中断整个流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchResults.isEmpty()) {
|
||||||
|
log.warn("所有人脸样本搜索都失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds);
|
||||||
|
throw new BaseException("人脸识别失败,请重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据模式整合多个搜索结果
|
||||||
|
mergedResult = resultMerger.merge(searchResults, faceSelectPostMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setSearchResult(mergedResult);
|
||||||
|
context.setSampleListIds(mergedResult.getSampleListIds());
|
||||||
|
|
||||||
|
log.info("自定义人脸搜索完成: faceId={}, mode={}, 匹配数={}",
|
||||||
|
faceId, faceSelectPostMode,
|
||||||
|
mergedResult.getSampleListIds() != null ? mergedResult.getSampleListIds().size() : 0);
|
||||||
|
|
||||||
|
return StageResult.success(String.format("自定义搜索完成,模式=%d", faceSelectPostMode));
|
||||||
|
|
||||||
|
} catch (BaseException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("自定义人脸搜索失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds, e);
|
||||||
|
return StageResult.failed("自定义人脸搜索失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.mapper.SourceMapper;
|
||||||
|
import com.ycwl.basic.mapper.VideoMapper;
|
||||||
|
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除旧关系Stage
|
||||||
|
* 负责在保存新关系前,删除该人脸的旧数据关系
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 删除member_source中该人脸的未购买关系
|
||||||
|
* 2. 删除member_video中该人脸的未购买关系
|
||||||
|
* 3. 清除缓存
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "delete_old_relations",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "删除人脸旧关系数据"
|
||||||
|
)
|
||||||
|
public class DeleteOldRelationsStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SourceMapper sourceMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private VideoMapper videoMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MemberRelationRepository memberRelationRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "DeleteOldRelations";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
Long memberId = context.getFace().getMemberId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug("删除人脸旧关系数据:faceId={}, memberId={}", faceId, memberId);
|
||||||
|
|
||||||
|
// 1. 删除member_source中的未购买关系
|
||||||
|
sourceMapper.deleteNotBuyFaceRelation(memberId, faceId);
|
||||||
|
|
||||||
|
// 2. 删除member_video中的未购买关系
|
||||||
|
videoMapper.deleteNotBuyFaceRelations(memberId, faceId);
|
||||||
|
|
||||||
|
// 3. 清除缓存
|
||||||
|
memberRelationRepository.clearSCacheByFace(faceId);
|
||||||
|
|
||||||
|
log.debug("人脸旧关系数据删除完成:faceId={}", faceId);
|
||||||
|
|
||||||
|
return StageResult.success("旧关系数据已删除");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("删除旧关系数据失败,faceId={}", faceId, e);
|
||||||
|
// 删除失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("删除旧关系数据失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.exception.BaseException;
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||||
|
import com.ycwl.basic.service.task.TaskFaceService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸识别Stage
|
||||||
|
* 负责执行核心的人脸识别搜索
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 调用taskFaceService.searchFace()执行人脸搜索
|
||||||
|
* 2. 将结果存入context.searchResult
|
||||||
|
* 3. 识别失败则返回FAILED
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "face_recognition",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "执行人脸识别搜索"
|
||||||
|
)
|
||||||
|
public class FaceRecognitionStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TaskFaceService taskFaceService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "FaceRecognition";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
try {
|
||||||
|
SearchFaceRespVo searchResult = taskFaceService.searchFace(
|
||||||
|
context.getFaceBodyAdapter(),
|
||||||
|
String.valueOf(context.getFace().getScenicId()),
|
||||||
|
context.getFace().getFaceUrl(),
|
||||||
|
"人脸识别");
|
||||||
|
|
||||||
|
if (searchResult == null) {
|
||||||
|
log.warn("人脸识别返回结果为空,faceId={}", context.getFaceId());
|
||||||
|
return StageResult.failed("人脸识别失败,请换一张试试把~");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setSearchResult(searchResult);
|
||||||
|
|
||||||
|
log.info("人脸识别完成: faceId={}, score={}, 匹配数={}",
|
||||||
|
context.getFaceId(),
|
||||||
|
searchResult.getScore(),
|
||||||
|
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0);
|
||||||
|
|
||||||
|
return StageResult.success(String.format("识别成功,匹配数=%d",
|
||||||
|
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0));
|
||||||
|
|
||||||
|
} catch (BaseException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("人脸识别服务调用失败,faceId={}, scenicId={}",
|
||||||
|
context.getFaceId(), context.getFace().getScenicId(), e);
|
||||||
|
return StageResult.failed("人脸识别失败,请换一张试试把~", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||||
|
import com.ycwl.basic.service.pc.processor.FaceRecoveryStrategy;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人脸识别补救Stage
|
||||||
|
* 负责执行人脸识别的补救逻辑(降级)
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.searchResult读取识别结果
|
||||||
|
* 2. 调用faceRecoveryStrategy.executeFaceRecoveryLogic()执行补救
|
||||||
|
* 3. 如果触发补救,更新searchResult并返回DEGRADED
|
||||||
|
* 4. 否则返回SUCCESS
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "face_recovery",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "执行人脸识别补救逻辑",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class FaceRecoveryStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FaceRecoveryStrategy faceRecoveryStrategy;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "FaceRecovery";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 只有当searchResult不为空时才执行
|
||||||
|
if (context.getSearchResult() == null) {
|
||||||
|
log.debug("searchResult为空,跳过补救逻辑,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
SearchFaceRespVo searchResult = context.getSearchResult();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:searchResult为空
|
||||||
|
if (searchResult == null) {
|
||||||
|
log.debug("searchResult为空,跳过补救逻辑,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("searchResult为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 执行补救逻辑(补救逻辑内部会判断是否需要触发)
|
||||||
|
SearchFaceRespVo recoveredResult = faceRecoveryStrategy.executeFaceRecoveryLogic(
|
||||||
|
searchResult,
|
||||||
|
context.getScenicConfig(),
|
||||||
|
context.getFaceBodyAdapter(),
|
||||||
|
context.getFace().getScenicId());
|
||||||
|
|
||||||
|
// 如果结果发生变化,说明触发了补救
|
||||||
|
if (recoveredResult != searchResult) {
|
||||||
|
context.setSearchResult(recoveredResult);
|
||||||
|
log.info("触发补救逻辑,重新搜索: faceId={}", faceId);
|
||||||
|
return StageResult.degraded("触发补救逻辑,重新搜索");
|
||||||
|
}
|
||||||
|
|
||||||
|
return StageResult.success("无需补救");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("补救逻辑执行失败,faceId={}", faceId, e);
|
||||||
|
// 补救失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("补救逻辑执行失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
|
||||||
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
|
import com.ycwl.basic.repository.DeviceRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按设备照片数量限制筛选样本Stage
|
||||||
|
* 负责根据设备配置的照片数量限制(limit_photo)筛选匹配样本
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.faceSamples读取样本实体缓存
|
||||||
|
* 2. 按设备ID分组
|
||||||
|
* 3. 对每个设备,根据其limit_photo配置筛选样本:
|
||||||
|
* - 如果样本数 > limit_photo + 2: 按时间排序,去掉首尾,保留中间limit_photo张
|
||||||
|
* - 如果样本数 > limit_photo + 1: 按时间排序,去掉尾部,保留前limit_photo张
|
||||||
|
* - 如果样本数 > limit_photo: 保留前limit_photo张
|
||||||
|
* - 否则: 保留全部
|
||||||
|
* 4. 更新context.sampleListIds
|
||||||
|
*
|
||||||
|
* 前置条件: context.faceSamples不为空 (由LoadMatchedSamplesStage加载)
|
||||||
|
* 配置说明: limit_photo=0或null表示不限制数量
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "filter_by_device_photo_limit",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "按设备照片数量限制筛选样本",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class FilterByDevicePhotoLimitStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DeviceRepository deviceRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "FilterByDevicePhotoLimit";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 检查faceSamples是否为空
|
||||||
|
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
|
||||||
|
if (faceSamples == null || faceSamples.isEmpty()) {
|
||||||
|
log.debug("faceSamples为空,跳过设备照片限制筛选,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
|
||||||
|
List<Long> sampleListIds = context.getSampleListIds();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:faceSamples为空
|
||||||
|
if (faceSamples == null || faceSamples.isEmpty()) {
|
||||||
|
log.debug("faceSamples为空,跳过设备照片限制筛选,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("faceSamples为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 构建样本ID到实体的映射
|
||||||
|
Map<Long, FaceSampleEntity> sampleMap = faceSamples.stream()
|
||||||
|
.collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a));
|
||||||
|
|
||||||
|
// 2. 按设备ID分组
|
||||||
|
Map<Long, List<FaceSampleEntity>> deviceSamplesMap = new LinkedHashMap<>();
|
||||||
|
Set<Long> passthroughSampleIds = new LinkedHashSet<>();
|
||||||
|
|
||||||
|
for (Long sampleId : sampleListIds) {
|
||||||
|
FaceSampleEntity sample = sampleMap.get(sampleId);
|
||||||
|
if (sample == null || sample.getDeviceId() == null) {
|
||||||
|
passthroughSampleIds.add(sampleId); // 无设备ID的样本直接保留
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
deviceSamplesMap
|
||||||
|
.computeIfAbsent(sample.getDeviceId(), key -> new ArrayList<>())
|
||||||
|
.add(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 对每个设备应用照片数量限制
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 读取设备配置
|
||||||
|
Integer limitPhoto = limitCache.computeIfAbsent(deviceId, id -> {
|
||||||
|
DeviceConfigManager deviceConfig = deviceRepository.getDeviceConfigManager(id);
|
||||||
|
return deviceConfig != null ? deviceConfig.getInteger("limit_photo") : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
List<Long> retainedForDevice = applyLimitForDevice(deviceId, deviceSamples, limitPhoto);
|
||||||
|
retainedSampleIds.addAll(retainedForDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 按原始顺序保留筛选后的样本ID
|
||||||
|
List<Long> resultIds = sampleListIds.stream()
|
||||||
|
.filter(retainedSampleIds::contains)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 5. 更新context
|
||||||
|
context.setSampleListIds(resultIds);
|
||||||
|
|
||||||
|
log.info("设备照片数量限制筛选完成: faceId={}, 原始样本数={}, 筛选后数={}",
|
||||||
|
faceId, sampleListIds.size(), resultIds.size());
|
||||||
|
|
||||||
|
return StageResult.success(String.format("设备限制筛选: %d → %d",
|
||||||
|
sampleListIds.size(), resultIds.size()));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("设备照片数量限制筛选失败,faceId={}", faceId, e);
|
||||||
|
// 筛选失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("设备照片数量限制筛选失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对单个设备的样本应用照片数量限制
|
||||||
|
*/
|
||||||
|
private List<Long> applyLimitForDevice(Long deviceId, List<FaceSampleEntity> deviceSamples, Integer limitPhoto) {
|
||||||
|
List<Long> deviceSampleIds = deviceSamples.stream()
|
||||||
|
.map(FaceSampleEntity::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 无限制或限制数量<=0,保留全部
|
||||||
|
if (limitPhoto == null || limitPhoto <= 0) {
|
||||||
|
log.debug("设备照片限制: 设备ID={}, 无限制, 保留{}张照片", deviceId, deviceSampleIds.size());
|
||||||
|
return deviceSampleIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sampleCount = deviceSamples.size();
|
||||||
|
|
||||||
|
// 样本数 > limit_photo + 2: 按时间排序,去掉首尾
|
||||||
|
if (sampleCount > (limitPhoto + 2)) {
|
||||||
|
List<Long> retained = processDeviceSamples(deviceSamples, limitPhoto, true);
|
||||||
|
log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 去首尾后最终{}张",
|
||||||
|
deviceId, limitPhoto, sampleCount, retained.size());
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 样本数 > limit_photo + 1: 按时间排序,去掉尾部
|
||||||
|
if (sampleCount > (limitPhoto + 1)) {
|
||||||
|
List<Long> retained = processDeviceSamples(deviceSamples, limitPhoto, false);
|
||||||
|
log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 去尾部后最终{}张",
|
||||||
|
deviceId, limitPhoto, sampleCount, retained.size());
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 样本数 > limit_photo: 保留前limit_photo张
|
||||||
|
if (sampleCount > limitPhoto) {
|
||||||
|
List<Long> retained = deviceSamples.stream()
|
||||||
|
.limit(limitPhoto)
|
||||||
|
.map(FaceSampleEntity::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 取前{}张",
|
||||||
|
deviceId, limitPhoto, sampleCount, retained.size());
|
||||||
|
return retained;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 样本数 <= limit_photo: 保留全部
|
||||||
|
log.debug("设备照片限制: 设备ID={}, 限制={}张, 原始{}张, 无需筛选, 保留全部",
|
||||||
|
deviceId, limitPhoto, sampleCount);
|
||||||
|
return deviceSampleIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理设备样本,根据参数决定是否去掉首尾
|
||||||
|
*
|
||||||
|
* @param deviceSamples 设备样本列表
|
||||||
|
* @param limitPhoto 限制数量
|
||||||
|
* @param removeBoth 是否去掉首尾,true去掉首尾,false只去掉尾部
|
||||||
|
* @return 处理后的样本ID列表
|
||||||
|
*/
|
||||||
|
private List<Long> processDeviceSamples(List<FaceSampleEntity> deviceSamples, int limitPhoto, boolean removeBoth) {
|
||||||
|
// 创建原始排序的索引映射,用于后续恢复排序
|
||||||
|
Map<Long, Integer> originalIndexMap = new HashMap<>();
|
||||||
|
for (int i = 0; i < deviceSamples.size(); i++) {
|
||||||
|
originalIndexMap.put(deviceSamples.get(i).getId(), i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按创建时间排序
|
||||||
|
List<FaceSampleEntity> sortedByCreateTime = deviceSamples.stream()
|
||||||
|
.sorted(Comparator.comparing(FaceSampleEntity::getCreateAt))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 根据参数决定去掉首尾还是只去掉尾部
|
||||||
|
List<FaceSampleEntity> filteredSamples;
|
||||||
|
if (removeBoth && sortedByCreateTime.size() > 2) {
|
||||||
|
// 去掉首尾
|
||||||
|
filteredSamples = sortedByCreateTime.subList(1, sortedByCreateTime.size() - 1);
|
||||||
|
} else if (!removeBoth && sortedByCreateTime.size() > 1) {
|
||||||
|
// 只去掉尾部
|
||||||
|
filteredSamples = sortedByCreateTime.subList(0, sortedByCreateTime.size() - 1);
|
||||||
|
} else {
|
||||||
|
filteredSamples = sortedByCreateTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取前limitPhoto个
|
||||||
|
List<FaceSampleEntity> limitedSamples = filteredSamples.stream()
|
||||||
|
.limit(limitPhoto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 按原始顺序排序
|
||||||
|
List<Long> resultIds = limitedSamples.stream()
|
||||||
|
.sorted(Comparator.comparing(sample -> originalIndexMap.get(sample.getId())))
|
||||||
|
.map(FaceSampleEntity::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return resultIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按时间范围筛选样本Stage
|
||||||
|
* 负责根据景区配置的游览时间(tour_time)筛选匹配样本
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.scenicConfig读取tour_time配置(分钟)
|
||||||
|
* 2. 从context.faceSamples读取样本实体缓存
|
||||||
|
* 3. 找到最新的样本,以其拍摄时间为基准
|
||||||
|
* 4. 筛选出时间范围内(最新样本时间 ± tour_time分钟)的样本
|
||||||
|
* 5. 更新context.sampleListIds
|
||||||
|
*
|
||||||
|
* 前置条件:
|
||||||
|
* - context.faceSamples不为空 (由LoadMatchedSamplesStage加载)
|
||||||
|
* - context.scenicConfig配置了tour_time
|
||||||
|
*
|
||||||
|
* 配置说明: tour_time=0或null表示不限制时间范围
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "filter_by_time_range",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "按游览时间范围筛选样本",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class FilterByTimeRangeStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "FilterByTimeRange";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 检查faceSamples是否为空
|
||||||
|
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
|
||||||
|
if (faceSamples == null || faceSamples.isEmpty()) {
|
||||||
|
log.debug("faceSamples为空,跳过时间范围筛选,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否配置了tour_time
|
||||||
|
Integer tourMinutes = context.getScenicConfig() != null
|
||||||
|
? context.getScenicConfig().getInteger("tour_time")
|
||||||
|
: null;
|
||||||
|
if (tourMinutes == null || tourMinutes <= 0) {
|
||||||
|
log.debug("景区未配置tour_time或配置为0,跳过时间范围筛选,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
List<FaceSampleEntity> faceSamples = context.getFaceSamples();
|
||||||
|
List<Long> sampleListIds = context.getSampleListIds();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:faceSamples为空
|
||||||
|
if (faceSamples == null || faceSamples.isEmpty()) {
|
||||||
|
log.debug("faceSamples为空,跳过时间范围筛选,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("faceSamples为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防御性检查:tour_time配置
|
||||||
|
Integer tourMinutes = context.getScenicConfig() != null
|
||||||
|
? context.getScenicConfig().getInteger("tour_time")
|
||||||
|
: null;
|
||||||
|
if (tourMinutes == null || tourMinutes <= 0) {
|
||||||
|
log.debug("景区未配置tour_time或配置为0,跳过时间范围筛选,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("未配置tour_time");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 构建样本ID到实体的映射
|
||||||
|
Map<Long, FaceSampleEntity> sampleMap = faceSamples.stream()
|
||||||
|
.collect(Collectors.toMap(FaceSampleEntity::getId, sample -> sample, (a, b) -> a));
|
||||||
|
|
||||||
|
// 2. 找到最新的样本(拍摄时间最晚)
|
||||||
|
FaceSampleEntity topMatchSample = faceSamples.stream()
|
||||||
|
.filter(sample -> sample.getCreateAt() != null)
|
||||||
|
.max(Comparator.comparing(FaceSampleEntity::getCreateAt))
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (topMatchSample == null || topMatchSample.getCreateAt() == null) {
|
||||||
|
log.warn("未找到有效的样本拍摄时间,保留所有样本,faceId={}", faceId);
|
||||||
|
return StageResult.success("样本无拍摄时间,保留所有");
|
||||||
|
}
|
||||||
|
|
||||||
|
Date referenceTime = topMatchSample.getCreateAt();
|
||||||
|
long referenceMillis = referenceTime.getTime();
|
||||||
|
long tourMillis = tourMinutes * 60 * 1000L;
|
||||||
|
|
||||||
|
// 3. 筛选时间范围内的样本
|
||||||
|
List<Long> filteredIds = sampleListIds.stream()
|
||||||
|
.filter(sampleId -> {
|
||||||
|
FaceSampleEntity sample = sampleMap.get(sampleId);
|
||||||
|
if (sample == null || sample.getCreateAt() == null) {
|
||||||
|
return false; // 无时间信息的样本被过滤
|
||||||
|
}
|
||||||
|
long timeDiff = Math.abs(sample.getCreateAt().getTime() - referenceMillis);
|
||||||
|
return timeDiff <= tourMillis;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 4. 更新context
|
||||||
|
context.setSampleListIds(filteredIds);
|
||||||
|
|
||||||
|
log.info("时间范围筛选完成: faceId={}, tour_time={}分钟, 原始样本数={}, 筛选后数={}",
|
||||||
|
faceId, tourMinutes, sampleListIds.size(), filteredIds.size());
|
||||||
|
|
||||||
|
return StageResult.success(String.format("时间筛选: %d → %d",
|
||||||
|
sampleListIds.size(), filteredIds.size()));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("时间范围筛选失败,faceId={}", faceId, e);
|
||||||
|
// 筛选失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("时间范围筛选失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.face.pipeline.helper.PuzzleGenerationOrchestrator;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成拼图模板Stage
|
||||||
|
* 负责触发景区拼图模板的异步生成任务
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context读取必要参数(scenicId, faceId, memberId, faceUrl)
|
||||||
|
* 2. 调用puzzleOrchestrator.generateAllTemplatesAsync()触发异步生成
|
||||||
|
* 3. 立即返回,不等待生成完成
|
||||||
|
*
|
||||||
|
* 业务说明:
|
||||||
|
* - 拼图生成是异步的,不影响主流程
|
||||||
|
* - 具体的拼图生成逻辑由PuzzleGenerationOrchestrator负责
|
||||||
|
* - Stage只负责触发任务,符合"薄Stage,厚Service"原则
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "generate_puzzle",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "异步生成拼图模板",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class GeneratePuzzleStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PuzzleGenerationOrchestrator puzzleOrchestrator;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "GeneratePuzzle";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Long scenicId = context.getFace().getScenicId();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
Long memberId = context.getFace().getMemberId();
|
||||||
|
String faceUrl = context.getFace().getFaceUrl();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 触发异步生成拼图模板
|
||||||
|
puzzleOrchestrator.generateAllTemplatesAsync(scenicId, faceId, memberId, faceUrl);
|
||||||
|
|
||||||
|
log.debug("拼图模板异步生成任务已提交: scenicId={}, faceId={}", scenicId, faceId);
|
||||||
|
|
||||||
|
return StageResult.success("拼图模板已提交异步生成");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("提交拼图生成任务失败: scenicId={}, faceId={}", scenicId, faceId, e);
|
||||||
|
// 拼图生成失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("提交拼图生成任务失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||||
|
import com.ycwl.basic.service.pc.processor.VideoRecreationHandler;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理视频重切Stage
|
||||||
|
* 负责触发视频重新切片处理
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context读取必要参数(scenicId, memberSourceList, faceId, memberId, sampleListIds, isNew)
|
||||||
|
* 2. 调用videoRecreationHandler.handleVideoRecreation()触发视频重切
|
||||||
|
*
|
||||||
|
* 前置条件: context.memberSourceList不为空
|
||||||
|
* 业务说明: 视频重切用于根据人脸识别结果重新生成个性化视频片段
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "handle_video_recreation",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "处理视频重切逻辑",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class HandleVideoRecreationStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private VideoRecreationHandler videoRecreationHandler;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "HandleVideoRecreation";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 只有当memberSourceList不为空时才执行
|
||||||
|
List<MemberSourceEntity> memberSourceList = context.getMemberSourceList();
|
||||||
|
if (memberSourceList == null || memberSourceList.isEmpty()) {
|
||||||
|
log.debug("memberSourceList为空,跳过视频重切,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Long scenicId = context.getFace().getScenicId();
|
||||||
|
List<MemberSourceEntity> memberSourceEntityList = context.getMemberSourceList();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
Long memberId = context.getFace().getMemberId();
|
||||||
|
List<Long> sampleListIds = context.getSampleListIds();
|
||||||
|
boolean isNew = context.isNew();
|
||||||
|
|
||||||
|
// 防御性检查:memberSourceList为空
|
||||||
|
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
|
||||||
|
log.debug("memberSourceList为空,跳过视频重切,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("memberSourceList为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 处理视频重切
|
||||||
|
videoRecreationHandler.handleVideoRecreation(
|
||||||
|
scenicId,
|
||||||
|
memberSourceEntityList,
|
||||||
|
faceId,
|
||||||
|
memberId,
|
||||||
|
sampleListIds,
|
||||||
|
isNew);
|
||||||
|
|
||||||
|
log.info("视频重切处理完成: faceId={}, scenicId={}, 源文件数={}",
|
||||||
|
faceId, scenicId, memberSourceEntityList.size());
|
||||||
|
|
||||||
|
return StageResult.success("视频重切处理完成");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理视频重切失败,faceId={}", faceId, e);
|
||||||
|
// 视频重切失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("视频重切处理失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.exception.BaseException;
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.mapper.FaceSampleMapper;
|
||||||
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载人脸样本Stage
|
||||||
|
* 负责加载用户选择的人脸样本数据
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.faceSampleIds读取用户选择的样本ID列表
|
||||||
|
* 2. 调用faceSampleMapper.listByIds()加载样本实体
|
||||||
|
* 3. 更新context.faceSamples
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "load_face_samples",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "加载用户选择的人脸样本"
|
||||||
|
)
|
||||||
|
public class LoadFaceSamplesStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FaceSampleMapper faceSampleMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "LoadFaceSamples";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
List<Long> faceSampleIds = context.getFaceSampleIds();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
if (faceSampleIds == null || faceSampleIds.isEmpty()) {
|
||||||
|
log.warn("faceSampleIds为空,faceId={}", faceId);
|
||||||
|
return StageResult.failed("faceSampleIds不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
List<FaceSampleEntity> faceSamples = faceSampleMapper.listByIds(faceSampleIds);
|
||||||
|
|
||||||
|
if (faceSamples.isEmpty()) {
|
||||||
|
log.warn("未找到指定的人脸样本,faceSampleIds: {}, faceId={}", faceSampleIds, faceId);
|
||||||
|
throw new BaseException("未找到指定的人脸样本");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setFaceSamples(faceSamples);
|
||||||
|
|
||||||
|
log.info("加载人脸样本成功: faceId={}, sampleCount={}", faceId, faceSamples.size());
|
||||||
|
return StageResult.success(String.format("加载了%d个人脸样本", faceSamples.size()));
|
||||||
|
|
||||||
|
} catch (BaseException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("加载人脸样本失败,faceId={}, faceSampleIds={}", faceId, faceSampleIds, e);
|
||||||
|
return StageResult.failed("加载人脸样本失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.mapper.FaceSampleMapper;
|
||||||
|
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载匹配样本实体Stage
|
||||||
|
* 负责将sampleListIds对应的样本实体加载到context.faceSamples,供后续Stage使用
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.sampleListIds读取匹配到的样本ID列表
|
||||||
|
* 2. 调用faceSampleMapper.listByIds()批量加载样本实体
|
||||||
|
* 3. 更新context.faceSamples作为样本实体缓存
|
||||||
|
*
|
||||||
|
* 设计目的:
|
||||||
|
* - 避免后续多个Stage重复调用faceSampleMapper.listByIds()
|
||||||
|
* - 统一加载时机,提高性能
|
||||||
|
* - 为后续筛选Stage提供样本实体数据源
|
||||||
|
*
|
||||||
|
* 前置条件: context.sampleListIds不为空
|
||||||
|
*
|
||||||
|
* 应用场景: 自定义匹配场景,在CustomFaceSearchStage之后
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "load_matched_samples",
|
||||||
|
optionalMode = StageOptionalMode.UNSUPPORT,
|
||||||
|
description = "加载匹配样本实体到缓存"
|
||||||
|
)
|
||||||
|
public class LoadMatchedSamplesStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FaceSampleMapper faceSampleMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "LoadMatchedSamples";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 检查sampleListIds是否为空
|
||||||
|
List<Long> sampleListIds = context.getSampleListIds();
|
||||||
|
if (sampleListIds == null || sampleListIds.isEmpty()) {
|
||||||
|
log.debug("sampleListIds为空,跳过加载匹配样本,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
List<Long> sampleListIds = context.getSampleListIds();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:如果sampleListIds为空,直接跳过
|
||||||
|
if (sampleListIds == null || sampleListIds.isEmpty()) {
|
||||||
|
log.debug("sampleListIds为空,跳过加载匹配样本,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("sampleListIds为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 批量加载样本实体
|
||||||
|
List<FaceSampleEntity> faceSamples = faceSampleMapper.listByIds(sampleListIds);
|
||||||
|
|
||||||
|
if (faceSamples == null || faceSamples.isEmpty()) {
|
||||||
|
log.warn("未找到任何匹配样本实体,faceId={}, sampleListIds={}", faceId, sampleListIds);
|
||||||
|
return StageResult.skipped("未找到匹配样本实体");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存入context缓存,供后续Stage使用
|
||||||
|
context.setFaceSamples(faceSamples);
|
||||||
|
|
||||||
|
log.info("加载匹配样本实体完成: faceId={}, 样本数={}", faceId, faceSamples.size());
|
||||||
|
|
||||||
|
return StageResult.success(String.format("已加载%d个样本实体", faceSamples.size()));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("加载匹配样本实体失败,faceId={}", faceId, e);
|
||||||
|
// 加载失败影响后续流程,返回失败
|
||||||
|
return StageResult.failed("加载匹配样本实体失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.mapper.SourceMapper;
|
||||||
|
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||||
|
import com.ycwl.basic.repository.MemberRelationRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化关联关系Stage
|
||||||
|
* 负责过滤并保存源文件关联关系到数据库
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.memberSourceList读取关联关系
|
||||||
|
* 2. 过滤已存在的关联关系和无效的source引用
|
||||||
|
* 3. 保存到数据库
|
||||||
|
* 4. 清除缓存
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "persist_relations",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "持久化源文件关联关系"
|
||||||
|
)
|
||||||
|
public class PersistRelationsStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SourceMapper sourceMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MemberRelationRepository memberRelationRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "PersistRelations";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 只有当memberSourceList不为空时才执行
|
||||||
|
List<MemberSourceEntity> memberSourceList = context.getMemberSourceList();
|
||||||
|
if (memberSourceList == null || memberSourceList.isEmpty()) {
|
||||||
|
log.debug("memberSourceList为空,跳过持久化,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
List<MemberSourceEntity> memberSourceEntityList = context.getMemberSourceList();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:memberSourceList为空
|
||||||
|
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
|
||||||
|
log.debug("memberSourceList为空,跳过持久化,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("memberSourceList为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 过滤已存在的关联关系
|
||||||
|
List<MemberSourceEntity> existingFiltered = sourceMapper.filterExistingRelations(memberSourceEntityList);
|
||||||
|
|
||||||
|
// 2. 过滤无效的source引用
|
||||||
|
List<MemberSourceEntity> validFiltered = sourceMapper.filterValidSourceRelations(existingFiltered);
|
||||||
|
|
||||||
|
if (!validFiltered.isEmpty()) {
|
||||||
|
// 3. 保存到数据库
|
||||||
|
sourceMapper.addRelations(validFiltered);
|
||||||
|
|
||||||
|
log.debug("创建关联关系: faceId={}, 原始数量={}, 过滤后数量={}",
|
||||||
|
faceId, memberSourceEntityList.size(), validFiltered.size());
|
||||||
|
} else {
|
||||||
|
log.warn("没有有效的关联关系可创建: faceId={}, 原始数量={}",
|
||||||
|
faceId, memberSourceEntityList.size());
|
||||||
|
return StageResult.skipped("没有有效的关联关系可创建");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 清除缓存
|
||||||
|
memberRelationRepository.clearSCacheByFace(faceId);
|
||||||
|
|
||||||
|
return StageResult.success(String.format("持久化了%d条关联关系", validFiltered.size()));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("持久化关联关系失败,faceId={}", faceId, e);
|
||||||
|
return StageResult.failed("保存关联关系失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
|
||||||
|
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||||
|
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||||
|
import com.ycwl.basic.repository.FaceRepository;
|
||||||
|
import com.ycwl.basic.repository.ScenicRepository;
|
||||||
|
import com.ycwl.basic.service.pc.ScenicService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 准备上下文Stage
|
||||||
|
* 负责加载人脸实体、景区配置、识别适配器等必要数据
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 加载FaceEntity(如不存在则失败)
|
||||||
|
* 2. 检查是否人工选择(是则跳过,除非isNew=true)
|
||||||
|
* 3. 加载ScenicConfigManager和IFaceBodyAdapter
|
||||||
|
* 4. 更新Context
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "prepare_context",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "准备人脸匹配上下文数据"
|
||||||
|
)
|
||||||
|
public class PrepareContextStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FaceRepository faceRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ScenicRepository scenicRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ScenicService scenicService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "PrepareContext";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
boolean isNew = context.isNew();
|
||||||
|
|
||||||
|
// 1. 加载人脸实体
|
||||||
|
FaceEntity face = faceRepository.getFace(faceId);
|
||||||
|
if (face == null) {
|
||||||
|
log.warn("人脸不存在,faceId: {}", faceId);
|
||||||
|
return StageResult.failed("人脸不存在,faceId: " + faceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setFace(face);
|
||||||
|
log.debug("加载人脸实体成功: faceId={}, memberId={}, scenicId={}",
|
||||||
|
faceId, face.getMemberId(), face.getScenicId());
|
||||||
|
|
||||||
|
// 2. 检查是否人工选择
|
||||||
|
// 人工选择的无需重新匹配(新用户除外)
|
||||||
|
if (!isNew && Integer.valueOf(1).equals(face.getIsManual())) {
|
||||||
|
log.info("人工选择的人脸,无需匹配,faceId: {}", faceId);
|
||||||
|
return StageResult.skipped("人工选择的人脸,无需重新匹配");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 加载景区配置
|
||||||
|
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(face.getScenicId());
|
||||||
|
context.setScenicConfig(scenicConfig);
|
||||||
|
log.debug("加载景区配置成功: scenicId={}", face.getScenicId());
|
||||||
|
|
||||||
|
// 4. 加载人脸识别适配器
|
||||||
|
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(face.getScenicId());
|
||||||
|
if (faceBodyAdapter == null) {
|
||||||
|
log.error("无法获取人脸识别适配器,scenicId: {}", face.getScenicId());
|
||||||
|
return StageResult.failed("人脸识别服务不可用,请稍后再试");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setFaceBodyAdapter(faceBodyAdapter);
|
||||||
|
log.debug("加载人脸识别适配器成功: scenicId={}", face.getScenicId());
|
||||||
|
|
||||||
|
return StageResult.success("上下文准备完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||||
|
import com.ycwl.basic.service.pc.processor.BuyStatusProcessor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理购买状态Stage
|
||||||
|
* 负责更新源文件的购买状态标记
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.memberSourceList读取源文件关联列表
|
||||||
|
* 2. 从context.freeSourceIds读取免费源文件ID列表
|
||||||
|
* 3. 调用buyStatusProcessor.processBuyStatus()更新购买状态
|
||||||
|
*
|
||||||
|
* 前置条件: context.memberSourceList不为空
|
||||||
|
* 业务说明: 购买状态影响前端显示和用户下载权限
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "process_buy_status",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "处理源文件购买状态",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class ProcessBuyStatusStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BuyStatusProcessor buyStatusProcessor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "ProcessBuyStatus";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 只有当memberSourceList不为空时才执行
|
||||||
|
List<MemberSourceEntity> memberSourceList = context.getMemberSourceList();
|
||||||
|
if (memberSourceList == null || memberSourceList.isEmpty()) {
|
||||||
|
log.debug("memberSourceList为空,跳过购买状态处理,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
List<MemberSourceEntity> memberSourceEntityList = context.getMemberSourceList();
|
||||||
|
List<Long> freeSourceIds = context.getFreeSourceIds();
|
||||||
|
Long memberId = context.getFace().getMemberId();
|
||||||
|
Long scenicId = context.getFace().getScenicId();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:memberSourceList为空
|
||||||
|
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
|
||||||
|
log.debug("memberSourceList为空,跳过购买状态处理,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("memberSourceList为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 处理购买状态
|
||||||
|
buyStatusProcessor.processBuyStatus(
|
||||||
|
memberSourceEntityList,
|
||||||
|
freeSourceIds,
|
||||||
|
memberId,
|
||||||
|
scenicId,
|
||||||
|
faceId);
|
||||||
|
|
||||||
|
log.info("购买状态处理完成: faceId={}, 源文件数={}, 免费数={}",
|
||||||
|
faceId, memberSourceEntityList.size(),
|
||||||
|
freeSourceIds != null ? freeSourceIds.size() : 0);
|
||||||
|
|
||||||
|
return StageResult.success("购买状态处理完成");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理购买状态失败,faceId={}", faceId, e);
|
||||||
|
// 购买状态处理失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("购买状态处理失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
|
||||||
|
import com.ycwl.basic.service.pc.processor.SourceRelationProcessor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理免费源文件Stage
|
||||||
|
* 负责根据业务规则确定哪些源文件可以免费访问
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.memberSourceList读取源文件关联列表
|
||||||
|
* 2. 调用sourceRelationProcessor.processFreeSourceLogic()确定免费源文件
|
||||||
|
* 3. 更新context.freeSourceIds
|
||||||
|
*
|
||||||
|
* 前置条件: context.memberSourceList不为空
|
||||||
|
* 后置条件: context.freeSourceIds已设置
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "process_free_source",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "处理免费源文件逻辑",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class ProcessFreeSourceStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SourceRelationProcessor sourceRelationProcessor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "ProcessFreeSource";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 只有当memberSourceList不为空时才执行
|
||||||
|
List<MemberSourceEntity> memberSourceList = context.getMemberSourceList();
|
||||||
|
if (memberSourceList == null || memberSourceList.isEmpty()) {
|
||||||
|
log.debug("memberSourceList为空,跳过免费逻辑,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
List<MemberSourceEntity> memberSourceEntityList = context.getMemberSourceList();
|
||||||
|
Long scenicId = context.getFace().getScenicId();
|
||||||
|
boolean isNew = context.isNew();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:memberSourceList为空
|
||||||
|
if (memberSourceEntityList == null || memberSourceEntityList.isEmpty()) {
|
||||||
|
log.debug("memberSourceList为空,跳过免费逻辑,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("memberSourceList为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 处理免费逻辑
|
||||||
|
List<Long> freeSourceIds = sourceRelationProcessor.processFreeSourceLogic(
|
||||||
|
memberSourceEntityList, scenicId, isNew);
|
||||||
|
|
||||||
|
context.setFreeSourceIds(freeSourceIds);
|
||||||
|
|
||||||
|
log.info("免费源文件处理完成: faceId={}, 总源文件数={}, 免费数={}",
|
||||||
|
faceId, memberSourceEntityList.size(), freeSourceIds != null ? freeSourceIds.size() : 0);
|
||||||
|
|
||||||
|
return StageResult.success(String.format("确定了%d个免费源文件",
|
||||||
|
freeSourceIds != null ? freeSourceIds.size() : 0));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理免费源文件失败,faceId={}", faceId, e);
|
||||||
|
// 免费逻辑失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("免费源文件处理失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.face.pipeline.enums.FaceMatchingScene;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录自定义匹配次数Stage
|
||||||
|
* 负责记录自定义人脸匹配调用次数,用于监控
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 仅在CUSTOM_MATCHING场景执行
|
||||||
|
* 2. 调用metricsRecorder.recordCustomMatchCount()记录次数
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "record_custom_match_metrics",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "记录自定义匹配指标",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class RecordCustomMatchMetricsStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FaceMetricsRecorder metricsRecorder;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "RecordCustomMatchMetrics";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 只有自定义匹配场景才执行
|
||||||
|
if (context.getScene() != FaceMatchingScene.CUSTOM_MATCHING) {
|
||||||
|
log.debug("非自定义匹配场景,跳过记录,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:只有自定义匹配场景才执行
|
||||||
|
if (context.getScene() != FaceMatchingScene.CUSTOM_MATCHING) {
|
||||||
|
log.debug("非自定义匹配场景,跳过记录,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("非自定义匹配场景");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
metricsRecorder.recordCustomMatchCount(faceId);
|
||||||
|
log.debug("记录自定义匹配次数: faceId={}", faceId);
|
||||||
|
return StageResult.success("自定义匹配指标记录完成");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("记录自定义匹配指标失败,faceId={}", faceId, e);
|
||||||
|
// 指标记录失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("指标记录失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.service.pc.helper.FaceMetricsRecorder;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录识别次数Stage
|
||||||
|
* 负责记录人脸识别调用次数,用于监控和防重复检查
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 调用metricsRecorder.recordRecognitionCount()记录识别次数
|
||||||
|
* 2. 检查searchResult是否触发低阈值检测
|
||||||
|
* 3. 如果是,调用metricsRecorder.recordLowThreshold()记录
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "record_metrics",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "记录人脸识别指标",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class RecordMetricsStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FaceMetricsRecorder metricsRecorder;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "RecordMetrics";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 记录识别次数
|
||||||
|
metricsRecorder.recordRecognitionCount(faceId);
|
||||||
|
log.debug("记录识别次数: faceId={}", faceId);
|
||||||
|
|
||||||
|
// 2. 检查是否触发低阈值检测
|
||||||
|
if (context.getSearchResult() != null && context.getSearchResult().isLowThreshold()) {
|
||||||
|
metricsRecorder.recordLowThreshold(faceId);
|
||||||
|
log.debug("触发低阈值检测,记录faceId: {}", faceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StageResult.success("识别指标记录完成");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("记录识别指标失败,faceId={}", faceId, e);
|
||||||
|
// 指标记录失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("指标记录失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.biz.TaskStatusBiz;
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置任务状态Stage
|
||||||
|
* 负责为新用户设置任务状态为待处理
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 仅在isNew=true时执行
|
||||||
|
* 2. 调用taskStatusBiz.setFaceCutStatus(faceId, 0)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "set_task_status",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "设置新用户任务状态"
|
||||||
|
)
|
||||||
|
public class SetTaskStatusStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TaskStatusBiz taskStatusBiz;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "SetTaskStatus";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(FaceMatchingContext context) {
|
||||||
|
// 只有新用户才执行
|
||||||
|
if (!context.isNew()) {
|
||||||
|
log.debug("非新用户,跳过设置任务状态,faceId={}", context.getFaceId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
// 防御性检查:只有新用户才执行
|
||||||
|
if (!context.isNew()) {
|
||||||
|
log.debug("非新用户,跳过设置任务状态,faceId={}", faceId);
|
||||||
|
return StageResult.skipped("非新用户");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
taskStatusBiz.setFaceCutStatus(faceId, 0);
|
||||||
|
log.debug("设置新用户任务状态: faceId={}, status=0", faceId);
|
||||||
|
return StageResult.success("任务状态已设置");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("设置任务状态失败,faceId={}", faceId, e);
|
||||||
|
// 任务状态设置失败不影响主流程,返回降级
|
||||||
|
return StageResult.degraded("任务状态设置失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.ycwl.basic.face.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.face.pipeline.core.FaceMatchingContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.mapper.FaceMapper;
|
||||||
|
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
|
||||||
|
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
|
||||||
|
import com.ycwl.basic.repository.FaceRepository;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人脸结果Stage
|
||||||
|
* 负责将人脸识别结果保存到数据库
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* 1. 从context.searchResult读取识别结果
|
||||||
|
* 2. 更新FaceEntity(score、matchResult、firstMatchRate、matchSampleIds)
|
||||||
|
* 3. 清除缓存
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "update_face_result",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "更新人脸识别结果到数据库"
|
||||||
|
)
|
||||||
|
public class UpdateFaceResultStage extends AbstractPipelineStage<FaceMatchingContext> {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FaceMapper faceMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FaceRepository faceRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "UpdateFaceResult";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<FaceMatchingContext> doExecute(FaceMatchingContext context) {
|
||||||
|
SearchFaceRespVo searchResult = context.getSearchResult();
|
||||||
|
if (searchResult == null) {
|
||||||
|
log.warn("searchResult为空,跳过更新人脸结果,faceId={}", context.getFaceId());
|
||||||
|
return StageResult.skipped("searchResult为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
FaceEntity originalFace = context.getFace();
|
||||||
|
Long faceId = context.getFaceId();
|
||||||
|
|
||||||
|
FaceEntity faceEntity = new FaceEntity();
|
||||||
|
faceEntity.setId(faceId);
|
||||||
|
faceEntity.setScore(searchResult.getScore());
|
||||||
|
faceEntity.setMatchResult(searchResult.getSearchResultJson());
|
||||||
|
|
||||||
|
if (searchResult.getFirstMatchRate() != null) {
|
||||||
|
faceEntity.setFirstMatchRate(BigDecimal.valueOf(searchResult.getFirstMatchRate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchResult.getSampleListIds() != null) {
|
||||||
|
faceEntity.setMatchSampleIds(searchResult.getSampleListIds().stream()
|
||||||
|
.map(String::valueOf)
|
||||||
|
.collect(Collectors.joining(",")));
|
||||||
|
}
|
||||||
|
|
||||||
|
faceEntity.setCreateAt(new Date());
|
||||||
|
faceEntity.setScenicId(originalFace.getScenicId());
|
||||||
|
faceEntity.setMemberId(originalFace.getMemberId());
|
||||||
|
faceEntity.setFaceUrl(originalFace.getFaceUrl());
|
||||||
|
|
||||||
|
faceMapper.update(faceEntity);
|
||||||
|
faceRepository.clearFaceCache(faceId);
|
||||||
|
|
||||||
|
log.debug("人脸结果更新成功:faceId={}, score={}, sampleCount={}",
|
||||||
|
faceId, searchResult.getScore(),
|
||||||
|
searchResult.getSampleListIds() != null ? searchResult.getSampleListIds().size() : 0);
|
||||||
|
|
||||||
|
return StageResult.success("人脸结果更新成功");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("更新人脸结果失败,faceId={}", context.getFaceId(), e);
|
||||||
|
return StageResult.failed("保存人脸识别结果失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ public class AliFaceBodyAdapter implements IFaceBodyAdapter {
|
|||||||
private static final Map<String, IRateLimiter> deleteDbLimiters = new ConcurrentHashMap<>();
|
private static final Map<String, IRateLimiter> deleteDbLimiters = new ConcurrentHashMap<>();
|
||||||
private static final Map<String, IRateLimiter> deleteEntityLimiters = new ConcurrentHashMap<>();
|
private static final Map<String, IRateLimiter> deleteEntityLimiters = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Getter // 添加getter,支持获取accessKeyId
|
||||||
private AliFaceBodyConfig config;
|
private AliFaceBodyConfig config;
|
||||||
|
|
||||||
public boolean setConfig(AliFaceBodyConfig config) {
|
public boolean setConfig(AliFaceBodyConfig config) {
|
||||||
@@ -184,10 +185,8 @@ public class AliFaceBodyAdapter implements IFaceBodyAdapter {
|
|||||||
addFaceRequest.setImageUrl(faceUrl);
|
addFaceRequest.setImageUrl(faceUrl);
|
||||||
addFaceRequest.setExtraData(extData);
|
addFaceRequest.setExtraData(extData);
|
||||||
AddFaceResp respVo = new AddFaceResp();
|
AddFaceResp respVo = new AddFaceResp();
|
||||||
try {
|
// QPS控制已由外层调度器管理,这里不再需要限流
|
||||||
addFaceLimiter.acquire();
|
// 移除阻塞等待: addFaceLimiter.acquire()
|
||||||
} catch (InterruptedException ignored) {
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
AddFaceResponse acsResponse = client.getAcsResponse(addFaceRequest);
|
AddFaceResponse acsResponse = client.getAcsResponse(addFaceRequest);
|
||||||
respVo.setScore(acsResponse.getData().getQualitieScore());
|
respVo.setScore(acsResponse.getData().getQualitieScore());
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ycwl.basic.facebody.adapter;
|
package com.ycwl.basic.facebody.adapter;
|
||||||
|
|
||||||
|
import cn.hutool.core.codec.Base64;
|
||||||
import com.baidu.aip.face.AipFace;
|
import com.baidu.aip.face.AipFace;
|
||||||
import com.ycwl.basic.facebody.entity.AddFaceResp;
|
import com.ycwl.basic.facebody.entity.AddFaceResp;
|
||||||
import com.ycwl.basic.facebody.entity.BceFaceBodyConfig;
|
import com.ycwl.basic.facebody.entity.BceFaceBodyConfig;
|
||||||
@@ -7,12 +8,18 @@ import com.ycwl.basic.facebody.entity.SearchFaceResp;
|
|||||||
import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
|
import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
|
||||||
import com.ycwl.basic.utils.ratelimiter.FixedRateLimiter;
|
import com.ycwl.basic.utils.ratelimiter.FixedRateLimiter;
|
||||||
import com.ycwl.basic.utils.ratelimiter.IRateLimiter;
|
import com.ycwl.basic.utils.ratelimiter.IRateLimiter;
|
||||||
|
import lombok.Getter;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.json.JSONArray;
|
import org.json.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
|
import java.net.URL;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -34,6 +41,8 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
|||||||
private static final Map<String, IRateLimiter> deleteDbLimiters = new ConcurrentHashMap<>();
|
private static final Map<String, IRateLimiter> deleteDbLimiters = new ConcurrentHashMap<>();
|
||||||
private static final Map<String, IRateLimiter> deleteEntityLimiters = new ConcurrentHashMap<>();
|
private static final Map<String, IRateLimiter> deleteEntityLimiters = new ConcurrentHashMap<>();
|
||||||
private static final Map<String, IRateLimiter> deleteFaceLimiters = new ConcurrentHashMap<>();
|
private static final Map<String, IRateLimiter> deleteFaceLimiters = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Getter // 添加getter,支持获取appId和addQps
|
||||||
private BceFaceBodyConfig config;
|
private BceFaceBodyConfig config;
|
||||||
|
|
||||||
public boolean setConfig(BceFaceBodyConfig config) {
|
public boolean setConfig(BceFaceBodyConfig config) {
|
||||||
@@ -143,15 +152,34 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
|||||||
options.put("user_info", extData);
|
options.put("user_info", extData);
|
||||||
// options.put("quality_control", "LOW");
|
// options.put("quality_control", "LOW");
|
||||||
options.put("action_type", "REPLACE");
|
options.put("action_type", "REPLACE");
|
||||||
try {
|
// QPS控制已由外层调度器管理,这里不再需要限流
|
||||||
addEntityLimiter.acquire();
|
// 移除阻塞等待: addEntityLimiter.acquire()
|
||||||
} catch (InterruptedException ignored) {
|
|
||||||
}
|
|
||||||
JSONObject response = client.addUser(faceUrl, "URL", dbName, entityId, options);
|
JSONObject response = client.addUser(faceUrl, "URL", dbName, entityId, options);
|
||||||
if (response.getInt("error_code") == 0) {
|
int errorCode = response.getInt("error_code");
|
||||||
|
if (errorCode == 0) {
|
||||||
AddFaceResp resp = new AddFaceResp();
|
AddFaceResp resp = new AddFaceResp();
|
||||||
resp.setScore(100f);
|
resp.setScore(100f);
|
||||||
return resp;
|
return resp;
|
||||||
|
} else if (errorCode == 222204) {
|
||||||
|
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
|
||||||
|
log.warn("无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
|
||||||
|
String base64Image = downloadImageAsBase64(faceUrl);
|
||||||
|
if (base64Image != null) {
|
||||||
|
// 重试时也不需要限流,由外层调度器控制
|
||||||
|
JSONObject retryResponse = client.addUser(base64Image, "BASE64", dbName, entityId, options);
|
||||||
|
if (retryResponse.getInt("error_code") == 0) {
|
||||||
|
log.info("使用base64重试添加人脸成功,entityId: {}", entityId);
|
||||||
|
AddFaceResp resp = new AddFaceResp();
|
||||||
|
resp.setScore(100f);
|
||||||
|
return resp;
|
||||||
|
} else {
|
||||||
|
log.warn("使用base64重试添加人脸仍失败!{}", retryResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("下载图片转base64失败,无法重试,URL: {}", faceUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log.warn("创建人脸失败!{}", response);
|
log.warn("创建人脸失败!{}", response);
|
||||||
return null;
|
return null;
|
||||||
@@ -284,7 +312,8 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
|||||||
} catch (InterruptedException ignored) {
|
} catch (InterruptedException ignored) {
|
||||||
}
|
}
|
||||||
JSONObject response = client.search(faceUrl, "URL", dbName, options);
|
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);
|
resp.setOriginalFaceScore(100f);
|
||||||
JSONObject resultObj = response.getJSONObject("result");
|
JSONObject resultObj = response.getJSONObject("result");
|
||||||
if (resultObj == null) {
|
if (resultObj == null) {
|
||||||
@@ -307,7 +336,52 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
|||||||
resp.setFirstMatchRate(result.getFirst().getScore());
|
resp.setFirstMatchRate(result.getFirst().getScore());
|
||||||
}
|
}
|
||||||
return resp;
|
return resp;
|
||||||
|
} else if (errorCode == 222204) {
|
||||||
|
// error_code: 222204 表示无法正常访问URL图片,尝试下载并转换为base64后重试
|
||||||
|
log.warn("搜索人脸时无法正常访问URL图片,错误码: 222204,尝试下载图片转base64后重试,URL: {}", faceUrl);
|
||||||
|
String base64Image = downloadImageAsBase64(faceUrl);
|
||||||
|
if (base64Image != null) {
|
||||||
|
try {
|
||||||
|
searchFaceLimiter.acquire();
|
||||||
|
} catch (InterruptedException ignored) {
|
||||||
|
}
|
||||||
|
JSONObject retryResponse = client.search(base64Image, "BASE64", dbName, options);
|
||||||
|
if (retryResponse.getInt("error_code") == 0) {
|
||||||
|
log.info("使用base64重试搜索人脸成功");
|
||||||
|
resp.setOriginalFaceScore(100f);
|
||||||
|
JSONObject resultObj = retryResponse.getJSONObject("result");
|
||||||
|
if (resultObj == null) {
|
||||||
|
resp.setFirstMatchRate(0f);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
JSONArray userList = resultObj.getJSONArray("user_list");
|
||||||
|
List<SearchFaceResultItem> result = new ArrayList<>();
|
||||||
|
for (int i = 0; i < userList.length(); i++) {
|
||||||
|
JSONObject user = userList.getJSONObject(i);
|
||||||
|
SearchFaceResultItem item = new SearchFaceResultItem();
|
||||||
|
item.setDbName(dbName);
|
||||||
|
item.setFaceId(user.getString("user_id"));
|
||||||
|
item.setExtData(user.getString("user_info"));
|
||||||
|
item.setScore(user.getBigDecimal("score").divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP).floatValue());
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
resp.setResult(result);
|
||||||
|
if (!result.isEmpty()) {
|
||||||
|
resp.setFirstMatchRate(result.getFirst().getScore());
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
} else {
|
||||||
|
log.warn("使用base64重试搜索人脸仍失败!{}", retryResponse);
|
||||||
|
resp.setOriginalFaceScore(0f);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.error("下载图片转base64失败,无法重试,URL: {}", faceUrl);
|
||||||
|
resp.setOriginalFaceScore(0f);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
log.warn("搜索人脸失败,错误码: {}, 响应: {}", errorCode, response);
|
||||||
resp.setOriginalFaceScore(0f);
|
resp.setOriginalFaceScore(0f);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
@@ -332,7 +406,53 @@ public class BceFaceBodyAdapter implements IFaceBodyAdapter {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载图片并转换为base64字符串
|
||||||
|
*
|
||||||
|
* @param imageUrl 图片URL
|
||||||
|
* @return base64编码的图片字符串,失败返回null
|
||||||
|
*/
|
||||||
|
private String downloadImageAsBase64(String imageUrl) {
|
||||||
|
BufferedImage image = null;
|
||||||
|
ByteArrayOutputStream baos = null;
|
||||||
|
try {
|
||||||
|
// 下载图片
|
||||||
|
URL url = new URL(imageUrl);
|
||||||
|
image = ImageIO.read(url);
|
||||||
|
if (image == null) {
|
||||||
|
log.error("无法读取图片,URL: {}", imageUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为字节数组
|
||||||
|
baos = new ByteArrayOutputStream();
|
||||||
|
String format = "jpg";
|
||||||
|
if (imageUrl.toLowerCase().endsWith(".png")) {
|
||||||
|
format = "png";
|
||||||
|
}
|
||||||
|
ImageIO.write(image, format, baos);
|
||||||
|
byte[] imageBytes = baos.toByteArray();
|
||||||
|
|
||||||
|
// 编码为base64
|
||||||
|
return Base64.encode(imageBytes);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("下载图片或转换base64失败,URL: {}", imageUrl, e);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (image != null) {
|
||||||
|
image.flush();
|
||||||
|
}
|
||||||
|
if (baos != null) {
|
||||||
|
try {
|
||||||
|
baos.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("关闭ByteArrayOutputStream失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private IRateLimiter getLimiter(LOCK_TYPE type) {
|
private IRateLimiter getLimiter(LOCK_TYPE type) {
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
case ADD_DB ->
|
case ADD_DB ->
|
||||||
|
|||||||
70
src/main/java/com/ycwl/basic/handler/MapTypeHandler.java
Normal file
70
src/main/java/com/ycwl/basic/handler/MapTypeHandler.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package com.ycwl.basic.handler;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.type.BaseTypeHandler;
|
||||||
|
import org.apache.ibatis.type.JdbcType;
|
||||||
|
|
||||||
|
import java.sql.CallableStatement;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map类型的TypeHandler,用于处理JSON字段与Map<String, Integer>的互转
|
||||||
|
* 主要用于机位快速评价等自由格式的JSON存储
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class MapTypeHandler extends BaseTypeHandler<Map<String, Integer>> {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final TypeReference<Map<String, Integer>> typeReference = new TypeReference<Map<String, Integer>>() {};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Integer> parameter, JdbcType jdbcType) throws SQLException {
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(parameter);
|
||||||
|
ps.setString(i, json);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("序列化Map为JSON失败", e);
|
||||||
|
throw new SQLException("序列化Map为JSON失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Integer> getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||||
|
String json = rs.getString(columnName);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Integer> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||||
|
String json = rs.getString(columnIndex);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Integer> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||||
|
String json = cs.getString(columnIndex);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析JSON字符串为Map
|
||||||
|
*/
|
||||||
|
private Map<String, Integer> parseJson(String json) {
|
||||||
|
if (json == null || json.trim().isEmpty()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, typeReference);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("解析JSON为Map失败, json={}", json, e);
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.ycwl.basic.handler;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.ibatis.type.BaseTypeHandler;
|
||||||
|
import org.apache.ibatis.type.JdbcType;
|
||||||
|
|
||||||
|
import java.sql.CallableStatement;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 嵌套Map类型的TypeHandler,用于处理JSON字段与Map<String, Map<String, Integer>>的互转
|
||||||
|
* 主要用于机位评价功能:外层key为机位ID,内层Map为该机位的各维度评分
|
||||||
|
*
|
||||||
|
* 数据格式示例:
|
||||||
|
* {
|
||||||
|
* "12345": {"清晰度": 5, "构图": 4, "色彩": 5, "整体效果": 4},
|
||||||
|
* "12346": {"清晰度": 4, "构图": 5, "色彩": 4, "整体效果": 5}
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class NestedMapTypeHandler extends BaseTypeHandler<Map<String, Map<String, Integer>>> {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final TypeReference<Map<String, Map<String, Integer>>> typeReference =
|
||||||
|
new TypeReference<Map<String, Map<String, Integer>>>() {};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setNonNullParameter(PreparedStatement ps, int i, Map<String, Map<String, Integer>> parameter, JdbcType jdbcType) throws SQLException {
|
||||||
|
try {
|
||||||
|
String json = objectMapper.writeValueAsString(parameter);
|
||||||
|
ps.setString(i, json);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("序列化嵌套Map为JSON失败", e);
|
||||||
|
throw new SQLException("序列化嵌套Map为JSON失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, Integer>> getNullableResult(ResultSet rs, String columnName) throws SQLException {
|
||||||
|
String json = rs.getString(columnName);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, Integer>> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
|
||||||
|
String json = rs.getString(columnIndex);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Map<String, Integer>> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
|
||||||
|
String json = cs.getString(columnIndex);
|
||||||
|
return parseJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析JSON字符串为嵌套Map
|
||||||
|
*/
|
||||||
|
private Map<String, Map<String, Integer>> parseJson(String json) {
|
||||||
|
if (json == null || json.trim().isEmpty()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return objectMapper.readValue(json, typeReference);
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
log.error("解析JSON为嵌套Map失败, json={}", json, e);
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,62 @@
|
|||||||
package com.ycwl.basic.image.enhancer;
|
package com.ycwl.basic.image.enhancer;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
|
||||||
|
import com.ycwl.basic.image.enhancer.adapter.BceImageSR;
|
||||||
|
import com.ycwl.basic.image.enhancer.adapter.IEnhancer;
|
||||||
|
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像增强器工厂
|
||||||
|
* 用于创建不同类型的图像增强器实例
|
||||||
|
*/
|
||||||
public class ImageEnhancerFactory {
|
public class ImageEnhancerFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增强器类型枚举
|
||||||
|
*/
|
||||||
|
public enum EnhancerType {
|
||||||
|
/**
|
||||||
|
* 图像清晰度增强
|
||||||
|
* 使用imageDefinitionEnhance接口,适合提升整体清晰度
|
||||||
|
*/
|
||||||
|
DEFINITION_ENHANCE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像超分辨率
|
||||||
|
* 使用imageQualityEnhance接口,适合放大图片同时保持质量
|
||||||
|
*/
|
||||||
|
SUPER_RESOLUTION
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建图像增强器
|
||||||
|
*
|
||||||
|
* @param type 增强器类型
|
||||||
|
* @param config 百度云配置
|
||||||
|
* @return 图像增强器实例
|
||||||
|
*/
|
||||||
|
public static IEnhancer createEnhancer(EnhancerType type, BceEnhancerConfig config) {
|
||||||
|
IEnhancer enhancer = switch (type) {
|
||||||
|
case DEFINITION_ENHANCE -> new BceImageEnhancer();
|
||||||
|
case SUPER_RESOLUTION -> new BceImageSR();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (enhancer instanceof BceImageEnhancer) {
|
||||||
|
((BceImageEnhancer) enhancer).setConfig(config);
|
||||||
|
} else if (enhancer instanceof BceImageSR) {
|
||||||
|
((BceImageSR) enhancer).setConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enhancer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建默认的图像增强器(清晰度增强)
|
||||||
|
*
|
||||||
|
* @param config 百度云配置
|
||||||
|
* @return 图像增强器实例
|
||||||
|
*/
|
||||||
|
public static IEnhancer createDefaultEnhancer(BceEnhancerConfig config) {
|
||||||
|
return createEnhancer(EnhancerType.DEFINITION_ENHANCE, config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
604
src/main/java/com/ycwl/basic/image/pipeline/CLAUDE.md
Normal file
604
src/main/java/com/ycwl/basic/image/pipeline/CLAUDE.md
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
# Image Pipeline 图片处理管线
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Image Pipeline 是一个通用的、可扩展的图片处理管线框架,用于组织和执行一系列图片处理操作(Stage)。
|
||||||
|
|
||||||
|
### 核心特性
|
||||||
|
|
||||||
|
- **责任链模式**: 将图片处理流程拆分为独立的 Stage,按顺序执行
|
||||||
|
- **Builder 模式**: 灵活组装管线,支持条件性添加 Stage
|
||||||
|
- **动态 Stage 添加**: 支持在运行时根据条件动态添加后续 Stage
|
||||||
|
- **降级策略**: 支持多级降级执行,确保管线在异常情况下的稳定性
|
||||||
|
- **配置驱动**: 支持通过外部配置控制 Stage 的启用/禁用
|
||||||
|
- **类型安全**: 使用泛型和枚举确保类型安全
|
||||||
|
- **解耦设计**: Context 独立于业务模型,支持多种使用场景
|
||||||
|
- **自动清理**: 无论成功或失败都会在 finally 中兜底调用 `context.cleanup()`
|
||||||
|
|
||||||
|
## 包结构
|
||||||
|
|
||||||
|
```
|
||||||
|
com.ycwl.basic.image.pipeline
|
||||||
|
├── annotation/ # 注解定义
|
||||||
|
│ └── StageConfig # Stage 配置注解
|
||||||
|
├── core/ # 核心类
|
||||||
|
│ ├── AbstractPipelineStage # Stage 抽象基类
|
||||||
|
│ ├── PhotoProcessContext # 管线上下文
|
||||||
|
│ ├── Pipeline # 管线执行器
|
||||||
|
│ ├── PipelineBuilder # 管线构建器
|
||||||
|
│ ├── PipelineStage # Stage 接口
|
||||||
|
│ └── StageResult # Stage 执行结果
|
||||||
|
├── enums/ # 枚举定义
|
||||||
|
│ ├── ImageSource # 图片来源枚举
|
||||||
|
│ ├── ImageType # 图片类型枚举
|
||||||
|
│ ├── PipelineScene # 管线场景枚举
|
||||||
|
│ └── StageOptionalMode # Stage 可选模式枚举
|
||||||
|
├── exception/ # 异常类
|
||||||
|
│ ├── PipelineException # 管线异常
|
||||||
|
│ └── StageExecutionException # Stage 执行异常
|
||||||
|
├── stages/ # 具体 Stage 实现
|
||||||
|
│ ├── CleanupStage # 清理临时文件
|
||||||
|
│ ├── ConditionalRotateStage # 条件性旋转
|
||||||
|
│ ├── DownloadStage # 下载图片
|
||||||
|
│ ├── ImageEnhanceStage # 图像增强(超分)
|
||||||
|
│ ├── ImageOrientationStage # 图像方向检测
|
||||||
|
│ ├── ImageQualityCheckStage # 图像质量检测
|
||||||
|
│ ├── PuzzleBorderStage # 拼图边框处理
|
||||||
|
│ ├── RestoreOrientationStage # 恢复图片方向
|
||||||
|
│ ├── SourcePhotoUpdateStage # 源图片更新
|
||||||
|
│ ├── UploadStage # 上传图片
|
||||||
|
│ └── WatermarkStage # 水印处理
|
||||||
|
└── util/ # 工具类
|
||||||
|
└── TempFileManager # 临时文件管理器
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
### 1. Pipeline - 管线执行器
|
||||||
|
|
||||||
|
**职责**: 按顺序执行一系列 Stage,管理执行流程和异常处理。
|
||||||
|
|
||||||
|
**关键特性**:
|
||||||
|
- 顺序执行所有 Stage
|
||||||
|
- 支持动态添加后续 Stage
|
||||||
|
- 循环检测(最大执行 100 个 Stage)
|
||||||
|
- 详细的日志输出(带状态图标)
|
||||||
|
|
||||||
|
**使用示例**:
|
||||||
|
```java
|
||||||
|
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("MyPipeline")
|
||||||
|
.addStage(new DownloadStage())
|
||||||
|
.addStage(new WatermarkStage())
|
||||||
|
.addStage(new UploadStage())
|
||||||
|
.addStage(new CleanupStage())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
boolean success = pipeline.execute(context);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. PhotoProcessContext - 管线上下文
|
||||||
|
|
||||||
|
**职责**: 在各个 Stage 之间传递状态和数据,提供临时文件管理。
|
||||||
|
|
||||||
|
**核心字段**:
|
||||||
|
- `processId`: 处理过程唯一标识,用于隔离临时文件
|
||||||
|
- `originalUrl`: 原图 URL
|
||||||
|
- `scenicId`: 景区 ID
|
||||||
|
- `imageType`: 图片类型(普通照片/拼图/手机上传)
|
||||||
|
- `tempFileManager`: 临时文件管理器
|
||||||
|
|
||||||
|
**静态工厂方法**:
|
||||||
|
```java
|
||||||
|
// 从打印订单创建(打印场景)
|
||||||
|
PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, scenicId);
|
||||||
|
|
||||||
|
// 为超分辨率场景创建
|
||||||
|
PhotoProcessContext context = PhotoProcessContext.forSuperResolution(itemId, url, scenicId);
|
||||||
|
|
||||||
|
// 使用 Builder 自定义创建
|
||||||
|
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||||
|
.processId("custom-id")
|
||||||
|
.originalUrl("https://example.com/image.jpg")
|
||||||
|
.scenicId(12345L)
|
||||||
|
.imageType(ImageType.NORMAL_PHOTO)
|
||||||
|
.source(ImageSource.IPC)
|
||||||
|
.scene(PipelineScene.IMAGE_PRINT)
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
**重要方法**:
|
||||||
|
- `getCurrentFile()`: 获取当前处理中的文件
|
||||||
|
- `updateProcessedFile(File)`: 更新处理后的文件
|
||||||
|
- `setResultUrl(String)`: 设置最终结果 URL(会触发回调)
|
||||||
|
- `cleanup()`: 清理所有临时文件
|
||||||
|
- `isStageEnabled(stageId, default)`: 判断 Stage 是否启用
|
||||||
|
|
||||||
|
### 3. AbstractPipelineStage - Stage 抽象基类
|
||||||
|
|
||||||
|
**职责**: 提供 Stage 的通用实现和模板方法。
|
||||||
|
|
||||||
|
**执行流程**:
|
||||||
|
```
|
||||||
|
shouldExecute() → beforeExecute() → doExecute() → afterExecute()
|
||||||
|
```
|
||||||
|
|
||||||
|
**子类需要实现**:
|
||||||
|
- `getName()`: 返回 Stage 名称
|
||||||
|
- `doExecute(context)`: 实现具体处理逻辑
|
||||||
|
- `shouldExecuteByBusinessLogic(context)`: (可选)实现条件判断
|
||||||
|
|
||||||
|
**Stage 执行判断逻辑**:
|
||||||
|
1. 检查 `@StageConfig` 注解
|
||||||
|
2. 根据 `optionalMode` 决定是否检查外部配置
|
||||||
|
- `FORCE_ON`: 强制执行,不检查外部配置
|
||||||
|
- `SUPPORT`: 检查外部配置(`context.isStageEnabled()`)
|
||||||
|
- `UNSUPPORT`: 不检查外部配置
|
||||||
|
3. 执行业务逻辑判断(`shouldExecuteByBusinessLogic()`)
|
||||||
|
|
||||||
|
### 4. StageResult - Stage 执行结果
|
||||||
|
|
||||||
|
**状态类型**:
|
||||||
|
- `SUCCESS`: 执行成功
|
||||||
|
- `SKIPPED`: 跳过执行
|
||||||
|
- `FAILED`: 执行失败(会终止管线)
|
||||||
|
- `DEGRADED`: 降级执行(继续管线但记录警告)
|
||||||
|
|
||||||
|
**静态工厂方法**:
|
||||||
|
```java
|
||||||
|
// 成功
|
||||||
|
StageResult.success();
|
||||||
|
StageResult.success("处理完成");
|
||||||
|
|
||||||
|
// 成功并动态添加后续 Stage
|
||||||
|
StageResult.successWithNext("质量不佳,添加增强", new ImageEnhanceStage());
|
||||||
|
|
||||||
|
// 跳过
|
||||||
|
StageResult.skipped("条件不满足");
|
||||||
|
|
||||||
|
// 失败
|
||||||
|
StageResult.failed("下载失败");
|
||||||
|
StageResult.failed("处理失败", exception);
|
||||||
|
|
||||||
|
// 降级
|
||||||
|
StageResult.degraded("使用备用方案");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. @StageConfig - Stage 配置注解
|
||||||
|
|
||||||
|
**字段**:
|
||||||
|
- `stageId`: Stage 唯一标识(用于外部配置控制)
|
||||||
|
- `optionalMode`: 可选模式
|
||||||
|
- `FORCE_ON`: 强制执行(如 DownloadStage、CleanupStage)
|
||||||
|
- `SUPPORT`: 支持外部控制(如 WatermarkStage、ImageEnhanceStage)
|
||||||
|
- `UNSUPPORT`: 不支持外部控制(如 RestoreOrientationStage)
|
||||||
|
- `defaultEnabled`: 默认是否启用
|
||||||
|
- `description`: 描述信息
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```java
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "watermark",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "水印处理",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage 列表
|
||||||
|
|
||||||
|
### 核心 Stage
|
||||||
|
|
||||||
|
| Stage | 职责 | Optional Mode | 执行条件 |
|
||||||
|
|-------|------|---------------|---------|
|
||||||
|
| DownloadStage | 从 URL 下载图片到本地 | FORCE_ON | 总是执行 |
|
||||||
|
| CleanupStage | 清理所有临时文件 | FORCE_ON | 总是执行(优先级 999) |
|
||||||
|
|
||||||
|
### 图片处理 Stage
|
||||||
|
|
||||||
|
| Stage | 职责 | Optional Mode | 执行条件 |
|
||||||
|
|-------|------|---------------|---------|
|
||||||
|
| ImageOrientationStage | 检测图片方向(横竖) | UNSUPPORT | 仅普通照片 |
|
||||||
|
| ConditionalRotateStage | 条件性旋转(竖图变横图) | UNSUPPORT | 仅竖图 |
|
||||||
|
| RestoreOrientationStage | 恢复图片方向(横图变回竖图) | UNSUPPORT | 需要旋转的照片 |
|
||||||
|
| WatermarkStage | 添加水印 | SUPPORT | 仅普通照片 |
|
||||||
|
| PuzzleBorderStage | 处理拼图边框 | UNSUPPORT | 仅拼图 |
|
||||||
|
| ImageEnhanceStage | 图像增强(超分) | SUPPORT | 可配置 |
|
||||||
|
| ImageQualityCheckStage | 图像质量检测 | SUPPORT | 仅普通照片 |
|
||||||
|
|
||||||
|
> **提示**:`ImageEnhanceStage` 的默认构造函数会尝试从 `BCE_IMAGE_APP_ID/BCE_IMAGE_API_KEY/BCE_IMAGE_SECRET_KEY` 环境变量读取百度云凭据;若未配置则自动跳过执行。
|
||||||
|
|
||||||
|
### 存储 Stage
|
||||||
|
|
||||||
|
| Stage | 职责 | Optional Mode | 执行条件 |
|
||||||
|
|-------|------|---------------|---------|
|
||||||
|
| UploadStage | 上传图片到存储服务 | FORCE_ON | 总是执行 |
|
||||||
|
| SourcePhotoUpdateStage | 更新源图片记录 | UNSUPPORT | 总是执行 |
|
||||||
|
|
||||||
|
### 辅助 Stage
|
||||||
|
|
||||||
|
| Stage | 职责 | Optional Mode | 执行条件 |
|
||||||
|
|-------|------|---------------|---------|
|
||||||
|
| NoOpStage | 调试/占位 Stage,不做任何处理 | UNSUPPORT | 仅用于保持流程完整或调试 |
|
||||||
|
|
||||||
|
## 典型管线示例
|
||||||
|
|
||||||
|
### 1. 打印照片处理管线
|
||||||
|
|
||||||
|
```java
|
||||||
|
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("PrintPipeline")
|
||||||
|
.addStage(new DownloadStage()) // 1. 下载
|
||||||
|
.addStage(new ImageOrientationStage()) // 2. 检测方向
|
||||||
|
.addStage(new ConditionalRotateStage()) // 3. 旋转竖图
|
||||||
|
.addStage(new WatermarkStage()) // 4. 添加水印
|
||||||
|
.addStage(new RestoreOrientationStage()) // 5. 恢复方向
|
||||||
|
.addStage(new UploadStage()) // 6. 上传
|
||||||
|
.addStage(new CleanupStage()) // 7. 清理
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 拼图处理管线
|
||||||
|
|
||||||
|
```java
|
||||||
|
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("PuzzlePipeline")
|
||||||
|
.addStage(new DownloadStage()) // 1. 下载
|
||||||
|
.addStage(new PuzzleBorderStage()) // 2. 添加拼图边框
|
||||||
|
.addStage(new UploadStage()) // 3. 上传
|
||||||
|
.addStage(new CleanupStage()) // 4. 清理
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 超分辨率增强管线
|
||||||
|
|
||||||
|
```java
|
||||||
|
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("SuperResolutionPipeline")
|
||||||
|
.addStage(new DownloadStage()) // 1. 下载
|
||||||
|
.addStage(new ImageEnhanceStage(config)) // 2. 超分增强
|
||||||
|
.addStage(new SourcePhotoUpdateStage(sourceService, sourceId)) // 3. 更新记录
|
||||||
|
.addStage(new CleanupStage()) // 4. 清理
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 带质量检测的管线(动态 Stage)
|
||||||
|
|
||||||
|
```java
|
||||||
|
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("QualityCheckPipeline")
|
||||||
|
.addStage(new DownloadStage()) // 1. 下载
|
||||||
|
.addStage(new ImageQualityCheckStage()) // 2. 质量检测(可能动态添加 ImageEnhanceStage)
|
||||||
|
.addStage(new WatermarkStage()) // 3. 水印
|
||||||
|
.addStage(new UploadStage()) // 4. 上传
|
||||||
|
.addStage(new CleanupStage()) // 5. 清理
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展指南
|
||||||
|
|
||||||
|
### 如何创建新的 Stage
|
||||||
|
|
||||||
|
1. **继承 AbstractPipelineStage**:
|
||||||
|
```java
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "my_stage",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "我的自定义 Stage",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class MyStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "MyStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||||
|
// 实现条件判断
|
||||||
|
return context.getImageType() == ImageType.NORMAL_PHOTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult doExecute(PhotoProcessContext context) {
|
||||||
|
File currentFile = context.getCurrentFile();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取输入文件
|
||||||
|
if (currentFile == null || !currentFile.exists()) {
|
||||||
|
return StageResult.failed("当前文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 处理图片
|
||||||
|
File outputFile = context.getTempFileManager()
|
||||||
|
.createTempFile("my_output", ".jpg");
|
||||||
|
|
||||||
|
// 执行具体处理逻辑
|
||||||
|
doSomethingWithImage(currentFile, outputFile);
|
||||||
|
|
||||||
|
// 3. 更新 Context
|
||||||
|
context.updateProcessedFile(outputFile);
|
||||||
|
|
||||||
|
// 4. 返回成功结果
|
||||||
|
return StageResult.success("处理完成");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("处理失败", e);
|
||||||
|
return StageResult.failed("处理失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doSomethingWithImage(File input, File output) {
|
||||||
|
// 具体实现
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **添加到管线**:
|
||||||
|
```java
|
||||||
|
pipeline.addStage(new MyStage());
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如何实现降级策略
|
||||||
|
|
||||||
|
参考 `WatermarkStage` 的实现,使用循环尝试多种方案:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
protected StageResult doExecute(PhotoProcessContext context) {
|
||||||
|
List<Strategy> strategies = Arrays.asList(
|
||||||
|
Strategy.ADVANCED,
|
||||||
|
Strategy.STANDARD,
|
||||||
|
Strategy.BASIC
|
||||||
|
);
|
||||||
|
|
||||||
|
for (int i = 0; i < strategies.size(); i++) {
|
||||||
|
Strategy strategy = strategies.get(i);
|
||||||
|
try {
|
||||||
|
StageResult result = tryStrategy(context, strategy);
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
// 使用了降级策略
|
||||||
|
return StageResult.degraded("降级到: " + strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("策略 {} 失败: {}", strategy, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有策略都失败
|
||||||
|
return StageResult.degraded("所有策略失败,跳过处理");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 如何动态添加后续 Stage
|
||||||
|
|
||||||
|
参考 `ImageQualityCheckStage` 的实现:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Override
|
||||||
|
protected StageResult doExecute(PhotoProcessContext context) {
|
||||||
|
boolean needsEnhancement = checkQuality(context.getCurrentFile());
|
||||||
|
|
||||||
|
if (needsEnhancement) {
|
||||||
|
ImageEnhanceStage enhanceStage = new ImageEnhanceStage();
|
||||||
|
return StageResult.successWithNext("质量不佳,添加增强", enhanceStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return StageResult.success("质量良好");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. Context 管理
|
||||||
|
|
||||||
|
- **总是使用静态工厂方法或 Builder**: 避免直接调用构造函数
|
||||||
|
- **及时清理临时文件**: 在 finally 块或使用 CleanupStage
|
||||||
|
- **使用回调更新外部状态**: 通过 `resultUrlCallback` 而非直接操作业务对象
|
||||||
|
|
||||||
|
### 2. Stage 设计
|
||||||
|
|
||||||
|
- **单一职责**: 每个 Stage 只做一件事
|
||||||
|
- **可组合**: Stage 应该可以灵活组合
|
||||||
|
- **幂等性**: 相同输入应产生相同输出
|
||||||
|
- **异常处理**: 捕获异常并返回 `StageResult.failed()` 或 `StageResult.degraded()`
|
||||||
|
- **日志记录**: 在关键操作处记录 debug/info 日志
|
||||||
|
|
||||||
|
### 3. 管线构建
|
||||||
|
|
||||||
|
- **CleanupStage 总是最后**: 确保临时文件总是被清理
|
||||||
|
- **DownloadStage 总是最前**: 确保有本地文件可用
|
||||||
|
- **合理使用 optionalMode**:
|
||||||
|
- 必需的 Stage 使用 `FORCE_ON`
|
||||||
|
- 可选的 Stage 使用 `SUPPORT`
|
||||||
|
- 内部逻辑控制的 Stage 使用 `UNSUPPORT`
|
||||||
|
|
||||||
|
### 4. 性能优化
|
||||||
|
|
||||||
|
- **复用 TempFileManager**: 自动管理临时文件生命周期
|
||||||
|
- **避免重复下载**: 使用 `context.getCurrentFile()` 获取最新文件
|
||||||
|
- **及时更新 processedFile**: 使用 `context.updateProcessedFile()` 通知下一个 Stage
|
||||||
|
|
||||||
|
### 5. 错误处理
|
||||||
|
|
||||||
|
- **失败即停止**: 使用 `StageResult.failed()` 终止管线
|
||||||
|
- **降级继续执行**: 使用 `StageResult.degraded()` 记录问题但继续执行
|
||||||
|
- **跳过非关键 Stage**: 使用 `StageResult.skipped()` 表示条件不满足
|
||||||
|
- **携带异常信息**: `StageResult.failed(message, exception)` 便于排查问题
|
||||||
|
|
||||||
|
## 配置控制
|
||||||
|
|
||||||
|
### 1. 景区级配置
|
||||||
|
|
||||||
|
通过 `ScenicConfigManager` 加载景区配置:
|
||||||
|
|
||||||
|
```java
|
||||||
|
context.setScenicConfigManager(scenicConfigManager);
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage 内部可以获取配置:
|
||||||
|
|
||||||
|
```java
|
||||||
|
ScenicConfigManager config = context.getScenicConfigManager();
|
||||||
|
Boolean enabled = config.getBoolean("my_feature_enabled");
|
||||||
|
String value = config.getString("my_setting");
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 请求级配置
|
||||||
|
|
||||||
|
通过 `loadStageConfig()` 加载请求参数:
|
||||||
|
|
||||||
|
```java
|
||||||
|
Map<String, Boolean> stageConfig = new HashMap<>();
|
||||||
|
stageConfig.put("watermark", false); // 禁用水印
|
||||||
|
stageConfig.put("image_enhance", true); // 启用增强
|
||||||
|
|
||||||
|
context.loadStageConfig(scenicConfigManager, stageConfig);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Stage 启用判断
|
||||||
|
|
||||||
|
在 `AbstractPipelineStage.shouldExecute()` 中自动处理:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 对于 optionalMode = SUPPORT 的 Stage
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "watermark",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
// 如果外部配置禁用了 watermark,则不执行
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试指南
|
||||||
|
|
||||||
|
### 单元测试结构
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
public void testStageSuccess() {
|
||||||
|
// 1. 准备 Context
|
||||||
|
PhotoProcessContext context = PhotoProcessContext.builder()
|
||||||
|
.processId("test-1")
|
||||||
|
.originalUrl("https://example.com/test.jpg")
|
||||||
|
.scenicId(123L)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 2. 创建 Stage
|
||||||
|
MyStage stage = new MyStage();
|
||||||
|
|
||||||
|
// 3. 执行
|
||||||
|
StageResult result = stage.execute(context);
|
||||||
|
|
||||||
|
// 4. 断言
|
||||||
|
assertTrue(result.isSuccess());
|
||||||
|
assertNotNull(context.getCurrentFile());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testStageSkipped() {
|
||||||
|
// 测试条件不满足时跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testStageFailed() {
|
||||||
|
// 测试异常情况
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testStageDegraded() {
|
||||||
|
// 测试降级情况
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 管线集成测试
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Test
|
||||||
|
public void testPipelineExecution() {
|
||||||
|
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<>("TestPipeline")
|
||||||
|
.addStage(new DownloadStage())
|
||||||
|
.addStage(new WatermarkStage())
|
||||||
|
.addStage(new UploadStage())
|
||||||
|
.addStage(new CleanupStage())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
PhotoProcessContext context = createTestContext();
|
||||||
|
|
||||||
|
boolean success = pipeline.execute(context);
|
||||||
|
|
||||||
|
assertTrue(success);
|
||||||
|
assertNotNull(context.getResultUrl());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 如何跳过某个 Stage?
|
||||||
|
|
||||||
|
A: 有三种方式:
|
||||||
|
1. 使用外部配置(适用于 `optionalMode = SUPPORT` 的 Stage)
|
||||||
|
2. 在 `shouldExecuteByBusinessLogic()` 中返回 false
|
||||||
|
3. 构建管线时不添加该 Stage
|
||||||
|
|
||||||
|
### Q: 如何在运行时决定是否添加某个 Stage?
|
||||||
|
|
||||||
|
A: 使用 `PipelineBuilder.addStageIf()`:
|
||||||
|
```java
|
||||||
|
builder.addStageIf(needWatermark, new WatermarkStage());
|
||||||
|
```
|
||||||
|
|
||||||
|
或者使用动态 Stage 添加(`StageResult.successWithNext()`)。
|
||||||
|
|
||||||
|
### Q: 如何处理 Stage 执行失败?
|
||||||
|
|
||||||
|
A: 返回 `StageResult.failed()`,管线会立即终止。如果希望继续执行,使用 `StageResult.degraded()`。
|
||||||
|
|
||||||
|
### Q: 临时文件什么时候被清理?
|
||||||
|
|
||||||
|
A: 由 `CleanupStage` 负责,通常放在管线最后。也可以手动调用 `context.cleanup()`。此外,`Pipeline` 在 finally 中还会再调用一次 `context.cleanup()`,保证失败或异常时也能释放所有临时文件。
|
||||||
|
|
||||||
|
### Q: 如何获取最终处理结果?
|
||||||
|
|
||||||
|
A: 使用 `context.getResultUrl()`,或者在构建 Context 时提供 `resultUrlCallback`。
|
||||||
|
|
||||||
|
### Q: 如何支持新的图片来源或场景?
|
||||||
|
|
||||||
|
A: 扩展 `ImageSource` 或 `PipelineScene` 枚举,然后在 Stage 中添加相应的判断逻辑。
|
||||||
|
|
||||||
|
## 架构演进
|
||||||
|
|
||||||
|
### 已实现的特性
|
||||||
|
|
||||||
|
- ✅ 责任链模式的基础管线框架
|
||||||
|
- ✅ Builder 模式的管线构建
|
||||||
|
- ✅ 动态 Stage 添加
|
||||||
|
- ✅ 多级降级策略
|
||||||
|
- ✅ 配置驱动的 Stage 控制
|
||||||
|
- ✅ Context 与业务模型解耦
|
||||||
|
- ✅ 类型安全的图片分类
|
||||||
|
|
||||||
|
### 未来可能的改进
|
||||||
|
|
||||||
|
- 🔄 支持并行执行某些 Stage
|
||||||
|
- 🔄 支持 Stage 执行超时控制
|
||||||
|
- 🔄 支持管线执行的暂停/恢复
|
||||||
|
- 🔄 支持更细粒度的性能监控
|
||||||
|
- 🔄 支持 Stage 执行的重试机制
|
||||||
|
- 🔄 支持管线执行的可视化追踪
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [ImageUtils 工具类](../utils/ImageUtils.java)
|
||||||
|
- [StorageFactory 存储工厂](../storage/StorageFactory.java)
|
||||||
|
- [WatermarkFactory 水印工厂](../image/watermark/ImageWatermarkFactory.java)
|
||||||
|
|
||||||
|
## 维护者
|
||||||
|
|
||||||
|
- 图片处理管线 - 基础架构团队
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.core;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageSource;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.PipelineScene;
|
||||||
|
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
|
||||||
|
import com.ycwl.basic.model.Crop;
|
||||||
|
import com.ycwl.basic.model.PrinterOrderItem;
|
||||||
|
import com.ycwl.basic.image.pipeline.util.TempFileManager;
|
||||||
|
import com.ycwl.basic.storage.adapters.IStorageAdapter;
|
||||||
|
import com.ycwl.basic.pipeline.core.PipelineContext;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片处理管线上下文
|
||||||
|
* 在各个Stage之间传递状态和数据
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class PhotoProcessContext implements PipelineContext {
|
||||||
|
|
||||||
|
// ==================== 核心字段(构造时必填)====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理过程唯一标识
|
||||||
|
* 用于 TempFileManager 创建隔离的临时文件目录
|
||||||
|
*/
|
||||||
|
private final String processId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原图 URL
|
||||||
|
*/
|
||||||
|
private final String originalUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区 ID
|
||||||
|
*/
|
||||||
|
private final Long scenicId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 临时文件管理器
|
||||||
|
*/
|
||||||
|
private final TempFileManager tempFileManager;
|
||||||
|
|
||||||
|
// ==================== 图片元信息 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片类型
|
||||||
|
*/
|
||||||
|
private ImageType imageType = ImageType.NORMAL_PHOTO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裁剪/旋转信息
|
||||||
|
*/
|
||||||
|
private Crop crop;
|
||||||
|
|
||||||
|
// ==================== 管线配置 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 景区配置管理器,用于获取景区相关配置
|
||||||
|
*/
|
||||||
|
private ScenicConfigManager scenicConfigManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管线处理场景(打印、增强等)
|
||||||
|
*/
|
||||||
|
private PipelineScene scene;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片来源(IPC、相机、手机等)
|
||||||
|
*/
|
||||||
|
private ImageSource source;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage开关配置表
|
||||||
|
* Key: stageId, Value: 是否启用
|
||||||
|
* 整合了景区配置和请求参数
|
||||||
|
*/
|
||||||
|
private Map<String, Boolean> stageEnabledMap = new HashMap<>();
|
||||||
|
|
||||||
|
// ==================== 处理过程状态 ====================
|
||||||
|
|
||||||
|
private File originalFile;
|
||||||
|
private File processedFile;
|
||||||
|
private boolean isLandscape = true;
|
||||||
|
private boolean rotationApplied = false;
|
||||||
|
private boolean cleaned = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像需要旋转的角度(用于后续Stage使用)
|
||||||
|
* 由 ImageOrientationStage 从 Crop.rotation 中提取并设置
|
||||||
|
*/
|
||||||
|
private Integer imageRotation;
|
||||||
|
|
||||||
|
private String resultUrl;
|
||||||
|
private IStorageAdapter storageAdapter;
|
||||||
|
|
||||||
|
// ==================== 回调 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结果 URL 回调
|
||||||
|
* 用于在 setResultUrl 时通知外部更新相关数据
|
||||||
|
*/
|
||||||
|
private Consumer<String> resultUrlCallback;
|
||||||
|
|
||||||
|
// ==================== 构造函数(私有)====================
|
||||||
|
|
||||||
|
private PhotoProcessContext(Builder builder) {
|
||||||
|
this.processId = builder.processId;
|
||||||
|
this.originalUrl = builder.originalUrl;
|
||||||
|
this.scenicId = builder.scenicId;
|
||||||
|
this.tempFileManager = new TempFileManager(processId);
|
||||||
|
|
||||||
|
this.imageType = builder.imageType;
|
||||||
|
this.crop = builder.crop;
|
||||||
|
this.scene = builder.scene;
|
||||||
|
this.source = builder.source;
|
||||||
|
this.resultUrlCallback = builder.resultUrlCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 静态工厂方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 PrinterOrderItem 创建 Context(打印场景兼容方法)
|
||||||
|
*
|
||||||
|
* @param orderItem 打印订单项
|
||||||
|
* @param scenicId 景区ID
|
||||||
|
* @return PhotoProcessContext
|
||||||
|
*/
|
||||||
|
public static PhotoProcessContext fromPrinterOrderItem(PrinterOrderItem orderItem, Long scenicId) {
|
||||||
|
return PhotoProcessContext.builder()
|
||||||
|
.processId(orderItem.getId().toString())
|
||||||
|
.originalUrl(orderItem.getCropUrl())
|
||||||
|
.scenicId(scenicId)
|
||||||
|
.imageType(ImageType.fromSourceId(orderItem.getSourceId()))
|
||||||
|
.crop(orderItem.getCrop())
|
||||||
|
.resultUrlCallback(url -> orderItem.setCropUrl(url))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为超分辨率场景创建 Context
|
||||||
|
*
|
||||||
|
* @param itemId 项目ID(用于临时文件隔离)
|
||||||
|
* @param url 原图URL
|
||||||
|
* @param scenicId 景区ID
|
||||||
|
* @return PhotoProcessContext
|
||||||
|
*/
|
||||||
|
public static PhotoProcessContext forSuperResolution(Long itemId, String url, Long scenicId) {
|
||||||
|
return PhotoProcessContext.builder()
|
||||||
|
.processId(itemId.toString())
|
||||||
|
.originalUrl(url)
|
||||||
|
.scenicId(scenicId)
|
||||||
|
.imageType(ImageType.NORMAL_PHOTO)
|
||||||
|
.source(ImageSource.IPC)
|
||||||
|
.scene(PipelineScene.SOURCE_PHOTO_SUPER_RESOLUTION)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Builder
|
||||||
|
*/
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 业务方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从景区配置和请求参数中加载Stage开关配置
|
||||||
|
*
|
||||||
|
* @param scenicConfigManager 景区配置管理器
|
||||||
|
* @param requestParams 请求参数中的Stage配置
|
||||||
|
*/
|
||||||
|
public void loadStageConfig(ScenicConfigManager scenicConfigManager, Map<String, Boolean> requestParams) {
|
||||||
|
// 请求参数覆盖
|
||||||
|
if (requestParams != null) {
|
||||||
|
stageEnabledMap.putAll(requestParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指定Stage是否启用
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @param defaultEnabled 默认值(当配置未指定时使用)
|
||||||
|
* @return true-启用, false-禁用
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isStageEnabled(String stageId, boolean defaultEnabled) {
|
||||||
|
return stageEnabledMap.getOrDefault(stageId, defaultEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指定Stage是否启用(默认为false)
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @return true-启用, false-禁用
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public boolean isStageEnabled(String stageId) {
|
||||||
|
return stageEnabledMap.getOrDefault(stageId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置指定Stage的启用状态
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @param enabled 是否启用
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public PhotoProcessContext setStageState(String stageId, boolean enabled) {
|
||||||
|
stageEnabledMap.put(stageId, enabled);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用指定Stage
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public PhotoProcessContext enableStage(String stageId) {
|
||||||
|
stageEnabledMap.put(stageId, true);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用指定Stage
|
||||||
|
*
|
||||||
|
* @param stageId Stage唯一标识
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public PhotoProcessContext disableStage(String stageId) {
|
||||||
|
stageEnabledMap.put(stageId, false);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量设置Stage启用状态
|
||||||
|
*
|
||||||
|
* @param stages Stage配置Map(stageId -> enabled)
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public PhotoProcessContext setStages(Map<String, Boolean> stages) {
|
||||||
|
if (stages != null) {
|
||||||
|
stageEnabledMap.putAll(stages);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空所有Stage配置
|
||||||
|
*
|
||||||
|
* @return this(支持链式调用)
|
||||||
|
*/
|
||||||
|
public PhotoProcessContext clearStages() {
|
||||||
|
stageEnabledMap.clear();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置最终处理结果URL
|
||||||
|
*/
|
||||||
|
public void setResultUrl(String url) {
|
||||||
|
this.resultUrl = url;
|
||||||
|
if (resultUrlCallback != null) {
|
||||||
|
resultUrlCallback.accept(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前处理中的文件
|
||||||
|
* 如果有processedFile则返回,否则返回originalFile
|
||||||
|
*/
|
||||||
|
public File getCurrentFile() {
|
||||||
|
return processedFile != null ? processedFile : originalFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新处理后的文件
|
||||||
|
*/
|
||||||
|
public void updateProcessedFile(File newFile) {
|
||||||
|
this.processedFile = newFile;
|
||||||
|
tempFileManager.registerTempFile(newFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理所有临时文件
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void cleanup() {
|
||||||
|
if (cleaned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tempFileManager.cleanup();
|
||||||
|
cleaned = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Builder ====================
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private String processId;
|
||||||
|
private String originalUrl;
|
||||||
|
private Long scenicId;
|
||||||
|
private ImageType imageType = ImageType.NORMAL_PHOTO;
|
||||||
|
private Crop crop;
|
||||||
|
private PipelineScene scene;
|
||||||
|
private ImageSource source;
|
||||||
|
private Consumer<String> resultUrlCallback;
|
||||||
|
|
||||||
|
public Builder processId(String processId) {
|
||||||
|
this.processId = processId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder originalUrl(String originalUrl) {
|
||||||
|
this.originalUrl = originalUrl;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder scenicId(Long scenicId) {
|
||||||
|
this.scenicId = scenicId;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder imageType(ImageType imageType) {
|
||||||
|
this.imageType = imageType;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder crop(Crop crop) {
|
||||||
|
this.crop = crop;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder scene(PipelineScene scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder source(ImageSource source) {
|
||||||
|
this.source = source;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder resultUrlCallback(Consumer<String> callback) {
|
||||||
|
this.resultUrlCallback = callback;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PhotoProcessContext build() {
|
||||||
|
// 参数校验
|
||||||
|
if (originalUrl == null || originalUrl.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("originalUrl is required");
|
||||||
|
}
|
||||||
|
if (scenicId == null) {
|
||||||
|
throw new IllegalArgumentException("scenicId is required");
|
||||||
|
}
|
||||||
|
// processId 可以自动生成
|
||||||
|
if (processId == null || processId.isBlank()) {
|
||||||
|
processId = UUID.randomUUID().toString();
|
||||||
|
}
|
||||||
|
return new PhotoProcessContext(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片来源枚举
|
||||||
|
* 用于标识图片的拍摄或上传来源
|
||||||
|
*/
|
||||||
|
public enum ImageSource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC设备(IP Camera)
|
||||||
|
* 景区固定安装的网络摄像头拍摄的照片
|
||||||
|
*/
|
||||||
|
IPC("ipc", "IPC设备"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 相机设备
|
||||||
|
* 工作人员使用专业相机拍摄的照片
|
||||||
|
*/
|
||||||
|
CAMERA("camera", "相机设备"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机上传
|
||||||
|
* 用户通过手机客户端上传的照片
|
||||||
|
*/
|
||||||
|
PHONE("phone", "手机上传"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未知来源
|
||||||
|
* 无法确定来源或其他特殊情况
|
||||||
|
*/
|
||||||
|
UNKNOWN("unknown", "未知来源");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
ImageSource(String code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据code获取枚举
|
||||||
|
*/
|
||||||
|
public static ImageSource fromCode(String code) {
|
||||||
|
if (code == null) {
|
||||||
|
return UNKNOWN;
|
||||||
|
}
|
||||||
|
for (ImageSource source : values()) {
|
||||||
|
if (source.code.equalsIgnoreCase(code)) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片类型枚举
|
||||||
|
* 用于区分管线处理的图片类型,替代通过 sourceId 判断的逻辑
|
||||||
|
*/
|
||||||
|
public enum ImageType {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 普通照片
|
||||||
|
* 对应原 sourceId > 0 的情况(IPC设备拍摄)
|
||||||
|
*/
|
||||||
|
NORMAL_PHOTO("normal", "普通照片"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图
|
||||||
|
* 对应原 sourceId == 0 的情况
|
||||||
|
*/
|
||||||
|
PUZZLE("puzzle", "拼图"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机上传
|
||||||
|
* 对应原 sourceId == null 的情况
|
||||||
|
*/
|
||||||
|
MOBILE_UPLOAD("mobile", "手机上传");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
ImageType(String code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 sourceId 推断图片类型
|
||||||
|
* 用于兼容现有数据结构
|
||||||
|
*
|
||||||
|
* @param sourceId 源ID(null=手机上传, 0=拼图, >0=普通照片)
|
||||||
|
* @return 对应的图片类型
|
||||||
|
*/
|
||||||
|
public static ImageType fromSourceId(Long sourceId) {
|
||||||
|
if (sourceId == null) {
|
||||||
|
return MOBILE_UPLOAD;
|
||||||
|
} else if (sourceId == 0) {
|
||||||
|
return PUZZLE;
|
||||||
|
} else {
|
||||||
|
return NORMAL_PHOTO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管线处理场景枚举
|
||||||
|
* 用于区分不同的图片处理业务场景
|
||||||
|
*/
|
||||||
|
public enum PipelineScene {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片打印场景
|
||||||
|
* 包括照片打印、拼图打印等
|
||||||
|
*/
|
||||||
|
IMAGE_PRINT("image_print", "图片打印"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片增强场景
|
||||||
|
* 包括图片美化、滤镜处理等
|
||||||
|
*/
|
||||||
|
IMAGE_ENHANCE("image_enhance", "图片增强"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 源图片超分辨率增强场景
|
||||||
|
* IPC设备拍摄的源图片进行质量提升
|
||||||
|
*/
|
||||||
|
SOURCE_PHOTO_SUPER_RESOLUTION("source_photo_sr", "源图片超分辨率增强"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI相机照片增强场景
|
||||||
|
* AI相机拍摄的照片进行超分辨率和质量增强
|
||||||
|
*/
|
||||||
|
AI_CAM_ENHANCE("ai_cam_enhance", "AI相机照片增强");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
PipelineScene(String code, String description) {
|
||||||
|
this.code = code;
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据code获取枚举
|
||||||
|
*/
|
||||||
|
public static PipelineScene fromCode(String code) {
|
||||||
|
if (code == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (PipelineScene scene : values()) {
|
||||||
|
if (scene.code.equals(code)) {
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.exception;
|
||||||
|
|
||||||
|
import com.ycwl.basic.pipeline.exception.PipelineException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage执行异常
|
||||||
|
*/
|
||||||
|
public class StageExecutionException extends PipelineException {
|
||||||
|
|
||||||
|
private final String stageName;
|
||||||
|
|
||||||
|
public StageExecutionException(String stageName, String message) {
|
||||||
|
super(String.format("Stage '%s' 执行失败: %s", stageName, message));
|
||||||
|
this.stageName = stageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StageExecutionException(String stageName, String message, Throwable cause) {
|
||||||
|
super(String.format("Stage '%s' 执行失败: %s", stageName, message), cause);
|
||||||
|
this.stageName = stageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStageName() {
|
||||||
|
return stageName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理临时文件Stage
|
||||||
|
* 总是在管线最后执行,清理所有临时文件
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "cleanup",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "清理临时文件",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class CleanupStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "CleanupStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getPriority() {
|
||||||
|
return 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
try {
|
||||||
|
int fileCount = context.getTempFileManager().getTempFileCount();
|
||||||
|
context.cleanup();
|
||||||
|
|
||||||
|
log.info("临时文件清理完成: 共{}个文件", fileCount);
|
||||||
|
return StageResult.success(String.format("已清理 %d 个临时文件", fileCount));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("临时文件清理失败,但不影响主流程", e);
|
||||||
|
return StageResult.degraded("清理失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.utils.ImageUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 条件旋转Stage
|
||||||
|
* 根据图片需要旋转的角度进行旋转(便于后续水印处理)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "rotate",
|
||||||
|
optionalMode = StageOptionalMode.UNSUPPORT,
|
||||||
|
description = "根据需要旋转图片"
|
||||||
|
)
|
||||||
|
public class ConditionalRotateStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "ConditionalRotateStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||||
|
Integer rotation = context.getImageRotation();
|
||||||
|
return context.getImageType() == ImageType.NORMAL_PHOTO && (rotation != null && rotation != 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
try {
|
||||||
|
File currentFile = context.getCurrentFile();
|
||||||
|
if (currentFile == null || !currentFile.exists()) {
|
||||||
|
return StageResult.failed("当前文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取需要旋转的角度
|
||||||
|
Integer rotation = context.getImageRotation();
|
||||||
|
if (rotation == null || rotation == 0) {
|
||||||
|
return StageResult.skipped("无需旋转");
|
||||||
|
}
|
||||||
|
|
||||||
|
File rotatedFile = context.getTempFileManager()
|
||||||
|
.createTempFile("rotated", ".jpg");
|
||||||
|
|
||||||
|
// 根据实际角度进行旋转
|
||||||
|
log.debug("旋转图片{}度: {} -> {}", rotation, currentFile.getName(), rotatedFile.getName());
|
||||||
|
rotateByDegree(currentFile, rotatedFile, rotation);
|
||||||
|
|
||||||
|
if (!rotatedFile.exists()) {
|
||||||
|
return StageResult.failed("旋转后的文件未生成");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.updateProcessedFile(rotatedFile);
|
||||||
|
context.setRotationApplied(true);
|
||||||
|
|
||||||
|
log.info("图片已旋转{}度", rotation);
|
||||||
|
return StageResult.success("已旋转" + rotation + "度");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("图片旋转失败", e);
|
||||||
|
return StageResult.failed("旋转失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据角度旋转图片
|
||||||
|
*/
|
||||||
|
private void rotateByDegree(File input, File output, int degree) throws Exception {
|
||||||
|
switch (degree) {
|
||||||
|
case 90:
|
||||||
|
ImageUtils.rotateImage90(input, output);
|
||||||
|
break;
|
||||||
|
case 180:
|
||||||
|
ImageUtils.rotateImage180(input, output);
|
||||||
|
break;
|
||||||
|
case 270:
|
||||||
|
ImageUtils.rotateImage270(input, output);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new IllegalArgumentException("不支持的旋转角度: " + degree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import cn.hutool.http.HttpUtil;
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载图片Stage
|
||||||
|
* 从URL下载原图到本地临时文件
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "download",
|
||||||
|
optionalMode = StageOptionalMode.FORCE_ON,
|
||||||
|
description = "下载图片",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class DownloadStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "DownloadStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
String url = context.getOriginalUrl();
|
||||||
|
if (StringUtils.isBlank(url)) {
|
||||||
|
return StageResult.failed("原图URL为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String fileExtension = determineFileExtension(context);
|
||||||
|
String filePrefix = context.getImageType() == ImageType.PUZZLE ? "puzzle" : "print";
|
||||||
|
|
||||||
|
File downloadFile = context.getTempFileManager()
|
||||||
|
.createTempFile(filePrefix, fileExtension);
|
||||||
|
|
||||||
|
log.debug("开始下载图片: {} -> {}", url, downloadFile.getName());
|
||||||
|
HttpUtil.downloadFile(url, downloadFile);
|
||||||
|
|
||||||
|
if (!downloadFile.exists() || downloadFile.length() == 0) {
|
||||||
|
return StageResult.failed("下载的文件不存在或为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setOriginalFile(downloadFile);
|
||||||
|
log.info("图片下载成功: {} ({}KB)", downloadFile.getName(),
|
||||||
|
downloadFile.length() / 1024);
|
||||||
|
|
||||||
|
return StageResult.success(String.format("已下载 %dKB", downloadFile.length() / 1024));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("图片下载失败: {}", url, e);
|
||||||
|
return StageResult.failed("下载失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String determineFileExtension(PhotoProcessContext context) {
|
||||||
|
if (context.getImageType() == ImageType.PUZZLE) {
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
String url = context.getOriginalUrl();
|
||||||
|
if (url.toLowerCase().endsWith(".png")) {
|
||||||
|
return ".png";
|
||||||
|
}
|
||||||
|
return ".jpg";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.enhancer.adapter.BceImageEnhancer;
|
||||||
|
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageSource;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像增强Stage
|
||||||
|
* 使用百度云图像增强API提升图片清晰度和质量
|
||||||
|
*
|
||||||
|
* 支持两种增强模式:
|
||||||
|
* 1. 图像清晰度增强 (BceImageEnhancer)
|
||||||
|
* 2. 图像超分辨率 (BceImageSR)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "image_enhance",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "图像增强处理",
|
||||||
|
defaultEnabled = false // 默认不启用,需要外部配置开启
|
||||||
|
)
|
||||||
|
public class ImageEnhanceStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
private BceEnhancerConfig enhancerConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数 - 使用默认配置
|
||||||
|
*/
|
||||||
|
public ImageEnhanceStage() {
|
||||||
|
this(buildConfigFromEnvironment());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数 - 使用自定义配置
|
||||||
|
*
|
||||||
|
* @param enhancerConfig 图像增强配置
|
||||||
|
*/
|
||||||
|
public ImageEnhanceStage(BceEnhancerConfig enhancerConfig) {
|
||||||
|
this.enhancerConfig = enhancerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "ImageEnhanceStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||||
|
// 仅对照片源为IPC的图片进行增强
|
||||||
|
return context.getSource() == ImageSource.IPC;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
// 检查配置是否完整
|
||||||
|
if (!isConfigValid()) {
|
||||||
|
log.warn("图像增强配置不完整,跳过增强处理。请在ImageEnhanceStage中配置百度云API凭证");
|
||||||
|
return StageResult.skipped("配置不完整,跳过增强");
|
||||||
|
}
|
||||||
|
|
||||||
|
File currentFile = context.getCurrentFile();
|
||||||
|
if (currentFile == null || !currentFile.exists()) {
|
||||||
|
return StageResult.skipped("当前文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug("开始图像增强: {}", currentFile.getName());
|
||||||
|
|
||||||
|
// 创建百度云图像增强客户端
|
||||||
|
BceImageEnhancer enhancer = new BceImageEnhancer();
|
||||||
|
enhancer.setConfig(enhancerConfig);
|
||||||
|
|
||||||
|
// 调用图像增强API
|
||||||
|
// 注意:百度云API需要传入图片URL,这里使用本地文件的绝对路径
|
||||||
|
String imageUrl = currentFile.getAbsolutePath();
|
||||||
|
MultipartFile enhancedImage = enhancer.enhance(imageUrl);
|
||||||
|
|
||||||
|
if (enhancedImage == null || enhancedImage.isEmpty()) {
|
||||||
|
log.warn("图像增强返回空结果,可能是API调用失败");
|
||||||
|
return StageResult.degraded("增强失败,使用原图");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存增强后的图片到临时文件
|
||||||
|
File enhancedFile = context.getTempFileManager()
|
||||||
|
.createTempFile("enhanced", ".jpg");
|
||||||
|
|
||||||
|
saveMultipartFileToFile(enhancedImage, enhancedFile);
|
||||||
|
|
||||||
|
if (!enhancedFile.exists() || enhancedFile.length() == 0) {
|
||||||
|
return StageResult.degraded("增强结果保存失败,使用原图");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新处理后的文件
|
||||||
|
context.updateProcessedFile(enhancedFile);
|
||||||
|
|
||||||
|
long originalSize = currentFile.length();
|
||||||
|
long enhancedSize = enhancedFile.length();
|
||||||
|
double sizeRatio = (double) enhancedSize / originalSize;
|
||||||
|
|
||||||
|
log.info("图像增强完成: 原始{}KB -> 增强后{}KB (比例: {})",
|
||||||
|
originalSize / 1024,
|
||||||
|
enhancedSize / 1024,
|
||||||
|
String.format("%.2f", sizeRatio));
|
||||||
|
|
||||||
|
return StageResult.success(String.format("已增强 (%dKB -> %dKB)",
|
||||||
|
originalSize / 1024,
|
||||||
|
enhancedSize / 1024));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("图像增强失败: {}", e.getMessage(), e);
|
||||||
|
|
||||||
|
// 增强失败时返回降级状态,继续使用原图
|
||||||
|
return StageResult.degraded("增强失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查配置是否有效
|
||||||
|
*/
|
||||||
|
private boolean isConfigValid() {
|
||||||
|
if (enhancerConfig == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String appId = enhancerConfig.getAppId();
|
||||||
|
String apiKey = enhancerConfig.getApiKey();
|
||||||
|
String secretKey = enhancerConfig.getSecretKey();
|
||||||
|
|
||||||
|
// 检查字段是否为 null 或空
|
||||||
|
if (appId == null || appId.isBlank() ||
|
||||||
|
apiKey == null || apiKey.isBlank() ||
|
||||||
|
secretKey == null || secretKey.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含 TODO 占位符
|
||||||
|
if (appId.contains("TODO") || apiKey.contains("TODO") || secretKey.contains("TODO")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BceEnhancerConfig buildConfigFromEnvironment() {
|
||||||
|
BceEnhancerConfig config = new BceEnhancerConfig();
|
||||||
|
config.setAppId(System.getenv("BCE_IMAGE_APP_ID"));
|
||||||
|
config.setApiKey(System.getenv("BCE_IMAGE_API_KEY"));
|
||||||
|
config.setSecretKey(System.getenv("BCE_IMAGE_SECRET_KEY"));
|
||||||
|
config.setQps(1.0f);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存MultipartFile到本地文件
|
||||||
|
*/
|
||||||
|
private void saveMultipartFileToFile(MultipartFile multipartFile, File targetFile) throws IOException {
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
|
||||||
|
fos.write(multipartFile.getBytes());
|
||||||
|
fos.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前配置(用于调试)
|
||||||
|
*/
|
||||||
|
public BceEnhancerConfig getEnhancerConfig() {
|
||||||
|
return enhancerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置配置(用于动态配置)
|
||||||
|
*/
|
||||||
|
public void setEnhancerConfig(BceEnhancerConfig enhancerConfig) {
|
||||||
|
this.enhancerConfig = enhancerConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageSource;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||||
|
import com.ycwl.basic.model.Crop;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.utils.ImageUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片方向检测Stage
|
||||||
|
* 检测图片是横图还是竖图,并记录到Context
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "orientation",
|
||||||
|
optionalMode = StageOptionalMode.UNSUPPORT,
|
||||||
|
description = "图片方向检测"
|
||||||
|
)
|
||||||
|
public class ImageOrientationStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "ImageOrientationStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||||
|
return context.getImageType() == ImageType.NORMAL_PHOTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
try {
|
||||||
|
File currentFile = context.getCurrentFile();
|
||||||
|
if (currentFile == null || !currentFile.exists()) {
|
||||||
|
return StageResult.failed("当前文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isLandscape = detectOrientation(currentFile, context.getCrop());
|
||||||
|
context.setLandscape(isLandscape);
|
||||||
|
|
||||||
|
// 保存 rotation 信息到 context,方便后续 Stage 使用
|
||||||
|
if (context.getCrop() != null && context.getCrop().getRotation() != null) {
|
||||||
|
context.setImageRotation(context.getCrop().getRotation());
|
||||||
|
}
|
||||||
|
|
||||||
|
String orientation = isLandscape ? "横图" : "竖图";
|
||||||
|
log.info("图片方向检测: {}", orientation);
|
||||||
|
|
||||||
|
return StageResult.success(orientation);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("图片方向检测失败", e);
|
||||||
|
return StageResult.failed("方向检测失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测图片方向
|
||||||
|
* 综合考虑物理分辨率和rotation字段
|
||||||
|
* rotation表示"需要旋转多少度照片才能正确显示"
|
||||||
|
*/
|
||||||
|
private boolean detectOrientation(File imageFile, Crop crop) {
|
||||||
|
try {
|
||||||
|
// 先获取物理分辨率方向
|
||||||
|
boolean physicalLandscape = ImageUtils.isLandscape(imageFile);
|
||||||
|
|
||||||
|
// 如果有rotation信息,需要综合判断
|
||||||
|
if (crop != null && crop.getRotation() != null) {
|
||||||
|
int rotation = crop.getRotation();
|
||||||
|
// rotation=90/270 会翻转方向
|
||||||
|
boolean isLandscape = (rotation == 90 || rotation == 270) ? !physicalLandscape : physicalLandscape;
|
||||||
|
log.debug("综合判断方向: physicalLandscape={}, rotation={}, finalLandscape={}",
|
||||||
|
physicalLandscape, rotation, isLandscape);
|
||||||
|
return isLandscape;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有rotation信息,直接使用物理分辨率
|
||||||
|
log.debug("从图片尺寸判断方向: isLandscape={}", physicalLandscape);
|
||||||
|
return physicalLandscape;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("从图片尺寸判断方向失败,默认为横图: {}", e.getMessage());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.utils.ImageUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像质量检测Stage
|
||||||
|
* 检测图片质量,如果检测到质量不佳则动态添加ImageEnhanceStage
|
||||||
|
*
|
||||||
|
* 此Stage展示了如何在运行时动态添加后续Stage的能力
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "quality_check",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "图像质量检测",
|
||||||
|
defaultEnabled = false // 默认不启用
|
||||||
|
)
|
||||||
|
public class ImageQualityCheckStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 质量阈值:图片尺寸小于此阈值认为质量不佳
|
||||||
|
* 例如:小于100KB的图片可能需要增强
|
||||||
|
*/
|
||||||
|
private static final long QUALITY_THRESHOLD_BYTES = 100 * 1024; // 100KB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分辨率阈值:图片分辨率低于此值认为质量不佳
|
||||||
|
*/
|
||||||
|
private static final int MIN_WIDTH = 800;
|
||||||
|
private static final int MIN_HEIGHT = 600;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "ImageQualityCheckStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||||
|
// 仅对普通照片执行质量检测
|
||||||
|
return context.getImageType() == ImageType.NORMAL_PHOTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
File currentFile = context.getCurrentFile();
|
||||||
|
if (currentFile == null || !currentFile.exists()) {
|
||||||
|
return StageResult.failed("当前文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查文件大小
|
||||||
|
long fileSize = currentFile.length();
|
||||||
|
log.debug("图像质量检测: 文件大小={}KB, 阈值={}KB",
|
||||||
|
fileSize / 1024,
|
||||||
|
QUALITY_THRESHOLD_BYTES / 1024);
|
||||||
|
|
||||||
|
boolean needsEnhancement = false;
|
||||||
|
String reason = "";
|
||||||
|
|
||||||
|
// 检查:文件大小
|
||||||
|
if (fileSize < QUALITY_THRESHOLD_BYTES) {
|
||||||
|
needsEnhancement = true;
|
||||||
|
reason = String.format("文件过小(%dKB < %dKB)",
|
||||||
|
fileSize / 1024,
|
||||||
|
QUALITY_THRESHOLD_BYTES / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 可以添加更多质量检测维度
|
||||||
|
// 例如:使用BufferedImage读取图片获取分辨率
|
||||||
|
// 例如:使用OpenCV进行图片质量评估
|
||||||
|
// 例如:检查图片的EXIF信息
|
||||||
|
|
||||||
|
// 如果需要增强,动态添加ImageEnhanceStage
|
||||||
|
if (needsEnhancement) {
|
||||||
|
log.info("检测到图片质量不佳({}), 动态添加ImageEnhanceStage", reason);
|
||||||
|
|
||||||
|
ImageEnhanceStage enhanceStage = new ImageEnhanceStage();
|
||||||
|
|
||||||
|
// 使用successWithNext返回,动态添加后续Stage
|
||||||
|
return StageResult.successWithNext(
|
||||||
|
"质量不佳,添加增强Stage: " + reason,
|
||||||
|
enhanceStage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 质量良好,无需增强
|
||||||
|
log.info("图像质量良好,无需增强: {}KB", fileSize / 1024);
|
||||||
|
return StageResult.success("质量良好,无需增强");
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("图像质量检测失败", e);
|
||||||
|
// 检测失败时不影响管线,继续执行
|
||||||
|
return StageResult.degraded("质量检测失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 示例:也可以根据其他条件动态添加不同的Stage
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private StageResult<PhotoProcessContext> checkAndAddMultipleStages(PhotoProcessContext context) {
|
||||||
|
// 示例:根据不同条件添加多个Stage
|
||||||
|
|
||||||
|
// 假设检测到需要多个增强操作
|
||||||
|
ImageEnhanceStage enhanceStage = new ImageEnhanceStage();
|
||||||
|
// 其他Stage...
|
||||||
|
|
||||||
|
// 可以一次性添加多个Stage
|
||||||
|
return StageResult.successWithNext(
|
||||||
|
"检测到需要多重处理",
|
||||||
|
enhanceStage
|
||||||
|
// 可以添加更多Stage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像缩放Stage
|
||||||
|
* 支持按比例放大或缩小图片
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "image_resize",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "图像缩放处理",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class ImageResizeStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
private final double scaleFactor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param scaleFactor 缩放比例(例如: 1.5表示放大1.5倍, 0.333表示缩小到1/3)
|
||||||
|
*/
|
||||||
|
public ImageResizeStage(double scaleFactor) {
|
||||||
|
if (scaleFactor <= 0) {
|
||||||
|
throw new IllegalArgumentException("scaleFactor must be positive");
|
||||||
|
}
|
||||||
|
this.scaleFactor = scaleFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "ImageResizeStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
File currentFile = context.getCurrentFile();
|
||||||
|
if (currentFile == null || !currentFile.exists()) {
|
||||||
|
return StageResult.skipped("当前文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
BufferedImage originalImage = null;
|
||||||
|
BufferedImage resizedImage = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug("开始图像缩放处理: file={}, scaleFactor={}", currentFile.getName(), scaleFactor);
|
||||||
|
|
||||||
|
// 读取原图
|
||||||
|
originalImage = ImageIO.read(currentFile);
|
||||||
|
if (originalImage == null) {
|
||||||
|
return StageResult.failed("无法读取图片文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
int originalWidth = originalImage.getWidth();
|
||||||
|
int originalHeight = originalImage.getHeight();
|
||||||
|
|
||||||
|
// 计算新尺寸
|
||||||
|
int newWidth = (int) Math.round(originalWidth * scaleFactor);
|
||||||
|
int newHeight = (int) Math.round(originalHeight * scaleFactor);
|
||||||
|
|
||||||
|
// 检查尺寸是否合理
|
||||||
|
if (newWidth <= 0 || newHeight <= 0) {
|
||||||
|
return StageResult.failed("缩放后尺寸无效: " + newWidth + "x" + newHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建缩放后的图像
|
||||||
|
resizedImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D g2d = resizedImage.createGraphics();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 设置高质量渲染选项
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||||
|
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
|
||||||
|
// 执行缩放
|
||||||
|
g2d.drawImage(originalImage, 0, 0, newWidth, newHeight, null);
|
||||||
|
} finally {
|
||||||
|
g2d.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存缩放后的图片
|
||||||
|
File resizedFile = context.getTempFileManager().createTempFile("resized", ".jpg");
|
||||||
|
ImageIO.write(resizedImage, "jpg", resizedFile);
|
||||||
|
|
||||||
|
if (!resizedFile.exists() || resizedFile.length() == 0) {
|
||||||
|
return StageResult.failed("缩放后图片保存失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新处理后的文件
|
||||||
|
context.updateProcessedFile(resizedFile);
|
||||||
|
|
||||||
|
log.info("图像缩放完成: {}x{} -> {}x{} (比例: {})",
|
||||||
|
originalWidth, originalHeight,
|
||||||
|
newWidth, newHeight,
|
||||||
|
scaleFactor);
|
||||||
|
|
||||||
|
return StageResult.success(String.format("缩放完成 (%dx%d -> %dx%d)",
|
||||||
|
originalWidth, originalHeight, newWidth, newHeight));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("图像缩放失败: {}", e.getMessage(), e);
|
||||||
|
return StageResult.failed("缩放失败: " + e.getMessage(), e);
|
||||||
|
} finally {
|
||||||
|
// 释放图像资源
|
||||||
|
if (originalImage != null) {
|
||||||
|
originalImage.flush();
|
||||||
|
}
|
||||||
|
if (resizedImage != null) {
|
||||||
|
resizedImage.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.enhancer.adapter.BceImageSR;
|
||||||
|
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageSource;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图像超分辨率Stage
|
||||||
|
* 使用百度云图像超分辨率API提升图片分辨率和清晰度
|
||||||
|
*
|
||||||
|
* 与ImageEnhanceStage的区别:
|
||||||
|
* - ImageEnhanceStage: 使用BceImageEnhancer进行清晰度增强
|
||||||
|
* - ImageSRStage: 使用BceImageSR进行超分辨率处理
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "image_sr",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "图像超分辨率处理",
|
||||||
|
defaultEnabled = false // 默认不启用,需要外部配置开启
|
||||||
|
)
|
||||||
|
public class ImageSRStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
private BceEnhancerConfig enhancerConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数 - 使用默认配置
|
||||||
|
*/
|
||||||
|
public ImageSRStage() {
|
||||||
|
this(buildConfigFromEnvironment());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数 - 使用自定义配置
|
||||||
|
*
|
||||||
|
* @param enhancerConfig 图像增强配置
|
||||||
|
*/
|
||||||
|
public ImageSRStage(BceEnhancerConfig enhancerConfig) {
|
||||||
|
this.enhancerConfig = enhancerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "ImageSRStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||||
|
// 仅对照片源为IPC的图片进行超分辨率处理
|
||||||
|
return context.getSource() == ImageSource.IPC;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
// 检查配置是否完整
|
||||||
|
if (!isConfigValid()) {
|
||||||
|
log.warn("图像超分辨率配置不完整,跳过处理。请配置百度云API凭证");
|
||||||
|
return StageResult.skipped("配置不完整,跳过超分辨率");
|
||||||
|
}
|
||||||
|
|
||||||
|
File currentFile = context.getCurrentFile();
|
||||||
|
if (currentFile == null || !currentFile.exists()) {
|
||||||
|
return StageResult.skipped("当前文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug("开始图像超分辨率处理: {}", currentFile.getName());
|
||||||
|
|
||||||
|
// 创建百度云图像超分辨率客户端
|
||||||
|
BceImageSR srEnhancer = new BceImageSR();
|
||||||
|
srEnhancer.setConfig(enhancerConfig);
|
||||||
|
|
||||||
|
// 调用图像超分辨率API
|
||||||
|
// 注意:百度云API需要传入图片URL,这里使用本地文件的绝对路径
|
||||||
|
String imageUrl = currentFile.getAbsolutePath();
|
||||||
|
MultipartFile enhancedImage = srEnhancer.enhance(imageUrl);
|
||||||
|
|
||||||
|
if (enhancedImage == null || enhancedImage.isEmpty()) {
|
||||||
|
log.warn("图像超分辨率返回空结果,可能是API调用失败");
|
||||||
|
return StageResult.degraded("超分辨率失败,使用原图");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存超分辨率后的图片到临时文件
|
||||||
|
File enhancedFile = context.getTempFileManager()
|
||||||
|
.createTempFile("sr_enhanced", ".jpg");
|
||||||
|
|
||||||
|
saveMultipartFileToFile(enhancedImage, enhancedFile);
|
||||||
|
|
||||||
|
if (!enhancedFile.exists() || enhancedFile.length() == 0) {
|
||||||
|
return StageResult.degraded("超分辨率结果保存失败,使用原图");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新处理后的文件
|
||||||
|
context.updateProcessedFile(enhancedFile);
|
||||||
|
|
||||||
|
long originalSize = currentFile.length();
|
||||||
|
long enhancedSize = enhancedFile.length();
|
||||||
|
double sizeRatio = (double) enhancedSize / originalSize;
|
||||||
|
|
||||||
|
log.info("图像超分辨率完成: 原始{}KB -> 超分后{}KB (比例: {})",
|
||||||
|
originalSize / 1024,
|
||||||
|
enhancedSize / 1024,
|
||||||
|
String.format("%.2f", sizeRatio));
|
||||||
|
|
||||||
|
return StageResult.success(String.format("超分辨率完成 (%dKB -> %dKB)",
|
||||||
|
originalSize / 1024,
|
||||||
|
enhancedSize / 1024));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("图像超分辨率失败: {}", e.getMessage(), e);
|
||||||
|
|
||||||
|
// 超分辨率失败时返回降级状态,继续使用原图
|
||||||
|
return StageResult.degraded("超分辨率失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查配置是否有效
|
||||||
|
*/
|
||||||
|
private boolean isConfigValid() {
|
||||||
|
if (enhancerConfig == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String appId = enhancerConfig.getAppId();
|
||||||
|
String apiKey = enhancerConfig.getApiKey();
|
||||||
|
String secretKey = enhancerConfig.getSecretKey();
|
||||||
|
|
||||||
|
// 检查字段是否为 null 或空
|
||||||
|
if (appId == null || appId.isBlank() ||
|
||||||
|
apiKey == null || apiKey.isBlank() ||
|
||||||
|
secretKey == null || secretKey.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否包含 TODO 占位符
|
||||||
|
if (appId.contains("TODO") || apiKey.contains("TODO") || secretKey.contains("TODO")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从环境变量构建配置
|
||||||
|
*/
|
||||||
|
private static BceEnhancerConfig buildConfigFromEnvironment() {
|
||||||
|
BceEnhancerConfig config = new BceEnhancerConfig();
|
||||||
|
config.setAppId(System.getenv("BCE_IMAGE_APP_ID"));
|
||||||
|
config.setApiKey(System.getenv("BCE_IMAGE_API_KEY"));
|
||||||
|
config.setSecretKey(System.getenv("BCE_IMAGE_SECRET_KEY"));
|
||||||
|
config.setQps(1.0f);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存MultipartFile到本地文件
|
||||||
|
*/
|
||||||
|
private void saveMultipartFileToFile(MultipartFile multipartFile, File targetFile) throws IOException {
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(targetFile)) {
|
||||||
|
fos.write(multipartFile.getBytes());
|
||||||
|
fos.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前配置(用于调试)
|
||||||
|
*/
|
||||||
|
public BceEnhancerConfig getEnhancerConfig() {
|
||||||
|
return enhancerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置配置(用于动态配置)
|
||||||
|
*/
|
||||||
|
public void setEnhancerConfig(BceEnhancerConfig enhancerConfig) {
|
||||||
|
this.enhancerConfig = enhancerConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
|
||||||
|
public class NoOpStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
return StageResult.skipped("无操作");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "NoOpStage";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.ycwl.basic.image.pipeline.stages;
|
||||||
|
|
||||||
|
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
|
||||||
|
import com.ycwl.basic.image.pipeline.enums.ImageType;
|
||||||
|
import com.ycwl.basic.pipeline.annotation.StageConfig;
|
||||||
|
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
|
||||||
|
import com.ycwl.basic.pipeline.core.StageResult;
|
||||||
|
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
|
||||||
|
import com.ycwl.basic.utils.ImageUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拼图边框处理Stage
|
||||||
|
* 为拼图添加白边框并向上偏移
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@StageConfig(
|
||||||
|
stageId = "puzzle_border",
|
||||||
|
optionalMode = StageOptionalMode.SUPPORT,
|
||||||
|
description = "拼图边框处理",
|
||||||
|
defaultEnabled = true
|
||||||
|
)
|
||||||
|
public class PuzzleBorderStage extends AbstractPipelineStage<PhotoProcessContext> {
|
||||||
|
|
||||||
|
private static final int BORDER_LR = 20;
|
||||||
|
private static final int BORDER_TB = 30;
|
||||||
|
private static final int SHIFT_UP = 15;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "PuzzleBorderStage";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldExecuteByBusinessLogic(PhotoProcessContext context) {
|
||||||
|
return context.getImageType() == ImageType.PUZZLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
|
||||||
|
try {
|
||||||
|
File currentFile = context.getCurrentFile();
|
||||||
|
if (currentFile == null || !currentFile.exists()) {
|
||||||
|
return StageResult.failed("当前文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
File processedFile = context.getTempFileManager()
|
||||||
|
.createTempFile("puzzle_processed", ".png");
|
||||||
|
|
||||||
|
log.debug("拼图添加边框: lr={}, tb={}, shiftUp={}", BORDER_LR, BORDER_TB, SHIFT_UP);
|
||||||
|
ImageUtils.addBorderAndShiftUp(currentFile, processedFile, BORDER_LR, BORDER_TB, SHIFT_UP);
|
||||||
|
|
||||||
|
if (!processedFile.exists()) {
|
||||||
|
return StageResult.failed("处理后的文件未生成");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.updateProcessedFile(processedFile);
|
||||||
|
log.info("拼图边框处理完成: {}KB", processedFile.length() / 1024);
|
||||||
|
|
||||||
|
return StageResult.success(String.format("边框处理完成 (%dKB)", processedFile.length() / 1024));
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("拼图边框处理失败", e);
|
||||||
|
return StageResult.failed("边框处理失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user