From 995e2ee65af0e05cc469277076edb184e6496c44 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Mon, 27 Apr 2026 09:18:10 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + app/.env | 5 +- app/amazon/__pycache__/main.cpython-312.pyc | Bin 29925 -> 29925 bytes backend-java/jstack-33832.txt | 1106 +++++++++++++++++ backend-java/pom.xml | 12 + .../exception/GlobalExceptionHandler.java | 3 + .../service/DistributedJobLockService.java | 8 +- .../config/AppearancePatentProperties.java | 16 + .../aiimage/config/PropertiesConfig.java | 2 +- .../aiimage/config/TaskFileJobConfig.java | 27 + .../config/TransientStorageProperties.java | 15 + .../client/AppearancePatentCozeClient.java | 156 +++ .../AppearancePatentController.java | 143 +++ .../dto/AppearancePatentParseRequest.java | 28 + .../dto/AppearancePatentParsedPayloadDto.java | 21 + .../dto/AppearancePatentResultRowDto.java | 33 + .../dto/AppearancePatentSourceFileDto.java | 15 + .../AppearancePatentSubmitResultRequest.java | 24 + .../dto/AppearancePatentTaskBatchRequest.java | 15 + .../model/vo/AppearancePatentDashboardVo.java | 17 + .../vo/AppearancePatentHistoryItemVo.java | 33 + .../model/vo/AppearancePatentHistoryVo.java | 14 + .../model/vo/AppearancePatentParseVo.java | 26 + .../model/vo/AppearancePatentParsedRowVo.java | 28 + .../model/vo/AppearancePatentTaskBatchVo.java | 16 + .../vo/AppearancePatentTaskDetailVo.java | 16 + .../model/vo/AppearancePatentTaskItemVo.java | 23 + .../AppearancePatentTaskCacheService.java | 144 +++ .../service/AppearancePatentTaskService.java | 1001 +++++++++++++++ .../BrandTaskProgressCacheService.java | 58 +- .../brand/service/BrandTaskService.java | 79 +- .../service/BrandTaskStorageService.java | 82 +- .../convert/service/ConvertRunService.java | 4 +- .../dedupe/service/DedupeRunService.java | 4 +- .../model/vo/DeleteBrandResultItemVo.java | 4 + .../service/DeleteBrandRunService.java | 244 +++- .../service/DeleteBrandStaleTaskService.java | 44 +- .../service/DeleteBrandTaskCacheService.java | 63 +- .../DeleteBrandTaskStorageService.java | 100 +- .../object/RustfsObjectStorageService.java | 85 ++ .../model/vo/PatrolDeleteResultItemVo.java | 4 + .../service/PatrolDeleteTaskCacheService.java | 74 +- .../service/PatrolDeleteTaskService.java | 147 ++- .../PermissionMenuSchemaInitializer.java | 12 +- .../controller/PriceTrackController.java | 2 +- .../dto/PriceTrackSubmitResultRequest.java | 3 + .../model/vo/PriceTrackResultItemVo.java | 4 + .../PriceTrackExcelAssemblyService.java | 18 +- .../service/PriceTrackTaskCacheService.java | 74 +- .../service/PriceTrackTaskService.java | 70 +- .../model/vo/ProductRiskResultItemVo.java | 4 + .../service/ProductRiskTaskCacheService.java | 74 +- .../service/ProductRiskTaskService.java | 75 +- .../model/vo/QueryAsinResultItemVo.java | 4 + .../service/QueryAsinTaskCacheService.java | 38 +- .../service/QueryAsinTaskService.java | 147 ++- .../service/ShopMatchTaskCacheService.java | 62 +- .../service/ShopMatchTaskService.java | 82 +- .../split/service/SplitRunService.java | 4 +- .../controller/TaskFileJobController.java | 52 + .../task/mapper/TaskFileJobMapper.java | 9 + .../mapper/TaskProgressSnapshotMapper.java | 9 + .../task/mapper/TaskResultItemMapper.java | 9 + .../task/mapper/TaskResultPayloadMapper.java | 9 + .../model/dto/TaskFileJobDispatchEvent.java | 14 + .../task/model/dto/TaskFileJobMessage.java | 16 + .../task/model/entity/TaskFileJobEntity.java | 28 + .../entity/TaskProgressSnapshotEntity.java | 28 + .../model/entity/TaskResultItemEntity.java | 29 + .../model/entity/TaskResultPayloadEntity.java | 26 + .../task/service/TaskFileJobConsumer.java | 39 + .../TaskFileJobDispatchCoordinator.java | 42 + .../service/TaskFileJobLocalDispatcher.java | 53 + .../task/service/TaskFileJobPublisher.java | 50 + .../task/service/TaskFileJobService.java | 225 ++++ .../service/TaskProgressSnapshotService.java | 98 ++ .../task/service/TaskResultFileJobWorker.java | 166 +++ .../task/service/TaskResultItemService.java | 233 ++++ .../service/TaskResultPayloadService.java | 139 +++ .../TaskScopePayloadStorageService.java | 257 +--- .../TransientPayloadStorageService.java | 149 +++ .../service/ZiniaoSessionCacheService.java | 61 +- .../service/ZiniaoShopIndexService.java | 43 +- .../resources/application-local.example.yml | 35 +- .../src/main/resources/application.yml | 35 + .../resources/db/V34__appearance_patent.sql | 85 ++ .../V35__task_result_payload_and_file_job.sql | 74 ++ frontend-vue/appearance-patent.html | 12 + frontend-vue/src/appearance-patent-main.ts | 7 + .../components/BrandAppearancePatentTab.vue | 487 ++++++++ .../brand/components/BrandConvertTab.vue | 8 +- .../pages/brand/components/BrandDedupeTab.vue | 9 +- .../brand/components/BrandDeleteBrandTab.vue | 39 +- .../brand/components/BrandPatrolDeleteTab.vue | 16 +- .../brand/components/BrandPriceTrackTab.vue | 66 +- .../brand/components/BrandProductRiskTab.vue | 2 +- .../brand/components/BrandQueryAsinTab.vue | 16 +- .../brand/components/BrandShopMatchTab.vue | 81 +- .../pages/brand/components/BrandSplitTab.vue | 9 +- .../pages/brand/components/BrandTopBar.vue | 2 + frontend-vue/src/shared/api/java-modules.ts | 171 +++ .../src/shared/task-progress-config.ts | 8 +- frontend-vue/vite.config.ts | 1 + new_web_source/assets/brand-UU-ckLq6.js | 1 - new_web_source/assets/convert-7wWJ02Tw.css | 1 - new_web_source/assets/convert.js | 1 - new_web_source/assets/dedupe-BpNHwt51.css | 1 - new_web_source/assets/dedupe.js | 1 - .../assets/delete-brand-CWLpe7lu.css | 1 - new_web_source/assets/delete-brand.js | 1 - new_web_source/assets/el-input-CezJelDw.css | 1 - .../assets/listingFilters-BpGOU_pJ.js | 1 - .../assets/listingFilters-CK58rX4v.css | 1 - .../assets/patrol-delete-BPu1AAPM.css | 1 - new_web_source/assets/patrol-delete.js | 1 - .../assets/price-track-85NbckiJ.css | 1 - .../assets/price-track-RLJUgMl_.css | 1 - new_web_source/assets/price-track.js | 1 - .../assets/product-risk-cfL3Ch5-.css | 1 - new_web_source/assets/product-risk.js | 1 - new_web_source/assets/pywebview-CeWJDVeG.js | 55 - new_web_source/assets/pywebview-ij4pgMq8.css | 1 - new_web_source/assets/query-asin-B0yMJ9Or.css | 1 - new_web_source/assets/query-asin-B5e5m-vs.css | 1 - new_web_source/assets/query-asin-Ba06RE4W.css | 1 - new_web_source/assets/query-asin-CcqwgsLE.css | 1 - new_web_source/assets/query-asin.js | 1 - new_web_source/assets/shop-match-Banz7DWL.css | 1 - new_web_source/assets/shop-match.js | 1 - new_web_source/assets/split-CRUIYKS6.css | 1 - new_web_source/assets/split.js | 1 - new_web_source/assets/zh-cn-DyKmB71i.js | 1 - new_web_source/convert.html | 8 +- new_web_source/dedupe.html | 8 +- new_web_source/delete-brand.html | 8 +- new_web_source/patrol-delete.html | 11 +- new_web_source/price-track.html | 11 +- new_web_source/product-risk.html | 9 +- new_web_source/query-asin.html | 11 +- new_web_source/shop-match.html | 11 +- new_web_source/split.html | 8 +- 141 files changed, 6957 insertions(+), 772 deletions(-) create mode 100644 backend-java/jstack-33832.txt create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/TransientStorageProperties.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentSourceFileDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentSubmitResultRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentTaskBatchRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentDashboardVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParseVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskBatchVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskDetailVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskCacheService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/object/RustfsObjectStorageService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskFileJobController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskFileJobMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskProgressSnapshotMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskResultItemMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskResultPayloadMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskFileJobDispatchEvent.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskFileJobMessage.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskFileJobEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskProgressSnapshotEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskResultItemEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskResultPayloadEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobConsumer.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobDispatchCoordinator.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobLocalDispatcher.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobPublisher.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressSnapshotService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultPayloadService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java create mode 100644 backend-java/src/main/resources/db/V34__appearance_patent.sql create mode 100644 backend-java/src/main/resources/db/V35__task_result_payload_and_file_job.sql create mode 100644 frontend-vue/appearance-patent.html create mode 100644 frontend-vue/src/appearance-patent-main.ts create mode 100644 frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue delete mode 100644 new_web_source/assets/brand-UU-ckLq6.js delete mode 100644 new_web_source/assets/convert-7wWJ02Tw.css delete mode 100644 new_web_source/assets/convert.js delete mode 100644 new_web_source/assets/dedupe-BpNHwt51.css delete mode 100644 new_web_source/assets/dedupe.js delete mode 100644 new_web_source/assets/delete-brand-CWLpe7lu.css delete mode 100644 new_web_source/assets/delete-brand.js delete mode 100644 new_web_source/assets/el-input-CezJelDw.css delete mode 100644 new_web_source/assets/listingFilters-BpGOU_pJ.js delete mode 100644 new_web_source/assets/listingFilters-CK58rX4v.css delete mode 100644 new_web_source/assets/patrol-delete-BPu1AAPM.css delete mode 100644 new_web_source/assets/patrol-delete.js delete mode 100644 new_web_source/assets/price-track-85NbckiJ.css delete mode 100644 new_web_source/assets/price-track-RLJUgMl_.css delete mode 100644 new_web_source/assets/price-track.js delete mode 100644 new_web_source/assets/product-risk-cfL3Ch5-.css delete mode 100644 new_web_source/assets/product-risk.js delete mode 100644 new_web_source/assets/pywebview-CeWJDVeG.js delete mode 100644 new_web_source/assets/pywebview-ij4pgMq8.css delete mode 100644 new_web_source/assets/query-asin-B0yMJ9Or.css delete mode 100644 new_web_source/assets/query-asin-B5e5m-vs.css delete mode 100644 new_web_source/assets/query-asin-Ba06RE4W.css delete mode 100644 new_web_source/assets/query-asin-CcqwgsLE.css delete mode 100644 new_web_source/assets/query-asin.js delete mode 100644 new_web_source/assets/shop-match-Banz7DWL.css delete mode 100644 new_web_source/assets/shop-match.js delete mode 100644 new_web_source/assets/split-CRUIYKS6.css delete mode 100644 new_web_source/assets/split.js delete mode 100644 new_web_source/assets/zh-cn-DyKmB71i.js diff --git a/.gitignore b/.gitignore index 55e3fff..5fc4f49 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ app/assets/ app/new_web_source app/user_data/ OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md + +架构.md \ No newline at end of file diff --git a/app/.env b/app/.env index a8104b3..517a315 100644 --- a/app/.env +++ b/app/.env @@ -13,7 +13,8 @@ client_name=ShuFuAI # java_api_base=http://47.111.163.154:18080 -java_api_base=http://127.0.0.1:18080 -# java_api_base=http://8.136.19.173:18080 +# java_api_base=http://127.0.0.1:18080 +java_api_base=http://8.136.19.173:18080 +# java_api_base=http://121.196.149.225:18080 diff --git a/app/amazon/__pycache__/main.cpython-312.pyc b/app/amazon/__pycache__/main.cpython-312.pyc index 7e8147dccbdc7c5e7b3a7eaba9b72faf437568d6..a92e3b52a8d2a1915c4897f75ad7e0bd2893d995 100644 GIT binary patch delta 22 ccmaF*lJV(FM()$Ryj%=G!1d|PM($fB0A_9oivR!s delta 22 ccmaF*lJV(FM()$Ryj%=GkihwBBloQm0Aol8_5c6? diff --git a/backend-java/jstack-33832.txt b/backend-java/jstack-33832.txt new file mode 100644 index 0000000..82de4e2 --- /dev/null +++ b/backend-java/jstack-33832.txt @@ -0,0 +1,1106 @@ +2026-04-27 08:55:51 +Full thread dump Java HotSpot(TM) 64-Bit Server VM (21.0.9+7-LTS-338 mixed mode, sharing): + +Threads class SMR info: +_java_thread_list=0x0000023371912e70, length=49, elements={ +0x0000023366303450, 0x0000023366309fe0, 0x000002336630d5c0, 0x000002336630e280, +0x000002336630fc60, 0x0000023366311870, 0x000002336631e0a0, 0x0000023366312c20, +0x000002336b23eb60, 0x000002336b2497c0, 0x000002336c9ea570, 0x000002336dcf88a0, +0x000002336dcf95c0, 0x000002336dcf8f30, 0x000002336dcf9c50, 0x000002336dcf74f0, +0x0000023372aec650, 0x0000023372aee090, 0x0000023372aed370, 0x0000023372aee720, +0x0000023372aeb2a0, 0x0000023372aeda00, 0x0000023372aeedb0, 0x0000023372aeb930, +0x0000023372aef440, 0x0000023372aefad0, 0x0000023372af0160, 0x0000023372af0e80, +0x0000023372af1510, 0x0000023371823030, 0x00000233718236c0, 0x0000023371822310, +0x00000233718229a0, 0x00000233718243e0, 0x0000023371826b40, 0x0000023371825100, +0x0000023371823d50, 0x0000023371824a70, 0x0000023371828c10, 0x00000233718271d0, +0x00000233718264b0, 0x0000023371828580, 0x0000023371827860, 0x0000023371827ef0, +0x0000023371825e20, 0x000002336e8db070, 0x000002336e8dc420, 0x000002336e8dd7d0, +0x000002336e8dbd90 +} + +"Reference Handler" #9 [36596] daemon prio=10 os_prio=2 cpu=15.62ms elapsed=372.99s tid=0x0000023366303450 nid=36596 waiting on condition [0x0000003920aff000] + java.lang.Thread.State: RUNNABLE + at java.lang.ref.Reference.waitForReferencePendingList(java.base@21.0.9/Native Method) + at java.lang.ref.Reference.processPendingReferences(java.base@21.0.9/Reference.java:246) + at java.lang.ref.Reference$ReferenceHandler.run(java.base@21.0.9/Reference.java:208) + +"Finalizer" #10 [37640] daemon prio=8 os_prio=1 cpu=0.00ms elapsed=372.99s tid=0x0000023366309fe0 nid=37640 in Object.wait() [0x0000003920bff000] + java.lang.Thread.State: WAITING (on object monitor) + at java.lang.Object.wait0(java.base@21.0.9/Native Method) + - waiting on <0x000000068c2c9638> (a java.lang.ref.NativeReferenceQueue$Lock) + at java.lang.Object.wait(java.base@21.0.9/Object.java:366) + at java.lang.Object.wait(java.base@21.0.9/Object.java:339) + at java.lang.ref.NativeReferenceQueue.await(java.base@21.0.9/NativeReferenceQueue.java:48) + at java.lang.ref.ReferenceQueue.remove0(java.base@21.0.9/ReferenceQueue.java:158) + at java.lang.ref.NativeReferenceQueue.remove(java.base@21.0.9/NativeReferenceQueue.java:89) + - locked <0x000000068c2c9638> (a java.lang.ref.NativeReferenceQueue$Lock) + at java.lang.ref.Finalizer$FinalizerThread.run(java.base@21.0.9/Finalizer.java:173) + +"Signal Dispatcher" #11 [29528] daemon prio=9 os_prio=2 cpu=0.00ms elapsed=372.99s tid=0x000002336630d5c0 nid=29528 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"Attach Listener" #12 [32964] daemon prio=5 os_prio=2 cpu=15.62ms elapsed=372.99s tid=0x000002336630e280 nid=32964 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"Service Thread" #13 [34584] daemon prio=9 os_prio=0 cpu=15.62ms elapsed=372.99s tid=0x000002336630fc60 nid=34584 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"Monitor Deflation Thread" #14 [13176] daemon prio=9 os_prio=0 cpu=15.62ms elapsed=372.99s tid=0x0000023366311870 nid=13176 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"C2 CompilerThread0" #15 [31784] daemon prio=9 os_prio=2 cpu=4265.62ms elapsed=372.99s tid=0x000002336631e0a0 nid=31784 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + No compile task + +"C1 CompilerThread0" #23 [24972] daemon prio=9 os_prio=2 cpu=1250.00ms elapsed=372.99s tid=0x0000023366312c20 nid=24972 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + No compile task + +"Notification Thread" #27 [36192] daemon prio=9 os_prio=0 cpu=15.62ms elapsed=372.94s tid=0x000002336b23eb60 nid=36192 runnable [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"Common-Cleaner" #28 [37400] daemon prio=8 os_prio=1 cpu=0.00ms elapsed=372.93s tid=0x000002336b2497c0 nid=37400 waiting on condition [0x00000039215ff000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068c2cebe0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1852) + at java.lang.ref.ReferenceQueue.await(java.base@21.0.9/ReferenceQueue.java:71) + at java.lang.ref.ReferenceQueue.remove0(java.base@21.0.9/ReferenceQueue.java:143) + at java.lang.ref.ReferenceQueue.remove(java.base@21.0.9/ReferenceQueue.java:218) + at jdk.internal.ref.CleanerImpl.run(java.base@21.0.9/CleanerImpl.java:140) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + at jdk.internal.misc.InnocuousThread.run(java.base@21.0.9/InnocuousThread.java:186) + +"AsyncAppender-Worker-DefaultAppender" #48 [35540] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=366.81s tid=0x000002336c9ea570 nid=35540 waiting on condition [0x00000039223ff000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068db92e28> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.ArrayBlockingQueue.take(java.base@21.0.9/ArrayBlockingQueue.java:420) + at org.apache.rocketmq.logging.ch.qos.logback.core.AsyncAppenderBase$Worker.run(AsyncAppenderBase.java:298) + +"Catalina-utility-1" #49 [32292] prio=1 os_prio=-2 cpu=93.75ms elapsed=365.95s tid=0x000002336dcf88a0 nid=32292 waiting on condition [0x0000003922bff000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068dbc1428> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1177) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"Catalina-utility-2" #50 [27800] prio=1 os_prio=-2 cpu=78.12ms elapsed=365.95s tid=0x000002336dcf95c0 nid=27800 waiting on condition [0x0000003922cff000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068dbc1428> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"container-0" #51 [36976] prio=5 os_prio=0 cpu=0.00ms elapsed=365.95s tid=0x000002336dcf8f30 nid=36976 waiting on condition [0x0000003922dff000] + java.lang.Thread.State: TIMED_WAITING (sleeping) + at java.lang.Thread.sleep0(java.base@21.0.9/Native Method) + at java.lang.Thread.sleep(java.base@21.0.9/Thread.java:509) + at org.apache.catalina.core.StandardServer.await(StandardServer.java:520) + at org.springframework.boot.web.embedded.tomcat.TomcatWebServer$1.run(TomcatWebServer.java:219) + +"Jndi-Dns-address-change-listener" #52 [33984] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=365.81s tid=0x000002336dcf9c50 nid=33984 runnable [0x0000003922efe000] + java.lang.Thread.State: RUNNABLE + at sun.net.dns.ResolverConfigurationImpl.notifyAddrChange0(java.base@21.0.9/Native Method) + at sun.net.dns.ResolverConfigurationImpl$AddressChangeListener.run(java.base@21.0.9/ResolverConfigurationImpl.java:179) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + at jdk.internal.misc.InnocuousThread.run(java.base@21.0.9/InnocuousThread.java:186) + +"lettuce-timer-3-1" #53 [29332] daemon prio=5 os_prio=0 cpu=46.88ms elapsed=365.63s tid=0x000002336dcf74f0 nid=29332 waiting on condition [0x0000003922fff000] + java.lang.Thread.State: TIMED_WAITING (sleeping) + at java.lang.Thread.sleep0(java.base@21.0.9/Native Method) + at java.lang.Thread.sleep(java.base@21.0.9/Thread.java:509) + at io.netty.util.HashedWheelTimer$Worker.waitForNextTick(HashedWheelTimer.java:598) + at io.netty.util.HashedWheelTimer$Worker.run(HashedWheelTimer.java:494) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"mysql-cj-abandoned-connection-cleanup" #54 [1808] daemon prio=5 os_prio=0 cpu=15.62ms elapsed=365.19s tid=0x0000023372aec650 nid=1808 waiting on condition [0x00000039232fe000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068d7c7970> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1852) + at java.lang.ref.ReferenceQueue.await(java.base@21.0.9/ReferenceQueue.java:71) + at java.lang.ref.ReferenceQueue.remove0(java.base@21.0.9/ReferenceQueue.java:143) + at java.lang.ref.ReferenceQueue.remove(java.base@21.0.9/ReferenceQueue.java:218) + at com.mysql.cj.jdbc.AbandonedConnectionCleanupThread.run(AbandonedConnectionCleanupThread.java:93) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1144) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"MQClientFactoryScheduledThread" #56 [32888] prio=5 os_prio=0 cpu=0.00ms elapsed=362.22s tid=0x0000023372aee090 nid=32888 waiting on condition [0x00000039233fe000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e32bee0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"NettyEventExecutor" #57 [9732] prio=5 os_prio=0 cpu=0.00ms elapsed=362.21s tid=0x0000023372aed370 nid=9732 waiting on condition [0x00000039234ff000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e32e5d8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.LinkedBlockingQueue.poll(java.base@21.0.9/LinkedBlockingQueue.java:460) + at org.apache.rocketmq.remoting.netty.NettyRemotingAbstract$NettyEventExecutor.run(NettyRemotingAbstract.java:701) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"ClientHouseKeepingService" #55 [32364] prio=5 os_prio=0 cpu=453.12ms elapsed=362.21s tid=0x0000023372aee720 nid=32364 waiting on condition [0x00000039235fe000] + java.lang.Thread.State: TIMED_WAITING (sleeping) + at java.lang.Thread.sleep0(java.base@21.0.9/Native Method) + at java.lang.Thread.sleep(java.base@21.0.9/Thread.java:509) + at io.netty.util.HashedWheelTimer$Worker.waitForNextTick(HashedWheelTimer.java:598) + at io.netty.util.HashedWheelTimer$Worker.run(HashedWheelTimer.java:494) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"PullMessageService" #58 [37548] prio=5 os_prio=0 cpu=0.00ms elapsed=362.21s tid=0x0000023372aeb2a0 nid=37548 waiting on condition [0x00000039236ff000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e3333e8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.rocketmq.client.impl.consumer.PullMessageService.run(PullMessageService.java:131) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"RebalanceService" #59 [24716] prio=5 os_prio=0 cpu=0.00ms elapsed=362.21s tid=0x0000023372aeda00 nid=24716 waiting on condition [0x00000039237fe000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e335ac0> (a org.apache.rocketmq.common.CountDownLatch2$Sync) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(java.base@21.0.9/AbstractQueuedSynchronizer.java:756) + at java.util.concurrent.locks.AbstractQueuedSynchronizer.tryAcquireSharedNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1126) + at org.apache.rocketmq.common.CountDownLatch2.await(CountDownLatch2.java:114) + at org.apache.rocketmq.common.ServiceThread.waitForRunning(ServiceThread.java:117) + at org.apache.rocketmq.client.impl.consumer.RebalanceService.run(RebalanceService.java:45) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"LatencyFaultToleranceScheduledThread" #60 [14148] prio=5 os_prio=0 cpu=0.00ms elapsed=362.21s tid=0x0000023372aeedb0 nid=14148 waiting on condition [0x00000039238ff000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e3381b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"RequestHouseKeepingService1" #61 [12344] prio=5 os_prio=0 cpu=93.75ms elapsed=362.21s tid=0x0000023372aeb930 nid=12344 waiting on condition [0x00000039239fe000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e33a8b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"LatencyFaultToleranceScheduledThread" #62 [28968] prio=5 os_prio=0 cpu=31.25ms elapsed=362.21s tid=0x0000023372aef440 nid=28968 waiting on condition [0x0000003923afe000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e33cfa8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"Client_192.168.9.14@33832#166861769321600_GuardForSyncSend" #63 [6832] prio=5 os_prio=0 cpu=1015.62ms elapsed=362.20s tid=0x0000023372aefad0 nid=6832 waiting on condition [0x0000003923bff000] + java.lang.Thread.State: TIMED_WAITING (sleeping) + at java.lang.Thread.sleep0(java.base@21.0.9/Native Method) + at java.lang.Thread.sleep(java.base@21.0.9/Thread.java:509) + at org.apache.rocketmq.client.producer.ProduceAccumulator$GuardForSyncSendService.doWork(ProduceAccumulator.java:109) + at org.apache.rocketmq.client.producer.ProduceAccumulator$GuardForSyncSendService.run(ProduceAccumulator.java:84) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"Client_192.168.9.14@33832#166861769321600_GuardForAsyncSend" #64 [17916] prio=5 os_prio=0 cpu=906.25ms elapsed=362.20s tid=0x0000023372af0160 nid=17916 waiting on condition [0x0000003923cff000] + java.lang.Thread.State: TIMED_WAITING (sleeping) + at java.lang.Thread.sleep0(java.base@21.0.9/Native Method) + at java.lang.Thread.sleep(java.base@21.0.9/Thread.java:509) + at org.apache.rocketmq.client.producer.ProduceAccumulator$GuardForAsyncSendService.doWork(ProduceAccumulator.java:154) + at org.apache.rocketmq.client.producer.ProduceAccumulator$GuardForAsyncSendService.run(ProduceAccumulator.java:131) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-1" #65 [26964] daemon prio=5 os_prio=0 cpu=140.62ms elapsed=361.73s tid=0x0000023372af0e80 nid=26964 waiting on condition [0x00000039230fe000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e86b6b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-2" #66 [9372] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=361.73s tid=0x0000023372af1510 nid=9372 waiting on condition [0x00000039231fe000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e86b6b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-3" #67 [35544] daemon prio=5 os_prio=0 cpu=78.12ms elapsed=361.73s tid=0x0000023371823030 nid=35544 waiting on condition [0x0000003923dfe000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e86b6b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-4" #68 [10336] daemon prio=5 os_prio=0 cpu=15.62ms elapsed=361.73s tid=0x00000233718236c0 nid=10336 waiting on condition [0x0000003923efe000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e86b6b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-5" #69 [35160] daemon prio=5 os_prio=0 cpu=265.62ms elapsed=361.73s tid=0x0000023371822310 nid=35160 waiting on condition [0x0000003923ffe000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e86b6b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-6" #70 [35128] daemon prio=5 os_prio=0 cpu=234.38ms elapsed=361.73s tid=0x00000233718229a0 nid=35128 waiting on condition [0x00000039240fe000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e86b6b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-7" #71 [35860] daemon prio=5 os_prio=0 cpu=125.00ms elapsed=361.73s tid=0x00000233718243e0 nid=35860 runnable [0x00000039241f8000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.Net.poll(java.base@21.0.9/Native Method) + at sun.nio.ch.NioSocketImpl.park(java.base@21.0.9/NioSocketImpl.java:191) + at sun.nio.ch.NioSocketImpl.park(java.base@21.0.9/NioSocketImpl.java:201) + at sun.nio.ch.NioSocketImpl.implRead(java.base@21.0.9/NioSocketImpl.java:309) + at sun.nio.ch.NioSocketImpl.read(java.base@21.0.9/NioSocketImpl.java:346) + at sun.nio.ch.NioSocketImpl$1.read(java.base@21.0.9/NioSocketImpl.java:796) + at java.net.Socket$SocketInputStream.read(java.base@21.0.9/Socket.java:1099) + at com.mysql.cj.protocol.ReadAheadInputStream.fill(ReadAheadInputStream.java:107) + at com.mysql.cj.protocol.ReadAheadInputStream.readFromUnderlyingStreamIfNecessary(ReadAheadInputStream.java:150) + at com.mysql.cj.protocol.ReadAheadInputStream.read(ReadAheadInputStream.java:180) + - locked <0x000000068e866a98> (a com.mysql.cj.protocol.ReadAheadInputStream) + at java.io.FilterInputStream.read(java.base@21.0.9/FilterInputStream.java:119) + at com.mysql.cj.protocol.FullReadInputStream.readFully(FullReadInputStream.java:64) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeaderLocal(SimplePacketReader.java:81) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:63) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:45) + at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:52) + at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:41) + at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:54) + at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:44) + at com.mysql.cj.protocol.a.NativeProtocol.readMessage(NativeProtocol.java:576) + at com.mysql.cj.protocol.a.NativeProtocol.checkErrorMessage(NativeProtocol.java:762) + at com.mysql.cj.protocol.a.NativeProtocol.sendCommand(NativeProtocol.java:701) + at com.mysql.cj.protocol.a.NativeProtocol.sendQueryPacket(NativeProtocol.java:1050) + at com.mysql.cj.NativeSession.execSQL(NativeSession.java:660) + at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:889) + - locked <0x000000068e8652c8> (a com.mysql.cj.jdbc.ConnectionImpl) + at com.mysql.cj.jdbc.ClientPreparedStatement.execute(ClientPreparedStatement.java:354) + - locked <0x000000068e8652c8> (a com.mysql.cj.jdbc.ConnectionImpl) + at com.zaxxer.hikari.pool.ProxyPreparedStatement.execute(ProxyPreparedStatement.java:44) + at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.execute(HikariProxyPreparedStatement.java) + at java.lang.invoke.LambdaForm$DMH/0x00000233010d2c00.invokeInterface(java.base@21.0.9/LambdaForm$DMH) + at java.lang.invoke.LambdaForm$MH/0x00000233011f4400.invoke(java.base@21.0.9/LambdaForm$MH) + at java.lang.invoke.Invokers$Holder.invokeExact_MT(java.base@21.0.9/Invokers$Holder) + at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.9/DirectMethodHandleAccessor.java:153) + at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.9/DirectMethodHandleAccessor.java:103) + at java.lang.reflect.Method.invoke(java.base@21.0.9/Method.java:580) + at org.apache.ibatis.logging.jdbc.PreparedStatementLogger.invoke(PreparedStatementLogger.java:58) + at jdk.proxy3.$Proxy198.execute(jdk.proxy3/Unknown Source) + at org.apache.ibatis.executor.statement.PreparedStatementHandler.update(PreparedStatementHandler.java:48) + at org.apache.ibatis.executor.statement.RoutingStatementHandler.update(RoutingStatementHandler.java:75) + at org.apache.ibatis.executor.SimpleExecutor.doUpdate(SimpleExecutor.java:50) + at org.apache.ibatis.executor.BaseExecutor.update(BaseExecutor.java:117) + at org.apache.ibatis.executor.CachingExecutor.update(CachingExecutor.java:76) + at org.apache.ibatis.session.defaults.DefaultSqlSession.update(DefaultSqlSession.java:197) + at org.apache.ibatis.session.defaults.DefaultSqlSession.insert(DefaultSqlSession.java:184) + at java.lang.invoke.LambdaForm$DMH/0x0000023301589800.invokeInterface(java.base@21.0.9/LambdaForm$DMH) + at java.lang.invoke.LambdaForm$MH/0x0000023301c5d800.invoke(java.base@21.0.9/LambdaForm$MH) + at java.lang.invoke.LambdaForm$MH/0x00000233010c8000.invokeExact_MT(java.base@21.0.9/LambdaForm$MH) + at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.9/DirectMethodHandleAccessor.java:155) + at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.9/DirectMethodHandleAccessor.java:103) + at java.lang.reflect.Method.invoke(java.base@21.0.9/Method.java:580) + at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:425) + at jdk.proxy2.$Proxy119.insert(jdk.proxy2/Unknown Source) + at org.mybatis.spring.SqlSessionTemplate.insert(SqlSessionTemplate.java:272) + at com.baomidou.mybatisplus.core.override.MybatisMapperMethod.execute(MybatisMapperMethod.java:59) + at com.baomidou.mybatisplus.core.override.MybatisMapperProxy$PlainMethodInvoker.invoke(MybatisMapperProxy.java:152) + at com.baomidou.mybatisplus.core.override.MybatisMapperProxy.invoke(MybatisMapperProxy.java:89) + at jdk.proxy2.$Proxy122.insert(jdk.proxy2/Unknown Source) + at com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService.run(DeleteBrandRunService.java:103) + at java.lang.invoke.LambdaForm$DMH/0x0000023301120c00.invokeVirtual(java.base@21.0.9/LambdaForm$DMH) + at java.lang.invoke.LambdaForm$MH/0x0000023301085000.invoke(java.base@21.0.9/LambdaForm$MH) + at java.lang.invoke.Invokers$Holder.invokeExact_MT(java.base@21.0.9/Invokers$Holder) + at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.9/DirectMethodHandleAccessor.java:154) + at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.9/DirectMethodHandleAccessor.java:103) + at java.lang.reflect.Method.invoke(java.base@21.0.9/Method.java:580) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:716) + at com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService$$SpringCGLIB$$0.run() + at com.nanri.aiimage.modules.deletebrand.controller.DeleteBrandRunController.run(DeleteBrandRunController.java:54) + at java.lang.invoke.LambdaForm$DMH/0x0000023301120c00.invokeVirtual(java.base@21.0.9/LambdaForm$DMH) + at java.lang.invoke.LambdaForm$MH/0x0000023301085000.invoke(java.base@21.0.9/LambdaForm$MH) + at java.lang.invoke.Invokers$Holder.invokeExact_MT(java.base@21.0.9/Invokers$Holder) + at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.9/DirectMethodHandleAccessor.java:154) + at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.9/DirectMethodHandleAccessor.java:103) + at java.lang.reflect.Method.invoke(java.base@21.0.9/Method.java:580) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at com.nanri.aiimage.config.RequestTraceFilter.doFilterInternal(RequestTraceFilter.java:48) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108) + at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$3(FilterChainProxy.java:231) + at org.springframework.security.web.FilterChainProxy$$Lambda/0x0000023301a9ade8.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$FilterObservation$SimpleFilterObservation.lambda$wrap$1(ObservationFilterChainDecorator.java:479) + at org.springframework.security.web.ObservationFilterChainDecorator$FilterObservation$SimpleFilterObservation$$Lambda/0x0000023301aa32d0.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation.lambda$wrap$1(ObservationFilterChainDecorator.java:340) + at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation$$Lambda/0x0000023301aa34e8.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator.lambda$wrapSecured$0(ObservationFilterChainDecorator.java:82) + at org.springframework.security.web.ObservationFilterChainDecorator$$Lambda/0x0000023301a9b218.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:128) + at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:100) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126) + at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:179) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.authentication.www.BasicAuthenticationFilter.doFilterInternal(BasicAuthenticationFilter.java:181) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:107) + at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:93) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90) + at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82) + at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter$$Lambda/0x0000023301a9cfd0.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation.lambda$wrap$0(ObservationFilterChainDecorator.java:323) + at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation$$Lambda/0x0000023301a9d208.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:224) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:233) + at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:191) + at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) + at org.springframework.web.servlet.handler.HandlerMappingIntrospector.lambda$createCacheFilter$3(HandlerMappingIntrospector.java:195) + at org.springframework.web.servlet.handler.HandlerMappingIntrospector$$Lambda/0x0000023301915230.doFilter(Unknown Source) + at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) + at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74) + at org.springframework.security.config.annotation.web.configuration.WebMvcSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebMvcSecurityConfiguration.java:230) + at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:362) + at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:278) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:113) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:397) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:905) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1743) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1190) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-8" #72 [28396] daemon prio=5 os_prio=0 cpu=62.50ms elapsed=361.73s tid=0x0000023371826b40 nid=28396 runnable [0x00000039242f8000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.Net.poll(java.base@21.0.9/Native Method) + at sun.nio.ch.NioSocketImpl.park(java.base@21.0.9/NioSocketImpl.java:191) + at sun.nio.ch.NioSocketImpl.park(java.base@21.0.9/NioSocketImpl.java:201) + at sun.nio.ch.NioSocketImpl.implRead(java.base@21.0.9/NioSocketImpl.java:309) + at sun.nio.ch.NioSocketImpl.read(java.base@21.0.9/NioSocketImpl.java:346) + at sun.nio.ch.NioSocketImpl$1.read(java.base@21.0.9/NioSocketImpl.java:796) + at java.net.Socket$SocketInputStream.read(java.base@21.0.9/Socket.java:1099) + at com.mysql.cj.protocol.ReadAheadInputStream.fill(ReadAheadInputStream.java:107) + at com.mysql.cj.protocol.ReadAheadInputStream.readFromUnderlyingStreamIfNecessary(ReadAheadInputStream.java:150) + at com.mysql.cj.protocol.ReadAheadInputStream.read(ReadAheadInputStream.java:180) + - locked <0x000000068e8aa5d0> (a com.mysql.cj.protocol.ReadAheadInputStream) + at java.io.FilterInputStream.read(java.base@21.0.9/FilterInputStream.java:119) + at com.mysql.cj.protocol.FullReadInputStream.readFully(FullReadInputStream.java:64) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeaderLocal(SimplePacketReader.java:81) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:63) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:45) + at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:52) + at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:41) + at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:54) + at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:44) + at com.mysql.cj.protocol.a.NativeProtocol.readMessage(NativeProtocol.java:576) + at com.mysql.cj.protocol.a.NativeProtocol.checkErrorMessage(NativeProtocol.java:762) + at com.mysql.cj.protocol.a.NativeProtocol.sendCommand(NativeProtocol.java:701) + at com.mysql.cj.protocol.a.NativeProtocol.sendQueryPacket(NativeProtocol.java:1050) + at com.mysql.cj.NativeSession.execSQL(NativeSession.java:660) + at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:889) + - locked <0x000000068e8aa868> (a com.mysql.cj.jdbc.ConnectionImpl) + at com.mysql.cj.jdbc.ClientPreparedStatement.execute(ClientPreparedStatement.java:354) + - locked <0x000000068e8aa868> (a com.mysql.cj.jdbc.ConnectionImpl) + at com.zaxxer.hikari.pool.ProxyPreparedStatement.execute(ProxyPreparedStatement.java:44) + at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.execute(HikariProxyPreparedStatement.java) + at java.lang.invoke.LambdaForm$DMH/0x00000233010d2c00.invokeInterface(java.base@21.0.9/LambdaForm$DMH) + at java.lang.invoke.LambdaForm$MH/0x00000233011f4400.invoke(java.base@21.0.9/LambdaForm$MH) + at java.lang.invoke.Invokers$Holder.invokeExact_MT(java.base@21.0.9/Invokers$Holder) + at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.9/DirectMethodHandleAccessor.java:153) + at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.9/DirectMethodHandleAccessor.java:103) + at java.lang.reflect.Method.invoke(java.base@21.0.9/Method.java:580) + at org.apache.ibatis.logging.jdbc.PreparedStatementLogger.invoke(PreparedStatementLogger.java:58) + at jdk.proxy3.$Proxy198.execute(jdk.proxy3/Unknown Source) + at org.apache.ibatis.executor.statement.PreparedStatementHandler.update(PreparedStatementHandler.java:48) + at org.apache.ibatis.executor.statement.RoutingStatementHandler.update(RoutingStatementHandler.java:75) + at org.apache.ibatis.executor.SimpleExecutor.doUpdate(SimpleExecutor.java:50) + at org.apache.ibatis.executor.BaseExecutor.update(BaseExecutor.java:117) + at org.apache.ibatis.executor.CachingExecutor.update(CachingExecutor.java:76) + at org.apache.ibatis.session.defaults.DefaultSqlSession.update(DefaultSqlSession.java:197) + at org.apache.ibatis.session.defaults.DefaultSqlSession.insert(DefaultSqlSession.java:184) + at java.lang.invoke.LambdaForm$DMH/0x0000023301589800.invokeInterface(java.base@21.0.9/LambdaForm$DMH) + at java.lang.invoke.LambdaForm$MH/0x0000023301c5dc00.invoke(java.base@21.0.9/LambdaForm$MH) + at java.lang.invoke.LambdaForm$MH/0x00000233010c8000.invokeExact_MT(java.base@21.0.9/LambdaForm$MH) + at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.9/DirectMethodHandleAccessor.java:155) + at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.9/DirectMethodHandleAccessor.java:103) + at java.lang.reflect.Method.invoke(java.base@21.0.9/Method.java:580) + at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:425) + at jdk.proxy2.$Proxy119.insert(jdk.proxy2/Unknown Source) + at org.mybatis.spring.SqlSessionTemplate.insert(SqlSessionTemplate.java:272) + at com.baomidou.mybatisplus.core.override.MybatisMapperMethod.execute(MybatisMapperMethod.java:59) + at com.baomidou.mybatisplus.core.override.MybatisMapperProxy$PlainMethodInvoker.invoke(MybatisMapperProxy.java:152) + at com.baomidou.mybatisplus.core.override.MybatisMapperProxy.invoke(MybatisMapperProxy.java:89) + at jdk.proxy2.$Proxy122.insert(jdk.proxy2/Unknown Source) + at com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService.run(DeleteBrandRunService.java:103) + at java.lang.invoke.LambdaForm$DMH/0x0000023301120c00.invokeVirtual(java.base@21.0.9/LambdaForm$DMH) + at java.lang.invoke.LambdaForm$MH/0x0000023301085000.invoke(java.base@21.0.9/LambdaForm$MH) + at java.lang.invoke.Invokers$Holder.invokeExact_MT(java.base@21.0.9/Invokers$Holder) + at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.9/DirectMethodHandleAccessor.java:154) + at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.9/DirectMethodHandleAccessor.java:103) + at java.lang.reflect.Method.invoke(java.base@21.0.9/Method.java:580) + at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355) + at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:716) + at com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService$$SpringCGLIB$$0.run() + at com.nanri.aiimage.modules.deletebrand.controller.DeleteBrandRunController.run(DeleteBrandRunController.java:54) + at java.lang.invoke.LambdaForm$DMH/0x0000023301120c00.invokeVirtual(java.base@21.0.9/LambdaForm$DMH) + at java.lang.invoke.LambdaForm$MH/0x0000023301085000.invoke(java.base@21.0.9/LambdaForm$MH) + at java.lang.invoke.Invokers$Holder.invokeExact_MT(java.base@21.0.9/Invokers$Holder) + at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.9/DirectMethodHandleAccessor.java:154) + at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.9/DirectMethodHandleAccessor.java:103) + at java.lang.reflect.Method.invoke(java.base@21.0.9/Method.java:580) + at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255) + at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) + at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926) + at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831) + at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) + at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) + at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979) + at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014) + at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:590) + at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885) + at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at com.nanri.aiimage.config.RequestTraceFilter.doFilterInternal(RequestTraceFilter.java:48) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108) + at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$3(FilterChainProxy.java:231) + at org.springframework.security.web.FilterChainProxy$$Lambda/0x0000023301a9ade8.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$FilterObservation$SimpleFilterObservation.lambda$wrap$1(ObservationFilterChainDecorator.java:479) + at org.springframework.security.web.ObservationFilterChainDecorator$FilterObservation$SimpleFilterObservation$$Lambda/0x0000023301aa32d0.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation.lambda$wrap$1(ObservationFilterChainDecorator.java:340) + at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation$$Lambda/0x0000023301aa34e8.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator.lambda$wrapSecured$0(ObservationFilterChainDecorator.java:82) + at org.springframework.security.web.ObservationFilterChainDecorator$$Lambda/0x0000023301a9b218.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:128) + at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:100) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126) + at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:120) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.authentication.AnonymousAuthenticationFilter.doFilter(AnonymousAuthenticationFilter.java:100) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter.doFilter(SecurityContextHolderAwareRequestFilter.java:179) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.savedrequest.RequestCacheAwareFilter.doFilter(RequestCacheAwareFilter.java:63) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.authentication.www.BasicAuthenticationFilter.doFilterInternal(BasicAuthenticationFilter.java:181) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:107) + at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:93) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.header.HeaderWriterFilter.doHeadersAfter(HeaderWriterFilter.java:90) + at org.springframework.security.web.header.HeaderWriterFilter.doFilterInternal(HeaderWriterFilter.java:75) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82) + at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:69) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter.doFilterInternal(WebAsyncManagerIntegrationFilter.java:62) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter$$Lambda/0x0000023301a9cfd0.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation.lambda$wrap$0(ObservationFilterChainDecorator.java:323) + at org.springframework.security.web.ObservationFilterChainDecorator$AroundFilterObservation$SimpleAroundFilterObservation$$Lambda/0x0000023301a9d208.doFilter(Unknown Source) + at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:224) + at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137) + at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:233) + at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:191) + at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) + at org.springframework.web.servlet.handler.HandlerMappingIntrospector.lambda$createCacheFilter$3(HandlerMappingIntrospector.java:195) + at org.springframework.web.servlet.handler.HandlerMappingIntrospector$$Lambda/0x0000023301915230.doFilter(Unknown Source) + at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113) + at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74) + at org.springframework.security.config.annotation.web.configuration.WebMvcSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebMvcSecurityConfiguration.java:230) + at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:362) + at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:278) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:113) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) + at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) + at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164) + at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140) + at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) + at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) + at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483) + at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:115) + at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) + at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) + at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344) + at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:397) + at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) + at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:905) + at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1743) + at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1190) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-9" #73 [31968] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=361.73s tid=0x0000023371825100 nid=31968 waiting on condition [0x00000039243fe000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e86b6b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-exec-10" #74 [14256] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=361.73s tid=0x0000023371823d50 nid=14256 waiting on condition [0x00000039244ff000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e86b6b0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.9/LinkedBlockingQueue.java:435) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117) + at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114) + at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175) + at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) + at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-Poller" #75 [25928] daemon prio=5 os_prio=0 cpu=31.25ms elapsed=361.72s tid=0x0000023371824a70 nid=25928 runnable [0x00000039245fe000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.WEPoll.wait(java.base@21.0.9/Native Method) + at sun.nio.ch.WEPollSelectorImpl.doSelect(java.base@21.0.9/WEPollSelectorImpl.java:114) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@21.0.9/SelectorImpl.java:130) + - locked <0x000000068e8ad8d0> (a sun.nio.ch.Util$2) + - locked <0x000000068e8ad870> (a sun.nio.ch.WEPollSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@21.0.9/SelectorImpl.java:142) + at org.apache.tomcat.util.net.NioEndpoint$Poller.run(NioEndpoint.java:757) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"http-nio-18080-Acceptor" #76 [14276] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=361.72s tid=0x0000023371828c10 nid=14276 runnable [0x00000039246ff000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.Net.accept(java.base@21.0.9/Native Method) + at sun.nio.ch.ServerSocketChannelImpl.implAccept(java.base@21.0.9/ServerSocketChannelImpl.java:433) + at sun.nio.ch.ServerSocketChannelImpl.accept(java.base@21.0.9/ServerSocketChannelImpl.java:399) + at org.apache.tomcat.util.net.NioEndpoint.serverSocketAccept(NioEndpoint.java:518) + at org.apache.tomcat.util.net.NioEndpoint.serverSocketAccept(NioEndpoint.java:71) + at org.apache.tomcat.util.net.Acceptor.run(Acceptor.java:128) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"aiimage-scheduling-1" #77 [12476] prio=5 os_prio=0 cpu=0.00ms elapsed=361.70s tid=0x00000233718271d0 nid=12476 waiting on condition [0x00000039247ff000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e344418> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"aiimage-scheduling-2" #78 [34996] prio=5 os_prio=0 cpu=109.38ms elapsed=361.70s tid=0x00000233718264b0 nid=34996 waiting on condition [0x00000039248fe000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e344418> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1177) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"aiimage-scheduling-3" #79 [31044] prio=5 os_prio=0 cpu=234.38ms elapsed=361.70s tid=0x0000023371828580 nid=31044 waiting on condition [0x00000039249fe000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e344418> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"aiimage-scheduling-4" #80 [26972] prio=5 os_prio=0 cpu=46.88ms elapsed=361.70s tid=0x0000023371827860 nid=26972 waiting on condition [0x0000003924aff000] + java.lang.Thread.State: WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e344418> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.park(java.base@21.0.9/LockSupport.java:371) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.9/AbstractQueuedSynchronizer.java:519) + at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.9/ForkJoinPool.java:3778) + at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.9/ForkJoinPool.java:3723) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.9/AbstractQueuedSynchronizer.java:1712) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1177) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"ForkJoinPool.commonPool-worker-1" #81 [28900] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=361.60s tid=0x0000023371827ef0 nid=28900 runnable [0x0000003924bfe000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.Net.poll(java.base@21.0.9/Native Method) + at sun.nio.ch.NioSocketImpl.park(java.base@21.0.9/NioSocketImpl.java:191) + at sun.nio.ch.NioSocketImpl.park(java.base@21.0.9/NioSocketImpl.java:201) + at sun.nio.ch.NioSocketImpl.implRead(java.base@21.0.9/NioSocketImpl.java:309) + at sun.nio.ch.NioSocketImpl.read(java.base@21.0.9/NioSocketImpl.java:346) + at sun.nio.ch.NioSocketImpl$1.read(java.base@21.0.9/NioSocketImpl.java:796) + at java.net.Socket$SocketInputStream.read(java.base@21.0.9/Socket.java:1099) + at com.mysql.cj.protocol.ReadAheadInputStream.fill(ReadAheadInputStream.java:107) + at com.mysql.cj.protocol.ReadAheadInputStream.readFromUnderlyingStreamIfNecessary(ReadAheadInputStream.java:150) + at com.mysql.cj.protocol.ReadAheadInputStream.read(ReadAheadInputStream.java:180) + - locked <0x000000068e876e48> (a com.mysql.cj.protocol.ReadAheadInputStream) + at java.io.FilterInputStream.read(java.base@21.0.9/FilterInputStream.java:119) + at com.mysql.cj.protocol.FullReadInputStream.readFully(FullReadInputStream.java:64) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeaderLocal(SimplePacketReader.java:81) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:63) + at com.mysql.cj.protocol.a.SimplePacketReader.readHeader(SimplePacketReader.java:45) + at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:52) + at com.mysql.cj.protocol.a.TimeTrackingPacketReader.readHeader(TimeTrackingPacketReader.java:41) + at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:54) + at com.mysql.cj.protocol.a.MultiPacketReader.readHeader(MultiPacketReader.java:44) + at com.mysql.cj.protocol.a.NativeProtocol.readMessage(NativeProtocol.java:576) + at com.mysql.cj.protocol.a.NativeProtocol.checkErrorMessage(NativeProtocol.java:762) + at com.mysql.cj.protocol.a.NativeProtocol.sendCommand(NativeProtocol.java:701) + at com.mysql.cj.protocol.a.NativeProtocol.sendQueryPacket(NativeProtocol.java:1050) + at com.mysql.cj.protocol.a.NativeProtocol.sendQueryString(NativeProtocol.java:997) + at com.mysql.cj.NativeSession.execSQL(NativeSession.java:658) + at com.mysql.cj.jdbc.StatementImpl.executeInternal(StatementImpl.java:730) + - locked <0x000000068e877800> (a com.mysql.cj.jdbc.ConnectionImpl) + at com.mysql.cj.jdbc.StatementImpl.execute(StatementImpl.java:653) + at com.zaxxer.hikari.pool.ProxyStatement.execute(ProxyStatement.java:94) + at com.zaxxer.hikari.pool.HikariProxyStatement.execute(HikariProxyStatement.java) + at org.springframework.jdbc.core.JdbcTemplate$1ExecuteStatementCallback.doInStatement(JdbcTemplate.java:435) + at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:393) + at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:444) + at com.nanri.aiimage.modules.permission.service.PermissionMenuSchemaInitializer.executeQuietly(PermissionMenuSchemaInitializer.java:108) + at com.nanri.aiimage.modules.permission.service.PermissionMenuSchemaInitializer.initializeInternal(PermissionMenuSchemaInitializer.java:44) + at com.nanri.aiimage.modules.permission.service.PermissionMenuSchemaInitializer$$Lambda/0x000002330196b410.run(Unknown Source) + at java.util.concurrent.CompletableFuture$AsyncRun.run(java.base@21.0.9/CompletableFuture.java:1804) + at java.util.concurrent.CompletableFuture$AsyncRun.exec(java.base@21.0.9/CompletableFuture.java:1796) + at java.util.concurrent.ForkJoinTask.doExec(java.base@21.0.9/ForkJoinTask.java:387) + at java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(java.base@21.0.9/ForkJoinPool.java:1310) + at java.util.concurrent.ForkJoinPool.scan(java.base@21.0.9/ForkJoinPool.java:1841) + at java.util.concurrent.ForkJoinPool.runWorker(java.base@21.0.9/ForkJoinPool.java:1806) + at java.util.concurrent.ForkJoinWorkerThread.run(java.base@21.0.9/ForkJoinWorkerThread.java:188) + +"lettuce-nioEventLoop-4-1" #82 [35608] daemon prio=5 os_prio=0 cpu=109.38ms elapsed=361.32s tid=0x0000023371825e20 nid=35608 runnable [0x0000003924dff000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.WEPoll.wait(java.base@21.0.9/Native Method) + at sun.nio.ch.WEPollSelectorImpl.doSelect(java.base@21.0.9/WEPollSelectorImpl.java:114) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@21.0.9/SelectorImpl.java:130) + - locked <0x000000068e890398> (a io.netty.channel.nio.SelectedSelectionKeySet) + - locked <0x000000068e890338> (a sun.nio.ch.WEPollSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@21.0.9/SelectorImpl.java:147) + at io.netty.channel.nio.SelectedSelectionKeySetSelector.select(SelectedSelectionKeySetSelector.java:68) + at io.netty.channel.nio.NioEventLoop.select(NioEventLoop.java:887) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:526) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:998) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"HikariPool-1 housekeeper" #83 [34564] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=361.06s tid=0x000002336e8db070 nid=34564 waiting on condition [0x00000039227ff000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e881800> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"DestroyJavaVM" #85 [35408] prio=5 os_prio=0 cpu=7171.88ms elapsed=361.05s tid=0x000002336e8dc420 nid=35408 waiting on condition [0x0000000000000000] + java.lang.Thread.State: RUNNABLE + +"boundedElastic-evictor-1" #86 [20552] daemon prio=5 os_prio=0 cpu=0.00ms elapsed=359.77s tid=0x000002336e8dd7d0 nid=20552 waiting on condition [0x00000039216fe000] + java.lang.Thread.State: TIMED_WAITING (parking) + at jdk.internal.misc.Unsafe.park(java.base@21.0.9/Native Method) + - parking to wait for <0x000000068e8978b8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) + at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.9/LockSupport.java:269) + at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.9/AbstractQueuedSynchronizer.java:1763) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:1182) + at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.9/ScheduledThreadPoolExecutor.java:899) + at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.9/ThreadPoolExecutor.java:1070) + at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.9/ThreadPoolExecutor.java:1130) + at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.9/ThreadPoolExecutor.java:642) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"lettuce-nioEventLoop-4-2" #88 [26732] daemon prio=5 os_prio=0 cpu=15.62ms elapsed=359.76s tid=0x000002336e8dbd90 nid=26732 runnable [0x00000039221fe000] + java.lang.Thread.State: RUNNABLE + at sun.nio.ch.WEPoll.wait(java.base@21.0.9/Native Method) + at sun.nio.ch.WEPollSelectorImpl.doSelect(java.base@21.0.9/WEPollSelectorImpl.java:114) + at sun.nio.ch.SelectorImpl.lockAndDoSelect(java.base@21.0.9/SelectorImpl.java:130) + - locked <0x000000068e89eed0> (a io.netty.channel.nio.SelectedSelectionKeySet) + - locked <0x000000068e89ee70> (a sun.nio.ch.WEPollSelectorImpl) + at sun.nio.ch.SelectorImpl.select(java.base@21.0.9/SelectorImpl.java:147) + at io.netty.channel.nio.SelectedSelectionKeySetSelector.select(SelectedSelectionKeySetSelector.java:68) + at io.netty.channel.nio.NioEventLoop.select(NioEventLoop.java:887) + at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:526) + at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:998) + at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74) + at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30) + at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596) + at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583) + +"G1 Conc#2" os_prio=2 cpu=62.50ms elapsed=371.46s tid=0x000002336d0b9c50 nid=13476 runnable + +"G1 Conc#1" os_prio=2 cpu=78.12ms elapsed=371.46s tid=0x000002336d0ba000 nid=28596 runnable + +"GC Thread#12" os_prio=2 cpu=31.25ms elapsed=371.73s tid=0x000002336c9d3da0 nid=32248 runnable + +"GC Thread#11" os_prio=2 cpu=31.25ms elapsed=371.73s tid=0x000002336ccfd490 nid=29844 runnable + +"GC Thread#10" os_prio=2 cpu=46.88ms elapsed=371.73s tid=0x000002336ccfd0e0 nid=30276 runnable + +"GC Thread#9" os_prio=2 cpu=31.25ms elapsed=371.73s tid=0x000002336d9ca340 nid=27712 runnable + +"GC Thread#8" os_prio=2 cpu=31.25ms elapsed=372.68s tid=0x000002336c0e1ed0 nid=34016 runnable + +"GC Thread#7" os_prio=2 cpu=31.25ms elapsed=372.68s tid=0x000002336c0e1b20 nid=31484 runnable + +"GC Thread#6" os_prio=2 cpu=46.88ms elapsed=372.68s tid=0x000002336c2db7d0 nid=28232 runnable + +"GC Thread#5" os_prio=2 cpu=31.25ms elapsed=372.68s tid=0x000002336c2dac10 nid=5780 runnable + +"GC Thread#4" os_prio=2 cpu=46.88ms elapsed=372.68s tid=0x000002336c219050 nid=10792 runnable + +"GC Thread#3" os_prio=2 cpu=46.88ms elapsed=372.68s tid=0x000002336c218ca0 nid=33432 runnable + +"GC Thread#2" os_prio=2 cpu=62.50ms elapsed=372.68s tid=0x000002336c2188f0 nid=29088 runnable + +"GC Thread#1" os_prio=2 cpu=31.25ms elapsed=372.68s tid=0x000002336bd51490 nid=33064 runnable + +"VM Thread" os_prio=2 cpu=15.62ms elapsed=373.00s tid=0x00000233662e8820 nid=22704 runnable + +"VM Periodic Task Thread" os_prio=2 cpu=140.62ms elapsed=373.00s tid=0x00000233662d1660 nid=28572 waiting on condition + +"G1 Service" os_prio=2 cpu=0.00ms elapsed=373.01s tid=0x00000233661a71d0 nid=30960 runnable + +"G1 Refine#0" os_prio=2 cpu=109.38ms elapsed=373.01s tid=0x000002334306e790 nid=35932 runnable + +"G1 Conc#0" os_prio=2 cpu=78.12ms elapsed=373.01s tid=0x0000023343042d40 nid=13768 runnable + +"G1 Main Marker" os_prio=2 cpu=0.00ms elapsed=373.01s tid=0x0000023343042320 nid=31632 runnable + +"GC Thread#0" os_prio=2 cpu=46.88ms elapsed=373.01s tid=0x0000023343034b90 nid=36916 runnable + +JNI global refs: 43, weak refs: 0 + diff --git a/backend-java/pom.xml b/backend-java/pom.xml index 71e4523..2fab57a 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -23,6 +23,8 @@ 4.0.3 3.17.4 5.8.36 + 2.3.5 + 8.5.17 @@ -81,6 +83,16 @@ hutool-all ${hutool.version} + + org.apache.rocketmq + rocketmq-spring-boot-starter + ${rocketmq-spring.version} + + + io.minio + minio + ${minio.version} + org.projectlombok lombok diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java b/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java index 73d65c6..ae7caf4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java +++ b/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java @@ -13,6 +13,9 @@ public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public ApiResponse handleBusinessException(BusinessException ex) { + if (Integer.valueOf(40901).equals(ex.getCode())) { + return ApiResponse.success("任务已结束,忽略重复提交", null); + } return ex.getCode() == null ? ApiResponse.fail(ex.getMessage()) : ApiResponse.fail(ex.getCode(), ex.getMessage()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/service/DistributedJobLockService.java b/backend-java/src/main/java/com/nanri/aiimage/common/service/DistributedJobLockService.java index 5ae7736..40f1772 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/common/service/DistributedJobLockService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/common/service/DistributedJobLockService.java @@ -36,7 +36,13 @@ public class DistributedJobLockService { Duration actualTtl = (ttl == null || ttl.isNegative() || ttl.isZero()) ? Duration.ofMinutes(10) : ttl; String key = buildLockKey(jobName); String token = ownerPrefix + ":" + UUID.randomUUID(); - Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(key, token, actualTtl); + Boolean locked; + try { + locked = stringRedisTemplate.opsForValue().setIfAbsent(key, token, actualTtl); + } catch (Exception ex) { + log.warn("[job-lock] acquire skipped jobName={} key={} msg={}", jobName, key, ex.getMessage()); + return null; + } if (!Boolean.TRUE.equals(locked)) { return null; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java new file mode 100644 index 0000000..b75b930 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/AppearancePatentProperties.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "aiimage.appearance-patent") +public class AppearancePatentProperties { + private String cozeBaseUrl = "https://api.coze.cn"; + private String cozeWorkflowPath = "/v1/workflow/run"; + private String cozeWorkflowId = "7632683471312355338"; + private String cozeToken = ""; + private int cozeBatchSize = 10; + private int staleTimeoutMinutes = 20; + private String staleFinalizeCron = "0 */2 * * * *"; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java index 6a48a49..5d1e0d5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java @@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Configuration; @Configuration -@EnableConfigurationProperties({OssProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class}) +@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, AppearancePatentProperties.class}) public class PropertiesConfig { } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java new file mode 100644 index 0000000..6cee12c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class TaskFileJobConfig { + + @Bean("taskFileJobDispatchExecutor") + public TaskExecutor taskFileJobDispatchExecutor( + @Value("${aiimage.result-file-job.local-dispatch-pool-size:2}") int poolSize, + @Value("${aiimage.result-file-job.local-dispatch-queue-capacity:200}") int queueCapacity) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + int normalizedPoolSize = Math.max(1, poolSize); + executor.setCorePoolSize(normalizedPoolSize); + executor.setMaxPoolSize(normalizedPoolSize); + executor.setQueueCapacity(Math.max(10, queueCapacity)); + executor.setThreadNamePrefix("task-file-job-dispatch-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(30); + executor.initialize(); + return executor; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/TransientStorageProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/TransientStorageProperties.java new file mode 100644 index 0000000..8458f3e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TransientStorageProperties.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "aiimage.transient-storage") +public class TransientStorageProperties { + private boolean enabled = false; + private String endpoint; + private String bucket; + private String accessKeyId; + private String accessKeySecret; + private String region = "us-east-1"; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java new file mode 100644 index 0000000..e1de001 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -0,0 +1,156 @@ +package com.nanri.aiimage.modules.appearancepatent.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.AppearancePatentProperties; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AppearancePatentCozeClient { + + private final AppearancePatentProperties properties; + private final ObjectMapper objectMapper; + + public List inspect(List rows, String prompt) { + if (rows == null || rows.isEmpty()) { + return List.of(); + } + if (properties.getCozeToken() == null || properties.getCozeToken().isBlank()) { + log.warn("[appearance-patent] coze token not configured, keep raw rows size={}", rows.size()); + return rows.stream().map(this::copy).toList(); + } + try { + String raw = postWorkflow(rows, prompt); + List results = parseResults(raw); + List merged = new ArrayList<>(); + for (int i = 0; i < rows.size(); i++) { + AppearancePatentResultRowDto row = copy(rows.get(i)); + if (i < results.size()) { + CozeResult result = results.get(i); + row.setTitleRisk(result.title()); + row.setAppearanceRisk(result.appearance()); + row.setPatentRisk(result.patent()); + row.setConclusion(result.result()); + } + merged.add(row); + } + return merged; + } catch (Exception ex) { + log.warn("[appearance-patent] coze batch failed size={} err={}", rows.size(), ex.getMessage()); + return rows.stream().map(this::copy).toList(); + } + } + + private String postWorkflow(List rows, String prompt) { + Map parameters = new LinkedHashMap<>(); + parameters.put("title_list", rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList()); + parameters.put("url_list", rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList()); + parameters.put("prompt", prompt == null ? "" : prompt); + + Map body = new LinkedHashMap<>(); + body.put("workflow_id", properties.getCozeWorkflowId()); + body.put("parameters", parameters); + + RestClient.RequestBodySpec request = restClient().post() + .uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath())) + .headers(headers -> { + headers.setBearerAuth(stripBearer(properties.getCozeToken())); + headers.setContentType(MediaType.APPLICATION_JSON); + }); + request.body(body); + return request.retrieve().body(String.class); + } + + private List parseResults(String raw) throws Exception { + JsonNode root = objectMapper.readTree(raw); + if (root.path("code").asInt(-1) != 0) { + throw new IllegalStateException(root.path("msg").asText("Coze response code is not 0")); + } + String dataText = root.path("data").asText(""); + if (dataText.isBlank()) { + return List.of(); + } + JsonNode dataRoot = objectMapper.readTree(dataText); + JsonNode array = dataRoot.path("data"); + List results = new ArrayList<>(); + if (array.isArray()) { + for (JsonNode node : array) { + results.add(new CozeResult( + text(node.get("title")), + text(node.get("appearance")), + text(firstNonNull(node.get("patent"), node.get("patent "))), + text(node.get("result")) + )); + } + } + return results; + } + + private RestClient restClient() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(10000); + requestFactory.setReadTimeout(60000); + return RestClient.builder().requestFactory(requestFactory).build(); + } + + private AppearancePatentResultRowDto copy(AppearancePatentResultRowDto source) { + AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); + row.setId(source.getId()); + row.setAsin(source.getAsin()); + row.setCountry(source.getCountry()); + row.setUrl(source.getUrl()); + row.setTitle(source.getTitle()); + row.setError(source.getError()); + row.setDone(source.getDone()); + row.setTitleRisk(source.getTitleRisk()); + row.setAppearanceRisk(source.getAppearanceRisk()); + row.setPatentRisk(source.getPatentRisk()); + row.setConclusion(source.getConclusion()); + return row; + } + + private JsonNode firstNonNull(JsonNode left, JsonNode right) { + return left == null || left.isNull() ? right : left; + } + + private String text(JsonNode node) { + return node == null || node.isNull() ? null : node.asText(); + } + + private String nonBlank(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private String stripBearer(String token) { + String normalized = token == null ? "" : token.trim(); + return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized; + } + + private String joinUrl(String baseUrl, String path) { + String base = baseUrl == null ? "" : baseUrl.trim(); + String suffix = path == null ? "" : path.trim(); + if (base.endsWith("/") && suffix.startsWith("/")) { + return base + suffix.substring(1); + } + if (!base.endsWith("/") && !suffix.startsWith("/")) { + return base + "/" + suffix; + } + return base + suffix; + } + + private record CozeResult(String title, String appearance, String patent, String result) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java new file mode 100644 index 0000000..8a7157a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java @@ -0,0 +1,143 @@ +package com.nanri.aiimage.modules.appearancepatent.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentTaskBatchRequest; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentDashboardVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParseVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskBatchVo; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestBody; +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.server.ResponseStatusException; + +import java.io.InputStream; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/appearance-patent") +@Tag(name = "外观专利检测", description = "外观专利检测任务接口。前端上传 Excel 后由 Java 解析并创建任务;Python 回传商品数据;Java 负责攒批调用 Coze、补齐子行、生成最终 xlsx 并上传 OSS。") +public class AppearancePatentController { + + private final AppearancePatentTaskService service; + + @PostMapping("/parse") + @Operation(summary = "解析 Excel 并创建任务", description = "解析上传后的 Excel 文件,提取 id、ASIN、国家、URL、标题等字段。返回给前端的数据只包含整数 id 和 n_1 行;n_2、n_3 等子行会保存在 OSS 解析载荷中,用于最终结果补齐。创建后的任务状态为 PENDING,不会自动推送 Python。") + public ApiResponse parse(@Valid @RequestBody AppearancePatentParseRequest request) { + return ApiResponse.success(service.parseAndCreateTask(request)); + } + + @GetMapping("/dashboard") + @Operation(summary = "查询外观专利检测总览", description = "查询当前用户的运行中、成功、失败和已结束任务数量。页面进入时请求一次即可,不需要持续轮询。") + public ApiResponse dashboard( + @Parameter(description = "当前用户 ID,用于隔离不同用户的任务和历史记录。", required = true, example = "1") + @RequestParam("user_id") Long userId) { + return ApiResponse.success(service.dashboard(userId)); + } + + @GetMapping("/history") + @Operation(summary = "查询外观专利检测历史", description = "查询当前用户最近的外观专利检测历史记录,包含源文件名、任务状态、行数、错误信息和最终 xlsx 下载地址。") + public ApiResponse history( + @Parameter(description = "当前用户 ID,用于查询该用户自己的历史记录。", required = true, example = "1") + @RequestParam("user_id") Long userId) { + return ApiResponse.success(service.history(userId)); + } + + @PostMapping("/tasks/progress/batch") + @Operation(summary = "批量查询任务进度", description = "前端只对活跃任务调用该接口,建议 6 秒一次。接口只返回轻量任务状态,不返回明细结果。") + public ApiResponse progress(@Valid @RequestBody AppearancePatentTaskBatchRequest request) { + return ApiResponse.success(service.progressBatch(request.getTaskIds())); + } + + @PostMapping("/tasks/{taskId}/activate") + @Operation(summary = "激活任务", description = "前端手动推送 Python 队列成功后调用,将任务从 PENDING 改为 RUNNING,并记录后端内部活跃时间。后续活跃时间由 Python 回传结果接口自动刷新,不需要单独心跳接口。") + public ApiResponse activate( + @Parameter(description = "外观专利检测任务 ID,即解析接口返回的 taskId。", required = true, example = "3938") + @PathVariable Long taskId, + @Parameter(description = "当前用户 ID,必须与创建任务的用户一致。", required = true, example = "1") + @RequestParam("user_id") Long userId) { + service.activateTask(taskId, userId); + return ApiResponse.success(null); + } + + @PostMapping("/tasks/{taskId}/result") + @Operation(summary = "提交 Python 回传结果", description = "Python 回传商品数据接口。items 可以是一条或多条;Java 先原样保存回传数据,再内部攒够 10 条调用 Coze。done=true 表示 Python 已完成全部回传,Java 会强制处理剩余不足 10 条的数据并生成最终 xlsx。") + public ApiResponse result( + @Parameter(description = "外观专利检测任务 ID。任务必须处于 RUNNING 状态。", required = true, example = "3938") + @PathVariable Long taskId, + @Valid @RequestBody AppearancePatentSubmitResultRequest request) { + service.submitResult(taskId, request); + return ApiResponse.success(null); + } + + @DeleteMapping("/tasks/{taskId}") + @Operation(summary = "删除任务", description = "删除当前用户的一条外观专利检测任务,同时清理任务结果、scope 状态和分片记录。") + public ApiResponse deleteTask( + @Parameter(description = "外观专利检测任务 ID。", required = true, example = "3938") + @PathVariable Long taskId, + @Parameter(description = "当前用户 ID,必须与创建任务的用户一致。", required = true, example = "1") + @RequestParam("user_id") Long userId) { + service.deleteTask(taskId, userId); + return ApiResponse.success(null); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除历史记录", description = "删除当前用户的一条外观专利检测历史记录。只删除 biz_file_result 记录,不主动删除任务主记录。") + public ApiResponse deleteHistory( + @Parameter(description = "历史结果记录 ID,即 history 接口返回的 resultId。", required = true, example = "1001") + @PathVariable Long resultId, + @Parameter(description = "当前用户 ID。", required = true, example = "1") + @RequestParam("user_id") Long userId) { + service.deleteHistory(resultId, userId); + return ApiResponse.success(null); + } + + @GetMapping("/results/{resultId}/download") + @Operation(summary = "下载外观专利检测结果文件") + public void downloadResult( + @Parameter(description = "结果记录 ID", required = true, example = "1001") + @PathVariable Long resultId, + @Parameter(description = "当前用户 ID", required = true, example = "1") + @RequestParam("user_id") Long userId, + jakarta.servlet.http.HttpServletResponse response) { + String url = service.resolveResultDownloadUrl(resultId, userId); + String filename = service.resolveResultDownloadFilename(resultId, userId); + if (url == null || url.isBlank()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果"); + } + try { + String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + response.setContentType("application/octet-stream"); + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename); + try (InputStream in = URI.create(url).toURL().openStream()) { + byte[] buffer = new byte[65536]; + int read; + while ((read = in.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, read); + } + response.getOutputStream().flush(); + } + } catch (Exception ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java new file mode 100644 index 0000000..30d2a25 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParseRequest.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.appearancepatent.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "外观专利检测解析请求") +public class AppearancePatentParseRequest { + @JsonProperty("user_id") + @NotNull + @Schema(description = "当前用户 ID。后端会把创建的任务、历史记录和结果文件归属到该用户。", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + private Long userId; + + @NotEmpty + @Schema(description = "已上传的 Excel 文件列表。当前外观专利检测只读取第一个文件;文件对象来自统一上传接口返回值。", requiredMode = Schema.RequiredMode.REQUIRED) + private List files; + + @JsonProperty("ai_prompt") + @JsonAlias({"aiPrompt", "prompt"}) + @Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 Coze workflow 时作为 prompt 参数传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。") + private String aiPrompt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java new file mode 100644 index 0000000..b231d94 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentParsedPayloadDto.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.appearancepatent.model.dto; + +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "外观专利检测解析载荷。通常保存到 OSS,数据库只保存 oss 指针。") +public class AppearancePatentParsedPayloadDto { + @Schema(description = "AI 提示词。") + private String aiPrompt; + @Schema(description = "Excel 原始表头列表。") + private List headers = new ArrayList<>(); + @Schema(description = "返回前端和推给 Python 的代表行,只包含整数 id 和 n_1 行。") + private List items = new ArrayList<>(); + @Schema(description = "完整有效行,包含 n_2、n_3 等解析后被前端过滤但最终需要补回的子行。") + private List allItems = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java new file mode 100644 index 0000000..9be1d36 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java @@ -0,0 +1,33 @@ +package com.nanri.aiimage.modules.appearancepatent.model.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "外观专利检测单行商品数据") +public class AppearancePatentResultRowDto { + @Schema(description = "Excel 中的 id。代表行通常是整数 id 或 n_1,例如 2_1;最终生成 xlsx 时,2_2、2_3 会复用同组 2_1 的 Coze 检测结果。", example = "2_1") + private String id; + @Schema(description = "亚马逊 ASIN。后端会统一按大写处理和匹配。", example = "B0CJ8SNXXV") + private String asin; + @Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国") + private String country; + @Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg") + private String url; + @Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual") + private String title; + @Schema(description = "单行错误信息。通常用于记录 Python 单行处理异常;Coze 失败时后端会尽量保留原始行,不强行写入风险结果。", example = "图片地址为空") + private String error; + @Schema(description = "单行完成标记。当前主要使用请求体顶层 done 控制任务收尾,该字段仅作兼容。", example = "true") + private Boolean done; + @Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + private String titleRisk; + @Schema(description = "Java 调用 Coze 后生成的外观维度检测结果,对应最终 xlsx 的“外观维度(外观设计专利)”列。Python 回传请求不要传该字段。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + private String appearanceRisk; + @JsonAlias({"patent ", "patent"}) + @Schema(description = "Java 调用 Coze 后生成的专利维度检测结果,对应最终 xlsx 的“专利维度(发明/实用新型专利)”列。兼容 Coze 返回字段 patent 和 patent 后带空格的情况;Python 回传请求不要传该字段。", example = "未发现明显发明或实用新型专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + private String patentRisk; + @Schema(description = "Java 调用 Coze 后生成的最终结论,对应最终 xlsx 的“结论”列。Python 回传请求不要传该字段。", example = "未发现明显侵权风险", accessMode = Schema.AccessMode.READ_ONLY) + private String conclusion; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentSourceFileDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentSourceFileDto.java new file mode 100644 index 0000000..8a92056 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentSourceFileDto.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.appearancepatent.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "外观专利检测源文件信息") +public class AppearancePatentSourceFileDto { + @Schema(description = "上传接口返回的临时文件 key。后端根据该 key 查找本地临时 Excel 文件并解析。", example = "uploads/20260426/appearance_patent_17.xlsx", requiredMode = Schema.RequiredMode.REQUIRED) + private String fileKey; + @Schema(description = "原始文件名。用于历史记录展示和最终结果文件命名。", example = "17.xlsx") + private String originalFilename; + @Schema(description = "相对目录路径。当前仅记录来源,外观专利检测不依赖该字段处理。", example = "xlsx/17.xlsx") + private String relativePath; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentSubmitResultRequest.java new file mode 100644 index 0000000..57685cc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentSubmitResultRequest.java @@ -0,0 +1,24 @@ +package com.nanri.aiimage.modules.appearancepatent.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "Python 回传外观专利检测结果请求") +public class AppearancePatentSubmitResultRequest { + @Schema(description = "本次 Python 回传的提交批次标识。建议同一个任务固定使用同一个值,例如 appearance-patent-{taskId};后端会结合该值和 chunkIndex 做分片幂等。", example = "appearance-patent-3938") + private String submissionId; + @Schema(description = "当前回传分片序号。建议从 1 开始递增;同一个 submissionId 下相同 chunkIndex 重复提交会被后端识别为重复分片并跳过重复处理。", example = "1") + private Integer chunkIndex; + @Schema(description = "本任务预计总分片数。如果 Python 是一条一条回传,可设置为总商品数;如果无法预估,可传 0 或 1,最终以 done=true 触发收尾。", example = "458") + private Integer chunkTotal; + @Schema(description = "是否为最后一次回传。true 表示 Python 已完成该任务全部数据回传;Java 会处理剩余不足 10 条的数据、组装最终 xlsx、上传 OSS 并收尾任务。", example = "false") + private Boolean done; + @Schema(description = "Python 侧任务级错误信息。非空时 Java 会记录错误并按失败任务收尾;普通单行 Coze 失败不建议写这里。", example = "浏览器执行异常,任务提前结束") + private String error; + @Schema(description = "本次回传的商品原始数据列表。可以一条一条传,也可以一次多条传;Python 只需要传 id、asin、country、url、title、error、done 等原始/执行字段。titleRisk、appearanceRisk、patentRisk、conclusion 由 Java 攒够 10 条调用 Coze 后生成,Python 不要传。") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentTaskBatchRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentTaskBatchRequest.java new file mode 100644 index 0000000..799b1f8 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentTaskBatchRequest.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.appearancepatent.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "外观专利检测批量进度查询请求") +public class AppearancePatentTaskBatchRequest { + @NotEmpty + @Schema(description = "需要查询进度的任务 ID 列表。前端只传正在轮询的活跃任务;后端会批量查询,避免每个任务单独请求。", example = "[3938,3939]", requiredMode = Schema.RequiredMode.REQUIRED) + private List taskIds; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentDashboardVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentDashboardVo.java new file mode 100644 index 0000000..c8c2254 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentDashboardVo.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.appearancepatent.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "外观专利检测总览统计") +public class AppearancePatentDashboardVo { + @Schema(description = "运行中任务数量。这里统计 RUNNING 状态任务。", example = "1") + private Long pendingTaskCount; + @Schema(description = "已结束任务数量,等于成功任务数加失败任务数。", example = "12") + private Long processedTaskCount; + @Schema(description = "成功任务数量。", example = "10") + private Long successTaskCount; + @Schema(description = "失败任务数量。", example = "2") + private Long failedTaskCount; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java new file mode 100644 index 0000000..cdd9e99 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryItemVo.java @@ -0,0 +1,33 @@ +package com.nanri.aiimage.modules.appearancepatent.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "外观专利检测历史记录项") +public class AppearancePatentHistoryItemVo { + @Schema(description = "结果记录 ID。删除历史、下载结果时使用。", example = "1001") + private Long resultId; + @Schema(description = "任务 ID。", example = "3938") + private Long taskId; + @Schema(description = "源 Excel 文件名。", example = "17.xlsx") + private String sourceFilename; + @Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx") + private String resultFilename; + @Schema(description = "最终结果文件下载地址。后端基于 OSS objectKey 生成的新鲜预签名 URL。", example = "https://bucket.oss-cn-hangzhou.aliyuncs.com/result/appearance_patent/xxx/17-result.xlsx?Expires=...") + private String downloadUrl; + private Long fileJobId; + private String fileStatus; + private String fileError; + private Boolean fileReady; + @Schema(description = "任务状态:PENDING=已解析待推送,RUNNING=执行中,SUCCESS=成功,FAILED=失败。", example = "SUCCESS") + private String taskStatus; + @Schema(description = "结果是否成功。true 表示任务完成并生成结果文件;false 表示失败或未完成。", example = "true") + private Boolean success; + @Schema(description = "错误信息。任务失败时返回,例如 Python 超时、结果文件生成失败等。", example = "Python interrupted before uploading final appearance patent result") + private String error; + @Schema(description = "最终结果行数。包含解析阶段被过滤但最终需要补回的 2_2、2_3 等子行。", example = "716") + private Integer rowCount; + @Schema(description = "历史记录创建时间,ISO 本地时间字符串。", example = "2026-04-26T10:30:00") + private String createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryVo.java new file mode 100644 index 0000000..dfa788e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentHistoryVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.appearancepatent.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "外观专利检测历史记录列表") +public class AppearancePatentHistoryVo { + @Schema(description = "历史记录项列表,默认返回最近 100 条。") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParseVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParseVo.java new file mode 100644 index 0000000..2532d12 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParseVo.java @@ -0,0 +1,26 @@ +package com.nanri.aiimage.modules.appearancepatent.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "外观专利检测解析结果") +public class AppearancePatentParseVo { + @Schema(description = "新创建的任务 ID。后续手动推 Python、激活任务、回传结果、查询进度都使用该 ID。", example = "3938") + private Long taskId; + @Schema(description = "源 Excel 文件名。", example = "17.xlsx") + private String sourceFilename; + @Schema(description = "Excel 中检测到的有效数据总行数,不含空行。", example = "716") + private Integer totalRows; + @Schema(description = "返回前端并准备推给 Python 的代表行数量。只包含整数 id 和 n_1 行。", example = "458") + private Integer acceptedRows; + @Schema(description = "解析时被过滤或缺少必要字段的行数。n_2、n_3 等子行会计入过滤数,但仍会保存在 OSS 解析载荷中用于最终补齐。", example = "258") + private Integer droppedRows; + @Schema(description = "本任务最终使用的 AI 提示词。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。") + private String aiPrompt; + @Schema(description = "返回前端的代表行列表。前端只展示样例,不展示完整明细;完整 allItems 已保存在 OSS 解析载荷中。") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java new file mode 100644 index 0000000..a286e4f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.appearancepatent.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Schema(description = "外观专利检测解析出的单行数据") +public class AppearancePatentParsedRowVo { + @Schema(description = "Excel 原始行号,从 1 开始。", example = "2") + private Integer rowIndex; + @Schema(description = "Excel 中原始 id 值。", example = "2_1") + private String sourceId; + @Schema(description = "前端展示和 Python 回传使用的 id。整数 id 原样保留,子数据第一条如 2_1 原样保留。", example = "2_1") + private String displayId; + @Schema(description = "亚马逊 ASIN。", example = "B0CJ8SNXXV") + private String asin; + @Schema(description = "国家或站点。", example = "英国") + private String country; + @Schema(description = "商品图片 URL 或商品 URL,供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg") + private String url; + @Schema(description = "商品标题。", example = "Women Floral Dress Summer Casual") + private String title; + @Schema(description = "该 Excel 行的原始列值映射。最终生成 xlsx 时可从这里读取价格等字段。", example = "{\"id\":\"2_1\",\"asin\":\"B0CJ8SNXXV\",\"国家\":\"英国\",\"价格\":\"12.99\"}") + private Map values = new LinkedHashMap<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskBatchVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskBatchVo.java new file mode 100644 index 0000000..7dd79c1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskBatchVo.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.appearancepatent.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "外观专利检测批量进度响应") +public class AppearancePatentTaskBatchVo { + @Schema(description = "查询到的任务详情列表。顺序按请求 taskIds 处理。") + private List items = new ArrayList<>(); + @Schema(description = "未找到或不属于外观专利检测模块的任务 ID 列表。", example = "[99999]") + private List missingTaskIds = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskDetailVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskDetailVo.java new file mode 100644 index 0000000..f71e369 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskDetailVo.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.appearancepatent.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "外观专利检测任务进度详情") +public class AppearancePatentTaskDetailVo { + @Schema(description = "任务主记录轻量信息。") + private AppearancePatentTaskItemVo task; + @Schema(description = "预留的任务明细列表。当前进度接口主要返回任务轻量状态,不返回完整结果明细。") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskItemVo.java new file mode 100644 index 0000000..955213a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentTaskItemVo.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.appearancepatent.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "外观专利检测任务轻量信息") +public class AppearancePatentTaskItemVo { + @Schema(description = "任务 ID。", example = "3938") + private Long id; + @Schema(description = "任务编号,后端自动生成。", example = "APPEARANCE_PATENT-1780000000000000000") + private String taskNo; + @Schema(description = "任务状态:PENDING=已解析待推送,RUNNING=执行中,SUCCESS=成功,FAILED=失败。", example = "RUNNING") + private String status; + @Schema(description = "任务级错误信息。失败时返回。", example = "生成外观专利检测结果失败") + private String errorMessage; + @Schema(description = "创建时间,ISO 本地时间字符串。", example = "2026-04-26T10:00:00") + private String createdAt; + @Schema(description = "最后更新时间,通常由 Python 回传结果或任务收尾更新。", example = "2026-04-26T10:05:00") + private String updatedAt; + @Schema(description = "完成时间。任务未结束时为空。", example = "2026-04-26T10:10:00") + private String finishedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskCacheService.java new file mode 100644 index 0000000..31e71db --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskCacheService.java @@ -0,0 +1,144 @@ +package com.nanri.aiimage.modules.appearancepatent.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AppearancePatentTaskCacheService { + + private static final long TTL_HOURS = 24; + + private final StringRedisTemplate stringRedisTemplate; + private final ObjectMapper objectMapper; + + public void appendPendingRow(Long taskId, Integer chunkIndex, AppearancePatentResultRowDto row) { + if (taskId == null || taskId <= 0 || chunkIndex == null || row == null) { + return; + } + try { + stringRedisTemplate.opsForList().rightPush( + pendingRowsKey(taskId), + objectMapper.writeValueAsString(new PendingRow(chunkIndex, row)) + ); + stringRedisTemplate.expire(pendingRowsKey(taskId), Duration.ofHours(TTL_HOURS)); + touchTaskHeartbeat(taskId); + } catch (Exception ignored) { + } + } + + public long pendingRowCount(Long taskId) { + Long size; + try { + size = stringRedisTemplate.opsForList().size(pendingRowsKey(taskId)); + } catch (Exception ex) { + log.warn("[appearance-patent-cache] pending row count degraded taskId={} msg={}", taskId, ex.getMessage()); + return 0L; + } + return size == null ? 0L : size; + } + + public List drainPendingRows(Long taskId, int limit) { + if (taskId == null || taskId <= 0 || limit <= 0) { + return List.of(); + } + String key = pendingRowsKey(taskId); + List values; + try { + values = stringRedisTemplate.opsForList().range(key, 0, limit - 1L); + } catch (Exception ex) { + log.warn("[appearance-patent-cache] drain range degraded taskId={} msg={}", taskId, ex.getMessage()); + return List.of(); + } + if (values == null || values.isEmpty()) { + return List.of(); + } + try { + stringRedisTemplate.opsForList().trim(key, values.size(), -1); + } catch (Exception ex) { + log.warn("[appearance-patent-cache] drain trim degraded taskId={} msg={}", taskId, ex.getMessage()); + return List.of(); + } + List rows = new ArrayList<>(); + for (String value : values) { + if (value == null || value.isBlank()) { + continue; + } + try { + rows.add(objectMapper.readValue(value, PendingRow.class)); + } catch (Exception ignored) { + } + } + touchTaskHeartbeat(taskId); + return rows; + } + + public void touchTaskHeartbeat(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + try { + stringRedisTemplate.opsForValue().set( + heartbeatKey(taskId), + String.valueOf(Instant.now().toEpochMilli()), + Duration.ofHours(TTL_HOURS) + ); + } catch (Exception ex) { + log.warn("[appearance-patent-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + } + } + + public long getTaskHeartbeatMillis(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0L; + } + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(heartbeatKey(taskId)); + } catch (Exception ex) { + log.warn("[appearance-patent-cache] get heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + return 0L; + } + if (raw == null || raw.isBlank()) { + return 0L; + } + try { + return Long.parseLong(raw); + } catch (NumberFormatException ignored) { + return 0L; + } + } + + public void deleteTaskCache(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + try { + stringRedisTemplate.delete(pendingRowsKey(taskId)); + stringRedisTemplate.delete(heartbeatKey(taskId)); + } catch (Exception ex) { + log.warn("[appearance-patent-cache] delete degraded taskId={} msg={}", taskId, ex.getMessage()); + } + } + + private String pendingRowsKey(Long taskId) { + return "appearance-patent:task:pending-rows:" + taskId; + } + + private String heartbeatKey(Long taskId) { + return "appearance-patent:task:heartbeat:" + taskId; + } + + public record PendingRow(Integer chunkIndex, AppearancePatentResultRowDto row) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java new file mode 100644 index 0000000..d64f984 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -0,0 +1,1001 @@ +package com.nanri.aiimage.modules.appearancepatent.service; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.crypto.digest.DigestUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.AppearancePatentProperties; +import com.nanri.aiimage.config.StorageProperties; +import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentCozeClient; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedPayloadDto; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSourceFileDto; +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentDashboardVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryItemVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParseVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskBatchVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskDetailVo; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskItemVo; +import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; +import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AppearancePatentTaskService { + + public static final String MODULE_TYPE = "APPEARANCE_PATENT"; + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_RUNNING = "RUNNING"; + private static final String STATUS_SUCCESS = "SUCCESS"; + private static final String STATUS_FAILED = "FAILED"; + private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final List RESULT_HEADERS = List.of( + "id", + "asin", + "国家", + "价格", + "标题维度(商标)", + "外观维度(外观设计专利)", + "专利维度(发明/实用新型专利)", + "结论" + ); + + private final LocalFileStorageService localFileStorageService; + private final OssStorageService ossStorageService; + private final StorageProperties storageProperties; + private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; + private final TaskScopeStateMapper taskScopeStateMapper; + private final TaskChunkMapper taskChunkMapper; + private final ObjectMapper objectMapper; + private final AppearancePatentCozeClient cozeClient; + private final AppearancePatentTaskCacheService taskCacheService; + private final AppearancePatentProperties properties; + private final TaskFileJobService taskFileJobService; + private final TransientPayloadStorageService transientPayloadStorageService; + + @Transactional + public AppearancePatentParseVo parseAndCreateTask(AppearancePatentParseRequest request) { + if (request.getUserId() == null || request.getUserId() <= 0) { + throw new BusinessException("user_id 不合法"); + } + if (request.getFiles() == null || request.getFiles().isEmpty()) { + throw new BusinessException("请先上传 Excel 文件"); + } + AppearancePatentSourceFileDto source = request.getFiles().getFirst(); + if (source.getFileKey() == null || source.getFileKey().isBlank()) { + throw new BusinessException("fileKey 不能为空"); + } + File input = localFileStorageService.findLocalSourceFile(source.getFileKey()); + if (input == null || !input.exists()) { + throw new BusinessException("源文件不存在"); + } + + ParsedWorkbook parsed = parseWorkbook(input); + if (parsed.acceptedRows.isEmpty()) { + throw new BusinessException("未解析到有效 ASIN 数据"); + } + + FileTaskEntity task = new FileTaskEntity(); + task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr()); + task.setModuleType(MODULE_TYPE); + task.setTaskMode("PYTHON_QUEUE"); + task.setStatus(STATUS_PENDING); + task.setSourceFileCount(parsed.acceptedRows.size()); + task.setSuccessFileCount(0); + task.setFailedFileCount(0); + task.setCreatedBy("user:" + request.getUserId()); + task.setUserId(request.getUserId()); + task.setCreatedAt(LocalDateTime.now()); + task.setUpdatedAt(LocalDateTime.now()); + try { + task.setRequestJson(objectMapper.writeValueAsString(request)); + task.setResultJson("{}"); + } catch (Exception ex) { + throw new BusinessException("序列化解析结果失败"); + } + fileTaskMapper.insert(task); + + String sourceScopeHash = DigestUtil.sha256Hex(source.getFileKey()); + String parsedPayload = buildParsedPayloadJson(request.getAiPrompt(), parsed.headers, parsed.acceptedRows, parsed.allRows); + String parsedPayloadPointer = storeParsedPayload(task.getId(), sourceScopeHash, parsedPayload); + task.setResultJson(buildTaskResultJson(request.getAiPrompt(), source.getFileKey(), parsedPayloadPointer)); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + + String sourceFilename = firstNonBlank(source.getOriginalFilename(), input.getName()); + FileResultEntity result = new FileResultEntity(); + result.setTaskId(task.getId()); + result.setModuleType(MODULE_TYPE); + result.setSourceFilename(sourceFilename); + result.setSourceFileUrl(source.getFileKey()); + result.setRowCount(parsed.acceptedRows.size()); + result.setUserId(request.getUserId()); + result.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(result); + + TaskScopeStateEntity scope = new TaskScopeStateEntity(); + scope.setTaskId(task.getId()); + scope.setModuleType(MODULE_TYPE); + scope.setScopeKey(source.getFileKey()); + scope.setScopeHash(sourceScopeHash); + scope.setParsedPayloadJson(writeJson(parsedPayloadPointer, "保存解析载荷指针失败")); + scope.setStateJson("{\"phase\":\"PARSED\"}"); + scope.setChunkTotal(0); + scope.setReceivedChunkCount(0); + scope.setCompleted(0); + scope.setCreatedAt(LocalDateTime.now()); + scope.setUpdatedAt(LocalDateTime.now()); + taskScopeStateMapper.insert(scope); + + AppearancePatentParseVo vo = new AppearancePatentParseVo(); + vo.setTaskId(task.getId()); + vo.setSourceFilename(sourceFilename); + vo.setTotalRows(parsed.totalRows); + vo.setAcceptedRows(parsed.acceptedRows.size()); + vo.setDroppedRows(parsed.droppedRows); + vo.setAiPrompt(normalize(request.getAiPrompt())); + vo.setItems(parsed.acceptedRows); + return vo; + } + + @Transactional + public void activateTask(Long taskId, Long userId) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || userId != null && !Objects.equals(userId, task.getUserId())) { + throw new BusinessException("任务不存在"); + } + if (STATUS_SUCCESS.equals(task.getStatus()) || STATUS_FAILED.equals(task.getStatus())) { + throw new BusinessException("任务已结束"); + } + task.setStatus(STATUS_RUNNING); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + taskCacheService.touchTaskHeartbeat(taskId); + } + + public AppearancePatentDashboardVo dashboard(Long userId) { + AppearancePatentDashboardVo vo = new AppearancePatentDashboardVo(); + vo.setPendingTaskCount(countTask(userId, STATUS_RUNNING)); + vo.setSuccessTaskCount(countTask(userId, STATUS_SUCCESS)); + vo.setFailedTaskCount(countTask(userId, STATUS_FAILED)); + vo.setProcessedTaskCount(vo.getSuccessTaskCount() + vo.getFailedTaskCount()); + return vo; + } + + public AppearancePatentHistoryVo history(Long userId) { + AppearancePatentHistoryVo vo = new AppearancePatentHistoryVo(); + List rows = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getUserId, userId) + .orderByDesc(FileResultEntity::getCreatedAt) + .last("limit 100")); + Map statusMap = new LinkedHashMap<>(); + List taskIds = rows.stream().map(FileResultEntity::getTaskId).filter(Objects::nonNull).distinct().toList(); + if (!taskIds.isEmpty()) { + for (FileTaskEntity task : fileTaskMapper.selectBatchIds(taskIds)) { + statusMap.put(task.getId(), task.getStatus()); + } + } + for (FileResultEntity row : rows) { + vo.getItems().add(toHistoryItem(row, statusMap.get(row.getTaskId()))); + } + return vo; + } + + public AppearancePatentTaskBatchVo progressBatch(List taskIds) { + AppearancePatentTaskBatchVo vo = new AppearancePatentTaskBatchVo(); + List normalizedIds = taskIds == null ? List.of() : taskIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (normalizedIds.isEmpty()) { + return vo; + } + Map taskMap = new LinkedHashMap<>(); + for (FileTaskEntity task : fileTaskMapper.selectBatchIds(normalizedIds)) { + if (task != null && MODULE_TYPE.equals(task.getModuleType())) { + taskMap.put(task.getId(), task); + } + } + for (Long taskId : normalizedIds) { + FileTaskEntity task = taskMap.get(taskId); + if (task == null) { + vo.getMissingTaskIds().add(taskId); + continue; + } + AppearancePatentTaskDetailVo detail = new AppearancePatentTaskDetailVo(); + detail.setTask(toTaskItem(task)); + vo.getItems().add(detail); + } + return vo; + } + + @Transactional + public void submitResult(Long taskId, AppearancePatentSubmitResultRequest request) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + if (!STATUS_RUNNING.equals(task.getStatus())) { + throw new BusinessException("任务不是运行中状态"); + } + + int chunkIndex = request.getChunkIndex() == null ? 0 : request.getChunkIndex(); + int chunkTotal = request.getChunkTotal() == null ? 1 : request.getChunkTotal(); + String scopeKey = firstNonBlank(request.getSubmissionId(), "task:" + taskId); + String scopeHash = DigestUtil.sha256Hex(scopeKey); + taskCacheService.touchTaskHeartbeat(taskId); + + TaskChunkEntity existing = taskChunkMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .eq(TaskChunkEntity::getChunkIndex, chunkIndex) + .last("limit 1")); + if (existing == null) { + List rawRows = request.getItems() == null ? List.of() : request.getItems(); + String payloadJson = writeJson(rawRows, "结果序列化失败"); + + TaskChunkEntity chunk = new TaskChunkEntity(); + chunk.setTaskId(taskId); + chunk.setModuleType(MODULE_TYPE); + chunk.setScopeKey(scopeKey); + chunk.setScopeHash(scopeHash); + chunk.setChunkIndex(chunkIndex); + chunk.setChunkTotal(chunkTotal); + String storedPayload = transientPayloadStorageService.storeChunkPayload(MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson); + chunk.setPayloadJson(storedPayload); + chunk.setPayloadHash(DigestUtil.sha256Hex(payloadJson)); + chunk.setCreatedAt(LocalDateTime.now()); + chunk.setUpdatedAt(LocalDateTime.now()); + boolean inserted = true; + try { + taskChunkMapper.insert(chunk); + } catch (DuplicateKeyException ex) { + inserted = false; + transientPayloadStorageService.deletePayloadIfPresent(storedPayload); + log.info("[appearance-patent] duplicate chunk inserted concurrently taskId={} scope={} chunk={}", taskId, scopeKey, chunkIndex); + } + if (inserted) { + for (AppearancePatentResultRowDto rawRow : rawRows) { + taskCacheService.appendPendingRow(taskId, chunkIndex, rawRow); + } + } + } else { + log.info("[appearance-patent] duplicate chunk ignored taskId={} scope={} chunk={}", taskId, scopeKey, chunkIndex); + } + + flushPendingRowsIfNeeded(task, scopeHash, Boolean.TRUE.equals(request.getDone())); + + TaskScopeStateEntity scope = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .eq(TaskScopeStateEntity::getScopeHash, scopeHash) + .last("limit 1")); + if (scope == null) { + scope = new TaskScopeStateEntity(); + scope.setTaskId(taskId); + scope.setModuleType(MODULE_TYPE); + scope.setScopeKey(scopeKey); + scope.setScopeHash(scopeHash); + scope.setCreatedAt(LocalDateTime.now()); + } + scope.setChunkTotal(chunkTotal); + scope.setReceivedChunkCount(countChunks(taskId, scopeHash)); + scope.setLastChunkAt(LocalDateTime.now()); + scope.setLastError(request.getError()); + scope.setCompleted(Boolean.TRUE.equals(request.getDone()) ? 1 : 0); + scope.setUpdatedAt(LocalDateTime.now()); + scope.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"DONE\"}"); + if (scope.getId() == null) { + taskScopeStateMapper.insert(scope); + } else { + taskScopeStateMapper.updateById(scope); + } + + if (Boolean.TRUE.equals(request.getDone()) || request.getError() != null && !request.getError().isBlank()) { + finalizeTask(task, request.getError(), allRowCount(task), true); + } else { + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + } + } + + @Transactional + public void deleteTask(Long taskId, Long userId) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !Objects.equals(userId, task.getUserId())) { + throw new BusinessException("任务不存在"); + } + fileResultMapper.delete(new LambdaQueryWrapper().eq(FileResultEntity::getTaskId, taskId).eq(FileResultEntity::getModuleType, MODULE_TYPE)); + deleteTransientTaskPayloads(taskId); + taskScopeStateMapper.delete(new LambdaQueryWrapper().eq(TaskScopeStateEntity::getTaskId, taskId).eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + taskChunkMapper.delete(new LambdaQueryWrapper().eq(TaskChunkEntity::getTaskId, taskId).eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + taskCacheService.deleteTaskCache(taskId); + fileTaskMapper.deleteById(taskId); + } + + public void deleteHistory(Long resultId, Long userId) { + FileResultEntity row = fileResultMapper.selectById(resultId); + if (row == null || !MODULE_TYPE.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) { + throw new BusinessException("记录不存在"); + } + fileResultMapper.deleteById(resultId); + } + + public String resolveResultDownloadUrl(Long resultId, Long userId) { + FileResultEntity row = fileResultMapper.selectById(resultId); + if (row == null || !MODULE_TYPE.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) { + throw new BusinessException("记录不存在"); + } + if (row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) { + throw new BusinessException("暂无可下载文件"); + } + return ossStorageService.generateFreshDownloadUrl(row.getResultFileUrl()); + } + + public String resolveResultDownloadFilename(Long resultId, Long userId) { + FileResultEntity row = fileResultMapper.selectById(resultId); + if (row == null || !MODULE_TYPE.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) { + throw new BusinessException("记录不存在"); + } + return row.getResultFilename() == null || row.getResultFilename().isBlank() + ? "appearance-patent-" + resultId + ".xlsx" + : row.getResultFilename(); + } + + @Scheduled(cron = "${aiimage.appearance-patent.stale-finalize-cron:0 */2 * * * *}") + @Transactional + public void finalizeStaleTasks() { + LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(1, properties.getStaleTimeoutMinutes())); + long thresholdMillis = threshold.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + List tasks = fileTaskMapper.selectList(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getStatus, STATUS_RUNNING) + .lt(FileTaskEntity::getUpdatedAt, threshold) + .last("limit 50")); + for (FileTaskEntity task : tasks) { + long heartbeatMillis = taskCacheService.getTaskHeartbeatMillis(task.getId()); + if (heartbeatMillis > thresholdMillis) { + continue; + } + flushPendingRowsIfNeeded(task, DigestUtil.sha256Hex("appearance-patent-" + task.getId()), true); + finalizeTask(task, "Python interrupted before uploading final appearance patent result", allRowCount(task), true); + } + } + + private List applyCozeInBatches(List items, FileTaskEntity task) { + if (items == null || items.isEmpty()) { + return List.of(); + } + String prompt = readAiPrompt(task); + int batchSize = Math.max(1, properties.getCozeBatchSize()); + List result = new ArrayList<>(); + for (int i = 0; i < items.size(); i += batchSize) { + result.addAll(cozeClient.inspect(items.subList(i, Math.min(i + batchSize, items.size())), prompt)); + } + return result; + } + + private void flushPendingRowsIfNeeded(FileTaskEntity task, String scopeHash, boolean force) { + int batchSize = Math.max(1, properties.getCozeBatchSize()); + while (force ? taskCacheService.pendingRowCount(task.getId()) > 0 : taskCacheService.pendingRowCount(task.getId()) >= batchSize) { + int drainSize = force ? batchSize : Math.max(1, batchSize); + List pendingRows = taskCacheService.drainPendingRows(task.getId(), drainSize); + if (pendingRows.isEmpty()) { + return; + } + List rawRows = pendingRows.stream() + .map(AppearancePatentTaskCacheService.PendingRow::row) + .toList(); + List cozeRows = cozeClient.inspect(rawRows, readAiPrompt(task)); + for (int i = 0; i < pendingRows.size(); i++) { + AppearancePatentTaskCacheService.PendingRow pending = pendingRows.get(i); + AppearancePatentResultRowDto resultRow = i < cozeRows.size() ? cozeRows.get(i) : pending.row(); + List persistedRows = expandRows(task, List.of(resultRow)); + updateChunkPayload(task.getId(), scopeHash, pending.chunkIndex(), persistedRows); + } + } + } + + private void updateChunkPayload(Long taskId, String scopeHash, Integer chunkIndex, List rows) { + TaskChunkEntity chunk = taskChunkMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .eq(TaskChunkEntity::getChunkIndex, chunkIndex) + .last("limit 1")); + if (chunk == null) { + return; + } + String payloadJson = writeJson(rows == null ? List.of() : rows, "结果序列化失败"); + String storedPayload = transientPayloadStorageService.storeChunkPayload(MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson); + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(chunk.getPayloadJson(), storedPayload); + chunk.setPayloadJson(storedPayload); + chunk.setPayloadHash(DigestUtil.sha256Hex(payloadJson)); + chunk.setUpdatedAt(LocalDateTime.now()); + taskChunkMapper.updateById(chunk); + } + + private List expandRows(FileTaskEntity task, List representatives) { + if (representatives == null || representatives.isEmpty()) { + return List.of(); + } + Map> allRowsByBaseId = loadAllRowsByBaseId(task); + List result = new ArrayList<>(); + Set emittedKeys = new LinkedHashSet<>(); + for (AppearancePatentResultRowDto representative : representatives) { + String baseId = baseId(representative.getId()); + List siblings = allRowsByBaseId.get(baseId); + if (siblings == null || siblings.isEmpty()) { + result.add(representative); + continue; + } + for (AppearancePatentParsedRowVo sibling : siblings) { + String key = sibling.getDisplayId() + "::" + sibling.getAsin() + "::" + sibling.getCountry(); + if (!emittedKeys.add(key)) { + continue; + } + result.add(copyResultToSibling(representative, sibling)); + } + } + return result; + } + + private Map> loadAllRowsByBaseId(FileTaskEntity task) { + Map> result = new LinkedHashMap<>(); + try { + for (AppearancePatentParsedRowVo row : readParsedPayload(task).getAllItems()) { + result.computeIfAbsent(baseId(row.getDisplayId()), key -> new ArrayList<>()).add(row); + } + } catch (Exception ex) { + log.warn("[appearance-patent] read all rows failed taskId={} err={}", task.getId(), ex.getMessage()); + } + return result; + } + + private int allRowCount(FileTaskEntity task) { + try { + return readParsedPayload(task).getAllItems().size(); + } catch (Exception ex) { + log.warn("[appearance-patent] read all row count failed taskId={} err={}", task.getId(), ex.getMessage()); + return 0; + } + } + + private AppearancePatentResultRowDto copyResultToSibling(AppearancePatentResultRowDto representative, AppearancePatentParsedRowVo sibling) { + AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); + row.setId(sibling.getDisplayId()); + row.setAsin(sibling.getAsin()); + row.setCountry(sibling.getCountry()); + row.setUrl(sibling.getUrl()); + row.setTitle(sibling.getTitle()); + row.setError(representative.getError()); + row.setTitleRisk(representative.getTitleRisk()); + row.setAppearanceRisk(representative.getAppearanceRisk()); + row.setPatentRisk(representative.getPatentRisk()); + row.setConclusion(representative.getConclusion()); + row.setDone(representative.getDone()); + return row; + } + + private String readAiPrompt(FileTaskEntity task) { + try { + return readParsedPayload(task).getAiPrompt(); + } catch (Exception ignored) { + return ""; + } + } + + private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) { + String finalError = error; + FileResultEntity result = null; + List rows = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, task.getId()) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .last("limit 1")); + if (!rows.isEmpty()) { + result = rows.getFirst(); + if (assembleWorkbook && shouldAssembleSynchronously()) { + try { + assembleResultWorkbook(task, result); + } catch (Exception ex) { + log.warn("[appearance-patent] assemble result workbook failed taskId={} err={}", task.getId(), ex.getMessage()); + finalError = firstNonBlank(finalError, "生成外观专利检测结果失败"); + } + } + } + boolean failed = finalError != null && !finalError.isBlank(); + task.setStatus(failed ? STATUS_FAILED : STATUS_SUCCESS); + task.setSuccessFileCount(failed ? 0 : 1); + task.setFailedFileCount(failed ? 1 : 0); + task.setErrorMessage(finalError); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + if (result != null) { + result.setSuccess(failed ? 0 : 1); + result.setErrorMessage(finalError); + result.setRowCount(rowCount > 0 ? rowCount : result.getRowCount()); + if (!failed && assembleWorkbook) { + result.setResultFilename(safeFileStem(result.getSourceFilename()) + "-result.xlsx"); + result.setResultFileUrl(null); + result.setResultFileSize(0L); + result.setResultContentType(CONTENT_TYPE_XLSX); + } + fileResultMapper.updateById(result); + if (!failed && assembleWorkbook) { + taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, result.getId(), "task:" + task.getId()); + } + } + taskCacheService.deleteTaskCache(task.getId()); + } + + private boolean shouldAssembleSynchronously() { + return false; + } + + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null || job.getResultId() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId()); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + FileResultEntity result = fileResultMapper.selectById(job.getResultId()); + if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { + throw new BusinessException("结果记录不存在"); + } + assembleResultWorkbook(task, result); + fileResultMapper.updateById(result); + } + + public void cleanupResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null) { + return; + } + deleteTransientTaskPayloads(job.getTaskId()); + taskChunkMapper.delete(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, job.getTaskId()) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + taskScopeStateMapper.delete(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, job.getTaskId()) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + } + + private void assembleResultWorkbook(FileTaskEntity task, FileResultEntity result) { + AppearancePatentParsedPayloadDto parsed = readParsedPayload(task); + Map resultMap = loadPersistedResultRows(task.getId()); + File outputDir = new File(storageProperties.getLocalTempDir(), "appearance-patent-result"); + if (!outputDir.exists() && !outputDir.mkdirs()) { + throw new BusinessException("创建结果目录失败"); + } + String filename = safeFileStem(result.getSourceFilename()) + "-result.xlsx"; + File xlsx = new File(outputDir, filename); + try { + writeResultWorkbook(xlsx, parsed, resultMap); + String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); + result.setResultFilename(filename); + result.setResultFileUrl(objectKey); + result.setResultFileSize(xlsx.length()); + result.setResultContentType(CONTENT_TYPE_XLSX); + result.setRowCount(parsed.getAllItems().size()); + } finally { + if (xlsx.exists() && !xlsx.delete()) { + log.warn("[appearance-patent] delete temp xlsx failed file={}", xlsx); + } + } + } + + private Map loadPersistedResultRows(Long taskId) { + Map result = new LinkedHashMap<>(); + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .orderByAsc(TaskChunkEntity::getChunkIndex)); + for (TaskChunkEntity chunk : chunks) { + if (chunk.getPayloadJson() == null || chunk.getPayloadJson().isBlank()) { + continue; + } + try { + String payloadJson = transientPayloadStorageService.resolvePayload(chunk.getPayloadJson(), "read appearance patent chunk failed"); + JsonNode array = objectMapper.readTree(payloadJson); + if (!array.isArray()) { + continue; + } + for (JsonNode node : array) { + AppearancePatentResultRowDto row = objectMapper.treeToValue(node, AppearancePatentResultRowDto.class); + result.put(rowKey(row.getId(), row.getAsin(), row.getCountry()), row); + } + } catch (Exception ex) { + log.warn("[appearance-patent] read chunk payload failed taskId={} chunk={} err={}", taskId, chunk.getChunkIndex(), ex.getMessage()); + } + } + return result; + } + + private void writeResultWorkbook(File xlsx, AppearancePatentParsedPayloadDto parsed, Map resultMap) { + try (SXSSFWorkbook workbook = new SXSSFWorkbook(200); FileOutputStream fos = new FileOutputStream(xlsx)) { + Sheet sheet = workbook.createSheet("外观专利检测结果"); + CellStyle headerStyle = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + headerStyle.setFont(font); + + Row header = sheet.createRow(0); + for (int i = 0; i < RESULT_HEADERS.size(); i++) { + Cell cell = header.createCell(i); + cell.setCellValue(RESULT_HEADERS.get(i)); + cell.setCellStyle(headerStyle); + } + + int rowIndex = 1; + for (AppearancePatentParsedRowVo parsedRow : parsed.getAllItems()) { + AppearancePatentResultRowDto resultRow = resultMap.get(rowKey(parsedRow.getDisplayId(), parsedRow.getAsin(), parsedRow.getCountry())); + Row row = sheet.createRow(rowIndex++); + int col = 0; + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), "")); + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); + row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "价格", "price")); + row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getTitleRisk(), "")); + row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getAppearanceRisk(), "")); + row.createCell(col++).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getPatentRisk(), "")); + row.createCell(col).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getConclusion(), "")); + } + workbook.write(fos); + workbook.dispose(); + } catch (Exception ex) { + throw new BusinessException("生成外观专利检测结果失败"); + } + } + + private ParsedWorkbook parseWorkbook(File input) { + DataFormatter formatter = new DataFormatter(); + try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) { + Sheet sheet = workbook.getSheetAt(0); + Row header = sheet.getRow(0); + if (header == null) { + throw new BusinessException("Excel 表头为空"); + } + Map headerMap = buildHeaderMap(header, formatter); + List headers = readHeaders(header, formatter); + int idCol = findRequiredHeader(headerMap, "id"); + int asinCol = findRequiredHeader(headerMap, "asin"); + int countryCol = findRequiredHeader(headerMap, "国家", "country"); + int urlCol = findOptionalHeader(headerMap, "url", "rul", "链接", "商品链接"); + int titleCol = findOptionalHeader(headerMap, "标题", "title", "商品标题"); + + List accepted = new ArrayList<>(); + List allRows = new ArrayList<>(); + int total = 0; + int dropped = 0; + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) { + continue; + } + String id = cell(row, idCol, formatter); + String asin = cell(row, asinCol, formatter).toUpperCase(Locale.ROOT); + String country = cell(row, countryCol, formatter); + if (id.isBlank() && asin.isBlank() && country.isBlank()) { + continue; + } + total++; + if (id.isBlank() || asin.isBlank() || country.isBlank()) { + dropped++; + continue; + } + AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo(); + vo.setRowIndex(i + 1); + vo.setSourceId(id); + vo.setDisplayId(normalizeDisplayId(id)); + vo.setAsin(asin); + vo.setCountry(country); + vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : ""); + vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : ""); + vo.setValues(readRowValues(row, headers, formatter)); + allRows.add(vo); + if (isAcceptedId(id)) { + accepted.add(vo); + } else { + dropped++; + } + } + return new ParsedWorkbook(total, dropped, headers, accepted, allRows); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + log.warn("[appearance-patent] parse failed file={} err={}", input, ex.getMessage()); + throw new BusinessException("解析 Excel 失败"); + } + } + + private Map buildHeaderMap(Row header, DataFormatter formatter) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < header.getLastCellNum(); i++) { + String val = normalize(formatter.formatCellValue(header.getCell(i))); + if (!val.isBlank()) { + map.putIfAbsent(val.toLowerCase(Locale.ROOT), i); + } + } + return map; + } + + private List readHeaders(Row header, DataFormatter formatter) { + List headers = new ArrayList<>(); + for (int i = 0; i < header.getLastCellNum(); i++) { + String val = normalize(formatter.formatCellValue(header.getCell(i))); + headers.add(val.isBlank() ? "列" + (i + 1) : val); + } + return headers; + } + + private Map readRowValues(Row row, List headers, DataFormatter formatter) { + Map values = new LinkedHashMap<>(); + for (int i = 0; i < headers.size(); i++) { + values.put(headers.get(i), cell(row, i, formatter)); + } + return values; + } + + private int findRequiredHeader(Map map, String... names) { + int idx = findOptionalHeader(map, names); + if (idx < 0) { + throw new BusinessException("缺少必要表头: " + String.join("/", names)); + } + return idx; + } + + private int findOptionalHeader(Map map, String... names) { + for (Map.Entry entry : map.entrySet()) { + for (String name : names) { + if (entry.getKey().contains(name.toLowerCase(Locale.ROOT))) { + return entry.getValue(); + } + } + } + return -1; + } + + private String cell(Row row, int col, DataFormatter formatter) { + return col < 0 ? "" : normalize(formatter.formatCellValue(row.getCell(col))); + } + + private boolean isAcceptedId(String id) { + String s = id.trim(); + return s.matches("\\d+") || s.matches("\\d+_1"); + } + + private String baseId(String id) { + String s = normalize(id); + int idx = s.indexOf('_'); + return idx > 0 ? s.substring(0, idx) : s; + } + + private String normalizeDisplayId(String id) { + return id == null ? "" : id.trim(); + } + + private long countTask(Long userId, String status) { + Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getUserId, userId) + .eq(FileTaskEntity::getStatus, status)); + return count == null ? 0L : count; + } + + private int countChunks(Long taskId, String scopeHash) { + Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash)); + return count == null ? 0 : count.intValue(); + } + + private AppearancePatentTaskItemVo toTaskItem(FileTaskEntity task) { + AppearancePatentTaskItemVo vo = new AppearancePatentTaskItemVo(); + vo.setId(task.getId()); + vo.setTaskNo(task.getTaskNo()); + vo.setStatus(task.getStatus()); + vo.setErrorMessage(task.getErrorMessage()); + vo.setCreatedAt(fmt(task.getCreatedAt())); + vo.setUpdatedAt(fmt(task.getUpdatedAt())); + vo.setFinishedAt(fmt(task.getFinishedAt())); + return vo; + } + + private AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, String taskStatus) { + AppearancePatentHistoryItemVo vo = new AppearancePatentHistoryItemVo(); + vo.setResultId(row.getId()); + vo.setTaskId(row.getTaskId()); + vo.setSourceFilename(row.getSourceFilename()); + vo.setResultFilename(row.getResultFilename()); + vo.setDownloadUrl(null); + attachFileJobState(vo, row); + vo.setTaskStatus(taskStatus); + vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); + vo.setError(row.getErrorMessage()); + vo.setRowCount(row.getRowCount()); + vo.setCreatedAt(fmt(row.getCreatedAt())); + return vo; + } + + private void attachFileJobState(AppearancePatentHistoryItemVo vo, FileResultEntity row) { + TaskFileJobEntity job = taskFileJobService.findAssembleJob(row.getTaskId(), MODULE_TYPE, row.getId()); + vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank()); + if (job == null) { + vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); + return; + } + vo.setFileJobId(job.getId()); + vo.setFileStatus(job.getStatus()); + vo.setFileError(job.getErrorMessage()); + } + + private String fmt(LocalDateTime t) { + return t == null ? null : t.toString(); + } + + private String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } + + private String normalize(String val) { + return val == null ? "" : val.replace("\ufeff", "").replace("\u3000", " ").trim().replaceAll("\\s+", " "); + } + + private String buildParsedPayloadJson(String aiPrompt, List headers, List rows, List allRows) { + AppearancePatentParsedPayloadDto payload = new AppearancePatentParsedPayloadDto(); + payload.setAiPrompt(normalize(aiPrompt)); + payload.setHeaders(headers == null ? List.of() : headers); + payload.setItems(rows == null ? List.of() : rows); + payload.setAllItems(allRows == null ? List.of() : allRows); + return writeJson(payload, "保存解析结果失败"); + } + + private String buildTaskResultJson(String aiPrompt, String sourceFileKey, String parsedPayloadPointer) { + Map payload = new LinkedHashMap<>(); + payload.put("aiPrompt", normalize(aiPrompt)); + payload.put("sourceFileKey", sourceFileKey); + payload.put("parsedPayloadRef", parsedPayloadPointer); + return writeJson(payload, "保存任务结果索引失败"); + } + + private String storeParsedPayload(Long taskId, String scopeHash, String parsedPayloadJson) { + return transientPayloadStorageService.storeParsedPayload(MODULE_TYPE, taskId, scopeHash, parsedPayloadJson, true); + } + + private AppearancePatentParsedPayloadDto readParsedPayload(FileTaskEntity task) { + try { + JsonNode root = objectMapper.readTree(task.getResultJson()); + String pointer = root.path("parsedPayloadRef").asText(""); + if (!pointer.isBlank()) { + String json = transientPayloadStorageService.resolvePayload(pointer, "read appearance patent parsed payload failed"); + return objectMapper.readValue(json, AppearancePatentParsedPayloadDto.class); + } + if (root.path("allItems").isArray()) { + return objectMapper.treeToValue(root, AppearancePatentParsedPayloadDto.class); + } + } catch (Exception ex) { + throw new BusinessException("读取解析载荷失败"); + } + return new AppearancePatentParsedPayloadDto(); + } + + private String rowKey(String id, String asin, String country) { + return normalize(id) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country); + } + + private String readValueByHeader(AppearancePatentParsedRowVo row, String... candidates) { + if (row == null || row.getValues() == null || row.getValues().isEmpty() || candidates == null) { + return ""; + } + for (Map.Entry entry : row.getValues().entrySet()) { + String header = normalize(entry.getKey()).toLowerCase(Locale.ROOT); + for (String candidate : candidates) { + String expected = normalize(candidate).toLowerCase(Locale.ROOT); + if (!expected.isBlank() && header.contains(expected)) { + return entry.getValue() == null ? "" : entry.getValue(); + } + } + } + return ""; + } + + private String safeFileStem(String filename) { + String name = filename == null || filename.isBlank() ? "appearance-patent" : filename; + int idx = name.lastIndexOf('.'); + if (idx > 0) { + name = name.substring(0, idx); + } + String safe = name.replaceAll("[\\\\/:*?\"<>|]", "_").trim(); + return safe.isBlank() ? "appearance-patent" : safe; + } + + private String writeJson(Object value, String message) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(message); + } + } + + private void deleteTransientTaskPayloads(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + List scopes = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .select(TaskScopeStateEntity::getParsedPayloadJson, TaskScopeStateEntity::getStateJson) + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); + if (scopes != null) { + for (TaskScopeStateEntity scope : scopes) { + transientPayloadStorageService.deletePayloadIfPresent(scope.getParsedPayloadJson()); + transientPayloadStorageService.deletePayloadIfPresent(scope.getStateJson()); + } + } + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .select(TaskChunkEntity::getPayloadJson) + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + if (chunks != null) { + for (TaskChunkEntity chunk : chunks) { + transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); + } + } + } + + private record ParsedWorkbook(int totalRows, int droppedRows, List headers, List acceptedRows, List allRows) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskProgressCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskProgressCacheService.java index be8759c..26f35bd 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskProgressCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskProgressCacheService.java @@ -2,6 +2,7 @@ package com.nanri.aiimage.modules.brand.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.config.BrandProgressProperties; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -11,6 +12,7 @@ import java.util.LinkedHashMap; import java.util.Map; @Service +@Slf4j public class BrandTaskProgressCacheService { public static final String PHASE_CRAWLING = "crawling"; @@ -53,8 +55,12 @@ public class BrandTaskProgressCacheService { values.put("finished_files", String.valueOf(Math.max(finishedFiles, 0))); values.put("updated_at", now); values.put("last_heartbeat_at", now); - stringRedisTemplate.opsForHash().putAll(key, values); - stringRedisTemplate.expire(key, ttl()); + try { + stringRedisTemplate.opsForHash().putAll(key, values); + stringRedisTemplate.expire(key, ttl()); + } catch (Exception ex) { + log.warn("[brand-progress-cache] save progress degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public void updatePhase(Long taskId, String phase, int finishedFiles, int fileTotal) { @@ -66,8 +72,12 @@ public class BrandTaskProgressCacheService { values.put("file_total", String.valueOf(Math.max(fileTotal, 0))); values.put("updated_at", now); values.put("last_heartbeat_at", now); - stringRedisTemplate.opsForHash().putAll(key, values); - stringRedisTemplate.expire(key, ttl()); + try { + stringRedisTemplate.opsForHash().putAll(key, values); + stringRedisTemplate.expire(key, ttl()); + } catch (Exception ex) { + log.warn("[brand-progress-cache] update phase degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public void markFailed(Long taskId, String message) { @@ -77,27 +87,49 @@ public class BrandTaskProgressCacheService { values.put("phase", PHASE_FAILED); values.put("updated_at", now); values.put("error_message", blankToEmpty(message)); - stringRedisTemplate.opsForHash().putAll(key, values); - stringRedisTemplate.expire(key, Duration.ofHours(brandProgressProperties.getFailedTtlHours())); + try { + stringRedisTemplate.opsForHash().putAll(key, values); + stringRedisTemplate.expire(key, Duration.ofHours(brandProgressProperties.getFailedTtlHours())); + } catch (Exception ex) { + log.warn("[brand-progress-cache] mark failed degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public Map getProgress(Long taskId) { - return stringRedisTemplate.opsForHash().entries(buildKey(taskId)); + try { + return stringRedisTemplate.opsForHash().entries(buildKey(taskId)); + } catch (Exception ex) { + log.warn("[brand-progress-cache] get progress degraded taskId={} msg={}", taskId, ex.getMessage()); + return Map.of(); + } } public boolean acquireFinalizeLock(Long taskId) { - Boolean ok = stringRedisTemplate.opsForValue() - .setIfAbsent(buildFinalizeLockKey(taskId), String.valueOf(Instant.now().toEpochMilli()), FINALIZE_LOCK_TTL); - return Boolean.TRUE.equals(ok); + try { + Boolean ok = stringRedisTemplate.opsForValue() + .setIfAbsent(buildFinalizeLockKey(taskId), String.valueOf(Instant.now().toEpochMilli()), FINALIZE_LOCK_TTL); + return Boolean.TRUE.equals(ok); + } catch (Exception ex) { + log.warn("[brand-progress-cache] acquire finalize lock degraded taskId={} msg={}", taskId, ex.getMessage()); + return false; + } } public void releaseFinalizeLock(Long taskId) { - stringRedisTemplate.delete(buildFinalizeLockKey(taskId)); + try { + stringRedisTemplate.delete(buildFinalizeLockKey(taskId)); + } catch (Exception ex) { + log.warn("[brand-progress-cache] release finalize lock degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public void delete(Long taskId) { - stringRedisTemplate.delete(buildKey(taskId)); - stringRedisTemplate.delete(buildFinalizeLockKey(taskId)); + try { + stringRedisTemplate.delete(buildKey(taskId)); + stringRedisTemplate.delete(buildFinalizeLockKey(taskId)); + } catch (Exception ex) { + log.warn("[brand-progress-cache] delete degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public String buildKey(Long taskId) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java index 7d3b590..6361298 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskService.java @@ -31,6 +31,9 @@ import com.nanri.aiimage.modules.brand.model.vo.LegacyBrandTaskDetailVo; import com.nanri.aiimage.modules.brand.model.vo.LegacyBrandTaskItemVo; import com.nanri.aiimage.modules.brand.model.vo.LegacyBrandTaskListVo; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; @@ -77,6 +80,7 @@ public class BrandTaskService { private static final String STATUS_SUCCESS = "success"; private static final String STATUS_FAILED = "failed"; private static final String STATUS_CANCELLED = "cancelled"; + private static final String MODULE_TYPE = "BRAND"; private final BrandCrawlTaskMapper brandCrawlTaskMapper; private final OssStorageService ossStorageService; @@ -86,6 +90,8 @@ public class BrandTaskService { private final BrandTaskStorageService brandTaskStorageService; private final DistributedJobLockService distributedJobLockService; private final ObjectMapper objectMapper; + private final TaskFileJobService taskFileJobService; + private final TaskProgressSnapshotService taskProgressSnapshotService; public BrandTaskCreateVo createTask(Long userId, BrandTaskCreateRequest request) { if (userId == null || userId <= 0) { @@ -244,6 +250,7 @@ public class BrandTaskService { int totalCount = sourceFiles.size(); markTaskRunning(taskId, totalCount); + afterTaskStarted(taskId, totalCount); log.info("[brand-submit] taskId={} chunks={} files={} thread={}", taskId, @@ -295,6 +302,7 @@ public class BrandTaskService { currentTotalLines, finishedCount); updateTaskProgress(taskId, finishedCount, totalCount); + saveBrandProgressSnapshot(taskId, STATUS_RUNNING, totalCount, finishedCount, 0, null); } tryFinalizeTask(taskId, request.getStrategy(), sourceFiles, cachedByUrl, finishedCount, totalCount); @@ -357,6 +365,23 @@ public class BrandTaskService { return ossStorageService.generateFreshDownloadUrl(stored); } + public String resolveResultObjectKey(Long taskId) { + BrandCrawlTaskEntity task = requireTask(taskId); + Object raw = parseJsonValue(task.getResultPaths()); + if (!(raw instanceof Map map)) { + return null; + } + Object zipObjectKey = map.get("zip_object_key"); + if (zipObjectKey instanceof String key && !key.isBlank()) { + return key; + } + Object zipUrl = map.get("zip_url"); + if (zipUrl instanceof String url && !url.isBlank()) { + return url; + } + return null; + } + private List listTaskEntities(Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("userId 不合法"); @@ -496,7 +521,7 @@ public class BrandTaskService { private void markTaskRunning(Long taskId, int totalCount) { int started = brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper() .eq(BrandCrawlTaskEntity::getId, taskId) - .ne(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED) + .in(BrandCrawlTaskEntity::getStatus, STATUS_PENDING, STATUS_RUNNING) .set(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING) .set(BrandCrawlTaskEntity::getProgressTotal, totalCount) .set(BrandCrawlTaskEntity::getUpdatedAt, LocalDateTime.now()) @@ -505,6 +530,13 @@ public class BrandTaskService { throw new BusinessException("任务已取消"); } } + private void afterTaskStarted(Long taskId, int totalCount) { + saveBrandProgressSnapshot(taskId, STATUS_RUNNING, totalCount, 0, 0, null); + } + + private void saveBrandProgressSnapshot(Long taskId, String status, int totalCount, int successCount, int failedCount, String message) { + taskProgressSnapshotService.save(taskId, MODULE_TYPE, status, totalCount, successCount, failedCount, null, message, null); + } private BrandCrawlTaskEntity requireActiveTask(Long taskId) { BrandCrawlTaskEntity task = requireTask(taskId); @@ -533,12 +565,33 @@ public class BrandTaskService { return; } try { - finalizeTask(taskId, strategy, sourceFiles, cachedByUrl, totalCount); + enqueueFinalizeTask(taskId, totalCount); } finally { brandTaskProgressCacheService.releaseFinalizeLock(taskId); } } + private void enqueueFinalizeTask(Long taskId, int totalCount) { + brandTaskProgressCacheService.updatePhase(taskId, BrandTaskProgressCacheService.PHASE_ASSEMBLING, totalCount, totalCount); + taskProgressSnapshotService.save(taskId, MODULE_TYPE, STATUS_RUNNING, totalCount, totalCount, 0, + null, "result file assembly queued", null); + taskFileJobService.enqueueAssembleResult(taskId, MODULE_TYPE, taskId, "task:" + taskId); + } + + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + BrandCrawlTaskEntity task = requireTask(job.getTaskId()); + List sourceFiles = parseSourceFiles(task.getFilePaths()); + if (sourceFiles.isEmpty()) { + throw new BusinessException("任务没有源文件"); + } + List cachedFiles = brandTaskStorageService.getParsedPayload(task.getId()); + Map cachedByUrl = indexCachedFiles(cachedFiles); + finalizeTask(task.getId(), normalizeStrategy(task.getStrategy()), sourceFiles, cachedByUrl, sourceFiles.size()); + } + private void finalizeTask(Long taskId, String strategy, List sourceFiles, @@ -601,6 +654,7 @@ public class BrandTaskService { if (updated == 0) { throw new BusinessException("任务已取消"); } + saveBrandProgressSnapshot(taskId, STATUS_SUCCESS, totalCount, totalCount, 0, null); brandTaskStorageService.deleteTaskData(taskId); brandTaskProgressCacheService.delete(taskId); log.info("[brand-finalize] taskId={} finalized success files={} elapsedMs={}", @@ -617,6 +671,7 @@ public class BrandTaskService { .set(BrandCrawlTaskEntity::getProgressTotal, totalCount) .set(BrandCrawlTaskEntity::getErrorMessage, ex.getMessage())); brandTaskProgressCacheService.markFailed(taskId, ex.getMessage()); + saveBrandProgressSnapshot(taskId, STATUS_FAILED, totalCount, brandTaskStorageService.countCompletedFiles(taskId), 1, ex.getMessage()); if (ex instanceof BusinessException businessException) { throw businessException; } @@ -643,10 +698,23 @@ public class BrandTaskService { } } + private Map indexCachedFiles(List cachedFiles) { + Map cachedByUrl = new LinkedHashMap<>(); + if (cachedFiles == null) { + return cachedByUrl; + } + for (BrandParsedFileCacheDto cachedFile : cachedFiles) { + if (cachedFile != null && cachedFile.getFileUrl() != null) { + cachedByUrl.put(cachedFile.getFileUrl(), cachedFile); + } + } + return cachedByUrl; + } + private void updateTaskProgress(Long taskId, int finishedCount, int totalCount) { brandCrawlTaskMapper.update(null, new LambdaUpdateWrapper() .eq(BrandCrawlTaskEntity::getId, taskId) - .ne(BrandCrawlTaskEntity::getStatus, STATUS_CANCELLED) + .in(BrandCrawlTaskEntity::getStatus, STATUS_PENDING, STATUS_RUNNING) .set(BrandCrawlTaskEntity::getStatus, STATUS_RUNNING) .set(BrandCrawlTaskEntity::getProgressCurrent, finishedCount) .set(BrandCrawlTaskEntity::getProgressTotal, totalCount) @@ -951,13 +1019,14 @@ public class BrandTaskService { List fullUrls = new ArrayList<>(); for (OutputEntry entry : entries) { // 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取并重新签名(见 OssStorageService.resolveObjectKey) - String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), "BRAND"); + String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), MODULE_TYPE); fullUrls.add(ossStorageService.getPublicUrl(objectKey)); } Map result = new LinkedHashMap<>(); result.put("urls", fullUrls); File zipFile = packageAsZip(taskId, entries); - String zipObjectKey = ossStorageService.uploadResultFile(zipFile, "BRAND"); + String zipObjectKey = ossStorageService.uploadResultFile(zipFile, MODULE_TYPE); + result.put("zip_object_key", zipObjectKey); result.put("zip_url", ossStorageService.getPublicUrl(zipObjectKey)); return result; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java index 9deb686..2da2092 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/brand/service/BrandTaskStorageService.java @@ -7,11 +7,11 @@ import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.modules.brand.model.dto.BrandCrawlResultFileDto; import com.nanri.aiimage.modules.brand.model.dto.BrandFileAggregateCacheDto; import com.nanri.aiimage.modules.brand.model.dto.BrandParsedFileCacheDto; -import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -30,12 +30,11 @@ import java.util.Map; public class BrandTaskStorageService { private static final String MODULE_TYPE = "BRAND"; - private static final String OSS_POINTER_PREFIX = "oss:"; private final TaskChunkMapper taskChunkMapper; private final TaskScopeStateMapper taskScopeStateMapper; private final ObjectMapper objectMapper; - private final OssStorageService ossStorageService; + private final TransientPayloadStorageService transientPayloadStorageService; @Transactional public void saveParsedPayload(Long taskId, List payload) { @@ -73,7 +72,7 @@ public class BrandTaskStorageService { if (state == null) { throw new BusinessException("Save brand task parsed payload failed"); } - deleteParsedPayloadIfNeeded(state.getParsedPayloadJson(), parsedJson); + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(state.getParsedPayloadJson(), parsedJson); taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) .set(TaskScopeStateEntity::getScopeKey, scopeKey) @@ -132,6 +131,7 @@ public class BrandTaskStorageService { throw new BusinessException("Brand task chunk conflict: " + scopeKey + "#" + file.getChunkIndex()); } + String storedPayload = transientPayloadStorageService.storeChunkPayload(MODULE_TYPE, taskId, scopeHash, file.getChunkIndex(), payloadJson); TaskChunkEntity entity = new TaskChunkEntity(); entity.setTaskId(taskId); entity.setModuleType(MODULE_TYPE); @@ -139,7 +139,7 @@ public class BrandTaskStorageService { entity.setScopeHash(scopeHash); entity.setChunkIndex(file.getChunkIndex()); entity.setChunkTotal(file.getChunkTotal()); - entity.setPayloadJson(payloadJson); + entity.setPayloadJson(storedPayload); entity.setPayloadHash(payloadHash); entity.setCreatedAt(now); entity.setUpdatedAt(now); @@ -153,9 +153,11 @@ public class BrandTaskStorageService { .eq(TaskChunkEntity::getChunkIndex, file.getChunkIndex()) .last("limit 1")); if (concurrentChunk != null && payloadHash.equals(concurrentChunk.getPayloadHash())) { + transientPayloadStorageService.deletePayloadIfPresent(storedPayload); BrandFileAggregateCacheDto aggregate = rebuildAggregate(taskId, scopeKey, scopeHash); return new ChunkStoreResult(false, false, countCompletedFiles(taskId), aggregate); } + transientPayloadStorageService.deletePayloadIfPresent(storedPayload); throw new BusinessException("Brand task chunk conflict: " + scopeKey + "#" + file.getChunkIndex()); } @@ -171,7 +173,8 @@ public class BrandTaskStorageService { return null; } try { - return objectMapper.readValue(state.getStateJson(), BrandFileAggregateCacheDto.class); + String aggregateJson = transientPayloadStorageService.resolvePayload(state.getStateJson(), "read brand task aggregate failed"); + return objectMapper.readValue(aggregateJson, BrandFileAggregateCacheDto.class); } catch (Exception ex) { throw new BusinessException("Read brand task aggregate failed"); } @@ -195,7 +198,8 @@ public class BrandTaskStorageService { continue; } try { - result.put(state.getScopeKey(), objectMapper.readValue(state.getStateJson(), BrandFileAggregateCacheDto.class)); + String aggregateJson = transientPayloadStorageService.resolvePayload(state.getStateJson(), "read brand task aggregate failed"); + result.put(state.getScopeKey(), objectMapper.readValue(aggregateJson, BrandFileAggregateCacheDto.class)); } catch (Exception ex) { throw new BusinessException("Read brand task aggregate failed"); } @@ -220,12 +224,22 @@ public class BrandTaskStorageService { return; } List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() - .select(TaskScopeStateEntity::getParsedPayloadJson) + .select(TaskScopeStateEntity::getParsedPayloadJson, TaskScopeStateEntity::getStateJson) .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); if (states != null) { for (TaskScopeStateEntity state : states) { - deleteParsedPayloadIfNeeded(state.getParsedPayloadJson(), null); + transientPayloadStorageService.deletePayloadIfPresent(state.getParsedPayloadJson()); + transientPayloadStorageService.deletePayloadIfPresent(state.getStateJson()); + } + } + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .select(TaskChunkEntity::getPayloadJson) + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + if (chunks != null) { + for (TaskChunkEntity chunk : chunks) { + transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); } } taskChunkMapper.delete(new LambdaQueryWrapper() @@ -246,12 +260,14 @@ public class BrandTaskStorageService { if (state == null) { throw new BusinessException("Brand task aggregate state missing: " + scopeKey); } + String storedAggregate = transientPayloadStorageService.storeScopePayload(MODULE_TYPE, taskId, scopeHash, aggregateJson, true); int chunkTotal = aggregate.getChunkTotal() == null ? 0 : aggregate.getChunkTotal(); int receivedCount = aggregate.getReceivedChunkCount() == null ? 0 : aggregate.getReceivedChunkCount(); + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(state.getStateJson(), storedAggregate); taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) .set(TaskScopeStateEntity::getScopeKey, scopeKey) - .set(TaskScopeStateEntity::getStateJson, aggregateJson) + .set(TaskScopeStateEntity::getStateJson, storedAggregate) .set(TaskScopeStateEntity::getChunkTotal, chunkTotal) .set(TaskScopeStateEntity::getReceivedChunkCount, receivedCount) .set(TaskScopeStateEntity::getCompleted, Boolean.TRUE.equals(aggregate.getCompleted()) ? 1 : 0) @@ -362,7 +378,8 @@ public class BrandTaskStorageService { private BrandCrawlResultFileDto readChunkPayload(String payloadJson) { try { - return objectMapper.readValue(payloadJson, BrandCrawlResultFileDto.class); + String resolvedPayload = transientPayloadStorageService.resolvePayload(payloadJson, "read brand task chunk failed"); + return objectMapper.readValue(resolvedPayload, BrandCrawlResultFileDto.class); } catch (Exception ex) { throw new BusinessException("Read brand task chunk failed"); } @@ -382,56 +399,17 @@ public class BrandTaskStorageService { } private String storeParsedPayload(Long taskId, String scopeHash, String payloadJson) { - try { - String pointer = OSS_POINTER_PREFIX + ossStorageService.uploadTaskParsedPayload(MODULE_TYPE, taskId, scopeHash, payloadJson); - return objectMapper.writeValueAsString(pointer); - } catch (Exception ex) { - throw new BusinessException("Save brand task parsed payload failed"); - } + return transientPayloadStorageService.storeParsedPayload(MODULE_TYPE, taskId, scopeHash, payloadJson, true); } private String resolveParsedPayload(String value) { - if (isBlank(value)) { - return value; - } - String ossPointer = extractOssPointer(value); - if (ossPointer == null) { - return value; - } try { - return ossStorageService.readObjectAsString(ossPointer.substring(OSS_POINTER_PREFIX.length())); + return transientPayloadStorageService.resolvePayload(value, "Read brand task parsed payload failed"); } catch (Exception ex) { throw new BusinessException("Read brand task parsed payload failed"); } } - private void deleteParsedPayloadIfNeeded(String oldValue, String newValue) { - String oldPointer = extractOssPointer(oldValue); - String newPointer = extractOssPointer(newValue); - if (oldPointer == null || oldPointer.equals(newPointer)) { - return; - } - try { - ossStorageService.deleteObject(oldPointer.substring(OSS_POINTER_PREFIX.length())); - } catch (Exception ignored) { - } - } - - private String extractOssPointer(String value) { - if (isBlank(value)) { - return null; - } - if (value.startsWith(OSS_POINTER_PREFIX)) { - return value; - } - try { - String decoded = objectMapper.readValue(value, String.class); - return decoded != null && decoded.startsWith(OSS_POINTER_PREFIX) ? decoded : null; - } catch (Exception ignored) { - return null; - } - } - private String writeJson(Object value, String message) { try { return objectMapper.writeValueAsString(value); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java index b8d3fd2..a8161ed 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java @@ -205,9 +205,7 @@ public class ConvertRunService { vo.setResultId(entity.getId()); vo.setSourceFilename(entity.getSourceFilename()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 - ? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()) - : null); + vo.setDownloadUrl(null); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setError(entity.getErrorMessage()); return vo; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java index 2d1ad09..804130e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java @@ -229,9 +229,7 @@ public class DedupeRunService { vo.setResultId(entity.getId()); vo.setSourceFilename(entity.getSourceFilename()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 - ? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()) - : null); + vo.setDownloadUrl(null); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setError(entity.getErrorMessage()); return vo; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java index 5f50a2c..22e8c29 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/model/vo/DeleteBrandResultItemVo.java @@ -50,6 +50,10 @@ public class DeleteBrandResultItemVo { @Schema(description = "下载地址") private String downloadUrl; + private Long fileJobId; + private String fileStatus; + private String fileError; + private Boolean fileReady; @Schema(description = "任务ID") private Long taskId; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java index f7d5a63..52e2e58 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java @@ -34,6 +34,8 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; @@ -80,6 +82,7 @@ public class DeleteBrandRunService { private final ObjectMapper objectMapper; private final DeleteBrandProgressProperties deleteBrandProgressProperties; private final TaskPressureProperties taskPressureProperties; + private final TaskFileJobService taskFileJobService; public DeleteBrandRunVo run(DeleteBrandRunRequest request) { if (request.getUserId() == null || request.getUserId() <= 0) { @@ -271,7 +274,8 @@ public class DeleteBrandRunService { item.setSourceFilename(entity.getSourceFilename()); item.setShopName(entity.getSourceFilename() == null ? null : FileUtil.mainName(entity.getSourceFilename())); item.setOutputFilename(entity.getResultFilename()); - item.setDownloadUrl(entity.getResultFileUrl() == null ? null : ossStorageService.getPublicUrl(entity.getResultFileUrl())); + item.setDownloadUrl(null); + attachFileJobState(item, entity); item.setTotalRows(entity.getRowCount()); item.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); item.setError(entity.getErrorMessage()); @@ -320,6 +324,18 @@ public class DeleteBrandRunService { return vo; } + private void attachFileJobState(DeleteBrandResultItemVo item, FileResultEntity entity) { + TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()); + item.setFileReady(entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank()); + if (job == null) { + item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null); + return; + } + item.setFileJobId(job.getId()); + item.setFileStatus(job.getStatus()); + item.setFileError(job.getErrorMessage()); + } + public DeleteBrandTaskDeletionStatusVo getTaskDeletionStatus(Long taskId, Long userId) { FileTaskEntity task = loadTaskForExecution(taskId); if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { @@ -373,12 +389,19 @@ public class DeleteBrandRunService { Map> grouped = new LinkedHashMap<>(); Map displayCountryNames = new LinkedHashMap<>(); - for (int rowNum = 2; rowNum <= sheet.getLastRowNum(); rowNum++) { + int firstDataRow = pairs.stream() + .mapToInt(CountryColumnPair::dataStartRowIndex) + .min() + .orElse(2); + for (int rowNum = firstDataRow; rowNum <= sheet.getLastRowNum(); rowNum++) { Row row = sheet.getRow(rowNum); if (row == null) { continue; } for (CountryColumnPair pair : pairs) { + if (rowNum < pair.dataStartRowIndex()) { + continue; + } String country = pair.country(); String asin = normalizeCellText(formatter.formatCellValue(row.getCell(pair.asinColumnIndex()))); String status = normalizeCellText(formatter.formatCellValue(row.getCell(pair.statusColumnIndex()))); @@ -457,7 +480,10 @@ public class DeleteBrandRunService { continue; } if ("删除ASIN".equals(firstHeader) && "状态".equals(secondHeader)) { - pairs.add(new CountryColumnPair(title, i, i + 1)); + pairs.add(new CountryColumnPair(title, i, i + 1, 2)); + i++; + } else if (!"店铺名".equals(title) && "状态".equals(secondHeader)) { + pairs.add(new CountryColumnPair(title, i, i + 1, 1)); i++; } } @@ -486,7 +512,7 @@ public class DeleteBrandRunService { .replaceAll("\\s+", " "); } - private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex) { + private record CountryColumnPair(String country, int asinColumnIndex, int statusColumnIndex, int dataStartRowIndex) { } private String resolveFileIdentity(DeleteBrandSourceFileDto sourceFile) { @@ -1085,58 +1111,111 @@ public class DeleteBrandRunService { return; } - Map parsedPayload = deleteBrandTaskStorageService.loadParsedPayload(taskId); - if (parsedPayload == null || parsedPayload.isEmpty()) { + int expectedFiles = deleteBrandTaskStorageService.countParsedPayloadScopes(taskId); + if (expectedFiles <= 0) { + tryFinalizeTaskFromTerminalResults(task); return; } - - int expectedFiles = parsedPayload.size(); // 移除 finishedFilesHint 的强依赖,因为 Redis 里的 finished_files 可能是旧值 // 既然进入 tryFinalizeTask,就说明有新分片到达,应该以 loadMergedChunks 为准 - Map> mergedByFile = deleteBrandTaskStorageService.loadMergedChunks(taskId); - int finishedFiles = countCompletedFiles(mergedByFile); + int finishedFiles = deleteBrandTaskStorageService.countCompletedScopes(taskId); if (finishedFiles < expectedFiles) { - Map progress = new LinkedHashMap<>(); - progress.put("finished_files", String.valueOf(finishedFiles)); - // 定时补偿只负责“再试一次 finalize”,不能把缺片任务重新续命 - // 否则 stale-check 会一直看不到超时任务 - if (!fromCompensation) { - progress.put("updated_at", String.valueOf(System.currentTimeMillis())); - } - deleteBrandTaskCacheService.saveProgress(taskId, progress); - - task.setSuccessFileCount(finishedFiles); - if (!fromCompensation) { - task.setUpdatedAt(LocalDateTime.now()); - } - fileTaskMapper.updateById(task); - deleteBrandTaskCacheService.saveTaskCache(task); + updateTaskFinalizeProgress(task, finishedFiles, fromCompensation); log.info("[DeleteBrand] tryFinalizeTask skipping -> finishedFiles ({}) < expectedFiles ({})", finishedFiles, expectedFiles); return; } - Map progress = new LinkedHashMap<>(); - progress.put("finished_files", String.valueOf(finishedFiles)); - progress.put("updated_at", String.valueOf(System.currentTimeMillis())); - if (fromCompensation) { - progress.put("last_heartbeat_at", String.valueOf(System.currentTimeMillis())); + updateTaskFinalizeProgress(task, finishedFiles, true); + Map parsedPayload = deleteBrandTaskStorageService.loadParsedPayload(taskId); + if (parsedPayload == null || parsedPayload.isEmpty()) { + tryFinalizeTaskFromTerminalResults(task); + return; } - deleteBrandTaskCacheService.saveProgress(taskId, progress, true); + Map> mergedByFile = deleteBrandTaskStorageService.loadMergedChunks(taskId); - task.setSuccessFileCount(finishedFiles); - task.setUpdatedAt(LocalDateTime.now()); - fileTaskMapper.updateById(task); - deleteBrandTaskCacheService.saveTaskCache(task); - log.info("[DeleteBrand] tryFinalizeTask calling finalizeTask -> finishedFiles: {}, expectedFiles: {}", finishedFiles, expectedFiles); finalizeTask(task, parsedPayload, mergedByFile); } + private void updateTaskFinalizeProgress(FileTaskEntity task, int finishedFiles, boolean fromCompensation) { + Map progress = new LinkedHashMap<>(); + progress.put("finished_files", String.valueOf(finishedFiles)); + if (!fromCompensation) { + progress.put("updated_at", String.valueOf(System.currentTimeMillis())); + } + deleteBrandTaskCacheService.saveProgress(task.getId(), progress); + + Integer currentFinished = task.getSuccessFileCount(); + if (fromCompensation && currentFinished != null && currentFinished == finishedFiles) { + return; + } + task.setSuccessFileCount(finishedFiles); + if (!fromCompensation) { + task.setUpdatedAt(LocalDateTime.now()); + } + fileTaskMapper.updateById(task); + deleteBrandTaskCacheService.saveTaskCache(task); + } + + private void tryFinalizeTaskFromTerminalResults(FileTaskEntity task) { + if (task == null || task.getId() == null) { + return; + } + List resultEntities = fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getTaskId, task.getId()) + .orderByAsc(FileResultEntity::getId)); + if (resultEntities == null || resultEntities.isEmpty()) { + return; + } + if (taskFileJobService.countUnfinishedAssembleJobs(task.getId(), MODULE_TYPE) > 0L) { + return; + } + + int successCount = 0; + int failedCount = 0; + for (FileResultEntity entity : resultEntities) { + boolean success = (entity.getSuccess() != null && entity.getSuccess() == 1) + || (entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank()); + boolean failed = !success && entity.getErrorMessage() != null && !entity.getErrorMessage().isBlank(); + if (!success && !failed) { + return; + } + if (success) { + successCount++; + } else { + failedCount++; + } + } + + if (task.getSourceFileCount() != null + && task.getSourceFileCount() > 0 + && successCount + failedCount < task.getSourceFileCount()) { + return; + } + + task.setStatus(successCount > 0 ? "SUCCESS" : "FAILED"); + task.setSuccessFileCount(successCount); + task.setFailedFileCount(failedCount); + task.setErrorMessage(successCount > 0 ? null : "任务中间数据缺失,已按结果行状态自动收尾"); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + deleteBrandTaskCacheService.saveTaskCache(task); + deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( + "phase", successCount > 0 ? "success" : DeleteBrandTaskCacheService.PHASE_FAILED, + "finished_files", String.valueOf(successCount), + "updated_at", String.valueOf(System.currentTimeMillis()) + ), true); + log.info("[DeleteBrand] finalized orphan running task from terminal results -> taskId: {}, status: {}, successFiles: {}, failedFiles: {}", + task.getId(), task.getStatus(), successCount, failedCount); + } + private int countCompletedFiles(Map> mergedByFile) { int finishedFiles = 0; for (List chunks : mergedByFile.values()) { @@ -1205,28 +1284,22 @@ public class DeleteBrandRunService { if (!isMergedFileCompleted(chunks)) { throw new BusinessException("存在未完成的结果分片: " + parsedFile.getSourceFilename()); } - MergedDeleteBrandFile mergedFile = mergeChunks(parsedFile, chunks); - File outputFile = buildResultWorkbookPreserveLayout(task.getId(), parsedFile, mergedFile); + mergeChunks(parsedFile, chunks); - deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( - "phase", DeleteBrandTaskCacheService.PHASE_UPLOADING, - "file_name", blankToEmpty(parsedFile.getSourceFilename()), - "updated_at", String.valueOf(System.currentTimeMillis()) - )); - - String objectKey = ossStorageService.uploadResultFile(outputFile, MODULE_TYPE); FileResultEntity resultEntity = resultEntityByIdentity.get(fileIdentity); if (resultEntity == null) { throw new BusinessException("结果记录不存在: " + parsedFile.getSourceFilename()); } - resultEntity.setResultFilename(outputFile.getName()); - resultEntity.setResultFileUrl(objectKey); - resultEntity.setResultFileSize(outputFile.length()); + String outputFilename = buildResultFilename(parsedFile); + resultEntity.setResultFilename(outputFilename); + resultEntity.setResultFileUrl(null); + resultEntity.setResultFileSize(0L); resultEntity.setResultContentType(CONTENT_TYPE_XLSX); resultEntity.setRowCount(parsedFile.getTotalRows()); resultEntity.setSuccess(1); resultEntity.setErrorMessage(null); fileResultMapper.updateById(resultEntity); + taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, resultEntity.getId(), fileIdentity); DeleteBrandResultItemVo item = new DeleteBrandResultItemVo(); item.setResultId(resultEntity.getId()); @@ -1235,8 +1308,8 @@ public class DeleteBrandRunService { item.setSourceFilename(parsedFile.getSourceFilename()); item.setShopName(parsedFile.getShopName()); item.setCompanyName(parsedFile.getCompanyName()); - item.setOutputFilename(outputFile.getName()); - item.setDownloadUrl(ossStorageService.getPublicUrl(objectKey)); + item.setOutputFilename(outputFilename); + item.setDownloadUrl(null); item.setTotalRows(parsedFile.getTotalRows()); item.setCountryCount(parsedFile.getCountries() == null ? 0 : parsedFile.getCountries().size()); item.setCountries(parsedFile.getCountries()); @@ -1263,8 +1336,6 @@ public class DeleteBrandRunService { task.setFinishedAt(LocalDateTime.now()); fileTaskMapper.updateById(task); deleteBrandTaskCacheService.saveTaskCache(task); - deleteBrandTaskStorageService.deleteTaskData(task.getId()); - deleteBrandTaskCacheService.saveProgress(task.getId(), Map.of( "phase", "success", "file_index", String.valueOf(parsedPayload.size()), @@ -1293,6 +1364,75 @@ public class DeleteBrandRunService { } } + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null || job.getResultId() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId()); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + FileResultEntity resultEntity = fileResultMapper.selectById(job.getResultId()); + if (resultEntity == null || !MODULE_TYPE.equals(resultEntity.getModuleType())) { + throw new BusinessException("结果记录不存在"); + } + Map parsedPayload = deleteBrandTaskStorageService.loadParsedPayload(job.getTaskId()); + Map> mergedByFile = deleteBrandTaskStorageService.loadMergedChunks(job.getTaskId()); + String fileIdentity = blankToEmpty(job.getScopeKey()); + if (fileIdentity.isBlank()) { + fileIdentity = blankToEmpty(resultEntity.getSourceFileUrl()); + } + if (fileIdentity.isBlank()) { + fileIdentity = blankToEmpty(resultEntity.getSourceFilename()); + } + DeleteBrandParsedFileCacheDto parsedFile = parsedPayload.get(fileIdentity); + if (parsedFile == null) { + throw new BusinessException("任务原始数据不存在: " + fileIdentity); + } + List chunks = mergedByFile.get(fileIdentity); + if (!isMergedFileCompleted(chunks)) { + throw new BusinessException("结果分片未完整: " + fileIdentity); + } + MergedDeleteBrandFile mergedFile = mergeChunks(parsedFile, chunks); + File outputFile = buildResultWorkbookPreserveLayout(job.getTaskId(), parsedFile, mergedFile); + try { + deleteBrandTaskCacheService.saveProgress(job.getTaskId(), Map.of( + "phase", DeleteBrandTaskCacheService.PHASE_UPLOADING, + "file_name", blankToEmpty(parsedFile.getSourceFilename()), + "updated_at", String.valueOf(System.currentTimeMillis()) + )); + String objectKey = ossStorageService.uploadResultFile(outputFile, MODULE_TYPE); + resultEntity.setResultFilename(outputFile.getName()); + resultEntity.setResultFileUrl(objectKey); + resultEntity.setResultFileSize(outputFile.length()); + resultEntity.setResultContentType(CONTENT_TYPE_XLSX); + resultEntity.setRowCount(parsedFile.getTotalRows()); + resultEntity.setSuccess(1); + resultEntity.setErrorMessage(null); + fileResultMapper.updateById(resultEntity); + } finally { + deleteQuietly(outputFile); + } + } + + public void cleanupResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null) { + return; + } + if (taskFileJobService.countUnfinishedAssembleJobs(job.getTaskId(), MODULE_TYPE) == 0L) { + deleteBrandTaskStorageService.deleteTaskData(job.getTaskId()); + } + } + + private String buildResultFilename(DeleteBrandParsedFileCacheDto parsedFile) { + String filename = blankToDefault(parsedFile.getSourceFilename(), "delete-brand-result.xlsx"); + String lower = filename.toLowerCase(Locale.ROOT); + if (!lower.endsWith(".xlsx") && !lower.endsWith(".xls")) { + return filename + ".xlsx"; + } + return filename; + } + private MergedDeleteBrandFile mergeChunks(DeleteBrandParsedFileCacheDto parsedFile, List chunks) { Integer chunkTotal = chunks.get(0).getChunkTotal(); for (DeleteBrandResultFileDto chunk : chunks) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java index 9ebe69b..d47ecba 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java @@ -185,20 +185,24 @@ public class DeleteBrandStaleTaskService { if (runningTasks.isEmpty()) { return stats; } + Map heartbeatByTaskId = productRiskTaskCacheService.getTaskHeartbeatMillisBatch( + runningTasks.stream().map(FileTaskEntity::getId).toList()); log.info("[stale-check] product-risk candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}", runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes); for (FileTaskEntity task : runningTasks) { - boolean hasUploadedPayload = productRiskTaskCacheService.hasAnyShopMergedPayload(task.getId()); - long lastPayloadHeartbeatMillis = productRiskTaskCacheService.getTaskHeartbeatMillis(task.getId()); + long lastPayloadHeartbeatMillis = heartbeatByTaskId.getOrDefault(task.getId(), 0L); boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0) || (task.getFailedFileCount() != null && task.getFailedFileCount() > 0); - boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows; if (lastPayloadHeartbeatMillis > 0L && nowMillis - lastPayloadHeartbeatMillis < staleTimeoutMillis) { stats.skippedTaskCount++; log.info("[stale-check] product-risk skip recent-task-heartbeat taskId={} lastPayloadHeartbeatMillis={} timeoutMinutes={} updatedAt={}", task.getId(), lastPayloadHeartbeatMillis, minutes, task.getUpdatedAt()); continue; } + boolean hasStartedProgress = lastPayloadHeartbeatMillis > 0L || hasFinishedRows; + if (!hasStartedProgress) { + hasStartedProgress = productRiskTaskCacheService.hasAnyShopMergedPayload(task.getId()); + } if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) { stats.skippedTaskCount++; log.info("[stale-check] product-risk skip initial-grace taskId={} createdAt={} initialThreshold={}", @@ -250,18 +254,22 @@ public class DeleteBrandStaleTaskService { if (runningTasks.isEmpty()) { return stats; } + Map heartbeatByTaskId = priceTrackTaskCacheService.getTaskHeartbeatMillisBatch( + runningTasks.stream().map(FileTaskEntity::getId).toList()); log.info("[stale-check] price-track candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}", runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes); for (FileTaskEntity task : runningTasks) { - long lastHeartbeatMillis = priceTrackTaskCacheService.getTaskHeartbeatMillis(task.getId()); - boolean hasUploadedPayload = priceTrackTaskCacheService.hasAnyShopMergedPayload(task.getId()); + long lastHeartbeatMillis = heartbeatByTaskId.getOrDefault(task.getId(), 0L); boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0) || (task.getFailedFileCount() != null && task.getFailedFileCount() > 0); - boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasUploadedPayload || hasFinishedRows; if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) { stats.skippedTaskCount++; continue; } + boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows; + if (!hasStartedProgress) { + hasStartedProgress = priceTrackTaskCacheService.hasAnyShopMergedPayload(task.getId()); + } if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) { stats.skippedTaskCount++; continue; @@ -312,20 +320,24 @@ public class DeleteBrandStaleTaskService { if (runningTasks.isEmpty()) { return stats; } + Map heartbeatByTaskId = shopMatchTaskCacheService.getTaskHeartbeatMillisBatch( + runningTasks.stream().map(FileTaskEntity::getId).toList()); log.info("[stale-check] shop-match candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}", runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes); for (FileTaskEntity task : runningTasks) { - long lastHeartbeatMillis = shopMatchTaskCacheService.getTaskHeartbeatMillis(task.getId()); - boolean hasUploadedPayload = shopMatchTaskCacheService.hasAnyShopMergedPayload(task.getId()); + long lastHeartbeatMillis = heartbeatByTaskId.getOrDefault(task.getId(), 0L); boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0) || (task.getFailedFileCount() != null && task.getFailedFileCount() > 0); - boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasUploadedPayload || hasFinishedRows; if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) { stats.skippedTaskCount++; log.info("[stale-check] shop-match skip recent-task-heartbeat taskId={} lastHeartbeatMillis={} timeoutMinutes={} updatedAt={}", task.getId(), lastHeartbeatMillis, minutes, task.getUpdatedAt()); continue; } + boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows; + if (!hasStartedProgress) { + hasStartedProgress = shopMatchTaskCacheService.hasAnyShopMergedPayload(task.getId()); + } if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) { stats.skippedTaskCount++; log.info("[stale-check] shop-match skip initial-grace taskId={} createdAt={} initialThreshold={}", @@ -378,16 +390,20 @@ public class DeleteBrandStaleTaskService { if (runningTasks.isEmpty()) { return stats; } + Map heartbeatByTaskId = patrolDeleteTaskCacheService.getTaskHeartbeatMillisBatch( + runningTasks.stream().map(FileTaskEntity::getId).toList()); for (FileTaskEntity task : runningTasks) { - long lastHeartbeatMillis = patrolDeleteTaskCacheService.getTaskHeartbeatMillis(task.getId()); - boolean hasUploadedPayload = patrolDeleteTaskCacheService.hasAnyShopMergedPayload(task.getId()); + long lastHeartbeatMillis = heartbeatByTaskId.getOrDefault(task.getId(), 0L); boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0) || (task.getFailedFileCount() != null && task.getFailedFileCount() > 0); - boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasUploadedPayload || hasFinishedRows; if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) { stats.skippedTaskCount++; continue; } + boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasFinishedRows; + if (!hasStartedProgress) { + hasStartedProgress = patrolDeleteTaskCacheService.hasAnyShopMergedPayload(task.getId()); + } if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) { stats.skippedTaskCount++; continue; @@ -431,6 +447,10 @@ public class DeleteBrandStaleTaskService { java.util.List runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper() .eq(FileTaskEntity::getModuleType, MODULE_TYPE_DELETE_BRAND) .eq(FileTaskEntity::getStatus, "RUNNING") + .and(wrapper -> wrapper + .gt(FileTaskEntity::getSuccessFileCount, 0) + .or() + .gt(FileTaskEntity::getFailedFileCount, 0)) .orderByAsc(FileTaskEntity::getUpdatedAt) .last("limit 100")); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java index 2552055..39bd653 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskCacheService.java @@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.deletebrand.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.config.TaskPressureProperties; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -11,6 +12,7 @@ import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor +@Slf4j public class DeleteBrandTaskCacheService { public static final String PHASE_CRAWLING = "crawling"; @@ -52,9 +54,13 @@ public class DeleteBrandTaskCacheService { } String key = buildProgressKey(taskId); - stringRedisTemplate.opsForHash().putAll(key, merged); - stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS)); - progressRedisFlushAt.put(taskId, now); + try { + stringRedisTemplate.opsForHash().putAll(key, merged); + stringRedisTemplate.expire(key, Duration.ofHours(PAYLOAD_TTL_HOURS)); + progressRedisFlushAt.put(taskId, now); + } catch (Exception ex) { + log.warn("[delete-brand-cache] save progress degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public java.util.Map getProgress(Long taskId) { @@ -63,7 +69,13 @@ public class DeleteBrandTaskCacheService { if (cached != null && cached.isFresh(now)) { return new java.util.LinkedHashMap<>(cached.values()); } - java.util.Map values = stringRedisTemplate.opsForHash().entries(buildProgressKey(taskId)); + java.util.Map values; + try { + values = stringRedisTemplate.opsForHash().entries(buildProgressKey(taskId)); + } catch (Exception ex) { + log.warn("[delete-brand-cache] load progress degraded taskId={} msg={}", taskId, ex.getMessage()); + return java.util.Collections.emptyMap(); + } if (!values.isEmpty()) { progressLocalCache.put(taskId, new LocalProgressCacheEntry(now, toStringMap(values))); } @@ -98,15 +110,24 @@ public class DeleteBrandTaskCacheService { return result; } - java.util.List pipelineResults = stringRedisTemplate.executePipelined( - (org.springframework.data.redis.core.RedisCallback) connection -> { - org.springframework.data.redis.serializer.RedisSerializer serializer = - stringRedisTemplate.getStringSerializer(); - for (Long taskId : missingTaskIds) { - connection.hGetAll(serializer.serialize(buildProgressKey(taskId))); - } - return null; - }); + java.util.List pipelineResults; + try { + pipelineResults = stringRedisTemplate.executePipelined( + (org.springframework.data.redis.core.RedisCallback) connection -> { + org.springframework.data.redis.serializer.RedisSerializer serializer = + stringRedisTemplate.getStringSerializer(); + for (Long taskId : missingTaskIds) { + connection.hGetAll(serializer.serialize(buildProgressKey(taskId))); + } + return null; + }); + } catch (Exception ex) { + log.warn("[delete-brand-cache] batch load progress degraded taskIds={} msg={}", missingTaskIds, ex.getMessage()); + for (Long taskId : missingTaskIds) { + result.put(taskId, java.util.Collections.emptyMap()); + } + return result; + } for (int i = 0; i < missingTaskIds.size(); i++) { Long taskId = missingTaskIds.get(i); @@ -129,8 +150,12 @@ public class DeleteBrandTaskCacheService { progressLocalCache.remove(taskId); progressRedisFlushAt.remove(taskId); taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildProgressKey(taskId)); - stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + try { + stringRedisTemplate.delete(buildProgressKey(taskId)); + stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + } catch (Exception ex) { + log.warn("[delete-brand-cache] delete degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public void saveTaskCache(com.nanri.aiimage.modules.task.model.entity.FileTaskEntity task) { @@ -180,7 +205,13 @@ public class DeleteBrandTaskCacheService { } java.util.List keys = missingIds.stream().map(this::buildTaskEntityKey).toList(); - java.util.List values = stringRedisTemplate.opsForValue().multiGet(keys); + java.util.List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[delete-brand-cache] batch load task cache degraded taskIds={} msg={}", missingIds, ex.getMessage()); + return result; + } for (int i = 0; i < missingIds.size(); i++) { Long taskId = missingIds.get(i); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java index 3cc4857..64388ff 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandTaskStorageService.java @@ -6,11 +6,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.modules.deletebrand.model.cache.DeleteBrandParsedFileCacheDto; import com.nanri.aiimage.modules.deletebrand.model.dto.DeleteBrandResultFileDto; -import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper; import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity; import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; @@ -32,12 +32,11 @@ import java.util.Map; public class DeleteBrandTaskStorageService { private static final String MODULE_TYPE = "DELETE_BRAND"; - private static final String OSS_POINTER_PREFIX = "oss:"; private final TaskChunkMapper taskChunkMapper; private final TaskScopeStateMapper taskScopeStateMapper; private final ObjectMapper objectMapper; - private final OssStorageService ossStorageService; + private final TransientPayloadStorageService transientPayloadStorageService; @Transactional public void saveParsedPayload(Long taskId, Map parsedPayloadByScope) { @@ -81,7 +80,7 @@ public class DeleteBrandTaskStorageService { if (existing == null) { throw new BusinessException("failed to save delete-brand parsed payload"); } - deleteParsedPayloadIfNeeded(existing.getParsedPayloadJson(), parsedPayloadPointer); + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(existing.getParsedPayloadJson(), parsedPayloadPointer); taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, existing.getId()) .set(TaskScopeStateEntity::getScopeKey, scopeKey) @@ -120,6 +119,28 @@ public class DeleteBrandTaskStorageService { return result; } + public int countParsedPayloadScopes(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .isNotNull(TaskScopeStateEntity::getParsedPayloadJson)); + return count == null ? 0 : count.intValue(); + } + + public int countCompletedScopes(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .eq(TaskScopeStateEntity::getCompleted, 1)); + return count == null ? 0 : count.intValue(); + } + @Transactional public boolean storeResultChunkIfChanged(Long taskId, String scopeKey, Integer chunkIndex, DeleteBrandResultFileDto chunk) { if (taskId == null || taskId <= 0) { @@ -145,6 +166,7 @@ public class DeleteBrandTaskStorageService { .last("limit 1")); boolean changed = true; if (existingChunk == null) { + String storedPayload = transientPayloadStorageService.storeChunkPayload(MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson); TaskChunkEntity entity = new TaskChunkEntity(); entity.setTaskId(taskId); entity.setModuleType(MODULE_TYPE); @@ -152,7 +174,7 @@ public class DeleteBrandTaskStorageService { entity.setScopeHash(scopeHash); entity.setChunkIndex(chunkIndex); entity.setChunkTotal(chunk.getChunkTotal() == null ? 0 : chunk.getChunkTotal()); - entity.setPayloadJson(payloadJson); + entity.setPayloadJson(storedPayload); entity.setPayloadHash(payloadHash); entity.setCreatedAt(now); entity.setUpdatedAt(now); @@ -169,11 +191,13 @@ public class DeleteBrandTaskStorageService { changed = !payloadHash.equals(concurrentChunk.getPayloadHash()); concurrentChunk.setScopeKey(normalizedScopeKey); concurrentChunk.setChunkTotal(chunk.getChunkTotal() == null ? 0 : chunk.getChunkTotal()); - concurrentChunk.setPayloadJson(payloadJson); + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(concurrentChunk.getPayloadJson(), storedPayload); + concurrentChunk.setPayloadJson(storedPayload); concurrentChunk.setPayloadHash(payloadHash); concurrentChunk.setUpdatedAt(now); taskChunkMapper.updateById(concurrentChunk); } else { + transientPayloadStorageService.deletePayloadIfPresent(storedPayload); throw ex; } } @@ -181,7 +205,9 @@ public class DeleteBrandTaskStorageService { changed = !payloadHash.equals(existingChunk.getPayloadHash()); existingChunk.setScopeKey(normalizedScopeKey); existingChunk.setChunkTotal(chunk.getChunkTotal() == null ? 0 : chunk.getChunkTotal()); - existingChunk.setPayloadJson(payloadJson); + String storedPayload = transientPayloadStorageService.storeChunkPayload(MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson); + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(existingChunk.getPayloadJson(), storedPayload); + existingChunk.setPayloadJson(storedPayload); existingChunk.setPayloadHash(payloadHash); existingChunk.setUpdatedAt(now); taskChunkMapper.updateById(existingChunk); @@ -210,7 +236,8 @@ public class DeleteBrandTaskStorageService { continue; } try { - DeleteBrandResultFileDto dto = objectMapper.readValue(entity.getPayloadJson(), DeleteBrandResultFileDto.class); + String payloadJson = transientPayloadStorageService.resolvePayload(entity.getPayloadJson(), "failed to load delete-brand result chunks"); + DeleteBrandResultFileDto dto = objectMapper.readValue(payloadJson, DeleteBrandResultFileDto.class); grouped.computeIfAbsent(entity.getScopeKey(), ignored -> new ArrayList<>()).add(dto); } catch (Exception ex) { throw new BusinessException("failed to load delete-brand result chunks"); @@ -228,12 +255,22 @@ public class DeleteBrandTaskStorageService { return; } List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() - .select(TaskScopeStateEntity::getParsedPayloadJson) + .select(TaskScopeStateEntity::getParsedPayloadJson, TaskScopeStateEntity::getStateJson) .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE)); if (states != null) { for (TaskScopeStateEntity state : states) { - deleteParsedPayloadIfNeeded(state.getParsedPayloadJson(), null); + transientPayloadStorageService.deletePayloadIfPresent(state.getParsedPayloadJson()); + transientPayloadStorageService.deletePayloadIfPresent(state.getStateJson()); + } + } + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .select(TaskChunkEntity::getPayloadJson) + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + if (chunks != null) { + for (TaskChunkEntity chunk : chunks) { + transientPayloadStorageService.deletePayloadIfPresent(chunk.getPayloadJson()); } } taskChunkMapper.delete(new LambdaQueryWrapper() @@ -322,56 +359,17 @@ public class DeleteBrandTaskStorageService { } private String storeParsedPayload(Long taskId, String scopeHash, String payloadJson) { - try { - String pointer = OSS_POINTER_PREFIX + ossStorageService.uploadTaskParsedPayload(MODULE_TYPE, taskId, scopeHash, payloadJson); - return objectMapper.writeValueAsString(pointer); - } catch (Exception ex) { - throw new BusinessException("failed to save delete-brand parsed payload"); - } + return transientPayloadStorageService.storeParsedPayload(MODULE_TYPE, taskId, scopeHash, payloadJson, true); } private String resolveParsedPayload(String value) { - if (isBlank(value)) { - return value; - } - String ossPointer = extractOssPointer(value); - if (ossPointer == null) { - return value; - } try { - return ossStorageService.readObjectAsString(ossPointer.substring(OSS_POINTER_PREFIX.length())); + return transientPayloadStorageService.resolvePayload(value, "failed to load delete-brand parsed payload"); } catch (Exception ex) { throw new BusinessException("failed to load delete-brand parsed payload"); } } - private void deleteParsedPayloadIfNeeded(String oldValue, String newValue) { - String oldPointer = extractOssPointer(oldValue); - String newPointer = extractOssPointer(newValue); - if (oldPointer == null || oldPointer.equals(newPointer)) { - return; - } - try { - ossStorageService.deleteObject(oldPointer.substring(OSS_POINTER_PREFIX.length())); - } catch (Exception ignored) { - } - } - - private String extractOssPointer(String value) { - if (isBlank(value)) { - return null; - } - if (value.startsWith(OSS_POINTER_PREFIX)) { - return value; - } - try { - String decoded = objectMapper.readValue(value, String.class); - return decoded != null && decoded.startsWith(OSS_POINTER_PREFIX) ? decoded : null; - } catch (Exception ignored) { - return null; - } - } - private String writeJson(Object value, String message) { try { return objectMapper.writeValueAsString(value); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/object/RustfsObjectStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/object/RustfsObjectStorageService.java new file mode 100644 index 0000000..b2a9f07 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/object/RustfsObjectStorageService.java @@ -0,0 +1,85 @@ +package com.nanri.aiimage.modules.file.service.object; + +import com.nanri.aiimage.config.TransientStorageProperties; +import io.minio.GetObjectArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class RustfsObjectStorageService { + + private final TransientStorageProperties properties; + + public boolean isConfigured() { + return notBlank(properties.getEndpoint()) + && notBlank(properties.getBucket()) + && notBlank(properties.getAccessKeyId()) + && notBlank(properties.getAccessKeySecret()); + } + + public String uploadText(String objectKey, String content) { + if (!isConfigured()) { + throw new IllegalStateException("transient storage is not configured"); + } + try (ByteArrayInputStream stream = new ByteArrayInputStream( + Objects.requireNonNullElse(content, "").getBytes(StandardCharsets.UTF_8))) { + buildClient().putObject(PutObjectArgs.builder() + .bucket(properties.getBucket()) + .object(objectKey) + .stream(stream, stream.available(), -1) + .contentType("application/json") + .build()); + return objectKey; + } catch (Exception ex) { + throw new IllegalStateException("failed to upload payload to transient storage", ex); + } + } + + public String readObjectAsString(String objectKey) { + if (!isConfigured()) { + throw new IllegalStateException("transient storage is not configured"); + } + try (var stream = buildClient().getObject(GetObjectArgs.builder() + .bucket(properties.getBucket()) + .object(objectKey) + .build())) { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalStateException("failed to read payload from transient storage", ex); + } + } + + public void deleteObject(String objectKey) { + if (!isConfigured() || !notBlank(objectKey)) { + return; + } + try { + buildClient().removeObject(RemoveObjectArgs.builder() + .bucket(properties.getBucket()) + .object(objectKey) + .build()); + } catch (Exception ex) { + throw new IllegalStateException("failed to delete payload from transient storage", ex); + } + } + + private MinioClient buildClient() { + return MinioClient.builder() + .endpoint(properties.getEndpoint()) + .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) + .region(properties.getRegion()) + .build(); + } + + private boolean notBlank(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/vo/PatrolDeleteResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/vo/PatrolDeleteResultItemVo.java index 9b827e2..838cff7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/vo/PatrolDeleteResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/model/vo/PatrolDeleteResultItemVo.java @@ -43,6 +43,10 @@ public class PatrolDeleteResultItemVo { private LocalDateTime finishedAt; private String outputFilename; private String downloadUrl; + private Long fileJobId; + private String fileStatus; + private String fileError; + private Boolean fileReady; @JsonProperty("countrySections") private List countrySections = new ArrayList<>(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskCacheService.java index 19212f1..083e02d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskCacheService.java @@ -6,6 +6,7 @@ import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteShopPayloadD import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -13,11 +14,13 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor +@Slf4j public class PatrolDeleteTaskCacheService { private static final String MODULE_TYPE = "PATROL_DELETE"; @@ -60,7 +63,13 @@ public class PatrolDeleteTaskCacheService { if (taskId == null || taskId <= 0) { return 0L; } - String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + } catch (Exception ex) { + log.warn("[patrol-delete-cache] get heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + return 0L; + } if (raw == null || raw.isBlank()) { return 0L; } @@ -71,11 +80,50 @@ public class PatrolDeleteTaskCacheService { } } + public Map getTaskHeartbeatMillisBatch(List taskIds) { + Map result = new LinkedHashMap<>(); + if (taskIds == null || taskIds.isEmpty()) { + return result; + } + List normalized = taskIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (normalized.isEmpty()) { + return result; + } + List keys = normalized.stream().map(this::buildTaskHeartbeatKey).toList(); + List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[patrol-delete-cache] batch get heartbeat degraded taskIds={} msg={}", normalized, ex.getMessage()); + return result; + } + for (int i = 0; i < normalized.size(); i++) { + String raw = values != null && i < values.size() ? values.get(i) : null; + if (raw == null || raw.isBlank()) { + result.put(normalized.get(i), 0L); + continue; + } + try { + result.put(normalized.get(i), Long.parseLong(raw)); + } catch (NumberFormatException ignored) { + result.put(normalized.get(i), 0L); + } + } + return result; + } + public void touchTaskHeartbeat(Long taskId) { - stringRedisTemplate.opsForValue().set( - buildTaskHeartbeatKey(taskId), - String.valueOf(Instant.now().toEpochMilli()), - Duration.ofHours(PAYLOAD_TTL_HOURS)); + try { + stringRedisTemplate.opsForValue().set( + buildTaskHeartbeatKey(taskId), + String.valueOf(Instant.now().toEpochMilli()), + Duration.ofHours(PAYLOAD_TTL_HOURS)); + } catch (Exception ex) { + log.warn("[patrol-delete-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public void deleteTaskCache(Long taskId) { @@ -83,8 +131,12 @@ public class PatrolDeleteTaskCacheService { return; } taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); - stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + try { + stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); + stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + } catch (Exception ex) { + log.warn("[patrol-delete-cache] delete cache degraded taskId={} msg={}", taskId, ex.getMessage()); + } taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); } @@ -133,7 +185,13 @@ public class PatrolDeleteTaskCacheService { return result; } java.util.List keys = missingIds.stream().map(this::buildTaskEntityKey).toList(); - java.util.List values = stringRedisTemplate.opsForValue().multiGet(keys); + java.util.List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[patrol-delete-cache] batch get task cache degraded taskIds={} msg={}", missingIds, ex.getMessage()); + return result; + } for (int i = 0; i < missingIds.size(); i++) { Long taskId = missingIds.get(i); String val = values != null && i < values.size() ? values.get(i) : null; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java index ffd31a8..d8387ca 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java @@ -24,6 +24,10 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService; +import com.nanri.aiimage.modules.task.service.TaskResultItemService; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -56,6 +60,9 @@ public class PatrolDeleteTaskService { private final ZiniaoShopSwitchService ziniaoShopSwitchService; private final ObjectMapper objectMapper; private final TaskPressureProperties taskPressureProperties; + private final TaskFileJobService taskFileJobService; + private final TaskResultItemService taskResultItemService; + private final TaskProgressSnapshotService taskProgressSnapshotService; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -185,8 +192,7 @@ public class PatrolDeleteTaskService { batch.getMissingTaskIds().add(taskId); continue; } - List snapshots = buildSnapshotFromDb(task, taskRows); - batch.getItems().addAll(snapshots); + batch.getItems().addAll(buildProgressItems(task, taskRows)); } return batch; } @@ -496,7 +502,11 @@ public class PatrolDeleteTaskService { private Map> buildSnapshotMap(Map taskMap) { Map> out = new LinkedHashMap<>(); for (Map.Entry entry : taskMap.entrySet()) { - out.put(entry.getKey(), indexSnapshotByResultId(parseTaskSnapshots(entry.getValue().getResultJson()))); + List snapshots = taskResultItemService.listResultSnapshots(entry.getKey(), MODULE_TYPE, PatrolDeleteResultItemVo.class); + if (snapshots.isEmpty()) { + snapshots = parseTaskSnapshots(entry.getValue().getResultJson()); + } + out.put(entry.getKey(), indexSnapshotByResultId(snapshots)); } return out; } @@ -513,9 +523,8 @@ public class PatrolDeleteTaskService { item.setCreatedAt(entity.getCreatedAt()); item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt()); item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename())); - item.setDownloadUrl(blank(entity.getResultFileUrl()) - ? item.getDownloadUrl() - : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); + item.setDownloadUrl(null); + attachFileJobState(item, entity); if (item.getCountrySections() == null) { item.setCountrySections(new ArrayList<>()); } @@ -525,6 +534,18 @@ public class PatrolDeleteTaskService { return item; } + private void attachFileJobState(PatrolDeleteResultItemVo item, FileResultEntity entity) { + TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()); + item.setFileReady(!blank(entity.getResultFileUrl())); + if (job == null) { + item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null); + return; + } + item.setFileJobId(job.getId()); + item.setFileStatus(job.getStatus()); + item.setFileError(job.getErrorMessage()); + } + private List dedupeItems(List items) { LinkedHashMap map = new LinkedHashMap<>(); for (PatrolDeleteTaskItemDto item : items) { @@ -569,6 +590,7 @@ public class PatrolDeleteTaskService { try { task.setRequestJson(objectMapper.writeValueAsString(requestItems)); task.setResultJson(objectMapper.writeValueAsString(snapshots)); + syncSnapshotTables(task, snapshots); fileTaskMapper.updateById(task); } catch (Exception ex) { throw new BusinessException("巡店删除任务快照保存失败"); @@ -585,6 +607,27 @@ public class PatrolDeleteTaskService { return list; } + private List buildProgressItems(FileTaskEntity task, List rows) { + List list = new ArrayList<>(); + for (FileResultEntity row : rows) { + PatrolDeleteResultItemVo item = new PatrolDeleteResultItemVo(); + item.setResultId(row.getId()); + item.setTaskId(row.getTaskId()); + item.setShopName(row.getSourceFilename()); + item.setShopId(row.getSourceFileUrl()); + item.setTaskStatus(task == null ? null : task.getStatus()); + item.setSuccess(row.getSuccess() == null ? null : row.getSuccess() == 1); + item.setError(row.getErrorMessage()); + item.setCreatedAt(row.getCreatedAt()); + item.setFinishedAt(task == null ? null : task.getFinishedAt()); + item.setOutputFilename(row.getResultFilename()); + item.setDownloadUrl(null); + attachFileJobState(item, row); + list.add(item); + } + return list; + } + private Map indexSnapshotByResultId(List snapshots) { Map map = new LinkedHashMap<>(); if (snapshots == null) { @@ -753,27 +796,25 @@ public class PatrolDeleteTaskService { .filter(item -> Boolean.TRUE.equals(item.getSuccess())) .toList(); if (!successItems.isEmpty()) { - File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "patrol-delete-result", String.valueOf(task.getId()))); - String filename = safeFileStem("巡店删除-" + task.getId()) + ".xlsx"; - File xlsx = FileUtil.file(workRoot, filename); - try { - excelAssemblyService.writeWorkbook(xlsx, successItems); - String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); - long fileSize = xlsx.length(); - int rowCount = excelAssemblyService.countRows(successItems); + String filename = safeFileStem("patrol-delete-" + task.getId()) + ".xlsx"; + int rowCount = excelAssemblyService.countRows(successItems); + FileResultEntity firstSuccessRow = null; for (FileResultEntity row : rows) { if (Integer.valueOf(1).equals(row.getSuccess())) { row.setResultFilename(filename); - row.setResultFileUrl(objectKey); - row.setResultFileSize(fileSize); + row.setResultFileUrl(null); + row.setResultFileSize(0L); row.setResultContentType(CONTENT_TYPE_XLSX); row.setRowCount(rowCount); fileResultMapper.updateById(row); + if (firstSuccessRow == null) { + firstSuccessRow = row; + } } } + if (firstSuccessRow != null) { + taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, firstSuccessRow.getId(), "task:" + task.getId()); snapshots = buildSnapshotFromDb(task, rows); - } finally { - FileUtil.del(xlsx); } } @@ -782,6 +823,48 @@ public class PatrolDeleteTaskService { taskCacheService.deleteTaskCache(task.getId()); } + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId()); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + List rows = listTaskRows(job.getTaskId()); + List snapshots = taskResultItemService.listResultSnapshots(job.getTaskId(), MODULE_TYPE, PatrolDeleteResultItemVo.class); + if (snapshots.isEmpty()) { + snapshots = buildSnapshotFromDb(task, rows); + } + List successItems = snapshots.stream() + .filter(item -> Boolean.TRUE.equals(item.getSuccess())) + .toList(); + if (successItems.isEmpty()) { + throw new BusinessException("没有可生成的巡店删除结果"); + } + File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "patrol-delete-result", String.valueOf(task.getId()))); + String filename = safeFileStem("patrol-delete-" + task.getId()) + ".xlsx"; + File xlsx = FileUtil.file(workRoot, filename); + try { + excelAssemblyService.writeWorkbook(xlsx, successItems); + String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); + long fileSize = xlsx.length(); + int rowCount = excelAssemblyService.countRows(successItems); + for (FileResultEntity row : rows) { + if (Integer.valueOf(1).equals(row.getSuccess())) { + row.setResultFilename(filename); + row.setResultFileUrl(objectKey); + row.setResultFileSize(fileSize); + row.setResultContentType(CONTENT_TYPE_XLSX); + row.setRowCount(rowCount); + fileResultMapper.updateById(row); + } + } + } finally { + FileUtil.del(xlsx); + } + } + private void markResultSuccess(FileResultEntity row) { row.setSuccess(1); row.setErrorMessage(null); @@ -818,11 +901,39 @@ public class PatrolDeleteTaskService { private void persistSnapshotJson(FileTaskEntity task, List snapshots) { try { task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots)); + syncSnapshotTables(task, snapshots); } catch (Exception ex) { throw new BusinessException("巡店删除任务快照保存失败"); } } + private void syncSnapshotTables(FileTaskEntity task, List snapshots) { + List safe = snapshots == null ? List.of() : snapshots; + taskResultItemService.replaceTaskSnapshots(task.getId(), MODULE_TYPE, safe, new TaskResultItemService.SnapshotKeyResolver() { + @Override + public Long resultId(Object snapshot) { + return ((PatrolDeleteResultItemVo) snapshot).getResultId(); + } + + @Override + public String scopeKey(Object snapshot) { + PatrolDeleteResultItemVo item = (PatrolDeleteResultItemVo) snapshot; + return firstNonBlank(item.getShopName(), "result:" + item.getResultId()); + } + }); + int successCount = 0; + int failedCount = 0; + for (PatrolDeleteResultItemVo item : safe) { + if (Boolean.TRUE.equals(item.getSuccess())) { + successCount++; + } else if (Boolean.FALSE.equals(item.getSuccess())) { + failedCount++; + } + } + taskProgressSnapshotService.save(task.getId(), MODULE_TYPE, firstNonBlank(task.getStatus(), "RUNNING"), + safe.size(), successCount, failedCount, null, task.getErrorMessage(), null); + } + private List copyCountrySections(List sections) { List copy = new ArrayList<>(); if (sections == null) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java index 346beac..c74e581 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java @@ -1,16 +1,20 @@ package com.nanri.aiimage.modules.permission.service; -import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.CompletableFuture; @Component @RequiredArgsConstructor @Slf4j +@ConditionalOnProperty(prefix = "aiimage.permission-schema-init", name = "enabled", havingValue = "true") public class PermissionMenuSchemaInitializer { private final JdbcTemplate jdbcTemplate; @@ -33,8 +37,12 @@ public class PermissionMenuSchemaInitializer { new DefaultAdminMenu("版本管理", "admin_version", "version", 80) ); - @PostConstruct + @EventListener(ApplicationReadyEvent.class) public void initialize() { + CompletableFuture.runAsync(this::initializeInternal); + } + + private void initializeInternal() { executeQuietly(""" CREATE TABLE IF NOT EXISTS columns ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java index 8a8a811..8caa38d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -215,7 +215,7 @@ public class PriceTrackController { + "未完成/处理中:本次只传增量行数据,shop.success 不传或传 null,后端仅合并缓存继续等待。" + "已完成:传最后一批数据并设置 shop.success=true,后端会使用该店铺累计后的完整数据出结果。" + "失败:传 shop.success=false 或传非空 shop.error。" - + "行级数据只保留表头字段:shopMallName、asin、price、recommendedPrice、minimumPrice、firstPlace、secondPlace、cartShopName、priceChangeStatus、modifyCount、status。" + + "行级数据只保留表头字段:shopMallName、asin、price、recommendedPrice、shippingFee、minimumPrice、firstPlace、secondPlace、cartShopName、priceChangeStatus、modifyCount、status。" ) public ApiResponse submitResult( @Parameter( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java index c9f8762..e0aeff8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java @@ -73,6 +73,9 @@ public class PriceTrackSubmitResultRequest { @Schema(description = "推荐价", example = "18.99") private String recommendedPrice; + @Schema(description = "运费", example = "2.50") + private String shippingFee; + @Schema(description = "最低价", example = "18.50") private String minimumPrice; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java index 444e775..0e3b3d7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java @@ -52,6 +52,10 @@ public class PriceTrackResultItemVo { @Schema(description = "预签名下载 URL,列表可能带回,也可通过下载接口获取") private String downloadUrl; + private Long fileJobId; + private String fileStatus; + private String fileError; + private Boolean fileReady; @Schema(description = "所属循环任务 ID", example = "1") private Long loopRunId; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java index 2741df9..6d313a1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackExcelAssemblyService.java @@ -24,6 +24,7 @@ public class PriceTrackExcelAssemblyService { "ASIN", "价格", "推荐价", + "运费", "最低价", "第一名", "第二名", @@ -56,13 +57,14 @@ public class PriceTrackExcelAssemblyService { row.createCell(1).setCellValue(valueOf(item.getAsin())); row.createCell(2).setCellValue(valueOf(item.getPrice())); row.createCell(3).setCellValue(valueOf(item.getRecommendedPrice())); - row.createCell(4).setCellValue(valueOf(item.getMinimumPrice())); - row.createCell(5).setCellValue(valueOf(item.getFirstPlace())); - row.createCell(6).setCellValue(valueOf(item.getSecondPlace())); - row.createCell(7).setCellValue(valueOf(item.getCartShopName())); - row.createCell(8).setCellValue(valueOf(item.getPriceChangeStatus())); - row.createCell(9).setCellValue(valueOf(item.getModifyCount())); - row.createCell(10).setCellValue(valueOf(item.getStatus())); + row.createCell(4).setCellValue(valueOf(item.getShippingFee())); + row.createCell(5).setCellValue(valueOf(item.getMinimumPrice())); + row.createCell(6).setCellValue(valueOf(item.getFirstPlace())); + row.createCell(7).setCellValue(valueOf(item.getSecondPlace())); + row.createCell(8).setCellValue(valueOf(item.getCartShopName())); + row.createCell(9).setCellValue(valueOf(item.getPriceChangeStatus())); + row.createCell(10).setCellValue(valueOf(item.getModifyCount())); + row.createCell(11).setCellValue(valueOf(item.getStatus())); } applyDefaultColumnWidths(sheet); } @@ -112,7 +114,7 @@ public class PriceTrackExcelAssemblyService { } private void applyDefaultColumnWidths(Sheet sheet) { - int[] widths = {22, 18, 12, 12, 12, 20, 20, 22, 18, 12, 12}; + int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 18, 12, 12}; for (int i = 0; i < widths.length; i++) { sheet.setColumnWidth(i, widths[i] * 256); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskCacheService.java index 6570567..13c104f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskCacheService.java @@ -6,6 +6,7 @@ import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequ import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -13,11 +14,13 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor +@Slf4j public class PriceTrackTaskCacheService { private static final String MODULE_TYPE = "PRICE_TRACK"; @@ -32,17 +35,27 @@ public class PriceTrackTaskCacheService { if (taskId == null || taskId <= 0) { return; } - stringRedisTemplate.opsForValue().set( - buildTaskHeartbeatKey(taskId), - String.valueOf(Instant.now().toEpochMilli()), - Duration.ofHours(HEARTBEAT_TTL_HOURS)); + try { + stringRedisTemplate.opsForValue().set( + buildTaskHeartbeatKey(taskId), + String.valueOf(Instant.now().toEpochMilli()), + Duration.ofHours(HEARTBEAT_TTL_HOURS)); + } catch (Exception ex) { + log.warn("[price-track-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public long getTaskHeartbeatMillis(Long taskId) { if (taskId == null || taskId <= 0) { return 0L; } - String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + } catch (Exception ex) { + log.warn("[price-track-cache] get heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + return 0L; + } if (raw == null || raw.isBlank()) { return 0L; } @@ -53,6 +66,41 @@ public class PriceTrackTaskCacheService { } } + public Map getTaskHeartbeatMillisBatch(List taskIds) { + Map result = new LinkedHashMap<>(); + if (taskIds == null || taskIds.isEmpty()) { + return result; + } + List normalized = taskIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (normalized.isEmpty()) { + return result; + } + List keys = normalized.stream().map(this::buildTaskHeartbeatKey).toList(); + List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[price-track-cache] batch get heartbeat degraded taskIds={} msg={}", normalized, ex.getMessage()); + return result; + } + for (int i = 0; i < normalized.size(); i++) { + String raw = values != null && i < values.size() ? values.get(i) : null; + if (raw == null || raw.isBlank()) { + result.put(normalized.get(i), 0L); + continue; + } + try { + result.put(normalized.get(i), Long.parseLong(raw)); + } catch (NumberFormatException ignored) { + result.put(normalized.get(i), 0L); + } + } + return result; + } + public PriceTrackSubmitResultRequest.ShopResult getShopMergedPayload(Long taskId, String shopKey) { return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, PriceTrackSubmitResultRequest.ShopResult.class); } @@ -82,8 +130,12 @@ public class PriceTrackTaskCacheService { return; } taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); - stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + try { + stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); + stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + } catch (Exception ex) { + log.warn("[price-track-cache] delete cache degraded taskId={} msg={}", taskId, ex.getMessage()); + } taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); } @@ -132,7 +184,13 @@ public class PriceTrackTaskCacheService { return result; } java.util.List keys = missingIds.stream().map(this::buildTaskEntityKey).toList(); - java.util.List values = stringRedisTemplate.opsForValue().multiGet(keys); + java.util.List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[price-track-cache] batch get task cache degraded taskIds={} msg={}", missingIds, ex.getMessage()); + return result; + } for (int i = 0; i < missingIds.size(); i++) { Long taskId = missingIds.get(i); String val = values != null && i < values.size() ? values.get(i) : null; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index 6c43fb3..e9ece5c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -26,6 +26,9 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskResultPayloadService; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; import cn.hutool.core.util.IdUtil; import lombok.RequiredArgsConstructor; @@ -66,6 +69,8 @@ public class PriceTrackTaskService { private final PriceTrackTaskCacheService priceTrackTaskCacheService; private final PriceTrackLoopRunService priceTrackLoopRunService; private final TaskPressureProperties taskPressureProperties; + private final TaskResultPayloadService taskResultPayloadService; + private final TaskFileJobService taskFileJobService; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -435,7 +440,7 @@ public class PriceTrackTaskService { continue; } try { - assembleShopResult(fr, shopKey, merged); + enqueueResultFileAssembly(fr, shopKey, merged); priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey); } catch (Exception ex) { markResultFailed(fr, ex.getMessage() == null ? "assemble price-track result failed" : ex.getMessage()); @@ -509,7 +514,7 @@ public class PriceTrackTaskService { continue; } try { - assembleShopResult(fr, shopKey, cachedPayload); + enqueueResultFileAssembly(fr, shopKey, cachedPayload); priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey); changed = true; log.warn("[price-track] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={} rowCount={}", @@ -618,6 +623,49 @@ public class PriceTrackTaskService { } } + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null || job.getResultId() == null || job.getScopeKey() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileResultEntity result = fileResultMapper.selectById(job.getResultId()); + if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { + throw new BusinessException("结果记录不存在"); + } + PriceTrackSubmitResultRequest.ShopResult payload = taskResultPayloadService.getLatest( + job.getTaskId(), MODULE_TYPE, job.getScopeKey(), PriceTrackSubmitResultRequest.ShopResult.class); + if (payload == null) { + throw new BusinessException("结果文件载荷不存在"); + } + assembleShopResult(result, job.getScopeKey(), payload); + } + + private void enqueueResultFileAssembly(FileResultEntity result, + String shopKey, + PriceTrackSubmitResultRequest.ShopResult payload) { + taskResultPayloadService.saveLatest(result.getTaskId(), MODULE_TYPE, shopKey, payload); + markResultFilePending(result, shopKey, payload); + taskFileJobService.enqueueAssembleResult(result.getTaskId(), MODULE_TYPE, result.getId(), shopKey); + } + + private void markResultFilePending(FileResultEntity result, + String shopKey, + PriceTrackSubmitResultRequest.ShopResult payload) { + Map> countries = + excelAssemblyService.normalizeCountriesMap(payload.getCountries()); + String displayName = payload.getShopName() != null && !payload.getShopName().isBlank() + ? payload.getShopName().trim() + : shopKey; + String stem = safeFileStem(displayName); + result.setSuccess(1); + result.setErrorMessage(null); + result.setResultFilename(stem + ".xlsx"); + result.setResultFileUrl(null); + result.setResultFileSize(0L); + result.setResultContentType(CONTENT_TYPE_XLSX); + result.setRowCount(excelAssemblyService.countRows(countries)); + fileResultMapper.updateById(result); + } + public Map>> parseMatchAsinRowsByCountry(List asinFiles, List countryCodes) { return parseAsinRowsByCountry(asinFiles, countryCodes); } @@ -901,8 +949,8 @@ public class PriceTrackTaskService { vo.setSuccess(ok); vo.setError(entity.getErrorMessage()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank() - ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); + vo.setDownloadUrl(null); + attachFileJobState(vo, entity); vo.setMatched(true); if (entity.getTaskId() != null) { mergeQueueFieldsFromRequest(vo, entity.getTaskId()); @@ -910,6 +958,18 @@ public class PriceTrackTaskService { return vo; } + private void attachFileJobState(PriceTrackResultItemVo vo, FileResultEntity entity) { + TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()); + vo.setFileReady(entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank()); + if (job == null) { + vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); + return; + } + vo.setFileJobId(job.getId()); + vo.setFileStatus(job.getStatus()); + vo.setFileError(job.getErrorMessage()); + } + private PriceTrackTaskDetailVo buildTaskDetail(FileTaskEntity task) { PriceTrackTaskDetailVo detail = new PriceTrackTaskDetailVo(); detail.setTask(toTaskItemVo(task)); @@ -1074,6 +1134,7 @@ public class PriceTrackTaskService { merged.setAsin(firstNonBlank(incoming.getAsin(), merged.getAsin())); merged.setPrice(firstNonBlank(incoming.getPrice(), merged.getPrice())); merged.setRecommendedPrice(firstNonBlank(incoming.getRecommendedPrice(), merged.getRecommendedPrice())); + merged.setShippingFee(firstNonBlank(incoming.getShippingFee(), merged.getShippingFee())); merged.setMinimumPrice(firstNonBlank(incoming.getMinimumPrice(), merged.getMinimumPrice())); merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace())); merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace())); @@ -1097,6 +1158,7 @@ public class PriceTrackTaskService { out.setAsin(row.getAsin()); out.setPrice(row.getPrice()); out.setRecommendedPrice(row.getRecommendedPrice()); + out.setShippingFee(row.getShippingFee()); out.setMinimumPrice(row.getMinimumPrice()); out.setFirstPlace(row.getFirstPlace()); out.setSecondPlace(row.getSecondPlace()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java index 41a6096..2504c58 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/vo/ProductRiskResultItemVo.java @@ -57,6 +57,10 @@ public class ProductRiskResultItemVo { @JsonProperty("downloadUrl") @Schema(description = "预签名下载 URL(列表里可能带;直链下载也可用 GET /results/{resultId}/download)") private String downloadUrl; + private Long fileJobId; + private String fileStatus; + private String fileError; + private Boolean fileReady; @JsonProperty("scheduledAt") @Schema(description = "定时执行时间,未设置时为空") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskCacheService.java index db0e5c9..14c6f56 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskCacheService.java @@ -6,6 +6,7 @@ import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskShopPayloadDto import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -13,11 +14,13 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor +@Slf4j public class ProductRiskTaskCacheService { private static final String MODULE_TYPE = "PRODUCT_RISK_RESOLVE"; @@ -60,7 +63,13 @@ public class ProductRiskTaskCacheService { if (taskId == null || taskId <= 0) { return 0L; } - String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + } catch (Exception ex) { + log.warn("[product-risk-cache] get heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + return 0L; + } if (raw == null || raw.isBlank()) { return 0L; } @@ -71,13 +80,52 @@ public class ProductRiskTaskCacheService { } } + public Map getTaskHeartbeatMillisBatch(List taskIds) { + Map result = new LinkedHashMap<>(); + if (taskIds == null || taskIds.isEmpty()) { + return result; + } + List normalized = taskIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (normalized.isEmpty()) { + return result; + } + List keys = normalized.stream().map(this::buildTaskHeartbeatKey).toList(); + List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[product-risk-cache] batch get heartbeat degraded taskIds={} msg={}", normalized, ex.getMessage()); + return result; + } + for (int i = 0; i < normalized.size(); i++) { + String raw = values != null && i < values.size() ? values.get(i) : null; + if (raw == null || raw.isBlank()) { + result.put(normalized.get(i), 0L); + continue; + } + try { + result.put(normalized.get(i), Long.parseLong(raw)); + } catch (NumberFormatException ignored) { + result.put(normalized.get(i), 0L); + } + } + return result; + } + public void deleteTaskCache(Long taskId) { if (taskId == null || taskId <= 0) { return; } taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); - stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + try { + stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); + stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + } catch (Exception ex) { + log.warn("[product-risk-cache] delete cache degraded taskId={} msg={}", taskId, ex.getMessage()); + } taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); } @@ -126,7 +174,13 @@ public class ProductRiskTaskCacheService { return result; } java.util.List keys = missingIds.stream().map(this::buildTaskEntityKey).toList(); - java.util.List values = stringRedisTemplate.opsForValue().multiGet(keys); + java.util.List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[product-risk-cache] batch get task cache degraded taskIds={} msg={}", missingIds, ex.getMessage()); + return result; + } for (int i = 0; i < missingIds.size(); i++) { Long taskId = missingIds.get(i); String val = values != null && i < values.size() ? values.get(i) : null; @@ -144,10 +198,14 @@ public class ProductRiskTaskCacheService { } public void touchTaskHeartbeat(Long taskId) { - stringRedisTemplate.opsForValue().set( - buildTaskHeartbeatKey(taskId), - String.valueOf(Instant.now().toEpochMilli()), - Duration.ofHours(PAYLOAD_TTL_HOURS)); + try { + stringRedisTemplate.opsForValue().set( + buildTaskHeartbeatKey(taskId), + String.valueOf(Instant.now().toEpochMilli()), + Duration.ofHours(PAYLOAD_TTL_HOURS)); + } catch (Exception ex) { + log.warn("[product-risk-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + } } private String buildTaskHeartbeatKey(Long taskId) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java index bb00826..1744916 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java @@ -24,6 +24,11 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService; +import com.nanri.aiimage.modules.task.service.TaskResultItemService; +import com.nanri.aiimage.modules.task.service.TaskResultPayloadService; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.IdUtil; @@ -59,6 +64,10 @@ public class ProductRiskTaskService { private final ObjectMapper objectMapper; private final ProductRiskTaskCacheService productRiskTaskCacheService; private final TaskPressureProperties taskPressureProperties; + private final TaskResultPayloadService taskResultPayloadService; + private final TaskFileJobService taskFileJobService; + private final TaskResultItemService taskResultItemService; + private final TaskProgressSnapshotService taskProgressSnapshotService; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = productRiskTaskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -553,7 +562,7 @@ public class ProductRiskTaskService { } try { - assembleShopResult(fr, shopKey, mergedPayload, workRoot); + enqueueResultFileAssembly(fr, shopKey, mergedPayload); assembledCount++; log.info("[product-risk] shop assembled taskId={} shop={} rowCount={} file={}", taskId, shopKey, fr.getRowCount(), fr.getResultFilename()); @@ -698,7 +707,7 @@ public class ProductRiskTaskService { } try { - assembleShopResult(fr, shopKey, cachedPayload, workRoot); + enqueueResultFileAssembly(fr, shopKey, cachedPayload); productRiskTaskCacheService.removeShopMergedPayload(taskId, shopKey); changed = true; log.warn("[product-risk] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={} rowCount={}", @@ -778,6 +787,49 @@ public class ProductRiskTaskService { } } + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null || job.getResultId() == null || job.getScopeKey() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileResultEntity result = fileResultMapper.selectById(job.getResultId()); + if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { + throw new BusinessException("结果记录不存在"); + } + ProductRiskShopPayloadDto payload = taskResultPayloadService.getLatest( + job.getTaskId(), MODULE_TYPE, job.getScopeKey(), ProductRiskShopPayloadDto.class); + if (payload == null) { + payload = taskResultItemService.getResultSnapshot(job.getTaskId(), MODULE_TYPE, job.getResultId(), ProductRiskShopPayloadDto.class); + } + if (payload == null) { + throw new BusinessException("结果文件载荷不存在"); + } + File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "product-risk-result", String.valueOf(job.getTaskId()))); + assembleShopResult(result, job.getScopeKey(), payload, workRoot); + } + + private void enqueueResultFileAssembly(FileResultEntity result, String shopKey, ProductRiskShopPayloadDto payload) { + taskResultPayloadService.saveLatest(result.getTaskId(), MODULE_TYPE, shopKey, payload); + taskResultItemService.replaceResultSnapshot(result.getTaskId(), MODULE_TYPE, result.getId(), shopKey, payload); + markResultFilePending(result, shopKey, payload); + taskFileJobService.enqueueAssembleResult(result.getTaskId(), MODULE_TYPE, result.getId(), shopKey); + } + + private void markResultFilePending(FileResultEntity result, String shopKey, ProductRiskShopPayloadDto payload) { + Map> countries = excelAssemblyService.normalizeCountriesMap(payload.getCountries()); + String displayName = payload.getShopName() != null && !payload.getShopName().isBlank() + ? payload.getShopName().trim() + : shopKey; + String stem = safeFileStem(displayName); + result.setResultFilename(stem + ".zip"); + result.setResultFileUrl(null); + result.setResultFileSize(0L); + result.setResultContentType(CONTENT_TYPE_ZIP); + result.setRowCount(excelAssemblyService.countRows(countries)); + result.setSuccess(1); + result.setErrorMessage(null); + fileResultMapper.updateById(result); + } + private void updateTaskStatusFromLatestRows(FileTaskEntity task, List latest, List batchErrors) { @@ -826,6 +878,8 @@ public class ProductRiskTaskService { log.warn("[product-risk] compact result json failed: {}", ex.getMessage()); } fileTaskMapper.updateById(task); + taskProgressSnapshotService.save(task.getId(), MODULE_TYPE, task.getStatus(), + latest.size(), ok, fail, null, task.getErrorMessage(), null); log.warn("[product-risk] status updated from latest rows taskId={} oldStatus={} newStatus={} ok={} fail={} allDone={} batchErrors={} errorMessage={}", task.getId(), oldStatus, task.getStatus(), ok, fail, allDone, batchErrors, task.getErrorMessage()); cleanupTaskCacheIfTerminal(task.getId(), task.getStatus()); @@ -910,9 +964,8 @@ public class ProductRiskTaskService { vo.setSuccess(ok); vo.setError(entity.getErrorMessage()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank() - ? null - : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); + vo.setDownloadUrl(null); + attachFileJobState(vo, entity); vo.setMatched(true); if (entity.getTaskId() != null) { mergeQueueFieldsFromRequest(vo, entity.getTaskId()); @@ -920,6 +973,18 @@ public class ProductRiskTaskService { return vo; } + private void attachFileJobState(ProductRiskResultItemVo vo, FileResultEntity entity) { + TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()); + vo.setFileReady(entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank()); + if (job == null) { + vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); + return; + } + vo.setFileJobId(job.getId()); + vo.setFileStatus(job.getStatus()); + vo.setFileError(job.getErrorMessage()); + } + private void mergeQueueFieldsFromRequest(ProductRiskResultItemVo vo, Long taskId) { FileTaskEntity t = loadTaskForExecution(taskId); if (t == null || t.getRequestJson() == null || t.getRequestJson().isBlank()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/vo/QueryAsinResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/vo/QueryAsinResultItemVo.java index 14aa5d0..3a3d47c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/vo/QueryAsinResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/vo/QueryAsinResultItemVo.java @@ -43,6 +43,10 @@ public class QueryAsinResultItemVo { private LocalDateTime finishedAt; private String outputFilename; private String downloadUrl; + private Long fileJobId; + private String fileStatus; + private String fileError; + private Boolean fileReady; @JsonProperty("queryAsins") private List queryAsins = new ArrayList<>(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskCacheService.java index 4c8c4db..45afebb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskCacheService.java @@ -6,6 +6,7 @@ import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -18,6 +19,7 @@ import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor +@Slf4j public class QueryAsinTaskCacheService { private static final String MODULE_TYPE = "QUERY_ASIN"; @@ -60,7 +62,13 @@ public class QueryAsinTaskCacheService { if (taskId == null || taskId <= 0) { return 0L; } - String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + } catch (Exception ex) { + log.warn("[query-asin-cache] get heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + return 0L; + } if (raw == null || raw.isBlank()) { return 0L; } @@ -72,10 +80,14 @@ public class QueryAsinTaskCacheService { } public void touchTaskHeartbeat(Long taskId) { - stringRedisTemplate.opsForValue().set( - buildTaskHeartbeatKey(taskId), - String.valueOf(Instant.now().toEpochMilli()), - Duration.ofHours(PAYLOAD_TTL_HOURS)); + try { + stringRedisTemplate.opsForValue().set( + buildTaskHeartbeatKey(taskId), + String.valueOf(Instant.now().toEpochMilli()), + Duration.ofHours(PAYLOAD_TTL_HOURS)); + } catch (Exception ex) { + log.warn("[query-asin-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public void deleteTaskCache(Long taskId) { @@ -83,8 +95,12 @@ public class QueryAsinTaskCacheService { return; } taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); - stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + try { + stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); + stringRedisTemplate.delete(buildTaskEntityKey(taskId)); + } catch (Exception ex) { + log.warn("[query-asin-cache] delete cache degraded taskId={} msg={}", taskId, ex.getMessage()); + } taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); } @@ -133,7 +149,13 @@ public class QueryAsinTaskCacheService { return result; } java.util.List keys = missingIds.stream().map(this::buildTaskEntityKey).toList(); - java.util.List values = stringRedisTemplate.opsForValue().multiGet(keys); + java.util.List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[query-asin-cache] batch get task cache degraded taskIds={} msg={}", missingIds, ex.getMessage()); + return result; + } for (int i = 0; i < missingIds.size(); i++) { Long taskId = missingIds.get(i); String val = values != null && i < values.size() ? values.get(i) : null; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java index e9b1eb6..344a744 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java @@ -24,6 +24,10 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService; +import com.nanri.aiimage.modules.task.service.TaskResultItemService; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -56,6 +60,9 @@ public class QueryAsinTaskService { private final ZiniaoShopSwitchService ziniaoShopSwitchService; private final ObjectMapper objectMapper; private final TaskPressureProperties taskPressureProperties; + private final TaskFileJobService taskFileJobService; + private final TaskResultItemService taskResultItemService; + private final TaskProgressSnapshotService taskProgressSnapshotService; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -185,8 +192,7 @@ public class QueryAsinTaskService { batch.getMissingTaskIds().add(taskId); continue; } - List snapshots = buildSnapshotFromDb(task, taskRows); - batch.getItems().addAll(snapshots); + batch.getItems().addAll(buildProgressItems(task, taskRows)); } return batch; } @@ -502,7 +508,11 @@ public class QueryAsinTaskService { private Map> buildSnapshotMap(Map taskMap) { Map> out = new LinkedHashMap<>(); for (Map.Entry entry : taskMap.entrySet()) { - out.put(entry.getKey(), indexSnapshotByResultId(parseTaskSnapshots(entry.getValue().getResultJson()))); + List snapshots = taskResultItemService.listResultSnapshots(entry.getKey(), MODULE_TYPE, QueryAsinResultItemVo.class); + if (snapshots.isEmpty()) { + snapshots = parseTaskSnapshots(entry.getValue().getResultJson()); + } + out.put(entry.getKey(), indexSnapshotByResultId(snapshots)); } return out; } @@ -519,9 +529,8 @@ public class QueryAsinTaskService { item.setCreatedAt(entity.getCreatedAt()); item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt()); item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename())); - item.setDownloadUrl(blank(entity.getResultFileUrl()) - ? item.getDownloadUrl() - : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); + item.setDownloadUrl(null); + attachFileJobState(item, entity); if (item.getCountryResults() == null) { item.setCountryResults(new ArrayList<>()); } @@ -531,6 +540,18 @@ public class QueryAsinTaskService { return item; } + private void attachFileJobState(QueryAsinResultItemVo item, FileResultEntity entity) { + TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()); + item.setFileReady(!blank(entity.getResultFileUrl())); + if (job == null) { + item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null); + return; + } + item.setFileJobId(job.getId()); + item.setFileStatus(job.getStatus()); + item.setFileError(job.getErrorMessage()); + } + private List dedupeItems(List items) { LinkedHashMap map = new LinkedHashMap<>(); for (QueryAsinTaskItemDto item : items) { @@ -575,6 +596,7 @@ public class QueryAsinTaskService { try { task.setRequestJson(objectMapper.writeValueAsString(requestItems)); task.setResultJson(objectMapper.writeValueAsString(snapshots)); + syncSnapshotTables(task, snapshots); fileTaskMapper.updateById(task); } catch (Exception ex) { throw new BusinessException("查询ASIN任务快照保存失败"); @@ -591,6 +613,27 @@ public class QueryAsinTaskService { return list; } + private List buildProgressItems(FileTaskEntity task, List rows) { + List list = new ArrayList<>(); + for (FileResultEntity row : rows) { + QueryAsinResultItemVo item = new QueryAsinResultItemVo(); + item.setResultId(row.getId()); + item.setTaskId(row.getTaskId()); + item.setShopName(row.getSourceFilename()); + item.setShopId(row.getSourceFileUrl()); + item.setTaskStatus(task == null ? null : task.getStatus()); + item.setSuccess(row.getSuccess() == null ? null : row.getSuccess() == 1); + item.setError(row.getErrorMessage()); + item.setCreatedAt(row.getCreatedAt()); + item.setFinishedAt(task == null ? null : task.getFinishedAt()); + item.setOutputFilename(row.getResultFilename()); + item.setDownloadUrl(null); + attachFileJobState(item, row); + list.add(item); + } + return list; + } + private Map indexSnapshotByResultId(List snapshots) { Map map = new LinkedHashMap<>(); if (snapshots == null) { @@ -773,27 +816,25 @@ public class QueryAsinTaskService { .filter(item -> Boolean.TRUE.equals(item.getSuccess())) .toList(); if (!successItems.isEmpty()) { - File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "query-asin-result", String.valueOf(task.getId()))); - String filename = safeFileStem("查询ASIN-" + task.getId()) + ".xlsx"; - File xlsx = FileUtil.file(workRoot, filename); - try { - excelAssemblyService.writeWorkbook(xlsx, successItems); - String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); - long fileSize = xlsx.length(); - int rowCount = excelAssemblyService.countRows(successItems); + String filename = safeFileStem("query-asin-" + task.getId()) + ".xlsx"; + int rowCount = excelAssemblyService.countRows(successItems); + FileResultEntity firstSuccessRow = null; for (FileResultEntity row : rows) { if (Integer.valueOf(1).equals(row.getSuccess())) { row.setResultFilename(filename); - row.setResultFileUrl(objectKey); - row.setResultFileSize(fileSize); + row.setResultFileUrl(null); + row.setResultFileSize(0L); row.setResultContentType(CONTENT_TYPE_XLSX); row.setRowCount(rowCount); fileResultMapper.updateById(row); + if (firstSuccessRow == null) { + firstSuccessRow = row; + } } } + if (firstSuccessRow != null) { + taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, firstSuccessRow.getId(), "task:" + task.getId()); snapshots = buildSnapshotFromDb(task, rows); - } finally { - FileUtil.del(xlsx); } } @@ -802,6 +843,48 @@ public class QueryAsinTaskService { taskCacheService.deleteTaskCache(task.getId()); } + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileTaskEntity task = fileTaskMapper.selectById(job.getTaskId()); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + List rows = listTaskRows(job.getTaskId()); + List snapshots = taskResultItemService.listResultSnapshots(job.getTaskId(), MODULE_TYPE, QueryAsinResultItemVo.class); + if (snapshots.isEmpty()) { + snapshots = buildSnapshotFromDb(task, rows); + } + List successItems = snapshots.stream() + .filter(item -> Boolean.TRUE.equals(item.getSuccess())) + .toList(); + if (successItems.isEmpty()) { + throw new BusinessException("没有可生成的查询ASIN结果"); + } + File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "query-asin-result", String.valueOf(task.getId()))); + String filename = safeFileStem("query-asin-" + task.getId()) + ".xlsx"; + File xlsx = FileUtil.file(workRoot, filename); + try { + excelAssemblyService.writeWorkbook(xlsx, successItems); + String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE); + long fileSize = xlsx.length(); + int rowCount = excelAssemblyService.countRows(successItems); + for (FileResultEntity row : rows) { + if (Integer.valueOf(1).equals(row.getSuccess())) { + row.setResultFilename(filename); + row.setResultFileUrl(objectKey); + row.setResultFileSize(fileSize); + row.setResultContentType(CONTENT_TYPE_XLSX); + row.setRowCount(rowCount); + fileResultMapper.updateById(row); + } + } + } finally { + FileUtil.del(xlsx); + } + } + private void markResultSuccess(FileResultEntity row) { row.setSuccess(1); row.setErrorMessage(null); @@ -838,11 +921,39 @@ public class QueryAsinTaskService { private void persistSnapshotJson(FileTaskEntity task, List snapshots) { try { task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots)); + syncSnapshotTables(task, snapshots); } catch (Exception ex) { throw new BusinessException("查询ASIN任务快照保存失败"); } } + private void syncSnapshotTables(FileTaskEntity task, List snapshots) { + List safe = snapshots == null ? List.of() : snapshots; + taskResultItemService.replaceTaskSnapshots(task.getId(), MODULE_TYPE, safe, new TaskResultItemService.SnapshotKeyResolver() { + @Override + public Long resultId(Object snapshot) { + return ((QueryAsinResultItemVo) snapshot).getResultId(); + } + + @Override + public String scopeKey(Object snapshot) { + QueryAsinResultItemVo item = (QueryAsinResultItemVo) snapshot; + return firstNonBlank(item.getShopName(), "result:" + item.getResultId()); + } + }); + int successCount = 0; + int failedCount = 0; + for (QueryAsinResultItemVo item : safe) { + if (Boolean.TRUE.equals(item.getSuccess())) { + successCount++; + } else if (Boolean.FALSE.equals(item.getSuccess())) { + failedCount++; + } + } + taskProgressSnapshotService.save(task.getId(), MODULE_TYPE, firstNonBlank(task.getStatus(), "RUNNING"), + safe.size(), successCount, failedCount, null, task.getErrorMessage(), null); + } + private List copyCountryResults(List results) { List copy = new ArrayList<>(); if (results == null) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java index b823dc8..a186396 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java @@ -18,6 +18,7 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -38,17 +39,27 @@ public class ShopMatchTaskCacheService { if (taskId == null || taskId <= 0) { return; } - stringRedisTemplate.opsForValue().set( - buildTaskHeartbeatKey(taskId), - String.valueOf(Instant.now().toEpochMilli()), - Duration.ofHours(PAYLOAD_TTL_HOURS)); + try { + stringRedisTemplate.opsForValue().set( + buildTaskHeartbeatKey(taskId), + String.valueOf(Instant.now().toEpochMilli()), + Duration.ofHours(PAYLOAD_TTL_HOURS)); + } catch (Exception ex) { + log.warn("[shop-match-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + } } public long getTaskHeartbeatMillis(Long taskId) { if (taskId == null || taskId <= 0) { return 0L; } - String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + } catch (Exception ex) { + log.warn("[shop-match-cache] get heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + return 0L; + } if (raw == null || raw.isBlank()) { return 0L; } @@ -59,6 +70,41 @@ public class ShopMatchTaskCacheService { } } + public Map getTaskHeartbeatMillisBatch(List taskIds) { + Map result = new LinkedHashMap<>(); + if (taskIds == null || taskIds.isEmpty()) { + return result; + } + List normalized = taskIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (normalized.isEmpty()) { + return result; + } + List keys = normalized.stream().map(this::buildTaskHeartbeatKey).toList(); + List values; + try { + values = stringRedisTemplate.opsForValue().multiGet(keys); + } catch (Exception ex) { + log.warn("[shop-match-cache] batch get heartbeat degraded taskIds={} msg={}", normalized, ex.getMessage()); + return result; + } + for (int i = 0; i < normalized.size(); i++) { + String raw = values != null && i < values.size() ? values.get(i) : null; + if (raw == null || raw.isBlank()) { + result.put(normalized.get(i), 0L); + continue; + } + try { + result.put(normalized.get(i), Long.parseLong(raw)); + } catch (NumberFormatException ignored) { + result.put(normalized.get(i), 0L); + } + } + return result; + } + public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) { return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, ShopMatchShopPayloadDto.class); } @@ -88,7 +134,11 @@ public class ShopMatchTaskCacheService { return; } taskEntityLocalCache.remove(taskId); - stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); + try { + stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); + } catch (Exception ex) { + log.warn("[shop-match-cache] delete heartbeat degraded taskId={} msg={}", taskId, ex.getMessage()); + } taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); try { Path taskDir = buildTaskDir(taskId); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java index 32f33f0..4f18f2d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java @@ -28,6 +28,9 @@ import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import com.nanri.aiimage.modules.task.service.TaskResultPayloadService; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -62,6 +65,8 @@ public class ShopMatchTaskService { private final ObjectMapper objectMapper; private final ShopMatchTaskCacheService shopMatchTaskCacheService; private final TaskPressureProperties taskPressureProperties; + private final TaskResultPayloadService taskResultPayloadService; + private final TaskFileJobService taskFileJobService; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId)); @@ -409,6 +414,12 @@ public class ShopMatchTaskService { if (state.getScheduleTimes() == null || stageIndex >= state.getScheduleTimes().size()) { throw new BusinessException("轮次配置缺失"); } + FileTaskEntity runningTask = findOtherRunningTask(userId, taskId); + if (runningTask != null) { + log.warn("[shop-match] activate serialized taskId={} stageIndex={} blockedByTaskId={} blockedUpdatedAt={}", + taskId, stageIndex, runningTask.getId(), runningTask.getUpdatedAt()); + throw new BusinessException("当前已有定时匹配任务执行中,请等待上一任务执行结束后再重试"); + } state.setActiveStageIndex(stageIndex); task.setStatus("RUNNING"); task.setUpdatedAt(now); @@ -511,7 +522,7 @@ public class ShopMatchTaskService { continue; } try { - assembleShopResult(result, shopKey, merged, workRoot); + enqueueResultFileAssembly(result, shopKey, merged); shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey); } catch (Exception ex) { markResultFailed(result, ex.getMessage() == null ? "结果组装失败" : ex.getMessage()); @@ -603,7 +614,7 @@ public class ShopMatchTaskService { } try { - assembleShopResult(result, shopKey, cachedPayload, workRoot); + enqueueResultFileAssembly(result, shopKey, cachedPayload); shopMatchTaskCacheService.removeShopMergedPayload(taskId, shopKey); changed = true; log.warn("[shop-match] stale finalize assembled taskId={} shop={} fromCompensation={} completedFlag={} rowCount={}", @@ -651,6 +662,43 @@ public class ShopMatchTaskService { } } + public void processResultFileJob(TaskFileJobEntity job) { + if (job == null || job.getTaskId() == null || job.getResultId() == null || job.getScopeKey() == null) { + throw new BusinessException("结果文件任务参数不完整"); + } + FileResultEntity result = fileResultMapper.selectById(job.getResultId()); + if (result == null || !MODULE_TYPE.equals(result.getModuleType())) { + throw new BusinessException("结果记录不存在"); + } + ShopMatchShopPayloadDto payload = taskResultPayloadService.getLatest( + job.getTaskId(), MODULE_TYPE, job.getScopeKey(), ShopMatchShopPayloadDto.class); + if (payload == null) { + throw new BusinessException("结果文件载荷不存在"); + } + File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "shop-match-result", String.valueOf(job.getTaskId()))); + assembleShopResult(result, job.getScopeKey(), payload, workRoot); + } + + private void enqueueResultFileAssembly(FileResultEntity result, String shopKey, ShopMatchShopPayloadDto payload) { + taskResultPayloadService.saveLatest(result.getTaskId(), MODULE_TYPE, shopKey, payload); + markResultFilePending(result, shopKey, payload); + taskFileJobService.enqueueAssembleResult(result.getTaskId(), MODULE_TYPE, result.getId(), shopKey); + } + + private void markResultFilePending(FileResultEntity result, String shopKey, ShopMatchShopPayloadDto payload) { + Map> countries = excelAssemblyService.normalizeCountriesMap(payload.getCountries()); + String displayName = payload.getShopName() != null && !payload.getShopName().isBlank() ? payload.getShopName().trim() : shopKey; + String stem = safeFileStem(displayName); + result.setResultFilename(stem + ".xlsx"); + result.setResultFileUrl(null); + result.setResultFileSize(0L); + result.setResultContentType(CONTENT_TYPE_XLSX); + result.setRowCount(excelAssemblyService.countRows(countries)); + result.setSuccess(1); + result.setErrorMessage(null); + fileResultMapper.updateById(result); + } + private void markResultFailed(FileResultEntity result, String message) { result.setSuccess(0); result.setErrorMessage(message); @@ -816,9 +864,8 @@ public class ShopMatchTaskService { vo.setSuccess(success); vo.setError(entity.getErrorMessage()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank() - ? null - : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); + vo.setDownloadUrl(null); + attachFileJobState(vo, entity); vo.setMatched(true); if (entity.getTaskId() != null) { mergeQueueFieldsFromRequest(vo, entity.getTaskId()); @@ -826,6 +873,18 @@ public class ShopMatchTaskService { return vo; } + private void attachFileJobState(ProductRiskResultItemVo vo, FileResultEntity entity) { + TaskFileJobEntity job = taskFileJobService.findAssembleJob(entity.getTaskId(), MODULE_TYPE, entity.getId()); + vo.setFileReady(entity.getResultFileUrl() != null && !entity.getResultFileUrl().isBlank()); + if (job == null) { + vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); + return; + } + vo.setFileJobId(job.getId()); + vo.setFileStatus(job.getStatus()); + vo.setFileError(job.getErrorMessage()); + } + private List buildStageVos(FileTaskEntity task, ShopMatchCreateTaskRequest request) { List stages = new ArrayList<>(); List scheduleTimes = request.getScheduleTimes() == null ? List.of() : request.getScheduleTimes(); @@ -942,6 +1001,19 @@ public class ShopMatchTaskService { shopMatchTaskCacheService.saveTaskCache(task); } + private FileTaskEntity findOtherRunningTask(Long userId, Long taskId) { + if (userId == null || userId <= 0) { + return null; + } + return fileTaskMapper.selectOne(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getUserId, userId) + .eq(FileTaskEntity::getStatus, "RUNNING") + .ne(taskId != null, FileTaskEntity::getId, taskId) + .orderByAsc(FileTaskEntity::getUpdatedAt) + .last("limit 1")); + } + private static String fmt(LocalDateTime time) { return time == null ? null : time.toString(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java index c47f882..474e36e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java @@ -173,9 +173,7 @@ public class SplitRunService { vo.setResultId(entity.getId()); vo.setSourceFilename(entity.getSourceFilename()); vo.setOutputFilename(entity.getResultFilename()); - vo.setDownloadUrl(entity.getSuccess() != null && entity.getSuccess() == 1 - ? ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()) - : null); + vo.setDownloadUrl(null); vo.setRowCount(entity.getRowCount()); vo.setSuccess(entity.getSuccess() != null && entity.getSuccess() == 1); vo.setError(entity.getErrorMessage()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskFileJobController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskFileJobController.java new file mode 100644 index 0000000..3217cb2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskFileJobController.java @@ -0,0 +1,52 @@ +package com.nanri.aiimage.modules.task.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskFileJobService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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 java.util.List; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/task-file-jobs") +@Tag(name = "任务结果文件 Job") +public class TaskFileJobController { + + private final TaskFileJobService taskFileJobService; + + @GetMapping + @Operation(summary = "查询结果文件生成 Job") + public ApiResponse> list( + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "module_type", required = false) String moduleType, + @RequestParam(value = "task_id", required = false) Long taskId, + @RequestParam(value = "limit", defaultValue = "100") int limit) { + return ApiResponse.success(taskFileJobService.listJobs(status, moduleType, taskId, limit)); + } + + @PostMapping("/{jobId}/retry") + @Operation(summary = "手动重试结果文件生成 Job") + public ApiResponse> retry(@PathVariable Long jobId) { + boolean updated = taskFileJobService.retry(jobId); + return ApiResponse.success(Map.of("jobId", jobId, "updated", updated)); + } + + @PostMapping("/stuck/reset") + @Operation(summary = "手动扫描并复位卡住的 RUNNING Job") + public ApiResponse> resetStuck( + @RequestParam(value = "timeout_minutes", defaultValue = "30") int timeoutMinutes, + @RequestParam(value = "limit", defaultValue = "100") int limit) { + int reset = taskFileJobService.resetStuckRunningJobs(timeoutMinutes, limit); + return ApiResponse.success(Map.of("reset", reset)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskFileJobMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskFileJobMapper.java new file mode 100644 index 0000000..af5dfea --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskFileJobMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskFileJobMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskProgressSnapshotMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskProgressSnapshotMapper.java new file mode 100644 index 0000000..b931e69 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskProgressSnapshotMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskProgressSnapshotMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskResultItemMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskResultItemMapper.java new file mode 100644 index 0000000..b0fef8c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskResultItemMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskResultItemMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskResultPayloadMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskResultPayloadMapper.java new file mode 100644 index 0000000..fda1f17 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/TaskResultPayloadMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskResultPayloadEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TaskResultPayloadMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskFileJobDispatchEvent.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskFileJobDispatchEvent.java new file mode 100644 index 0000000..2145d47 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskFileJobDispatchEvent.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.task.model.dto; + +import java.time.LocalDateTime; + +public record TaskFileJobDispatchEvent( + Long jobId, + Long taskId, + String moduleType, + Long resultId, + String scopeKey, + String jobType, + LocalDateTime createdAt +) { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskFileJobMessage.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskFileJobMessage.java new file mode 100644 index 0000000..9b5465b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskFileJobMessage.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.task.model.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class TaskFileJobMessage { + private Long jobId; + private Long taskId; + private String moduleType; + private Long resultId; + private String scopeKey; + private String jobType; + private LocalDateTime createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskFileJobEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskFileJobEntity.java new file mode 100644 index 0000000..9f95af6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskFileJobEntity.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.task.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_task_file_job") +public class TaskFileJobEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private String moduleType; + private Long resultId; + private String scopeKey; + private String jobType; + private String status; + private Integer retryCount; + private String errorMessage; + private String resultFileUrl; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime finishedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskProgressSnapshotEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskProgressSnapshotEntity.java new file mode 100644 index 0000000..3a23c21 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskProgressSnapshotEntity.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.task.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_task_progress_snapshot") +public class TaskProgressSnapshotEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private String moduleType; + private String status; + private Integer totalCount; + private Integer successCount; + private Integer failedCount; + private Integer pendingCount; + private String currentScopeKey; + private String message; + private String snapshotJson; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskResultItemEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskResultItemEntity.java new file mode 100644 index 0000000..6562f90 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskResultItemEntity.java @@ -0,0 +1,29 @@ +package com.nanri.aiimage.modules.task.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_task_result_item") +public class TaskResultItemEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private String moduleType; + private Long resultId; + private String scopeKey; + private String scopeHash; + private String itemKey; + private String countryCode; + private String asin; + private String status; + private String payloadJson; + private String payloadHash; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskResultPayloadEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskResultPayloadEntity.java new file mode 100644 index 0000000..711b6e1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskResultPayloadEntity.java @@ -0,0 +1,26 @@ +package com.nanri.aiimage.modules.task.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_task_result_payload") +public class TaskResultPayloadEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private String moduleType; + private String scopeKey; + private String scopeHash; + private String submissionId; + private String payloadJson; + private String payloadHash; + private Long version; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobConsumer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobConsumer.java new file mode 100644 index 0000000..b340bcb --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobConsumer.java @@ -0,0 +1,39 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper; +import com.nanri.aiimage.modules.task.model.dto.TaskFileJobMessage; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.annotation.RocketMQMessageListener; +import org.apache.rocketmq.spring.core.RocketMQListener; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "aiimage.result-file-job", name = "mq-enabled", havingValue = "true") +@RocketMQMessageListener( + topic = "${aiimage.result-file-job.topic:aiimage-result-file-job}", + consumerGroup = "${aiimage.result-file-job.consumer-group:aiimage-result-file-job-consumer}" +) +public class TaskFileJobConsumer implements RocketMQListener { + + private final TaskFileJobMapper taskFileJobMapper; + private final TaskResultFileJobWorker taskResultFileJobWorker; + + @Override + public void onMessage(TaskFileJobMessage message) { + if (message == null || message.getJobId() == null) { + return; + } + TaskFileJobEntity job = taskFileJobMapper.selectById(message.getJobId()); + if (job == null) { + log.warn("[task-file-job] mq message ignored, job not found jobId={} taskId={} moduleType={}", + message.getJobId(), message.getTaskId(), message.getModuleType()); + return; + } + taskResultFileJobWorker.process(job); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobDispatchCoordinator.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobDispatchCoordinator.java new file mode 100644 index 0000000..f797853 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobDispatchCoordinator.java @@ -0,0 +1,42 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent; +import com.nanri.aiimage.modules.task.model.dto.TaskFileJobMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class TaskFileJobDispatchCoordinator { + + private final TaskFileJobPublisher taskFileJobPublisher; + private final TaskFileJobLocalDispatcher taskFileJobLocalDispatcher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onTaskFileJobDispatch(TaskFileJobDispatchEvent event) { + if (event == null || event.jobId() == null) { + return; + } + TaskFileJobMessage message = new TaskFileJobMessage(); + message.setJobId(event.jobId()); + message.setTaskId(event.taskId()); + message.setModuleType(event.moduleType()); + message.setResultId(event.resultId()); + message.setScopeKey(event.scopeKey()); + message.setJobType(event.jobType()); + message.setCreatedAt(event.createdAt()); + boolean published = taskFileJobPublisher.publish(message); + if (published) { + return; + } + boolean dispatched = taskFileJobLocalDispatcher.dispatch(event.jobId(), event.taskId(), event.moduleType()); + if (dispatched) { + log.info("[task-file-job] local async dispatch scheduled jobId={} taskId={} moduleType={}", + event.jobId(), event.taskId(), event.moduleType()); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobLocalDispatcher.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobLocalDispatcher.java new file mode 100644 index 0000000..9cfea22 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobLocalDispatcher.java @@ -0,0 +1,53 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskFileJobLocalDispatcher { + + private final TaskFileJobMapper taskFileJobMapper; + private final ObjectProvider taskResultFileJobWorkerProvider; + + @Qualifier("taskFileJobDispatchExecutor") + private final TaskExecutor taskFileJobDispatchExecutor; + + @Value("${aiimage.result-file-job.local-dispatch-enabled:true}") + private boolean localDispatchEnabled; + + public boolean dispatch(Long jobId, Long taskId, String moduleType) { + if (!localDispatchEnabled || jobId == null || jobId <= 0) { + return false; + } + taskFileJobDispatchExecutor.execute(() -> { + try { + TaskFileJobEntity job = taskFileJobMapper.selectById(jobId); + if (job == null) { + log.warn("[task-file-job] local dispatch ignored, job not found jobId={} taskId={} moduleType={}", + jobId, taskId, moduleType); + return; + } + TaskResultFileJobWorker taskResultFileJobWorker = taskResultFileJobWorkerProvider.getIfAvailable(); + if (taskResultFileJobWorker == null) { + log.warn("[task-file-job] local dispatch ignored, worker unavailable jobId={} taskId={} moduleType={}", + jobId, taskId, moduleType); + return; + } + taskResultFileJobWorker.process(job); + } catch (Exception ex) { + log.warn("[task-file-job] local dispatch failed jobId={} taskId={} moduleType={} msg={}", + jobId, taskId, moduleType, ex.getMessage(), ex); + } + }); + return true; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobPublisher.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobPublisher.java new file mode 100644 index 0000000..3d06a59 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobPublisher.java @@ -0,0 +1,50 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.model.dto.TaskFileJobMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.rocketmq.spring.core.RocketMQTemplate; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskFileJobPublisher { + + private final ObjectProvider rocketMQTemplateProvider; + + @Value("${aiimage.result-file-job.mq-enabled:false}") + private boolean mqEnabled; + + @Value("${aiimage.result-file-job.topic:aiimage-result-file-job}") + private String topic; + + public boolean publish(TaskFileJobMessage message) { + if (message == null || message.getJobId() == null) { + return false; + } + if (!mqEnabled) { + log.info("[task-file-job] mq disabled, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={}", + message.getJobId(), message.getTaskId(), message.getModuleType()); + return false; + } + RocketMQTemplate template = rocketMQTemplateProvider.getIfAvailable(); + if (template == null) { + log.warn("[task-file-job] RocketMQTemplate unavailable, jobId={} taskId={} moduleType={}", + message.getJobId(), message.getTaskId(), message.getModuleType()); + return false; + } + try { + template.convertAndSend(topic, message); + log.info("[task-file-job] mq published jobId={} taskId={} moduleType={} topic={}", + message.getJobId(), message.getTaskId(), message.getModuleType(), topic); + return true; + } catch (Exception ex) { + log.warn("[task-file-job] publish RocketMQ failed, fallback to local dispatcher/scheduled worker jobId={} taskId={} moduleType={} msg={}", + message.getJobId(), message.getTaskId(), message.getModuleType(), ex.getMessage()); + return false; + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java new file mode 100644 index 0000000..65358e7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java @@ -0,0 +1,225 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper; +import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TaskFileJobService { + + public static final String JOB_TYPE_ASSEMBLE_RESULT = "ASSEMBLE_RESULT"; + + private final TaskFileJobMapper taskFileJobMapper; + private final ApplicationEventPublisher applicationEventPublisher; + + @Transactional + public TaskFileJobEntity enqueueAssembleResult(Long taskId, String moduleType, Long resultId, String scopeKey) { + if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank() || resultId == null || resultId <= 0) { + return null; + } + LocalDateTime now = LocalDateTime.now(); + TaskFileJobEntity existing = findJob(taskId, moduleType, resultId, JOB_TYPE_ASSEMBLE_RESULT); + if (existing == null) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setResultId(resultId); + entity.setScopeKey(scopeKey); + entity.setJobType(JOB_TYPE_ASSEMBLE_RESULT); + entity.setStatus("PENDING"); + entity.setRetryCount(0); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskFileJobMapper.insert(entity); + publishDispatchEvent(entity); + return entity; + } catch (DuplicateKeyException ignored) { + existing = findJob(taskId, moduleType, resultId, JOB_TYPE_ASSEMBLE_RESULT); + } + } + if (existing == null) { + return null; + } + if ("SUCCESS".equals(existing.getStatus()) || "RUNNING".equals(existing.getStatus())) { + return existing; + } + taskFileJobMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskFileJobEntity::getId, existing.getId()) + .set(TaskFileJobEntity::getScopeKey, scopeKey) + .set(TaskFileJobEntity::getStatus, "PENDING") + .set(TaskFileJobEntity::getErrorMessage, null) + .set(TaskFileJobEntity::getUpdatedAt, now) + .set(TaskFileJobEntity::getFinishedAt, null)); + TaskFileJobEntity refreshed = taskFileJobMapper.selectById(existing.getId()); + publishDispatchEvent(refreshed); + return refreshed; + } + + public List listRunnableJobs(int limit) { + return taskFileJobMapper.selectList(new LambdaQueryWrapper() + .in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED")) + .lt(TaskFileJobEntity::getRetryCount, 5) + .orderByAsc(TaskFileJobEntity::getUpdatedAt) + .last("limit " + Math.max(1, Math.min(limit, 100)))); + } + + public List listJobs(String status, String moduleType, Long taskId, int limit) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByDesc(TaskFileJobEntity::getUpdatedAt) + .last("limit " + Math.max(1, Math.min(limit, 500))); + if (status != null && !status.isBlank()) { + wrapper.eq(TaskFileJobEntity::getStatus, status.trim()); + } + if (moduleType != null && !moduleType.isBlank()) { + wrapper.eq(TaskFileJobEntity::getModuleType, moduleType.trim()); + } + if (taskId != null && taskId > 0) { + wrapper.eq(TaskFileJobEntity::getTaskId, taskId); + } + return taskFileJobMapper.selectList(wrapper); + } + + @Transactional + public boolean retry(Long jobId) { + if (jobId == null || jobId <= 0) { + return false; + } + int updated = taskFileJobMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskFileJobEntity::getId, jobId) + .ne(TaskFileJobEntity::getStatus, "SUCCESS") + .set(TaskFileJobEntity::getStatus, "PENDING") + .set(TaskFileJobEntity::getRetryCount, 0) + .set(TaskFileJobEntity::getErrorMessage, null) + .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()) + .set(TaskFileJobEntity::getFinishedAt, null)); + if (updated > 0) { + TaskFileJobEntity refreshed = taskFileJobMapper.selectById(jobId); + publishDispatchEvent(refreshed); + return true; + } + return false; + } + + @Transactional + public int resetStuckRunningJobs(int stuckMinutes, int limit) { + LocalDateTime threshold = LocalDateTime.now().minusMinutes(Math.max(5, stuckMinutes)); + List jobs = taskFileJobMapper.selectList(new LambdaQueryWrapper() + .eq(TaskFileJobEntity::getStatus, "RUNNING") + .lt(TaskFileJobEntity::getUpdatedAt, threshold) + .orderByAsc(TaskFileJobEntity::getUpdatedAt) + .last("limit " + Math.max(1, Math.min(limit, 200)))); + int reset = 0; + for (TaskFileJobEntity job : jobs) { + int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount(); + if (retryCount >= 5) { + taskFileJobMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskFileJobEntity::getId, job.getId()) + .eq(TaskFileJobEntity::getStatus, "RUNNING") + .set(TaskFileJobEntity::getStatus, "FAILED") + .set(TaskFileJobEntity::getErrorMessage, "文件生成任务运行超时") + .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())); + continue; + } + int updated = taskFileJobMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskFileJobEntity::getId, job.getId()) + .eq(TaskFileJobEntity::getStatus, "RUNNING") + .set(TaskFileJobEntity::getStatus, "PENDING") + .set(TaskFileJobEntity::getErrorMessage, "文件生成任务运行超时,已重新排队") + .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())); + if (updated > 0) { + reset++; + publishDispatchEvent(taskFileJobMapper.selectById(job.getId())); + } + } + return reset; + } + + @Transactional + public boolean markRunning(Long jobId) { + if (jobId == null || jobId <= 0) { + return false; + } + return taskFileJobMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskFileJobEntity::getId, jobId) + .in(TaskFileJobEntity::getStatus, List.of("PENDING", "FAILED")) + .set(TaskFileJobEntity::getStatus, "RUNNING") + .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())) > 0; + } + + @Transactional + public void markSuccess(TaskFileJobEntity job, String resultFileUrl) { + taskFileJobMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskFileJobEntity::getId, job.getId()) + .set(TaskFileJobEntity::getStatus, "SUCCESS") + .set(TaskFileJobEntity::getErrorMessage, null) + .set(TaskFileJobEntity::getResultFileUrl, resultFileUrl) + .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now()) + .set(TaskFileJobEntity::getFinishedAt, LocalDateTime.now())); + } + + @Transactional + public void markFailed(TaskFileJobEntity job, String message) { + int retryCount = job.getRetryCount() == null ? 0 : job.getRetryCount() + 1; + taskFileJobMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskFileJobEntity::getId, job.getId()) + .set(TaskFileJobEntity::getStatus, "FAILED") + .set(TaskFileJobEntity::getRetryCount, retryCount) + .set(TaskFileJobEntity::getErrorMessage, message == null ? "结果文件生成失败" : message) + .set(TaskFileJobEntity::getUpdatedAt, LocalDateTime.now())); + } + + public TaskFileJobEntity findAssembleJob(Long taskId, String moduleType, Long resultId) { + return findJob(taskId, moduleType, resultId, JOB_TYPE_ASSEMBLE_RESULT); + } + + public long countUnfinishedAssembleJobs(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank()) { + return 0L; + } + Long count = taskFileJobMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskFileJobEntity::getTaskId, taskId) + .eq(TaskFileJobEntity::getModuleType, moduleType) + .eq(TaskFileJobEntity::getJobType, JOB_TYPE_ASSEMBLE_RESULT) + .ne(TaskFileJobEntity::getStatus, "SUCCESS")); + return count == null ? 0L : count; + } + + private TaskFileJobEntity findJob(Long taskId, String moduleType, Long resultId, String jobType) { + if (taskId == null || moduleType == null || moduleType.isBlank() || resultId == null || jobType == null || jobType.isBlank()) { + return null; + } + return taskFileJobMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskFileJobEntity::getTaskId, taskId) + .eq(TaskFileJobEntity::getModuleType, moduleType) + .eq(TaskFileJobEntity::getResultId, resultId) + .eq(TaskFileJobEntity::getJobType, jobType) + .last("limit 1")); + } + + private void publishDispatchEvent(TaskFileJobEntity entity) { + if (entity == null || entity.getId() == null) { + return; + } + applicationEventPublisher.publishEvent(new TaskFileJobDispatchEvent( + entity.getId(), + entity.getTaskId(), + entity.getModuleType(), + entity.getResultId(), + entity.getScopeKey(), + entity.getJobType(), + LocalDateTime.now() + )); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressSnapshotService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressSnapshotService.java new file mode 100644 index 0000000..d310ab5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressSnapshotService.java @@ -0,0 +1,98 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.task.mapper.TaskProgressSnapshotMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class TaskProgressSnapshotService { + + private final TaskProgressSnapshotMapper taskProgressSnapshotMapper; + private final ObjectMapper objectMapper; + + @Transactional + public void save(Long taskId, + String moduleType, + String status, + int totalCount, + int successCount, + int failedCount, + String currentScopeKey, + String message, + Object snapshot) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(status)) { + return; + } + int pendingCount = Math.max(0, totalCount - successCount - failedCount); + String snapshotJson = snapshot == null ? null : writeJson(snapshot); + LocalDateTime now = LocalDateTime.now(); + TaskProgressSnapshotEntity existing = find(taskId, moduleType); + if (existing == null) { + TaskProgressSnapshotEntity entity = new TaskProgressSnapshotEntity(); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setStatus(status); + entity.setTotalCount(totalCount); + entity.setSuccessCount(successCount); + entity.setFailedCount(failedCount); + entity.setPendingCount(pendingCount); + entity.setCurrentScopeKey(currentScopeKey); + entity.setMessage(message); + entity.setSnapshotJson(snapshotJson); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskProgressSnapshotMapper.insert(entity); + return; + } catch (DuplicateKeyException ignored) { + existing = find(taskId, moduleType); + } + } + if (existing == null) { + throw new BusinessException("保存任务进度快照失败"); + } + taskProgressSnapshotMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskProgressSnapshotEntity::getId, existing.getId()) + .set(TaskProgressSnapshotEntity::getStatus, status) + .set(TaskProgressSnapshotEntity::getTotalCount, totalCount) + .set(TaskProgressSnapshotEntity::getSuccessCount, successCount) + .set(TaskProgressSnapshotEntity::getFailedCount, failedCount) + .set(TaskProgressSnapshotEntity::getPendingCount, pendingCount) + .set(TaskProgressSnapshotEntity::getCurrentScopeKey, currentScopeKey) + .set(TaskProgressSnapshotEntity::getMessage, message) + .set(TaskProgressSnapshotEntity::getSnapshotJson, snapshotJson) + .set(TaskProgressSnapshotEntity::getUpdatedAt, now)); + } + + public TaskProgressSnapshotEntity find(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return null; + } + return taskProgressSnapshotMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskProgressSnapshotEntity::getTaskId, taskId) + .eq(TaskProgressSnapshotEntity::getModuleType, moduleType) + .last("limit 1")); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException("序列化任务进度快照失败"); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java new file mode 100644 index 0000000..2a83f7f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java @@ -0,0 +1,166 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TaskResultFileJobWorker { + + private final TaskFileJobService taskFileJobService; + private final TaskResultPayloadService taskResultPayloadService; + private final FileResultMapper fileResultMapper; + private final TaskFileJobLocalDispatcher taskFileJobLocalDispatcher; + private final ShopMatchTaskService shopMatchTaskService; + private final PriceTrackTaskService priceTrackTaskService; + private final ProductRiskTaskService productRiskTaskService; + private final QueryAsinTaskService queryAsinTaskService; + private final PatrolDeleteTaskService patrolDeleteTaskService; + private final AppearancePatentTaskService appearancePatentTaskService; + private final DeleteBrandRunService deleteBrandRunService; + private final BrandTaskService brandTaskService; + + @Value("${aiimage.result-file-job.local-worker-enabled:true}") + private boolean localWorkerEnabled; + + @Value("${aiimage.result-file-job.batch-size:20}") + private int batchSize; + + @Value("${aiimage.result-file-job.stuck-timeout-minutes:30}") + private int stuckTimeoutMinutes; + + @Scheduled(fixedDelayString = "${aiimage.result-file-job.local-worker-delay-ms:15000}") + public void runPendingJobs() { + if (!localWorkerEnabled) { + return; + } + List jobs = taskFileJobService.listRunnableJobs(batchSize); + if (jobs == null || jobs.isEmpty()) { + return; + } + log.info("[task-file-job] scheduled worker picked jobs count={}", jobs.size()); + for (TaskFileJobEntity job : jobs) { + boolean dispatched = taskFileJobLocalDispatcher.dispatch( + job.getId(), + job.getTaskId(), + job.getModuleType() + ); + if (!dispatched) { + process(job); + } + } + } + + @Scheduled(fixedDelayString = "${aiimage.result-file-job.stuck-scan-delay-ms:60000}") + public void resetStuckJobs() { + int reset = taskFileJobService.resetStuckRunningJobs(stuckTimeoutMinutes, batchSize); + if (reset > 0) { + log.warn("[task-file-job] reset stuck running jobs count={} timeoutMinutes={}", reset, stuckTimeoutMinutes); + } + } + + public void process(TaskFileJobEntity job) { + if (job == null || job.getId() == null || !taskFileJobService.markRunning(job.getId())) { + return; + } + long startedAt = System.currentTimeMillis(); + try { + dispatch(job); + String resultFileUrl = resolveResultFileUrl(job); + taskFileJobService.markSuccess(job, resultFileUrl); + cleanupAfterSuccess(job); + log.info("[task-file-job] process success jobId={} taskId={} moduleType={} resultId={} elapsedMs={} resultFileUrl={}", + job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), + System.currentTimeMillis() - startedAt, resultFileUrl); + } catch (Exception ex) { + String message = ex.getMessage() == null ? "结果文件生成失败" : ex.getMessage(); + log.warn("[task-file-job] process failed jobId={} taskId={} moduleType={} resultId={} msg={}", + job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), message); + taskFileJobService.markFailed(job, message); + } + } + + private String resolveResultFileUrl(TaskFileJobEntity job) { + if ("BRAND".equals(job.getModuleType())) { + return brandTaskService.resolveResultObjectKey(job.getTaskId()); + } + if (job.getResultId() == null) { + return null; + } + FileResultEntity result = fileResultMapper.selectById(job.getResultId()); + return result == null ? null : result.getResultFileUrl(); + } + + private void dispatch(TaskFileJobEntity job) { + String moduleType = job.getModuleType(); + if ("SHOP_MATCH".equals(moduleType)) { + shopMatchTaskService.processResultFileJob(job); + return; + } + if ("PRICE_TRACK".equals(moduleType)) { + priceTrackTaskService.processResultFileJob(job); + return; + } + if ("PRODUCT_RISK_RESOLVE".equals(moduleType)) { + productRiskTaskService.processResultFileJob(job); + return; + } + if ("QUERY_ASIN".equals(moduleType)) { + queryAsinTaskService.processResultFileJob(job); + return; + } + if ("PATROL_DELETE".equals(moduleType)) { + patrolDeleteTaskService.processResultFileJob(job); + return; + } + if ("APPEARANCE_PATENT".equals(moduleType)) { + appearancePatentTaskService.processResultFileJob(job); + return; + } + if ("DELETE_BRAND".equals(moduleType)) { + deleteBrandRunService.processResultFileJob(job); + return; + } + if ("BRAND".equals(moduleType)) { + brandTaskService.processResultFileJob(job); + return; + } + throw new IllegalArgumentException("unsupported result file job module: " + moduleType); + } + + private void cleanupAfterSuccess(TaskFileJobEntity job) { + String moduleType = job.getModuleType(); + if ("SHOP_MATCH".equals(moduleType) + || "PRICE_TRACK".equals(moduleType) + || "PRODUCT_RISK_RESOLVE".equals(moduleType) + || "QUERY_ASIN".equals(moduleType) + || "PATROL_DELETE".equals(moduleType)) { + taskResultPayloadService.deleteLatest(job.getTaskId(), moduleType, job.getScopeKey()); + return; + } + if ("APPEARANCE_PATENT".equals(moduleType)) { + appearancePatentTaskService.cleanupResultFileJob(job); + return; + } + if ("DELETE_BRAND".equals(moduleType)) { + deleteBrandRunService.cleanupResultFileJob(job); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java new file mode 100644 index 0000000..1b6f23f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultItemService.java @@ -0,0 +1,233 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.task.mapper.TaskResultItemMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TaskResultItemService { + + private final TaskResultItemMapper taskResultItemMapper; + private final ObjectMapper objectMapper; + private final TransientPayloadStorageService transientPayloadStorageService; + + @Transactional + public void replaceResultSnapshot(Long taskId, String moduleType, Long resultId, String scopeKey, Object snapshot) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || resultId == null || resultId <= 0 || snapshot == null) { + return; + } + String normalizedScopeKey = firstNonBlank(scopeKey, "result:" + resultId).trim(); + String itemKey = "result:" + resultId; + upsert(taskId, moduleType, resultId, normalizedScopeKey, itemKey, null, null, null, snapshot); + } + + @Transactional + public void replaceTaskSnapshots(Long taskId, String moduleType, List snapshots, SnapshotKeyResolver resolver) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || snapshots == null) { + return; + } + for (Object snapshot : snapshots) { + if (snapshot == null) { + continue; + } + Long resultId = resolver == null ? null : resolver.resultId(snapshot); + if (resultId == null || resultId <= 0) { + continue; + } + replaceResultSnapshot(taskId, moduleType, resultId, resolver.scopeKey(snapshot), snapshot); + } + } + + public List listResultSnapshots(Long taskId, String moduleType, Class clazz) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return List.of(); + } + List rows = taskResultItemMapper.selectList(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, moduleType) + .likeRight(TaskResultItemEntity::getItemKey, "result:") + .orderByAsc(TaskResultItemEntity::getResultId) + .orderByAsc(TaskResultItemEntity::getId)); + List out = new ArrayList<>(); + for (TaskResultItemEntity row : rows) { + T value = readPayload(row, clazz); + if (value != null) { + out.add(value); + } + } + return out; + } + + public T getResultSnapshot(Long taskId, String moduleType, Long resultId, Class clazz) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || resultId == null || resultId <= 0) { + return null; + } + TaskResultItemEntity row = taskResultItemMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, moduleType) + .eq(TaskResultItemEntity::getResultId, resultId) + .eq(TaskResultItemEntity::getItemKey, "result:" + resultId) + .last("limit 1")); + return readPayload(row, clazz); + } + + public long countResultSnapshots(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return 0L; + } + Long count = taskResultItemMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, moduleType) + .likeRight(TaskResultItemEntity::getItemKey, "result:")); + return count == null ? 0L : count; + } + + @Transactional + public void deleteTaskItems(Long taskId, String moduleType) { + if (taskId == null || taskId <= 0 || isBlank(moduleType)) { + return; + } + List rows = taskResultItemMapper.selectList(new LambdaQueryWrapper() + .select(TaskResultItemEntity::getId, TaskResultItemEntity::getPayloadJson) + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, moduleType)); + if (rows != null) { + for (TaskResultItemEntity row : rows) { + transientPayloadStorageService.deletePayloadIfPresent(row.getPayloadJson()); + } + } + taskResultItemMapper.delete(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, moduleType)); + } + + private void upsert(Long taskId, + String moduleType, + Long resultId, + String scopeKey, + String itemKey, + String countryCode, + String asin, + String status, + Object payload) { + String payloadJson = writeJson(payload); + String normalizedScopeKey = firstNonBlank(scopeKey, "task:" + taskId).trim(); + String normalizedItemKey = firstNonBlank(itemKey, "result:" + resultId).trim(); + String scopeHash = hash(normalizedScopeKey); + String storedPayload = transientPayloadStorageService.storeResultItemPayload( + moduleType, taskId, scopeHash, normalizedItemKey, payloadJson); + String payloadHash = hash(payloadJson); + LocalDateTime now = LocalDateTime.now(); + + TaskResultItemEntity existing = find(taskId, moduleType, scopeHash, normalizedItemKey); + if (existing == null) { + TaskResultItemEntity entity = new TaskResultItemEntity(); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setResultId(resultId); + entity.setScopeKey(normalizedScopeKey); + entity.setScopeHash(scopeHash); + entity.setItemKey(normalizedItemKey); + entity.setCountryCode(countryCode); + entity.setAsin(asin); + entity.setStatus(status); + entity.setPayloadJson(storedPayload); + entity.setPayloadHash(payloadHash); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskResultItemMapper.insert(entity); + return; + } catch (DuplicateKeyException ignored) { + existing = find(taskId, moduleType, scopeHash, normalizedItemKey); + } + } + if (existing == null) { + throw new BusinessException("保存任务结果明细失败"); + } + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(existing.getPayloadJson(), storedPayload); + taskResultItemMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskResultItemEntity::getId, existing.getId()) + .set(TaskResultItemEntity::getResultId, resultId) + .set(TaskResultItemEntity::getScopeKey, normalizedScopeKey) + .set(TaskResultItemEntity::getCountryCode, countryCode) + .set(TaskResultItemEntity::getAsin, asin) + .set(TaskResultItemEntity::getStatus, status) + .set(TaskResultItemEntity::getPayloadJson, storedPayload) + .set(TaskResultItemEntity::getPayloadHash, payloadHash) + .set(TaskResultItemEntity::getUpdatedAt, now)); + } + + private TaskResultItemEntity find(Long taskId, String moduleType, String scopeHash, String itemKey) { + return taskResultItemMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskResultItemEntity::getTaskId, taskId) + .eq(TaskResultItemEntity::getModuleType, moduleType) + .eq(TaskResultItemEntity::getScopeHash, scopeHash) + .eq(TaskResultItemEntity::getItemKey, itemKey) + .last("limit 1")); + } + + private T readPayload(TaskResultItemEntity row, Class clazz) { + if (row == null || isBlank(row.getPayloadJson())) { + return null; + } + try { + String payloadJson = transientPayloadStorageService.resolvePayload(row.getPayloadJson(), "read task result item failed"); + return objectMapper.readValue(payloadJson, clazz); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("读取任务结果明细失败"); + } + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException("序列化任务结果明细失败"); + } + } + + private String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException("failed to hash task result item", ex); + } + } + + private String firstNonBlank(String primary, String fallback) { + return primary == null || primary.isBlank() ? fallback : primary; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + public interface SnapshotKeyResolver { + Long resultId(Object snapshot); + + String scopeKey(Object snapshot); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultPayloadService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultPayloadService.java new file mode 100644 index 0000000..e6fdf36 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultPayloadService.java @@ -0,0 +1,139 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.task.mapper.TaskResultPayloadMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskResultPayloadEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class TaskResultPayloadService { + + private static final String LATEST_SUBMISSION_ID = "latest"; + + private final TaskResultPayloadMapper taskResultPayloadMapper; + private final ObjectMapper objectMapper; + private final TransientPayloadStorageService transientPayloadStorageService; + + @Transactional + public void saveLatest(Long taskId, String moduleType, String scopeKey, Object payload) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey) || payload == null) { + return; + } + String normalizedScopeKey = scopeKey.trim(); + String scopeHash = hash(normalizedScopeKey); + String payloadJson = writeJson(payload); + String storedPayload = transientPayloadStorageService.storeResultPayload( + moduleType, taskId, scopeHash, LATEST_SUBMISSION_ID, payloadJson); + String payloadHash = hash(payloadJson); + LocalDateTime now = LocalDateTime.now(); + + TaskResultPayloadEntity existing = findLatest(taskId, moduleType, scopeHash); + if (existing == null) { + TaskResultPayloadEntity entity = new TaskResultPayloadEntity(); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setScopeKey(normalizedScopeKey); + entity.setScopeHash(scopeHash); + entity.setSubmissionId(LATEST_SUBMISSION_ID); + entity.setPayloadJson(storedPayload); + entity.setPayloadHash(payloadHash); + entity.setVersion(1L); + entity.setCreatedAt(now); + entity.setUpdatedAt(now); + try { + taskResultPayloadMapper.insert(entity); + return; + } catch (DuplicateKeyException ignored) { + existing = findLatest(taskId, moduleType, scopeHash); + } + } + if (existing == null) { + throw new BusinessException("保存任务结果载荷失败"); + } + long nextVersion = existing.getVersion() == null ? 1L : existing.getVersion() + 1L; + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(existing.getPayloadJson(), storedPayload); + taskResultPayloadMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskResultPayloadEntity::getId, existing.getId()) + .set(TaskResultPayloadEntity::getScopeKey, normalizedScopeKey) + .set(TaskResultPayloadEntity::getPayloadJson, storedPayload) + .set(TaskResultPayloadEntity::getPayloadHash, payloadHash) + .set(TaskResultPayloadEntity::getVersion, nextVersion) + .set(TaskResultPayloadEntity::getUpdatedAt, now)); + } + + public T getLatest(Long taskId, String moduleType, String scopeKey, Class clazz) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey)) { + return null; + } + TaskResultPayloadEntity entity = findLatest(taskId, moduleType, hash(scopeKey.trim())); + if (entity == null || isBlank(entity.getPayloadJson())) { + return null; + } + try { + String payloadJson = transientPayloadStorageService.resolvePayload(entity.getPayloadJson(), "read task result payload failed"); + return objectMapper.readValue(payloadJson, clazz); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("读取任务结果载荷失败"); + } + } + + @Transactional + public void deleteLatest(Long taskId, String moduleType, String scopeKey) { + if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeKey)) { + return; + } + TaskResultPayloadEntity existing = findLatest(taskId, moduleType, hash(scopeKey.trim())); + if (existing != null) { + transientPayloadStorageService.deletePayloadIfPresent(existing.getPayloadJson()); + taskResultPayloadMapper.deleteById(existing.getId()); + } + } + + private TaskResultPayloadEntity findLatest(Long taskId, String moduleType, String scopeHash) { + return taskResultPayloadMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskResultPayloadEntity::getTaskId, taskId) + .eq(TaskResultPayloadEntity::getModuleType, moduleType) + .eq(TaskResultPayloadEntity::getScopeHash, scopeHash) + .eq(TaskResultPayloadEntity::getSubmissionId, LATEST_SUBMISSION_ID) + .last("limit 1")); + } + + private String writeJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException("序列化任务结果载荷失败"); + } + } + + private String hash(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest((value == null ? "" : value).getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException("failed to hash task result payload", ex); + } + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java index 362f1c4..5c4aeb5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskScopePayloadStorageService.java @@ -3,9 +3,7 @@ package com.nanri.aiimage.modules.task.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.config.TaskPressureProperties; import com.nanri.aiimage.common.exception.BusinessException; -import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper; import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; import lombok.RequiredArgsConstructor; @@ -13,37 +11,23 @@ import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; import java.nio.charset.StandardCharsets; +import java.nio.file.Path; import java.security.MessageDigest; import java.time.LocalDateTime; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; @Service @RequiredArgsConstructor public class TaskScopePayloadStorageService { - private static final Set OSS_BACKED_STATE_MODULES = new HashSet<>(Set.of( - "SHOP_MATCH", - "PRODUCT_RISK_RESOLVE", - "PRICE_TRACK", - "PATROL_DELETE", - "QUERY_ASIN" - )); - private static final String OSS_POINTER_PREFIX = "oss:"; private static final String LOCAL_BUFFER_DIR = "task-scope-buffer"; private final TaskScopeStateMapper taskScopeStateMapper; private final ObjectMapper objectMapper; - private final OssStorageService ossStorageService; - private final TaskPressureProperties taskPressureProperties; + private final TransientPayloadStorageService transientPayloadStorageService; @Transactional public void saveScopePayload(Long taskId, String moduleType, String scopeKey, T payload) { @@ -53,13 +37,11 @@ public class TaskScopePayloadStorageService { String normalizedScopeKey = normalize(scopeKey); String scopeHash = hash(normalizedScopeKey); String payloadJson = writeJson(payload, "写入任务范围载荷失败"); + String stateJson = transientPayloadStorageService.storeScopePayload( + moduleType, taskId, scopeHash, payloadJson, true); LocalDateTime now = LocalDateTime.now(); + TaskScopeStateEntity state = getScopeState(taskId, moduleType, scopeHash); - if (shouldStoreStatePayloadInOss(moduleType)) { - saveOssBackedScopePayload(taskId, moduleType, normalizedScopeKey, scopeHash, payloadJson, now, state); - return; - } - String stateJson = storeStatePayload(moduleType, taskId, scopeHash, payloadJson); if (state == null) { TaskScopeStateEntity entity = new TaskScopeStateEntity(); entity.setTaskId(taskId); @@ -84,7 +66,7 @@ public class TaskScopePayloadStorageService { if (state == null) { throw new BusinessException("写入任务范围载荷失败"); } - deleteReplacedStatePayloadIfNeeded(state.getStateJson(), stateJson); + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(state.getStateJson(), stateJson); taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) .set(TaskScopeStateEntity::getScopeKey, normalizedScopeKey) @@ -145,8 +127,7 @@ public class TaskScopePayloadStorageService { if (state == null) { return; } - deleteBufferedPayload(taskId, moduleType, state.getScopeHash()); - deleteStatePayloadIfNeeded(state.getStateJson()); + transientPayloadStorageService.deletePayloadIfPresent(state.getStateJson()); boolean hasParsedPayload = !isBlank(state.getParsedPayloadJson()); if (hasParsedPayload) { taskScopeStateMapper.update(null, new LambdaUpdateWrapper() @@ -195,8 +176,7 @@ public class TaskScopePayloadStorageService { if (state == null || state.getId() == null) { continue; } - deleteBufferedPayload(taskId, moduleType, state.getScopeHash()); - deleteStatePayloadIfNeeded(state.getStateJson()); + transientPayloadStorageService.deletePayloadIfPresent(state.getStateJson()); if (!isBlank(state.getParsedPayloadJson())) { taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) @@ -221,6 +201,9 @@ public class TaskScopePayloadStorageService { String scopeKey = normalize(entry.getKey()); String scopeHash = hash(scopeKey); String parsedPayloadJson = writeJson(entry.getValue(), "写入任务解析载荷失败"); + String storedParsedPayload = transientPayloadStorageService.storeParsedPayload( + moduleType, taskId, scopeHash, parsedPayloadJson, true); + TaskScopeStateEntity state = getScopeState(taskId, moduleType, scopeHash); if (state == null) { TaskScopeStateEntity entity = new TaskScopeStateEntity(); @@ -228,7 +211,7 @@ public class TaskScopePayloadStorageService { entity.setModuleType(moduleType); entity.setScopeKey(scopeKey); entity.setScopeHash(scopeHash); - entity.setParsedPayloadJson(parsedPayloadJson); + entity.setParsedPayloadJson(storedParsedPayload); entity.setChunkTotal(0); entity.setReceivedChunkCount(0); entity.setCompleted(0); @@ -244,11 +227,11 @@ public class TaskScopePayloadStorageService { if (state == null) { throw new BusinessException("写入任务解析载荷失败"); } - deleteParsedPayloadIfNeeded(state.getParsedPayloadJson(), parsedPayloadJson); + transientPayloadStorageService.deleteReplacedPayloadIfNeeded(state.getParsedPayloadJson(), storedParsedPayload); taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) .set(TaskScopeStateEntity::getScopeKey, scopeKey) - .set(TaskScopeStateEntity::getParsedPayloadJson, parsedPayloadJson) + .set(TaskScopeStateEntity::getParsedPayloadJson, storedParsedPayload) .set(TaskScopeStateEntity::getUpdatedAt, now)); } } @@ -271,7 +254,9 @@ public class TaskScopePayloadStorageService { continue; } try { - result.put(state.getScopeKey(), objectMapper.readValue(resolveParsedPayload(state.getParsedPayloadJson()), clazz)); + String payloadJson = transientPayloadStorageService.resolvePayload( + state.getParsedPayloadJson(), "read parsed payload failed"); + result.put(state.getScopeKey(), objectMapper.readValue(payloadJson, clazz)); } catch (Exception ex) { throw new BusinessException("读取任务原始载荷失败"); } @@ -281,32 +266,7 @@ public class TaskScopePayloadStorageService { @Transactional public boolean recoverBufferedScopePayload(Long taskId, String moduleType, String scopeHash) { - if (taskId == null || taskId <= 0 || isBlank(moduleType) || isBlank(scopeHash)) { - return false; - } - String payloadJson = readBufferedPayload(taskId, moduleType, scopeHash); - if (payloadJson == null) { - return false; - } - TaskScopeStateEntity state = getScopeState(taskId, moduleType, scopeHash); - if (state == null) { - deleteBufferedPayload(taskId, moduleType, scopeHash); - return false; - } - if (!shouldStoreStatePayloadInOss(moduleType)) { - deleteBufferedPayload(taskId, moduleType, scopeHash); - return false; - } - LocalDateTime now = LocalDateTime.now(); - String stateJson = storeStatePayload(moduleType, taskId, scopeHash, payloadJson); - deleteReplacedStatePayloadIfNeeded(state.getStateJson(), stateJson); - taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .set(TaskScopeStateEntity::getStateJson, stateJson) - .set(TaskScopeStateEntity::getLastChunkAt, now) - .set(TaskScopeStateEntity::getUpdatedAt, now)); - deleteBufferedPayload(taskId, moduleType, scopeHash); - return true; + return false; } public Path getBufferedPayloadRoot() { @@ -321,126 +281,17 @@ public class TaskScopePayloadStorageService { .last("limit 1")); } - private String storeStatePayload(String moduleType, Long taskId, String scopeHash, String payloadJson) { - if (!shouldStoreStatePayloadInOss(moduleType)) { - return payloadJson; - } - try { - String pointer = OSS_POINTER_PREFIX + ossStorageService.uploadTaskScopePayload(moduleType, taskId, scopeHash, payloadJson); - return objectMapper.writeValueAsString(pointer); - } catch (Exception ex) { - throw new BusinessException("写入任务范围载荷失败"); - } - } - private String resolveStatePayload(TaskScopeStateEntity state) { if (state == null || isBlank(state.getStateJson())) { return null; } - String bufferedPayload = readBufferedPayload(state.getTaskId(), state.getModuleType(), state.getScopeHash()); - if (bufferedPayload != null) { - return bufferedPayload; - } - String ossPointer = extractOssPointer(state.getStateJson()); - if (ossPointer == null) { - return state.getStateJson(); - } try { - return ossStorageService.readObjectAsString(stripOssPointerPrefix(ossPointer)); + return transientPayloadStorageService.resolvePayload(state.getStateJson(), "read task scope payload failed"); } catch (Exception ex) { throw new BusinessException("读取任务范围载荷失败"); } } - private void deleteStatePayloadIfNeeded(String value) { - String ossPointer = extractOssPointer(value); - if (ossPointer == null) { - return; - } - try { - ossStorageService.deleteObject(stripOssPointerPrefix(ossPointer)); - } catch (Exception ignored) { - } - } - - private String resolveParsedPayload(String value) { - if (isBlank(value) || !isOssPointer(value)) { - return value; - } - try { - return ossStorageService.readObjectAsString(stripOssPointerPrefix(value)); - } catch (Exception ex) { - throw new BusinessException("读取任务解析载荷失败"); - } - } - - private void deleteParsedPayloadIfNeeded(String oldValue, String newValue) { - if (isBlank(oldValue) || oldValue.equals(newValue) || !isOssPointer(oldValue)) { - return; - } - try { - ossStorageService.deleteObject(stripOssPointerPrefix(oldValue)); - } catch (Exception ignored) { - } - } - - private void deleteReplacedStatePayloadIfNeeded(String oldValue, String newValue) { - if (isBlank(oldValue) || oldValue.equals(newValue)) { - return; - } - deleteStatePayloadIfNeeded(oldValue); - } - - private void saveOssBackedScopePayload(Long taskId, - String moduleType, - String normalizedScopeKey, - String scopeHash, - String payloadJson, - LocalDateTime now, - TaskScopeStateEntity state) { - if (state == null) { - String stateJson = storeStatePayload(moduleType, taskId, scopeHash, payloadJson); - TaskScopeStateEntity entity = new TaskScopeStateEntity(); - entity.setTaskId(taskId); - entity.setModuleType(moduleType); - entity.setScopeKey(normalizedScopeKey); - entity.setScopeHash(scopeHash); - entity.setStateJson(stateJson); - entity.setChunkTotal(0); - entity.setReceivedChunkCount(0); - entity.setCompleted(0); - entity.setLastChunkAt(now); - entity.setLastError(null); - entity.setCreatedAt(now); - entity.setUpdatedAt(now); - try { - taskScopeStateMapper.insert(entity); - deleteBufferedPayload(taskId, moduleType, scopeHash); - return; - } catch (DuplicateKeyException ignored) { - state = getScopeState(taskId, moduleType, scopeHash); - } - } - if (state == null) { - throw new BusinessException("刷新任务范围缓冲载荷失败"); - } - - writeBufferedPayload(taskId, moduleType, scopeHash, payloadJson); - if (!shouldFlushBufferedPayload(state, now)) { - return; - } - - String stateJson = storeStatePayload(moduleType, taskId, scopeHash, payloadJson); - deleteReplacedStatePayloadIfNeeded(state.getStateJson(), stateJson); - taskScopeStateMapper.update(null, new LambdaUpdateWrapper() - .eq(TaskScopeStateEntity::getId, state.getId()) - .set(TaskScopeStateEntity::getScopeKey, normalizedScopeKey) - .set(TaskScopeStateEntity::getStateJson, stateJson) - .set(TaskScopeStateEntity::getLastChunkAt, now) - .set(TaskScopeStateEntity::getUpdatedAt, now)); - deleteBufferedPayload(taskId, moduleType, scopeHash); - } - private String writeJson(Object value, String message) { try { return objectMapper.writeValueAsString(value); @@ -457,76 +308,6 @@ public class TaskScopePayloadStorageService { return value == null || value.isBlank(); } - private boolean shouldStoreStatePayloadInOss(String moduleType) { - return OSS_BACKED_STATE_MODULES.contains(normalize(moduleType).toUpperCase()); - } - - private boolean isOssPointer(String value) { - return !isBlank(value) && value.startsWith(OSS_POINTER_PREFIX); - } - - private String extractOssPointer(String value) { - if (isBlank(value)) { - return null; - } - if (isOssPointer(value)) { - return value; - } - try { - String decoded = objectMapper.readValue(value, String.class); - return isOssPointer(decoded) ? decoded : null; - } catch (Exception ignored) { - return null; - } - } - - private String stripOssPointerPrefix(String value) { - return value.substring(OSS_POINTER_PREFIX.length()); - } - - private boolean shouldFlushBufferedPayload(TaskScopeStateEntity state, LocalDateTime now) { - long flushIntervalMillis = Math.max(0L, taskPressureProperties.getScopePayloadFlushIntervalMillis()); - if (flushIntervalMillis <= 0L || state.getUpdatedAt() == null) { - return true; - } - return java.time.Duration.between(state.getUpdatedAt(), now).toMillis() >= flushIntervalMillis; - } - - private void writeBufferedPayload(Long taskId, String moduleType, String scopeHash, String payloadJson) { - try { - Path path = buildBufferedPayloadPath(taskId, moduleType, scopeHash); - Files.createDirectories(path.getParent()); - Files.writeString(path, payloadJson, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); - } catch (IOException ex) { - throw new BusinessException("写入任务范围缓冲载荷失败"); - } - } - - private String readBufferedPayload(Long taskId, String moduleType, String scopeHash) { - try { - Path path = buildBufferedPayloadPath(taskId, moduleType, scopeHash); - if (!Files.isRegularFile(path)) { - return null; - } - return Files.readString(path); - } catch (IOException ex) { - return null; - } - } - - private void deleteBufferedPayload(Long taskId, String moduleType, String scopeHash) { - try { - Path path = buildBufferedPayloadPath(taskId, moduleType, scopeHash); - Files.deleteIfExists(path); - } catch (IOException ignored) { - } - } - - private Path buildBufferedPayloadPath(Long taskId, String moduleType, String scopeHash) { - String normalizedModuleType = normalize(moduleType).toLowerCase(); - return Path.of(System.getProperty("java.io.tmpdir"), LOCAL_BUFFER_DIR, normalizedModuleType, String.valueOf(taskId), scopeHash + ".json"); - } - private String hash(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java new file mode 100644 index 0000000..022fcab --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TransientPayloadStorageService.java @@ -0,0 +1,149 @@ +package com.nanri.aiimage.modules.task.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.TransientStorageProperties; +import com.nanri.aiimage.modules.file.service.object.RustfsObjectStorageService; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Locale; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TransientPayloadStorageService { + + private static final String RUSTFS_POINTER_PREFIX = "rustfs:"; + private static final String OSS_POINTER_PREFIX = "oss:"; + + private final TransientStorageProperties properties; + private final RustfsObjectStorageService rustfsObjectStorageService; + private final OssStorageService ossStorageService; + private final ObjectMapper objectMapper; + + public boolean isWriteEnabled() { + return properties.isEnabled() && rustfsObjectStorageService.isConfigured(); + } + + public String storeScopePayload(String moduleType, Long taskId, String scopeHash, String content, boolean encodeAsJsonString) { + return store("task-scope", moduleType, taskId, scopeHash, "latest", content, encodeAsJsonString); + } + + public String storeParsedPayload(String moduleType, Long taskId, String scopeHash, String content, boolean encodeAsJsonString) { + return store("task-parsed", moduleType, taskId, scopeHash, "latest", content, encodeAsJsonString); + } + + public String storeResultPayload(String moduleType, Long taskId, String scopeHash, String submissionId, String content) { + return store("task-result-payload", moduleType, taskId, scopeHash, submissionId, content, true); + } + + public String storeResultItemPayload(String moduleType, Long taskId, String scopeHash, String itemKey, String content) { + return store("task-result-item", moduleType, taskId, scopeHash, itemKey, content, true); + } + + public String storeChunkPayload(String moduleType, Long taskId, String scopeHash, Integer chunkIndex, String content) { + String entryKey = chunkIndex == null ? UUID.randomUUID().toString() : "chunk-" + chunkIndex; + return store("task-chunk", moduleType, taskId, scopeHash, entryKey, content, true); + } + + public String resolvePayload(String value, String errorMessage) { + String pointer = extractPointer(value); + if (pointer == null) { + return value; + } + try { + if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) { + return rustfsObjectStorageService.readObjectAsString(pointer.substring(RUSTFS_POINTER_PREFIX.length())); + } + if (pointer.startsWith(OSS_POINTER_PREFIX)) { + return ossStorageService.readObjectAsString(pointer.substring(OSS_POINTER_PREFIX.length())); + } + } catch (Exception ex) { + throw new IllegalStateException(errorMessage, ex); + } + return value; + } + + public void deletePayloadIfPresent(String value) { + String pointer = extractPointer(value); + if (pointer == null) { + return; + } + if (pointer.startsWith(RUSTFS_POINTER_PREFIX)) { + rustfsObjectStorageService.deleteObject(pointer.substring(RUSTFS_POINTER_PREFIX.length())); + return; + } + if (pointer.startsWith(OSS_POINTER_PREFIX)) { + ossStorageService.deleteObject(pointer.substring(OSS_POINTER_PREFIX.length())); + } + } + + public void deleteReplacedPayloadIfNeeded(String oldValue, String newValue) { + String oldPointer = extractPointer(oldValue); + String newPointer = extractPointer(newValue); + if (oldPointer == null || oldPointer.equals(newPointer)) { + return; + } + deletePayloadIfPresent(oldPointer); + } + + public String extractPointer(String value) { + if (value == null || value.isBlank()) { + return null; + } + if (isPointer(value)) { + return value; + } + try { + String decoded = objectMapper.readValue(value, String.class); + return isPointer(decoded) ? decoded : null; + } catch (Exception ignored) { + return null; + } + } + + private boolean isPointer(String value) { + return value != null && (value.startsWith(RUSTFS_POINTER_PREFIX) || value.startsWith(OSS_POINTER_PREFIX)); + } + + private String store(String category, + String moduleType, + Long taskId, + String scopeHash, + String entryKey, + String content, + boolean encodeAsJsonString) { + if (!isWriteEnabled()) { + return content; + } + String objectKey = buildObjectKey(category, moduleType, taskId, scopeHash, entryKey); + String pointer = RUSTFS_POINTER_PREFIX + rustfsObjectStorageService.uploadText(objectKey, content); + if (!encodeAsJsonString) { + return pointer; + } + try { + return objectMapper.writeValueAsString(pointer); + } catch (Exception ex) { + throw new IllegalStateException("failed to encode transient storage pointer", ex); + } + } + + private String buildObjectKey(String category, String moduleType, Long taskId, String scopeHash, String entryKey) { + String normalizedCategory = normalize(category, "unknown"); + String normalizedModuleType = normalize(moduleType, "unknown"); + String normalizedScopeHash = normalize(scopeHash, UUID.randomUUID().toString()); + String normalizedEntryKey = normalize(entryKey, "latest"); + return String.format("%s/%s/%s/%s/%s.json", + normalizedCategory, + normalizedModuleType, + taskId == null ? 0L : taskId, + normalizedScopeHash, + normalizedEntryKey); + } + + private String normalize(String value, String fallback) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + return normalized.isBlank() ? fallback : normalized.replaceAll("[^a-z0-9._-]+", "_"); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java index 61b4a51..71099c7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoSessionCacheService.java @@ -2,11 +2,11 @@ package com.nanri.aiimage.modules.ziniao.service; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.ZiniaoProperties; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoSessionCacheDto; import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -15,6 +15,7 @@ import java.util.List; @Service @RequiredArgsConstructor +@Slf4j public class ZiniaoSessionCacheService { private final StringRedisTemplate stringRedisTemplate; @@ -22,62 +23,96 @@ public class ZiniaoSessionCacheService { private final ZiniaoProperties ziniaoProperties; public void saveSession(ZiniaoSessionCacheDto session) { + if (session == null || session.getSessionId() == null || session.getSessionId().isBlank()) { + return; + } try { - stringRedisTemplate.opsForValue().set(buildSessionKey(session.getSessionId()), objectMapper.writeValueAsString(session), Duration.ofHours(ziniaoProperties.getSessionTtlHours())); + stringRedisTemplate.opsForValue().set( + buildSessionKey(session.getSessionId()), + objectMapper.writeValueAsString(session), + Duration.ofHours(ziniaoProperties.getSessionTtlHours())); } catch (Exception ex) { - throw new BusinessException("保存紫鸟会话失败"); + log.warn("[ziniao-session-cache] save session degraded sessionId={} msg={}", + session.getSessionId(), ex.getMessage()); } } public ZiniaoSessionCacheDto getSession(String sessionId) { - String raw = stringRedisTemplate.opsForValue().get(buildSessionKey(sessionId)); + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(buildSessionKey(sessionId)); + } catch (Exception ex) { + log.warn("[ziniao-session-cache] get session degraded sessionId={} msg={}", sessionId, ex.getMessage()); + return null; + } if (raw == null || raw.isBlank()) { return null; } try { return objectMapper.readValue(raw, ZiniaoSessionCacheDto.class); } catch (Exception ex) { - throw new BusinessException("读取紫鸟会话失败"); + log.warn("[ziniao-session-cache] parse session degraded sessionId={} msg={}", sessionId, ex.getMessage()); + return null; } } public void saveShops(String sessionId, List shops) { try { - stringRedisTemplate.opsForValue().set(buildShopsKey(sessionId), objectMapper.writeValueAsString(shops), Duration.ofMinutes(ziniaoProperties.getShopsCacheMinutes())); + stringRedisTemplate.opsForValue().set( + buildShopsKey(sessionId), + objectMapper.writeValueAsString(shops), + Duration.ofMinutes(ziniaoProperties.getShopsCacheMinutes())); } catch (Exception ex) { - throw new BusinessException("保存紫鸟店铺列表失败"); + log.warn("[ziniao-session-cache] save shops degraded sessionId={} msg={}", sessionId, ex.getMessage()); } } public List getShops(String sessionId) { - String raw = stringRedisTemplate.opsForValue().get(buildShopsKey(sessionId)); + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(buildShopsKey(sessionId)); + } catch (Exception ex) { + log.warn("[ziniao-session-cache] get shops degraded sessionId={} msg={}", sessionId, ex.getMessage()); + return List.of(); + } if (raw == null || raw.isBlank()) { return List.of(); } try { return objectMapper.readValue(raw, new TypeReference>() {}); } catch (Exception ex) { - throw new BusinessException("读取紫鸟店铺列表失败"); + log.warn("[ziniao-session-cache] parse shops degraded sessionId={} msg={}", sessionId, ex.getMessage()); + return List.of(); } } public void saveCurrentShop(String sessionId, ZiniaoShopCacheDto shop) { try { - stringRedisTemplate.opsForValue().set(buildCurrentShopKey(sessionId), objectMapper.writeValueAsString(shop), Duration.ofHours(ziniaoProperties.getSessionTtlHours())); + stringRedisTemplate.opsForValue().set( + buildCurrentShopKey(sessionId), + objectMapper.writeValueAsString(shop), + Duration.ofHours(ziniaoProperties.getSessionTtlHours())); } catch (Exception ex) { - throw new BusinessException("保存当前紫鸟店铺失败"); + log.warn("[ziniao-session-cache] save current shop degraded sessionId={} msg={}", sessionId, ex.getMessage()); } } public ZiniaoShopCacheDto getCurrentShop(String sessionId) { - String raw = stringRedisTemplate.opsForValue().get(buildCurrentShopKey(sessionId)); + String raw; + try { + raw = stringRedisTemplate.opsForValue().get(buildCurrentShopKey(sessionId)); + } catch (Exception ex) { + log.warn("[ziniao-session-cache] get current shop degraded sessionId={} msg={}", sessionId, ex.getMessage()); + return null; + } if (raw == null || raw.isBlank()) { return null; } try { return objectMapper.readValue(raw, ZiniaoShopCacheDto.class); } catch (Exception ex) { - throw new BusinessException("读取当前紫鸟店铺失败"); + log.warn("[ziniao-session-cache] parse current shop degraded sessionId={} msg={}", sessionId, ex.getMessage()); + return null; } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java index 40339bb..a446d9a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java @@ -101,6 +101,8 @@ public class ZiniaoShopIndexService { return pendingResult("店铺索引信息不完整,请等待后台刷新"); } + boolean treatAsFresh = isFresh(entry) || shouldBypassFreshnessBecauseWhitelistFailure(entry); + if (!buildOpenStoreUrl) { ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); vo.setMatched(true); @@ -110,8 +112,8 @@ public class ZiniaoShopIndexService { vo.setPlatform(entry.getPlatform()); vo.setMatchedUserId(entry.getMatchedUserId()); vo.setOpenStoreUrl(null); - vo.setMatchStatus(isFresh(entry) ? MATCH_STATUS_MATCHED : MATCH_STATUS_STALE); - vo.setMatchMessage(isFresh(entry) ? null : "店铺索引已过保鲜期,请等待后台刷新"); + vo.setMatchStatus(treatAsFresh ? MATCH_STATUS_MATCHED : MATCH_STATUS_STALE); + vo.setMatchMessage(treatAsFresh ? null : "店铺索引已过保鲜期,请等待后台刷新"); return vo; } @@ -145,8 +147,8 @@ public class ZiniaoShopIndexService { vo.setMatchStatus(MATCH_STATUS_STALE); vo.setMatchMessage("打开店铺链接暂时无法生成,请稍后重试"); } else { - vo.setMatchStatus(isFresh(entry) ? MATCH_STATUS_MATCHED : MATCH_STATUS_STALE); - vo.setMatchMessage(isFresh(entry) ? null : "店铺索引已过保鲜期,请等待后台刷新"); + vo.setMatchStatus(treatAsFresh ? MATCH_STATUS_MATCHED : MATCH_STATUS_STALE); + vo.setMatchMessage(treatAsFresh ? null : "店铺索引已过保鲜期,请等待后台刷新"); } return vo; } @@ -406,7 +408,19 @@ public class ZiniaoShopIndexService { private ZiniaoShopMatchResultVo staleOrPendingResult(String defaultMessage, ZiniaoShopIndexEntryDto entry) { if (entry != null && STATUS_STALE.equals(entry.getStatus())) { ZiniaoShopMatchResultVo vo = new ZiniaoShopMatchResultVo(); - vo.setMatched(false); + boolean hasReusableMatch = entry.getShopId() != null + && !entry.getShopId().isBlank() + && entry.getMatchedUserId() != null + && entry.getMatchedUserId() > 0; + vo.setMatched(hasReusableMatch); + if (hasReusableMatch) { + vo.setShopId(entry.getShopId()); + vo.setShopName(entry.getShopName()); + vo.setCompanyName(entry.getCompanyName()); + vo.setPlatform(entry.getPlatform()); + vo.setMatchedUserId(entry.getMatchedUserId()); + vo.setOpenStoreUrl(null); + } vo.setMatchStatus(MATCH_STATUS_STALE); vo.setMatchMessage(defaultMessage); return vo; @@ -538,6 +552,25 @@ public class ZiniaoShopIndexService { return Instant.now().toEpochMilli() - entry.getLastRefreshedAt() <= freshWindowMillis; } + private boolean shouldBypassFreshnessBecauseWhitelistFailure(ZiniaoShopIndexEntryDto entry) { + if (entry == null || !STATUS_ACTIVE.equals(entry.getStatus())) { + return false; + } + if (entry.getLastRefreshedAt() == null || entry.getLastRefreshedAt() <= 0) { + return false; + } + ZiniaoShopIndexRefreshCursorDto cursor = getRefreshCursor(); + if (cursor == null || !"FAILED".equals(cursor.getStatus())) { + return false; + } + String message = cursor.getMessage(); + if (message == null || !message.contains("白名单")) { + return false; + } + Long lastFinishedAt = cursor.getLastFinishedAt(); + return lastFinishedAt != null && lastFinishedAt >= entry.getLastRefreshedAt(); + } + private Duration resolveEntryTtl() { Integer ttlHours = ziniaoProperties.getShopIndexEntryTtlHours(); if (ttlHours == null || ttlHours <= 0) { diff --git a/backend-java/src/main/resources/application-local.example.yml b/backend-java/src/main/resources/application-local.example.yml index d76c3a9..3c5db4e 100644 --- a/backend-java/src/main/resources/application-local.example.yml +++ b/backend-java/src/main/resources/application-local.example.yml @@ -3,15 +3,22 @@ AIIMAGE_SERVER_PORT=18080 -AIIMAGE_DB_URL=jdbc:mysql://159.75.121.33:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false -AIIMAGE_DB_USERNAME=AIimage -AIIMAGE_DB_PASSWORD=WTFrb5y6hNLz6hNy +AIIMAGE_DB_URL=jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false +AIIMAGE_DB_USERNAME=change-me +AIIMAGE_DB_PASSWORD=change-me AIIMAGE_OSS_REGION=cn-hangzhou AIIMAGE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com -AIIMAGE_OSS_BUCKET=nanri-ai-images -AIIMAGE_OSS_ACCESS_KEY_ID=LTAI5tNpyvzMNz9f2dHarsm8 -AIIMAGE_OSS_ACCESS_KEY_SECRET=bQSZnFH455i8tzyOgeahJmUzwmhynz +AIIMAGE_OSS_BUCKET=change-me +AIIMAGE_OSS_ACCESS_KEY_ID=change-me +AIIMAGE_OSS_ACCESS_KEY_SECRET=change-me + +AIIMAGE_TRANSIENT_STORAGE_ENABLED=true +AIIMAGE_TRANSIENT_STORAGE_ENDPOINT=http://change-me +AIIMAGE_TRANSIENT_STORAGE_BUCKET=change-me +AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID=change-me +AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET=change-me +AIIMAGE_TRANSIENT_STORAGE_REGION=us-east-1 AIIMAGE_STORAGE_LOCAL_TEMP_DIR=F:/project/crawler-plugin/backend-java/data/tmp @@ -19,6 +26,22 @@ AIIMAGE_MODULE_CLEANUP_ENABLED=true AIIMAGE_MODULE_CLEANUP_CRON=0 0 0 * * * AIIMAGE_MODULE_CLEANUP_MODULE_TYPES=DEDUPE,SPLIT,CONVERT,DELETE_BRAND +AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL=https://api.coze.cn +AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH=/v1/workflow/run +AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID=7632683471312355338 +AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN= +AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=10 +AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=20 + +AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876 +AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED=true +AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED=true +AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_POOL_SIZE=2 +AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_QUEUE_CAPACITY=200 +AIIMAGE_RESULT_FILE_JOB_LOCAL_WORKER_ENABLED=false +AIIMAGE_RESULT_FILE_JOB_LOCAL_WORKER_DELAY_MS=10000 +AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES=30 + AIIMAGE_ZINIAO_ENABLED=false AIIMAGE_ZINIAO_BASE_URL=https://sbappstoreapi.ziniao.com/openapi-router AIIMAGE_ZINIAO_API_KEY=change-me diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index b740648..1220fbd 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -28,6 +28,12 @@ spring: database: ${AIIMAGE_REDIS_DATABASE:0} timeout: ${AIIMAGE_REDIS_TIMEOUT:5s} +rocketmq: + name-server: ${AIIMAGE_ROCKETMQ_NAME_SERVER:121.196.149.225:9876} + producer: + group: ${AIIMAGE_ROCKETMQ_PRODUCER_GROUP:aiimage-backend-producer} + send-message-timeout: ${AIIMAGE_ROCKETMQ_SEND_TIMEOUT_MS:3000} + management: health: db: @@ -67,6 +73,13 @@ aiimage: bucket: ${AIIMAGE_OSS_BUCKET:change-me} access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:change-me} access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:change-me} + transient-storage: + enabled: ${AIIMAGE_TRANSIENT_STORAGE_ENABLED:false} + endpoint: ${AIIMAGE_TRANSIENT_STORAGE_ENDPOINT:} + bucket: ${AIIMAGE_TRANSIENT_STORAGE_BUCKET:} + access-key-id: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_ID:} + access-key-secret: ${AIIMAGE_TRANSIENT_STORAGE_ACCESS_KEY_SECRET:} + region: ${AIIMAGE_TRANSIENT_STORAGE_REGION:us-east-1} storage: local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp} cleanup-enabled: ${AIIMAGE_STORAGE_CLEANUP_ENABLED:true} @@ -96,6 +109,8 @@ aiimage: enabled: ${AIIMAGE_MODULE_CLEANUP_ENABLED:true} cron: ${AIIMAGE_MODULE_CLEANUP_CRON:0 0 0 * * *} module-types: ${AIIMAGE_MODULE_CLEANUP_MODULE_TYPES:DEDUPE,SPLIT,CONVERT,DELETE_BRAND,PRODUCT_RISK_RESOLVE,PRICE_TRACK,SHOP_MATCH,PATROL_DELETE} + permission-schema-init: + enabled: ${AIIMAGE_PERMISSION_SCHEMA_INIT_ENABLED:false} task-pressure: local-task-entity-cache-millis: ${AIIMAGE_TASK_LOCAL_ENTITY_CACHE_MILLIS:3000} db-select-batch-size: ${AIIMAGE_TASK_DB_SELECT_BATCH_SIZE:200} @@ -103,6 +118,26 @@ aiimage: scope-payload-buffer-retention-hours: ${AIIMAGE_TASK_SCOPE_PAYLOAD_BUFFER_RETENTION_HOURS:24} scope-payload-recovery-max-files: ${AIIMAGE_TASK_SCOPE_PAYLOAD_RECOVERY_MAX_FILES:200} scope-payload-cleanup-cron: ${AIIMAGE_TASK_SCOPE_PAYLOAD_CLEANUP_CRON:15 */30 * * * *} + result-file-job: + mq-enabled: ${AIIMAGE_RESULT_FILE_JOB_MQ_ENABLED:false} + topic: ${AIIMAGE_RESULT_FILE_JOB_TOPIC:aiimage-result-file-job} + consumer-group: ${AIIMAGE_RESULT_FILE_JOB_CONSUMER_GROUP:aiimage-result-file-job-consumer} + local-dispatch-enabled: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_ENABLED:true} + local-dispatch-pool-size: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_POOL_SIZE:2} + local-dispatch-queue-capacity: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_DISPATCH_QUEUE_CAPACITY:200} + local-worker-enabled: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_WORKER_ENABLED:true} + local-worker-delay-ms: ${AIIMAGE_RESULT_FILE_JOB_LOCAL_WORKER_DELAY_MS:10000} + stuck-scan-delay-ms: ${AIIMAGE_RESULT_FILE_JOB_STUCK_SCAN_DELAY_MS:60000} + stuck-timeout-minutes: ${AIIMAGE_RESULT_FILE_JOB_STUCK_TIMEOUT_MINUTES:30} + batch-size: ${AIIMAGE_RESULT_FILE_JOB_BATCH_SIZE:20} + appearance-patent: + coze-base-url: ${AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL:https://api.coze.cn} + coze-workflow-path: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH:/v1/workflow/run} + coze-workflow-id: ${AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID:7632683471312355338} + coze-token: ${AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN:} + coze-batch-size: ${AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE:10} + stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:20} + stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *} security: shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key} internal-token: ${AIIMAGE_INTERNAL_TOKEN:} diff --git a/backend-java/src/main/resources/db/V34__appearance_patent.sql b/backend-java/src/main/resources/db/V34__appearance_patent.sql new file mode 100644 index 0000000..bd8aa66 --- /dev/null +++ b/backend-java/src/main/resources/db/V34__appearance_patent.sql @@ -0,0 +1,85 @@ +-- 外观专利检测模块复用通用任务表: +-- biz_file_task 保存任务主记录,module_type = APPEARANCE_PATENT +-- biz_file_result 保存最终 xlsx 结果文件和历史记录 +-- biz_task_scope_state 保存解析载荷 OSS 指针、scope 状态和心跳辅助状态 +-- biz_task_chunk 保存 Python 回传分片和 Java/Coze 处理后的分片结果 + +-- 兼容老库:如果通用任务表缺少 user_id,则补齐用户归属字段。 +SET @biz_file_task_user_id_exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_file_task' + AND COLUMN_NAME = 'user_id' +); +SET @sql_add_biz_file_task_user_id := IF( + @biz_file_task_user_id_exists = 0, + 'ALTER TABLE biz_file_task ADD COLUMN user_id BIGINT NULL COMMENT ''用户ID'' AFTER created_by', + 'SELECT 1' +); +PREPARE stmt_add_biz_file_task_user_id FROM @sql_add_biz_file_task_user_id; +EXECUTE stmt_add_biz_file_task_user_id; +DEALLOCATE PREPARE stmt_add_biz_file_task_user_id; + +SET @biz_file_result_user_id_exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_file_result' + AND COLUMN_NAME = 'user_id' +); +SET @sql_add_biz_file_result_user_id := IF( + @biz_file_result_user_id_exists = 0, + 'ALTER TABLE biz_file_result ADD COLUMN user_id BIGINT NULL COMMENT ''用户ID'' AFTER error_message', + 'SELECT 1' +); +PREPARE stmt_add_biz_file_result_user_id FROM @sql_add_biz_file_result_user_id; +EXECUTE stmt_add_biz_file_result_user_id; +DEALLOCATE PREPARE stmt_add_biz_file_result_user_id; + +-- 外观专利检测常用查询索引:按用户查任务、按用户查历史。 +SET @idx_file_task_module_user_created_exists := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_file_task' + AND INDEX_NAME = 'idx_biz_file_task_module_user_created' +); +SET @sql_add_idx_file_task_module_user_created := IF( + @idx_file_task_module_user_created_exists = 0, + 'ALTER TABLE biz_file_task ADD INDEX idx_biz_file_task_module_user_created (module_type, user_id, created_at)', + 'SELECT 1' +); +PREPARE stmt_add_idx_file_task_module_user_created FROM @sql_add_idx_file_task_module_user_created; +EXECUTE stmt_add_idx_file_task_module_user_created; +DEALLOCATE PREPARE stmt_add_idx_file_task_module_user_created; + +SET @idx_file_result_module_user_created_exists := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_file_result' + AND INDEX_NAME = 'idx_biz_file_result_module_user_created' +); +SET @sql_add_idx_file_result_module_user_created := IF( + @idx_file_result_module_user_created_exists = 0, + 'ALTER TABLE biz_file_result ADD INDEX idx_biz_file_result_module_user_created (module_type, user_id, created_at)', + 'SELECT 1' +); +PREPARE stmt_add_idx_file_result_module_user_created FROM @sql_add_idx_file_result_module_user_created; +EXECUTE stmt_add_idx_file_result_module_user_created; +DEALLOCATE PREPARE stmt_add_idx_file_result_module_user_created; + +-- 前端栏目权限兜底:外观专利检测属于前端工具下的页面。 +INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`) +SELECT '外观专利检测', 'appearance_patent', 'app', 'appearance-patent', 111 +WHERE NOT EXISTS ( + SELECT 1 FROM `columns` WHERE `column_key` = 'appearance_patent' +); + +UPDATE `columns` +SET `name` = '外观专利检测', + `menu_type` = 'app', + `route_path` = 'appearance-patent', + `sort_order` = 111 +WHERE `column_key` = 'appearance_patent'; diff --git a/backend-java/src/main/resources/db/V35__task_result_payload_and_file_job.sql b/backend-java/src/main/resources/db/V35__task_result_payload_and_file_job.sql new file mode 100644 index 0000000..2c38347 --- /dev/null +++ b/backend-java/src/main/resources/db/V35__task_result_payload_and_file_job.sql @@ -0,0 +1,74 @@ +CREATE TABLE IF NOT EXISTS biz_task_result_payload ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key', + task_id BIGINT NOT NULL COMMENT 'task id', + module_type VARCHAR(32) NOT NULL COMMENT 'module type', + scope_key VARCHAR(1000) NOT NULL COMMENT 'scope identifier, such as shop name or file key', + scope_hash CHAR(64) NOT NULL COMMENT 'hash of scope key', + submission_id VARCHAR(128) NULL COMMENT 'idempotency key from submitter when available', + payload_json JSON NOT NULL COMMENT 'raw or normalized callback payload', + payload_hash CHAR(64) NOT NULL COMMENT 'sha256 of payload json', + version BIGINT NOT NULL DEFAULT 1 COMMENT 'scope payload version', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time', + UNIQUE KEY uk_task_scope_submission (task_id, module_type, scope_hash, submission_id), + KEY idx_task_module_scope (task_id, module_type, scope_hash), + KEY idx_task_module_updated (task_id, module_type, updated_at) +) COMMENT='task result callback payload detail'; + +CREATE TABLE IF NOT EXISTS biz_task_file_job ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key', + task_id BIGINT NOT NULL COMMENT 'task id', + module_type VARCHAR(32) NOT NULL COMMENT 'module type', + result_id BIGINT NULL COMMENT 'biz_file_result id when job is scoped to one result row', + scope_key VARCHAR(1000) NULL COMMENT 'scope identifier', + job_type VARCHAR(32) NOT NULL DEFAULT 'ASSEMBLE_RESULT' COMMENT 'job type', + status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCESS/FAILED', + retry_count INT NOT NULL DEFAULT 0 COMMENT 'retry count', + error_message VARCHAR(1000) NULL COMMENT 'last error', + result_file_url VARCHAR(1000) NULL COMMENT 'generated file object key or url', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time', + finished_at DATETIME NULL COMMENT 'finished time', + UNIQUE KEY uk_file_job_scope (task_id, module_type, result_id, job_type), + KEY idx_file_job_status (status, updated_at), + KEY idx_file_job_task (task_id, module_type) +) COMMENT='async result file assembly job'; + +CREATE TABLE IF NOT EXISTS biz_task_result_item ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key', + task_id BIGINT NOT NULL COMMENT 'task id', + module_type VARCHAR(32) NOT NULL COMMENT 'module type', + result_id BIGINT NULL COMMENT 'biz_file_result id when available', + scope_key VARCHAR(1000) NOT NULL COMMENT 'scope identifier, such as shop name or file key', + scope_hash CHAR(64) NOT NULL COMMENT 'hash of scope key', + item_key VARCHAR(512) NOT NULL COMMENT 'dedupe key, such as country + asin', + country_code VARCHAR(32) NULL COMMENT 'country code', + asin VARCHAR(128) NULL COMMENT 'asin or item id', + status VARCHAR(64) NULL COMMENT 'business status', + payload_json JSON NOT NULL COMMENT 'normalized row payload', + payload_hash CHAR(64) NOT NULL COMMENT 'sha256 of payload json', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time', + UNIQUE KEY uk_task_scope_item (task_id, module_type, scope_hash, item_key), + KEY idx_result_item_result (result_id), + KEY idx_result_item_task (task_id, module_type), + KEY idx_result_item_country_asin (task_id, module_type, country_code, asin) +) COMMENT='normalized task result item detail'; + +CREATE TABLE IF NOT EXISTS biz_task_progress_snapshot ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'primary key', + task_id BIGINT NOT NULL COMMENT 'task id', + module_type VARCHAR(32) NOT NULL COMMENT 'module type', + status VARCHAR(32) NOT NULL COMMENT 'task status', + total_count INT NOT NULL DEFAULT 0 COMMENT 'total item count', + success_count INT NOT NULL DEFAULT 0 COMMENT 'success count', + failed_count INT NOT NULL DEFAULT 0 COMMENT 'failed count', + pending_count INT NOT NULL DEFAULT 0 COMMENT 'pending count', + current_scope_key VARCHAR(1000) NULL COMMENT 'current scope identifier', + message VARCHAR(1000) NULL COMMENT 'progress message', + snapshot_json JSON NULL COMMENT 'small progress snapshot', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'updated time', + UNIQUE KEY uk_task_progress_snapshot (task_id, module_type), + KEY idx_progress_module_status (module_type, status, updated_at) +) COMMENT='lightweight task progress snapshot'; diff --git a/frontend-vue/appearance-patent.html b/frontend-vue/appearance-patent.html new file mode 100644 index 0000000..8aca6ce --- /dev/null +++ b/frontend-vue/appearance-patent.html @@ -0,0 +1,12 @@ + + + + + + 外观专利检测 + + +
+ + + diff --git a/frontend-vue/src/appearance-patent-main.ts b/frontend-vue/src/appearance-patent-main.ts new file mode 100644 index 0000000..8cb25e1 --- /dev/null +++ b/frontend-vue/src/appearance-patent-main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import '@/styles/main.css' +import BrandAppearancePatentTab from '@/pages/brand/components/BrandAppearancePatentTab.vue' + +createApp(BrandAppearancePatentTab).use(ElementPlus).mount('#app') diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue new file mode 100644 index 0000000..1fbfb03 --- /dev/null +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -0,0 +1,487 @@ +