From cbde22ec79f8559d33b3aa60ff71c4a621573cf0 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Sun, 19 Jul 2026 22:23:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E4=BB=BB=E5=8A=A1=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/AppearancePatentCozeClient.java | 1 + .../AppearancePatentController.java | 2 +- .../dto/AppearancePatentResultRowDto.java | 14 +- .../service/AppearancePatentTaskService.java | 13 +- .../model/dto/CollectDataSubmitRowDto.java | 2 +- .../controller/DeleteBrandRunController.java | 2 +- .../controller/ImageVideoController.java | 2 +- .../service/ImageVideoArchiveService.java | 43 ++ .../service/ImageVideoAsyncTaskService.java | 18 +- .../controller/PatrolDeleteController.java | 2 +- .../service/PermissionMenuService.java | 17 + .../controller/PriceTrackController.java | 13 +- .../dto/PriceTrackCreateTaskRequest.java | 6 +- .../dto/PriceTrackDispatchFailedRequest.java | 14 + .../dto/PriceTrackLoopRunCreateRequest.java | 2 +- .../dto/PriceTrackMatchShopsRequest.java | 10 +- .../dto/PriceTrackSubmitResultRequest.java | 2 +- .../model/vo/PriceTrackCreateTaskVo.java | 14 +- .../model/vo/PriceTrackMatchShopsVo.java | 32 +- .../model/vo/PriceTrackResultItemVo.java | 2 +- .../model/vo/SkipPriceAsinCheckVo.java | 10 +- .../service/PriceTrackTaskService.java | 40 ++ .../model/dto/ProductRiskRowDto.java | 18 + .../ProductRiskExcelAssemblyService.java | 84 ++- .../service/ProductRiskTaskService.java | 27 + .../model/dto/QueryAsinAsinStatusDto.java | 2 +- .../controller/QueryAsinController.java | 4 +- .../controller/SkipPriceAsinController.java | 4 +- .../dto/QueryAsinCountryUpdateRequest.java | 2 +- .../SkipPriceAsinCountryUpdateRequest.java | 2 +- .../model/dto/SkipPriceAsinDetailDto.java | 2 +- .../shopmatch/model/dto/ShopMatchRowDto.java | 2 +- .../controller/SimilarAsinController.java | 2 +- .../model/dto/SimilarAsinResultRowDto.java | 16 +- .../controller/TaskHeartbeatController.java | 8 +- .../task/model/dto/TaskHeartbeatRequest.java | 8 +- .../task/model/vo/TaskHeartbeatVo.java | 10 +- .../V74__image_video_task_data_permission.sql | 16 + .../AppearancePatentCozeClientTest.java | 15 + .../AppearancePatentTaskServiceTest.java | 12 + .../service/ImageVideoArchiveServiceTest.java | 43 ++ .../ImageVideoAsyncTaskServiceTest.java | 63 ++ .../service/PermissionMenuServiceTest.java | 70 +++ .../service/PriceTrackTaskServiceTest.java | 89 +++ .../ProductRiskExcelAssemblyServiceTest.java | 82 +++ backend/blueprints/admin_api.py | 393 ++++++++++-- backend/config.py | 4 +- backend/static/admin.js | 581 +++++++++++++++--- backend/tests/test_image_video_admin.py | 229 ++++++- backend/web_source/admin.html | 153 +++-- frontend-vue/new_web_source.zip | Bin 0 -> 567768 bytes .../brand/components/BrandDeleteBrandTab.vue | 7 + .../brand/components/BrandPatrolDeleteTab.vue | 6 + .../brand/components/BrandPriceTrackTab.vue | 76 ++- .../brand/components/BrandProductRiskTab.vue | 7 + .../brand/components/BrandQueryAsinTab.vue | 6 + .../brand/components/BrandShopMatchTab.vue | 6 + .../brand/components/BrandWithdrawTab.vue | 6 + frontend-vue/src/shared/api/java-modules.ts | 9 + .../components/ZiniaoVersionSetting.vue | 35 ++ .../src/shared/utils/ziniao-version.ts | 20 + frontend-vue/src/styles/main.css | 64 ++ 62 files changed, 2164 insertions(+), 280 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackDispatchFailedRequest.java create mode 100644 backend-java/src/main/resources/db/V74__image_video_task_data_permission.sql create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java create mode 100644 frontend-vue/new_web_source.zip create mode 100644 frontend-vue/src/shared/components/ZiniaoVersionSetting.vue create mode 100644 frontend-vue/src/shared/utils/ziniao-version.ts 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 index 8ab8366..30e8ac1 100644 --- 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 @@ -676,6 +676,7 @@ public class AppearancePatentCozeClient { row.setCountry(source.getCountry()); row.setSku(source.getSku()); row.setBrand(source.getBrand()); + row.setPrice(source.getPrice()); row.setUrl(source.getUrl()); row.setTitle(source.getTitle()); row.setError(source.getError()); 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 index 9986777..0ac7c5c 100644 --- 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 @@ -87,7 +87,7 @@ public class AppearancePatentController { public ApiResponse history( @Parameter(description = "当前用户 ID,用于查询该用户自己的历史记录。", required = true, example = "1") @RequestParam("user_id") Long userId, - @Parameter(description = "history limit, default 50, max 100", example = "50") + @Parameter(description = "历史记录条数,默认 50,最大 100", example = "50") @RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) { return ApiResponse.success(service.history(userId, limit)); } 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 index ca2e087..a2f0163 100644 --- 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 @@ -33,10 +33,14 @@ public class AppearancePatentResultRowDto { @JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"}) private String sku; - @Schema(description = "Python collected product brand. Preferred over the source Excel brand when exporting.", example = "Nike") + @Schema(description = "Python 采集到的商品品牌。导出时优先于源 Excel 中的品牌。", example = "Nike") @JsonAlias({"Brand", "品牌"}) private String brand; + @Schema(description = "Python 采集并通过结果接口回传的商品价格。最终导出不再使用源 Excel 价格。", example = "12.99") + @JsonAlias({"Price", "价格"}) + private String price; + @Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg") @JsonAlias({ "imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url", @@ -58,7 +62,7 @@ public class AppearancePatentResultRowDto { private Boolean done; @JsonAlias({"row_status", "rowStatus", "Status"}) - @Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY) private String status; @Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) @@ -75,15 +79,15 @@ public class AppearancePatentResultRowDto { private String conclusion; @JsonAlias({"title_reason", "titleReason"}) - @Schema(description = "Coze title reason", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String titleReason; @JsonAlias({"appearance_reason", "appearanceReason"}) - @Schema(description = "Coze appearance reason", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String appearanceReason; @JsonAlias({"patent_reason", "patentReason", "patent reason"}) - @Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String patentReason; @JsonAlias({"score", "Score", "评分"}) 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 index 3fecb82..9f3afea 100644 --- 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 @@ -1036,7 +1036,12 @@ public class AppearancePatentTaskService { } Map persistedRows = readChunkRows(chunk); for (AppearancePatentResultRowDto row : rows) { - persistedRows.put(rowKey(row), row); + String key = rowKey(row); + AppearancePatentResultRowDto submittedRow = persistedRows.get(key); + if (submittedRow != null) { + row.setPrice(submittedRow.getPrice()); + } + persistedRows.put(key, row); } String payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "appearance patent chunk payload merge failed"); String oldPayload = chunk.getPayloadJson(); @@ -3328,7 +3333,7 @@ public class AppearancePatentTaskService { row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name")); row.createCell(col++).setCellValue(resolveBrand(resultRow, parsedRow)); - row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getPrice(), "")); + row.createCell(col++).setCellValue(resolvePrice(resultRow)); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle())); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl())); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku())); @@ -4190,6 +4195,10 @@ public class AppearancePatentTaskService { return ""; } + static String resolvePrice(AppearancePatentResultRowDto resultRow) { + return resultRow == null || resultRow.getPrice() == null ? "" : resultRow.getPrice().trim(); + } + private boolean hasInfringingPersistedConclusion(Long taskId) { if (taskId == null) { return false; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitRowDto.java index e37b298..949ef1e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/model/dto/CollectDataSubmitRowDto.java @@ -14,7 +14,7 @@ public class CollectDataSubmitRowDto { private String brand; @JsonAlias({"ASIN", "asin"}) - @Schema(description = "ASIN", example = "B0XXXXXXX") + @Schema(description = "商品 ASIN", example = "B0XXXXXXX") private String asin; @JsonAlias({"价格", "price"}) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java index 654d0f7..2d92320 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java @@ -149,7 +149,7 @@ public class DeleteBrandRunController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "Download one delete-brand result") + @Operation(summary = "下载单个删除品牌结果") public void downloadResult( @PathVariable Long resultId, @Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java index a026029..26b3bf3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java @@ -100,7 +100,7 @@ public class ImageVideoController { } @GetMapping("/tasks/{taskId}") - @Operation(summary = "Query image video async task") + @Operation(summary = "查询图生视频异步任务") public ApiResponse getTask( @PathVariable Long taskId, @RequestParam("user_id") Long userId) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveService.java index 66954af..695ed38 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveService.java @@ -35,6 +35,8 @@ public class ImageVideoArchiveService { static final String TASK_TYPE = "IMAGE_VIDEO_WORKFLOW"; private static final int BATCH_SIZE = 20; + private static final int RETENTION_CLEANUP_BATCH_SIZE = 200; + private static final int RETENTION_DAYS = 3; private static final Set VIDEO_KEYS = Set.of( "videourl", "video", "outputvideourl", "resultvideourl" ); @@ -103,6 +105,47 @@ public class ImageVideoArchiveService { } } + @Scheduled(fixedDelayString = "${aiimage.image-video.retention-cleanup-delay-ms:600000}") + public void cleanupExpiredWorkflowTasks() { + LocalDateTime cutoff = LocalDateTime.now().minusDays(RETENTION_DAYS); + List expiredTasks = taskMapper.selectList( + new LambdaQueryWrapper() + .eq(ImageVideoAsyncTaskEntity::getTaskType, TASK_TYPE) + .lt(ImageVideoAsyncTaskEntity::getSubmittedAt, cutoff) + .orderByAsc(ImageVideoAsyncTaskEntity::getId) + .last("LIMIT " + RETENTION_CLEANUP_BATCH_SIZE)); + int deleted = 0; + for (ImageVideoAsyncTaskEntity task : expiredTasks) { + if (!deleteArchivedObjects(task)) { + continue; + } + deleted += taskMapper.deleteById(task.getId()); + } + if (deleted > 0) { + log.info("[image-video] expired workflow task cleanup deleted={} cutoff={}", deleted, cutoff); + } + } + + private boolean deleteArchivedObjects(ImageVideoAsyncTaskEntity task) { + Set objectKeys = new LinkedHashSet<>(); + for (Map archivedVideo : readArchivedVideos(task.getArchivedVideosJson())) { + Object objectKey = archivedVideo.get("objectKey"); + if (objectKey != null && !String.valueOf(objectKey).isBlank()) { + objectKeys.add(String.valueOf(objectKey)); + } + } + for (String objectKey : objectKeys) { + try { + ossStorageService.deleteObject(objectKey); + } catch (Exception ex) { + log.warn("[image-video] expired task OSS cleanup failed taskId={} objectKey={} error={}", + task.getId(), objectKey, ex.getMessage()); + return false; + } + } + return true; + } + private void backfillDerivedFields(ImageVideoAsyncTaskEntity task) { if (task.getArchiveStatus() != null) { return; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java index 614e20a..cd4712b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java @@ -32,6 +32,7 @@ public class ImageVideoAsyncTaskService { private static final int DISPATCH_BATCH_SIZE = 20; private static final int POLL_BATCH_SIZE = 50; + private static final int FAILED_TASK_RETENTION_MINUTES = 10; private static final Set TERMINAL_STATUSES = Set.of( "SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED", "FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED" @@ -102,7 +103,11 @@ public class ImageVideoAsyncTaskService { throw new BusinessException("Image video task not found"); } task = recoverFalseFailedTask(task); - return toVo(task); + ImageVideoAsyncTaskVo result = toVo(task); + if (TaskStatus.FAILED.name().equals(task.getStatus())) { + taskMapper.deleteById(task.getId()); + } + return result; } @Scheduled(fixedDelayString = "${aiimage.image-video.async-task-dispatch-delay-ms:1000}") @@ -123,6 +128,17 @@ public class ImageVideoAsyncTaskService { tasks.forEach(task -> cozeTaskExecutor.execute(() -> pollTask(task.getId()))); } + @Scheduled(fixedDelayString = "${aiimage.image-video.failed-task-cleanup-delay-ms:60000}") + public void cleanupFailedTasks() { + LocalDateTime cutoff = LocalDateTime.now().minusMinutes(FAILED_TASK_RETENTION_MINUTES); + int deleted = taskMapper.delete(new LambdaQueryWrapper() + .eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.FAILED.name()) + .lt(ImageVideoAsyncTaskEntity::getUpdatedAt, cutoff)); + if (deleted > 0) { + log.info("[image-video] removed expired failed tasks count={}", deleted); + } + } + private ImageVideoAsyncTaskVo submit(TaskType type, Long userId, Object payload) { if (userId == null || userId <= 0) { throw new BusinessException("userId is required"); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java index 7776071..c4eeba0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java @@ -156,7 +156,7 @@ public class PatrolDeleteController { public ApiResponse history( @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @RequestParam("user_id") Long userId, - @Parameter(description = "history limit, default 30, max 100", example = "30") + @Parameter(description = "历史记录条数,默认 30,最大 100", example = "30") @RequestParam(value = "limit", required = false) Integer limit) { return ApiResponse.success(patrolDeleteTaskService.listHistory(userId, limit)); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index 54ed17e..ba5f493 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -34,6 +34,7 @@ public class PermissionMenuService { public static final String MENU_TYPE_APP = "app"; public static final String MENU_TYPE_ADMIN = "admin"; + private static final String IMAGE_VIDEO_DATA_PERMISSION_KEY = "admin_image_video_task_data"; private final PermissionMenuMapper permissionMenuMapper; private final UserColumnPermissionMapper userColumnPermissionMapper; @@ -166,6 +167,22 @@ public class PermissionMenuService { } } + PermissionMenuEntity imageVideoDataPermission = permissionMenuMapper.selectOne( + new LambdaQueryWrapper() + .eq(PermissionMenuEntity::getColumnKey, IMAGE_VIDEO_DATA_PERMISSION_KEY) + .last("LIMIT 1")); + if (imageVideoDataPermission != null) { + requestedIds = new ArrayList<>(requestedIds); + Long existingCount = userColumnPermissionMapper.selectCount( + new LambdaQueryWrapper() + .eq(UserColumnPermissionEntity::getUserId, userId) + .eq(UserColumnPermissionEntity::getColumnId, imageVideoDataPermission.getId())); + requestedIds.remove(imageVideoDataPermission.getId()); + if (existingCount != null && existingCount > 0) { + requestedIds.add(imageVideoDataPermission.getId()); + } + } + userColumnPermissionMapper.delete(new LambdaUpdateWrapper() .eq(UserColumnPermissionEntity::getUserId, userId)); for (Long columnId : requestedIds) { 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 afbe0eb..02a79c9 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 @@ -5,6 +5,7 @@ import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackDispatchFailedRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest; @@ -145,7 +146,7 @@ public class PriceTrackController { @PathVariable Long taskId, @Parameter(description = "国家代码", required = true, example = "DE") @RequestParam("country") String country, - @Parameter(description = "ASIN", required = true, example = "B0XXXX") + @Parameter(description = "商品 ASIN", required = true, example = "B0XXXX") @RequestParam("asin") String asin) { return ApiResponse.success(priceTrackTaskService.checkTaskSkipAsin(taskId, country, asin)); } @@ -182,6 +183,16 @@ public class PriceTrackController { return ApiResponse.success(priceTrackTaskService.createTask(request)); } + @PostMapping("/tasks/{taskId}/dispatch-failed") + @Operation(summary = "标记 Python 入队失败", description = "前端创建任务后若 Python 队列拒绝接收,立即将任务及占位店铺标记为失败。") + public ApiResponse markDispatchFailed( + @PathVariable Long taskId, + @RequestParam("user_id") Long userId, + @Valid @RequestBody PriceTrackDispatchFailedRequest request) { + priceTrackTaskService.markDispatchFailed(taskId, userId, request.getErrorMessage()); + return ApiResponse.success(null); + } + @PostMapping("/loop-runs") @Operation(summary = "创建跟价循环任务", description = "创建整批店铺的多轮循环主任务。") public ApiResponse createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java index 204fb92..212a450 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java @@ -33,12 +33,12 @@ public class PriceTrackCreateTaskRequest { @NotEmpty(message = "国家列表不能为空") @Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]") private List countryCodes; - @Schema(description = "optional loop run id", example = "1") + @Schema(description = "可选的循环运行 ID", example = "1") private Long loopRunId; - @Schema(description = "optional round index, 1-based", example = "2") + @Schema(description = "可选的轮次序号,从 1 开始", example = "2") private Integer roundIndex; - @Schema(description = "optional shop index, 0-based", example = "0") + @Schema(description = "可选的店铺序号,从 0 开始", example = "0") private Integer shopIndex; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackDispatchFailedRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackDispatchFailedRequest.java new file mode 100644 index 0000000..e3ed14f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackDispatchFailedRequest.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.pricetrack.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "跟价任务推送 Python 队列失败请求") +public class PriceTrackDispatchFailedRequest { + + @NotBlank(message = "errorMessage不能为空") + @Schema(description = "Python 队列拒绝或调用失败的原始原因", example = "当前店铺正在执行中") + private String errorMessage; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunCreateRequest.java index 7aa353b..43052b1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunCreateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackLoopRunCreateRequest.java @@ -32,7 +32,7 @@ public class PriceTrackLoopRunCreateRequest { private List countryCodes = new ArrayList<>(); @NotBlank(message = "executionMode不能为空") - @Schema(description = "FINITE or INFINITE", example = "FINITE") + @Schema(description = "循环模式:FINITE 表示有限次数,INFINITE 表示无限循环", example = "FINITE") private String executionMode; @Schema(description = "固定轮次时必填", example = "3") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackMatchShopsRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackMatchShopsRequest.java index c405caf..e06c976 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackMatchShopsRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackMatchShopsRequest.java @@ -8,20 +8,20 @@ import lombok.Data; import java.util.List; @Data -@Schema(description = "Price track match shops request") +@Schema(description = "跟价店铺匹配请求") public class PriceTrackMatchShopsRequest { @NotNull(message = "userId is required") - @Schema(description = "Current user id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") private Long userId; @NotEmpty(message = "shopNames cannot be empty") - @Schema(description = "Shop names to match", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "需要匹配的店铺名称", requiredMode = Schema.RequiredMode.REQUIRED) private List shopNames; - @Schema(description = "Selected ASIN file local paths") + @Schema(description = "已选择的 ASIN 文件本地路径") private List asinFiles; - @Schema(description = "Selected country codes") + @Schema(description = "已选择的国家代码") private List countryCodes; } 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 544b064..8bd800c 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 @@ -65,7 +65,7 @@ public class PriceTrackSubmitResultRequest { @Schema(description = "店铺商城名称", example = "Amazon.de") private String shopMallName; - @Schema(description = "ASIN", example = "B0ABCDE123") + @Schema(description = "商品 ASIN", example = "B0ABCDE123") private String asin; @Schema(description = "价格", example = "19.99") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java index 50f998c..effad2e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java @@ -7,23 +7,23 @@ import java.util.List; import java.util.Map; @Data -@Schema(description = "price track create task response") +@Schema(description = "跟价任务创建响应") public class PriceTrackCreateTaskVo { - @Schema(description = "task id", example = "200") + @Schema(description = "任务 ID", example = "200") private Long taskId; - @Schema(description = "initial task items") + @Schema(description = "初始任务项") private List items; - @Schema(description = "all skip asins grouped by country") + @Schema(description = "按国家分组的全部跳过跟价 ASIN") private Map> skipAsinsByCountry; - @Schema(description = "all skip asin details grouped by country code, each item includes asin and minimumPrice") + @Schema(description = "按国家代码分组的全部跳过跟价 ASIN 明细,每项包含 asin 和 minimumPrice") private Map>> skipAsinDetailsByCountry; - @Schema(description = "parsed asin rows by country") + @Schema(description = "按国家分组的已解析 ASIN 行") private Map>> asinRowsByCountry; - @Schema(description = "minimum price grouped by country code and asin") + @Schema(description = "按国家代码和 ASIN 分组的最低价格") private Map> minimumPriceByCountryAndAsin; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java index 8475179..b3c48e0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java @@ -8,53 +8,53 @@ import java.util.List; import java.util.Map; @Data -@Schema(description = "Price track shop match response") +@Schema(description = "跟价店铺匹配响应") public class PriceTrackMatchShopsVo { - @Schema(description = "Match results in the same order as requested shop names") + @Schema(description = "匹配结果,顺序与请求中的店铺名称一致") private List items = new ArrayList<>(); - @Schema(description = "All skip ASINs grouped by country code") + @Schema(description = "按国家代码分组的全部跳过跟价 ASIN") private Map> skipAsinsByCountry; - @Schema(description = "All skip ASIN details grouped by country code, each item includes asin and minimumPrice") + @Schema(description = "按国家代码分组的全部跳过跟价 ASIN 明细,每项包含 asin 和 minimumPrice") private Map>> skipAsinDetailsByCountry; - @Schema(description = "Parsed asin rows grouped by country code") + @Schema(description = "按国家代码分组的已解析 ASIN 行") private Map>> asinRowsByCountry; - @Schema(description = "Minimum price grouped by country code and asin") + @Schema(description = "按国家代码和 ASIN 分组的最低价格") private Map> minimumPriceByCountryAndAsin; @Data - @Schema(description = "Single matched shop item") + @Schema(description = "单个店铺匹配结果") public static class PriceTrackShopQueueItem { - @Schema(description = "Shop name", example = "Demo Shop") + @Schema(description = "店铺名称", example = "Demo Shop") private String shopName; - @Schema(description = "Shop mall name", example = "Amazon.de") + @Schema(description = "店铺商城名称", example = "Amazon.de") private String shopMallName; - @Schema(description = "Matched shop id") + @Schema(description = "匹配到的店铺 ID") private Object shopId; - @Schema(description = "Platform", example = "Amazon") + @Schema(description = "平台", example = "Amazon") private String platform; - @Schema(description = "Company name") + @Schema(description = "公司名称") private String companyName; - @Schema(description = "Whether the shop matched") + @Schema(description = "店铺是否匹配成功") private Boolean matched; - @Schema(description = "Match status, such as MATCHED or PENDING") + @Schema(description = "匹配状态,例如 MATCHED 或 PENDING") private String matchStatus; - @Schema(description = "Readable match message") + @Schema(description = "可读的匹配结果说明") private String matchMessage; - @Schema(description = "All skip ASINs grouped by country code") + @Schema(description = "按国家代码分组的全部跳过跟价 ASIN") private Map> skipAsins; } } 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 35063af..b5c8817 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 @@ -17,7 +17,7 @@ public class PriceTrackResultItemVo { @Schema(description = "店铺名称,详情页会尽量恢复创建任务时的展示名") private String shopName; - @Schema(description = "Shop mall name") + @Schema(description = "店铺商城名称") private String shopMallName; @Schema(description = "店铺 ID(紫鸟)") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinCheckVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinCheckVo.java index 5326940..9abdf6e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinCheckVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/SkipPriceAsinCheckVo.java @@ -4,18 +4,18 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data -@Schema(description = "single skip price asin lookup response") +@Schema(description = "单个跳过跟价 ASIN 查询响应") public class SkipPriceAsinCheckVo { - @Schema(description = "country code", example = "DE") + @Schema(description = "国家代码", example = "DE") private String country; - @Schema(description = "normalized asin", example = "B0XXXX") + @Schema(description = "规范化后的 ASIN", example = "B0XXXX") private String asin; - @Schema(description = "whether the asin exists in skip price asin table") + @Schema(description = "该 ASIN 是否存在于跳过跟价 ASIN 表中") private boolean exists; - @Schema(description = "configured minimum price, empty when not configured or not found") + @Schema(description = "已配置的最低价格;未配置或未找到时为空") private String minimumPrice; } 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 dde33ab..72be048 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 @@ -277,6 +277,46 @@ public class PriceTrackTaskService { } } + @Transactional + public void markDispatchFailed(Long taskId, Long userId, String errorMessage) { + if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); + if (errorMessage == null || errorMessage.isBlank()) throw new BusinessException("errorMessage 不能为空"); + String normalizedError = errorMessage.trim(); + try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { + FileTaskEntity task = loadTaskForExecution(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { + throw new BusinessException("任务不存在"); + } + if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { + return; + } + List results = fileResultMapper.selectList( + new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId)); + if (results.isEmpty()) { + task.setStatus("FAILED"); + task.setErrorMessage(normalizedError); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + cleanupTaskCacheIfTerminal(taskId, task.getStatus()); + priceTrackLoopRunService.syncLoopRunAfterChildTerminal(taskId); + return; + } + for (FileResultEntity result : results) { + boolean succeeded = result.getSuccess() != null && result.getSuccess() == 1; + boolean failed = result.getErrorMessage() != null && !result.getErrorMessage().isBlank(); + if (!succeeded && !failed) { + markResultFailed(result, normalizedError); + } + } + updateTaskStatusFromLatestRows(task, results); + log.warn("[price-track] Python dispatch failed taskId={} userId={} error={}", taskId, userId, normalizedError); + } + } + @Transactional public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) { if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/dto/ProductRiskRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/dto/ProductRiskRowDto.java index a0a059f..99215ba 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/dto/ProductRiskRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/model/dto/ProductRiskRowDto.java @@ -4,6 +4,9 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import java.util.ArrayList; +import java.util.List; + @Data @Schema(description = "单国 sheet 内一行数据,与商品风险处理 Excel 模板列一致") public class ProductRiskRowDto { @@ -30,4 +33,19 @@ public class ProductRiskRowDto { @JsonProperty("removeStatus") @Schema(description = "移除状态") private String removeStatus; + + @JsonProperty("violationDetails") + @Schema(description = "该 ASIN 的逐项违规处理明细") + private List violationDetails = new ArrayList<>(); + + @Data + @Schema(description = "单项违规处理结果") + public static class ViolationDetail { + + @Schema(description = "违规原因标题", example = "商品安全违规") + private String reason; + + @Schema(description = "处理状态", allowableValues = {"成功", "失败"}) + private String status; + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java index c2411b5..e227743 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyService.java @@ -5,8 +5,13 @@ import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskRowDto; import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode; 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.BorderStyle; +import org.apache.poi.ss.usermodel.HorizontalAlignment; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.apache.poi.ss.util.CellRangeAddress; import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; @@ -15,6 +20,7 @@ import java.io.FileOutputStream; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; @Service @Slf4j @@ -30,7 +36,10 @@ public class ProductRiskExcelAssemblyService { "状态", "完成", "移除ASIN", - "移除状态" + "移除状态", + "ASIN", + "原因", + "状态" }; public void writeWorkbook(File outputXlsx, String shopDisplayName, Map> countries) { @@ -45,16 +54,38 @@ public class ProductRiskExcelAssemblyService { headerRow.createCell(c).setCellValue(HEADER[c]); } List rows = safe.getOrDefault(sheetName, List.of()); + CellStyle detailStyle = createDetailStyle(workbook); + CellStyle detailAsinStyle = createDetailAsinStyle(workbook); int rowIdx = 1; for (ProductRiskRowDto dto : rows) { - Row row = sheet.createRow(rowIdx++); - createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName)); - createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku()); - createTextCell(row, 2, dto == null ? null : dto.getStatus()); - createTextCell(row, 3, completionDisplayValue(dto)); - createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin()); - createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus()); + List details = violationDetails(dto); + int groupSize = Math.max(1, details.size()); + String asin = firstNonBlank( + dto == null ? null : dto.getProductAsinSku(), + dto == null ? null : dto.getRemoveAsin()); + for (int offset = 0; offset < groupSize; offset++) { + Row row = sheet.createRow(rowIdx + offset); + if (offset == 0) { + createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName)); + createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku()); + createTextCell(row, 2, dto == null ? null : dto.getStatus()); + createTextCell(row, 3, completionDisplayValue(dto)); + createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin()); + createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus()); + } + if (offset < details.size()) { + ProductRiskRowDto.ViolationDetail detail = details.get(offset); + createStyledTextCell(row, 6, asin, detailAsinStyle); + createStyledTextCell(row, 7, detail.getReason(), detailStyle); + createStyledTextCell(row, 8, detail.getStatus(), detailStyle); + } + } + if (details.size() > 1) { + sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx + details.size() - 1, 6, 6)); + } + rowIdx += groupSize; } + applyDetailHeaderStyle(headerRow, detailStyle); applyDefaultColumnWidths(sheet); } workbook.write(fos); @@ -104,6 +135,41 @@ public class ProductRiskExcelAssemblyService { cell.setCellValue(value == null ? "" : value); } + private static void createStyledTextCell(Row row, int index, String value, CellStyle style) { + Cell cell = row.createCell(index); + cell.setCellValue(value == null ? "" : value); + cell.setCellStyle(style); + } + + private static CellStyle createDetailStyle(SXSSFWorkbook workbook) { + CellStyle style = workbook.createCellStyle(); + style.setBorderTop(BorderStyle.THIN); + style.setBorderBottom(BorderStyle.THIN); + style.setBorderLeft(BorderStyle.THIN); + style.setBorderRight(BorderStyle.THIN); + style.setVerticalAlignment(VerticalAlignment.CENTER); + return style; + } + + private static CellStyle createDetailAsinStyle(SXSSFWorkbook workbook) { + CellStyle style = createDetailStyle(workbook); + style.setAlignment(HorizontalAlignment.CENTER); + return style; + } + + private static void applyDetailHeaderStyle(Row headerRow, CellStyle style) { + for (int column = 6; column <= 8; column++) { + headerRow.getCell(column).setCellStyle(style); + } + } + + private static List violationDetails(ProductRiskRowDto row) { + if (row == null || row.getViolationDetails() == null) { + return List.of(); + } + return row.getViolationDetails().stream().filter(Objects::nonNull).toList(); + } + private static String firstNonBlank(String primary, String fallback) { if (primary != null && !primary.isBlank()) { return primary; @@ -150,7 +216,7 @@ public class ProductRiskExcelAssemblyService { } private void applyDefaultColumnWidths(Sheet sheet) { - int[] widths = {20, 22, 18, 10, 18, 18}; + int[] widths = {20, 22, 18, 10, 18, 18, 18, 24, 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/productrisk/service/ProductRiskTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java index 433f053..d451a34 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 @@ -1245,6 +1245,8 @@ public class ProductRiskTaskService { merged.setDone(incoming.getDone() != null ? incoming.getDone() : merged.getDone()); merged.setRemoveAsin(firstNonBlank(incoming.getRemoveAsin(), merged.getRemoveAsin())); merged.setRemoveStatus(firstNonBlank(incoming.getRemoveStatus(), merged.getRemoveStatus())); + merged.setViolationDetails(mergeViolationDetails( + merged.getViolationDetails(), incoming.getViolationDetails())); return merged; } @@ -1259,6 +1261,31 @@ public class ProductRiskTaskService { out.setDone(row.getDone()); out.setRemoveAsin(row.getRemoveAsin()); out.setRemoveStatus(row.getRemoveStatus()); + out.setViolationDetails(cloneViolationDetails(row.getViolationDetails())); + return out; + } + + static List mergeViolationDetails( + List existing, + List incoming) { + return cloneViolationDetails(incoming == null || incoming.isEmpty() ? existing : incoming); + } + + private static List cloneViolationDetails( + List details) { + if (details == null || details.isEmpty()) { + return new ArrayList<>(); + } + List out = new ArrayList<>(details.size()); + for (ProductRiskRowDto.ViolationDetail detail : details) { + if (detail == null) { + continue; + } + ProductRiskRowDto.ViolationDetail copy = new ProductRiskRowDto.ViolationDetail(); + copy.setReason(detail.getReason()); + copy.setStatus(detail.getStatus()); + out.add(copy); + } return out; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinAsinStatusDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinAsinStatusDto.java index ed11fc1..9500ccd 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinAsinStatusDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinAsinStatusDto.java @@ -7,7 +7,7 @@ import lombok.Data; @Schema(description = "查询 ASIN 单条结果") public class QueryAsinAsinStatusDto { - @Schema(description = "ASIN", example = "B0BRZZR3N2") + @Schema(description = "商品 ASIN", example = "B0BRZZR3N2") private String asin; @Schema(description = "Python 查询后返回的状态", example = "正常") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java index 8fc1869..cee07d4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java @@ -48,7 +48,7 @@ public class QueryAsinController { @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize, @Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "店铺名") @RequestParam(required = false) String shopName, - @Parameter(description = "ASIN") @RequestParam(required = false) String asin, + @Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin, @Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { return ApiResponse.success(queryAsinService.page( @@ -66,7 +66,7 @@ public class QueryAsinController { public ResponseEntity export( @Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "店铺名") @RequestParam(required = false) String shopName, - @Parameter(description = "ASIN") @RequestParam(required = false) String asin, + @Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin, @Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { byte[] bytes = queryAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java index d78fbf9..e72dcaa 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java @@ -48,7 +48,7 @@ public class SkipPriceAsinController { @Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize, @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName, - @Parameter(description = "ASIN") @RequestParam(name = "asin", required = false) String asin, + @Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin, @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId, @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { return ApiResponse.success(skipPriceAsinService.page( @@ -66,7 +66,7 @@ public class SkipPriceAsinController { public ResponseEntity export( @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName, - @Parameter(description = "ASIN") @RequestParam(name = "asin", required = false) String asin, + @Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin, @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId, @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/QueryAsinCountryUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/QueryAsinCountryUpdateRequest.java index 59e9704..4e223da 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/QueryAsinCountryUpdateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/QueryAsinCountryUpdateRequest.java @@ -7,7 +7,7 @@ import lombok.Data; @Data public class QueryAsinCountryUpdateRequest { - @Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "商品 ASIN", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "ASIN 不能为空") private String asin; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCountryUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCountryUpdateRequest.java index 049cd53..6d1c99c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCountryUpdateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinCountryUpdateRequest.java @@ -9,7 +9,7 @@ import java.math.BigDecimal; @Data public class SkipPriceAsinCountryUpdateRequest { - @Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "商品 ASIN", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "ASIN 不能为空") private String asin; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinDetailDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinDetailDto.java index a17e5cf..5199ac3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinDetailDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/SkipPriceAsinDetailDto.java @@ -8,7 +8,7 @@ import lombok.Data; @Schema(description = "跳过 ASIN 明细") public class SkipPriceAsinDetailDto { - @Schema(description = "ASIN") + @Schema(description = "商品 ASIN") private String asin; @JsonProperty("minimumPrice") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchRowDto.java index cf2e9b4..c6818cb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/model/dto/ShopMatchRowDto.java @@ -9,7 +9,7 @@ import lombok.Data; @Schema(description = "匹配店铺单行结果") public class ShopMatchRowDto { - @Schema(description = "ASIN") + @Schema(description = "商品 ASIN") private String asin; @JsonAlias("minimum_price") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java index 5c6a291..ac0cf47 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java @@ -109,7 +109,7 @@ public class SimilarAsinController { public ApiResponse history( @Parameter(description = "当前用户 ID", required = true, example = "1") @RequestParam("user_id") Long userId, - @Parameter(description = "history limit, default 50, max 100", example = "50") + @Parameter(description = "历史记录条数,默认 50,最大 100", example = "50") @RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) { return ApiResponse.success(service.history(userId, limit)); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java index cb2c9c3..20ea9e8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java @@ -45,7 +45,7 @@ public class SimilarAsinResultRowDto { private String price; @JsonAlias({"alibaba", "alibaba_items", "alibabaItems", "alibaba_products", "alibabaProducts"}) - @Schema(description = "Alibaba candidates returned by Python, each item contains url and price") + @Schema(description = "Python 返回的阿里巴巴候选商品,每项包含 URL 和价格") private List alibaba = new ArrayList<>(); @Schema(description = "商品主图 URL。Python 端解析的代表图。允许传字符串或数组(数组取首个非空),与 urls 互不影响。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg") @@ -85,7 +85,7 @@ public class SimilarAsinResultRowDto { private Boolean done; @JsonAlias({"row_status", "rowStatus", "Status"}) - @Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY) private String status; @Schema(description = "兼容旧版 Coze 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) @@ -102,15 +102,15 @@ public class SimilarAsinResultRowDto { private String conclusion; @JsonAlias({"title_reason", "titleReason"}) - @Schema(description = "Coze title reason", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String titleReason; @JsonAlias({"appearance_reason", "appearanceReason"}) - @Schema(description = "Coze appearance reason", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String appearanceReason; @JsonAlias({"patent_reason", "patentReason", "patent reason"}) - @Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Coze 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String patentReason; @JsonAlias({"main_url", "mainUrl", "main_image_url", "mainImageUrl", "main_img", "mainImg", "主图URL", "主图链接"}) @@ -270,15 +270,15 @@ public class SimilarAsinResultRowDto { } @Data - @Schema(description = "Alibaba candidate") + @Schema(description = "阿里巴巴候选商品") public static class AlibabaItem { @JsonAlias({"url", "image_url", "imageUrl", "img_url", "imgUrl", "link"}) - @Schema(description = "Alibaba image or product URL") + @Schema(description = "阿里巴巴商品图片或商品 URL") private String url; @JsonAlias({"price", "浠锋牸"}) - @Schema(description = "Alibaba candidate price") + @Schema(description = "阿里巴巴候选商品价格") private Object price; public String getUrl() { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java index 8f0f25a..94c7005 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/controller/TaskHeartbeatController.java @@ -18,17 +18,17 @@ import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @RequestMapping("/api/tasks") -@Tag(name = "Task heartbeat", description = "Unified heartbeat API for Python task workers") +@Tag(name = "任务心跳", description = "供 Python 任务执行器统一上报运行状态和进度的心跳接口") public class TaskHeartbeatController { private final TaskHeartbeatService taskHeartbeatService; @PostMapping("/{taskId}/heartbeat") @Operation( - summary = "Task heartbeat", - description = "Python calls this during execution. The backend resolves biz_file_task or brand task by taskId only.") + summary = "上报任务心跳", + description = "Python 在任务执行期间调用。后端仅根据 taskId 识别文件任务或品牌任务并更新心跳。") public ApiResponse heartbeat( - @Parameter(description = "Task ID", required = true, example = "200") + @Parameter(description = "任务 ID", required = true, example = "200") @PathVariable Long taskId, @Valid @RequestBody(required = false) TaskHeartbeatRequest request) { return ApiResponse.success(taskHeartbeatService.heartbeat(taskId, request)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskHeartbeatRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskHeartbeatRequest.java index b7c0a90..453efcb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskHeartbeatRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskHeartbeatRequest.java @@ -6,15 +6,15 @@ import lombok.Data; @Data @JsonIgnoreProperties(ignoreUnknown = true) -@Schema(description = "Task heartbeat request") +@Schema(description = "任务心跳请求") public class TaskHeartbeatRequest { - @Schema(description = "Optional phase label", example = "crawling") + @Schema(description = "可选的任务阶段标识", example = "crawling") private String phase; - @Schema(description = "Optional current progress", example = "42") + @Schema(description = "可选的当前进度", example = "42") private Integer current; - @Schema(description = "Optional total progress", example = "1100") + @Schema(description = "可选的总进度", example = "1100") private Integer total; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskHeartbeatVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskHeartbeatVo.java index 214fb03..28eb07e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskHeartbeatVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskHeartbeatVo.java @@ -4,19 +4,19 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data -@Schema(description = "Task heartbeat response") +@Schema(description = "任务心跳响应") public class TaskHeartbeatVo { - @Schema(description = "Whether the task can continue", example = "true") + @Schema(description = "任务是否可以继续执行", example = "true") private boolean alive; - @Schema(description = "Task status", example = "RUNNING") + @Schema(description = "任务状态", example = "RUNNING") private String status; - @Schema(description = "Resolved task module", example = "PRODUCT_RISK_RESOLVE") + @Schema(description = "识别出的任务模块", example = "PRODUCT_RISK_RESOLVE") private String moduleType; - @Schema(description = "Message", example = "ok") + @Schema(description = "提示信息", example = "ok") private String message; public static TaskHeartbeatVo alive(String moduleType, String status) { diff --git a/backend-java/src/main/resources/db/V74__image_video_task_data_permission.sql b/backend-java/src/main/resources/db/V74__image_video_task_data_permission.sql new file mode 100644 index 0000000..c3d7955 --- /dev/null +++ b/backend-java/src/main/resources/db/V74__image_video_task_data_permission.sql @@ -0,0 +1,16 @@ +INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`) +SELECT '视频任务数据查看', 'admin_image_video_task_data', 'internal', 'image-video-task-data', 0 +WHERE NOT EXISTS ( + SELECT 1 FROM `columns` WHERE `column_key` = 'admin_image_video_task_data' +); + +INSERT IGNORE INTO `user_column_permission` (`user_id`, `column_id`) +SELECT old_perm.`user_id`, data_col.`id` +FROM `user_column_permission` old_perm +INNER JOIN `columns` old_col ON old_col.`id` = old_perm.`column_id` +INNER JOIN `columns` data_col ON data_col.`column_key` = 'admin_image_video_task_data' +LEFT JOIN `user_column_permission` existing + ON existing.`user_id` = old_perm.`user_id` + AND existing.`column_id` = data_col.`id` +WHERE old_col.`column_key` = 'admin_image_video_tasks' + AND existing.`user_id` IS NULL; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java index 3982e85..5d5f205 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java @@ -149,4 +149,19 @@ class AppearancePatentCozeClientTest { assertThat(merged.get(0).getAppearanceRisk()).isNullOrEmpty(); verify(brandCheckClient, never()).checkTitleText(anyString(), anyString()); } + + @Test + void preserveSubmittedPriceWhenMergingCozeResult() throws Exception { + AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); + row.setId("1"); + row.setPrice("12.99"); + String dataText = """ + {"data":[{"row_id":"1","title":"无","appearance":"无侵权","result":"无侵权"}]} + """; + + List merged = client.mergeRowsFromDataText(List.of(row), dataText); + + assertThat(merged).hasSize(1); + assertThat(merged.get(0).getPrice()).isEqualTo("12.99"); + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java index d40bc8d..dbdd9b9 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java @@ -34,4 +34,16 @@ class AppearancePatentTaskServiceTest { parsedRow.getValues().clear(); assertEquals("", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow)); } + + @Test + void resultPriceUsesOnlyPythonSubmittedValue() { + assertEquals("", AppearancePatentTaskService.resolvePrice(null)); + + AppearancePatentResultRowDto resultRow = new AppearancePatentResultRowDto(); + resultRow.setPrice(" 12.99 "); + assertEquals("12.99", AppearancePatentTaskService.resolvePrice(resultRow)); + + resultRow.setPrice(null); + assertEquals("", AppearancePatentTaskService.resolvePrice(resultRow)); + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveServiceTest.java index 5b540b6..23c5939 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoArchiveServiceTest.java @@ -13,10 +13,53 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; class ImageVideoArchiveServiceTest { + @Test + void deletesExpiredWorkflowTasksAndArchivedObjects() { + ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class); + OssStorageService ossStorageService = mock(OssStorageService.class); + ImageVideoArchiveService archiveService = new ImageVideoArchiveService( + taskMapper, ossStorageService, new ImageVideoProperties(), new ObjectMapper()); + ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity(); + task.setId(10L); + task.setTaskType(ImageVideoArchiveService.TASK_TYPE); + task.setArchivedVideosJson("[{\"objectKey\":\"result/image_video/10/video.mp4\"}]"); + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + when(taskMapper.deleteById(10L)).thenReturn(1); + + archiveService.cleanupExpiredWorkflowTasks(); + + verify(ossStorageService).deleteObject("result/image_video/10/video.mp4"); + verify(taskMapper).deleteById(10L); + } + + @Test + void keepsExpiredTaskWhenArchivedObjectDeletionFails() { + ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class); + OssStorageService ossStorageService = mock(OssStorageService.class); + ImageVideoArchiveService archiveService = new ImageVideoArchiveService( + taskMapper, ossStorageService, new ImageVideoProperties(), new ObjectMapper()); + ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity(); + task.setId(11L); + task.setTaskType(ImageVideoArchiveService.TASK_TYPE); + task.setArchivedVideosJson("[{\"objectKey\":\"result/image_video/11/video.mp4\"}]"); + when(taskMapper.selectList(any())).thenReturn(List.of(task)); + doThrow(new IllegalStateException("OSS unavailable")) + .when(ossStorageService).deleteObject("result/image_video/11/video.mp4"); + + archiveService.cleanupExpiredWorkflowTasks(); + + verify(taskMapper, never()).deleteById(11L); + } + @Test void extractsNestedAndMultipleVideoUrlsWithoutDuplicates() { Map result = Map.of( diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java index a178e04..7155452 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskServiceTest.java @@ -139,6 +139,69 @@ class ImageVideoAsyncTaskServiceTest { verify(taskMapper).updateById(task); } + @Test + void failedTaskIsReturnedOnceAndThenDeleted() { + ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class); + ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService( + taskMapper, + mock(ImageVideoCozeService.class), + mock(ImageVideoWorkflowConfigService.class), + mock(ImageVideoArchiveService.class), + new ObjectMapper(), + Runnable::run); + ImageVideoAsyncTaskEntity task = waitingWorkflowTask(); + task.setStatus("FAILED"); + task.setCozeStatus("FAIL"); + task.setErrorMessage("model unavailable"); + when(taskMapper.selectOne(any())).thenReturn(task); + + var result = service.getTask(98L, 1L); + + assertEquals("FAILED", result.getStatus()); + assertEquals("model unavailable", result.getErrorMessage()); + verify(taskMapper).deleteById(98L); + } + + @Test + void recoverableFailedTaskIsNotDeleted() { + ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class); + ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService( + taskMapper, + mock(ImageVideoCozeService.class), + mock(ImageVideoWorkflowConfigService.class), + mock(ImageVideoArchiveService.class), + new ObjectMapper(), + Runnable::run); + ImageVideoAsyncTaskEntity task = waitingWorkflowTask(); + task.setStatus("FAILED"); + task.setCozeStatus("RUNNING"); + task.setErrorMessage("Coze workflow finished with status RUNNING"); + when(taskMapper.selectOne(any())).thenReturn(task); + + var result = service.getTask(98L, 1L); + + assertEquals("WAITING", result.getStatus()); + verify(taskMapper).updateById(task); + verify(taskMapper, never()).deleteById(98L); + } + + @Test + void cleanupRemovesFailedTasksThatWereNotPolled() { + ImageVideoAsyncTaskMapper taskMapper = mock(ImageVideoAsyncTaskMapper.class); + ImageVideoAsyncTaskService service = new ImageVideoAsyncTaskService( + taskMapper, + mock(ImageVideoCozeService.class), + mock(ImageVideoWorkflowConfigService.class), + mock(ImageVideoArchiveService.class), + new ObjectMapper(), + Runnable::run); + when(taskMapper.delete(any())).thenReturn(3); + + service.cleanupFailedTasks(); + + verify(taskMapper).delete(any()); + } + private ImageVideoAsyncTaskEntity waitingDouyinTask() { ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity(); task.setId(79L); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java new file mode 100644 index 0000000..2dd4a72 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java @@ -0,0 +1,70 @@ +package com.nanri.aiimage.modules.permission.service; + +import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper; +import com.nanri.aiimage.modules.permission.mapper.PermissionMenuMapper; +import com.nanri.aiimage.modules.permission.mapper.UserColumnPermissionMapper; +import com.nanri.aiimage.modules.permission.model.dto.UserColumnPermissionUpdateRequest; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.permission.model.entity.PermissionMenuEntity; +import com.nanri.aiimage.modules.permission.model.entity.UserColumnPermissionEntity; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PermissionMenuServiceTest { + + @Test + void preservesExistingImageVideoPermissionDuringGenericReplacement() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + when(userMapper.selectById(7L)).thenReturn(new AdminUserEntity()); + when(menuMapper.selectCount(any())).thenReturn(1L); + when(menuMapper.selectOne(any())).thenReturn(imageVideoPermission()); + when(permissionMapper.selectCount(any())).thenReturn(1L); + + UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest(); + request.setColumnIds(List.of(2L)); + service.updateUserColumnPermissions(7L, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserColumnPermissionEntity.class); + verify(permissionMapper, times(2)).insert(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(UserColumnPermissionEntity::getColumnId) + .containsExactly(2L, 75L); + } + + @Test + void doesNotGrantImageVideoPermissionThroughGenericReplacement() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + when(userMapper.selectById(8L)).thenReturn(new AdminUserEntity()); + when(menuMapper.selectCount(any())).thenReturn(1L); + when(menuMapper.selectOne(any())).thenReturn(imageVideoPermission()); + when(permissionMapper.selectCount(any())).thenReturn(0L); + + UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest(); + request.setColumnIds(List.of(75L)); + service.updateUserColumnPermissions(8L, request); + + verify(permissionMapper, times(0)).insert(any(UserColumnPermissionEntity.class)); + } + + private PermissionMenuEntity imageVideoPermission() { + PermissionMenuEntity entity = new PermissionMenuEntity(); + entity.setId(75L); + entity.setColumnKey("admin_image_video_tasks"); + return entity; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java new file mode 100644 index 0000000..56f6ccf --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java @@ -0,0 +1,89 @@ +package com.nanri.aiimage.modules.pricetrack.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.config.TaskPressureProperties; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper; +import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService; +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.service.TaskDistributedLockService; +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PriceTrackTaskServiceTest { + + @Mock private FileTaskMapper fileTaskMapper; + @Mock private FileResultMapper fileResultMapper; + @Mock private PriceTrackShopCandidateMapper candidateMapper; + @Mock private ZiniaoShopSwitchService ziniaoShopSwitchService; + @Mock private OssStorageService ossStorageService; + @Mock private ObjectMapper objectMapper; + @Mock private SkipPriceAsinService skipPriceAsinService; + @Mock private PriceTrackExcelAssemblyService excelAssemblyService; + @Mock private PriceTrackTaskCacheService priceTrackTaskCacheService; + @Mock private PriceTrackLoopRunService priceTrackLoopRunService; + @Mock private TaskPressureProperties taskPressureProperties; + @Mock private TaskResultPayloadService taskResultPayloadService; + @Mock private TaskFileJobService taskFileJobService; + @Mock private TaskDistributedLockService taskDistributedLockService; + + @InjectMocks private PriceTrackTaskService service; + + @Test + void markDispatchFailedImmediatelyFinalizesUnqueuedTask() { + long taskId = 19788L; + long userId = 671L; + String error = "当前店铺 张志云 正在执行中,请等待店铺完成之后重试"; + + FileTaskEntity task = new FileTaskEntity(); + task.setId(taskId); + task.setUserId(userId); + task.setModuleType("PRICE_TRACK"); + task.setStatus("RUNNING"); + task.setRequestJson("{}"); + + FileResultEntity result = new FileResultEntity(); + result.setId(9001L); + result.setTaskId(taskId); + result.setModuleType("PRICE_TRACK"); + result.setSourceFilename("张志云"); + result.setSuccess(0); + + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + when(taskDistributedLockService.acquire("PRICE_TRACK", taskId)).thenReturn(lock); + when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task)); + when(fileResultMapper.selectList(any())).thenReturn(List.of(result)); + + service.markDispatchFailed(taskId, userId, error); + + assertEquals("FAILED", task.getStatus()); + assertEquals("张志云: " + error, task.getErrorMessage()); + assertNotNull(task.getFinishedAt()); + assertEquals(error, result.getErrorMessage()); + verify(fileResultMapper).updateById(result); + verify(fileTaskMapper).updateById(task); + verify(priceTrackTaskCacheService).deleteTaskCache(taskId); + verify(priceTrackLoopRunService).syncLoopRunAfterChildTerminal(taskId); + verify(lock).close(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyServiceTest.java index 05cbd42..8a91fee 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskExcelAssemblyServiceTest.java @@ -1,9 +1,11 @@ package com.nanri.aiimage.modules.productrisk.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskRowDto; import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.Test; @@ -15,6 +17,9 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class ProductRiskExcelAssemblyServiceTest { @@ -47,6 +52,76 @@ class ProductRiskExcelAssemblyServiceTest { } } + @Test + void writesViolationDetailsAndMergesRepeatedAsinCells() throws Exception { + Path xlsx = Files.createTempFile("product-risk-details-", ".xlsx"); + try { + ProductRiskRowDto row = row("B0D2LG8R11", "处理失败", Boolean.FALSE); + row.setViolationDetails(List.of( + detail("商品安全违规", "成功"), + detail("商品安全违规", "失败"), + detail("商品安全违规", "成功"))); + ProductRiskRowDto secondRow = row("B0987884CV", "处理完成", Boolean.FALSE); + secondRow.setViolationDetails(List.of( + detail("商品安全违规", "成功"), + detail("商品详情页违规", "成功"))); + + service.writeWorkbook( + xlsx.toFile(), + "店铺A", + Map.of(ProductRiskCountryCode.DE.getSheetName(), List.of(row, secondRow))); + + try (InputStream in = Files.newInputStream(xlsx); + Workbook workbook = new XSSFWorkbook(in)) { + Sheet sheet = workbook.getSheet(ProductRiskCountryCode.DE.getSheetName()); + assertEquals("ASIN", sheet.getRow(0).getCell(6).getStringCellValue()); + assertEquals("原因", sheet.getRow(0).getCell(7).getStringCellValue()); + assertEquals("状态", sheet.getRow(0).getCell(8).getStringCellValue()); + assertEquals("B0D2LG8R11", sheet.getRow(1).getCell(6).getStringCellValue()); + assertEquals("商品安全违规", sheet.getRow(2).getCell(7).getStringCellValue()); + assertEquals("失败", sheet.getRow(2).getCell(8).getStringCellValue()); + assertTrue(sheet.getMergedRegions().contains(new CellRangeAddress(1, 3, 6, 6))); + assertTrue(sheet.getMergedRegions().contains(new CellRangeAddress(4, 5, 6, 6))); + assertEquals("处理失败", sheet.getRow(1).getCell(2).getStringCellValue()); + assertEquals("B0987884CV", sheet.getRow(4).getCell(1).getStringCellValue()); + assertEquals("B0987884CV", sheet.getRow(4).getCell(6).getStringCellValue()); + assertEquals("商品详情页违规", sheet.getRow(5).getCell(7).getStringCellValue()); + } + } finally { + Files.deleteIfExists(xlsx); + } + } + + @Test + void violationDetailsUseLatestNonEmptyListAndIgnoreEmptyRetry() { + List original = List.of(detail("旧原因", "成功")); + List replacement = List.of( + detail("商品安全违规", "成功"), + detail("商品安全违规", "失败")); + + List kept = + ProductRiskTaskService.mergeViolationDetails(original, List.of()); + List replaced = + ProductRiskTaskService.mergeViolationDetails(original, replacement); + + assertEquals(1, kept.size()); + assertEquals("旧原因", kept.get(0).getReason()); + assertEquals(2, replaced.size()); + assertEquals("商品安全违规", replaced.get(1).getReason()); + assertEquals("失败", replaced.get(1).getStatus()); + assertNotSame(replacement.get(0), replaced.get(0)); + } + + @Test + void oldPayloadWithoutViolationDetailsRemainsCompatible() throws Exception { + ProductRiskRowDto row = new ObjectMapper().readValue( + "{\"shopName\":\"店铺A\",\"productAsinSku\":\"B0D2LG8R11\",\"status\":\"处理完成\"}", + ProductRiskRowDto.class); + + assertNotNull(row.getViolationDetails()); + assertTrue(row.getViolationDetails().isEmpty()); + } + private static ProductRiskRowDto row(String productAsinSku, String status, Boolean done) { ProductRiskRowDto row = new ProductRiskRowDto(); row.setShopName("店铺A"); @@ -55,4 +130,11 @@ class ProductRiskExcelAssemblyServiceTest { row.setDone(done); return row; } + + private static ProductRiskRowDto.ViolationDetail detail(String reason, String status) { + ProductRiskRowDto.ViolationDetail detail = new ProductRiskRowDto.ViolationDetail(); + detail.setReason(reason); + detail.setStatus(status); + return detail; + } } diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 3e37695..88b56f4 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -4,13 +4,15 @@ import json import os import re +import tempfile import threading +import zipfile from datetime import datetime from urllib.parse import quote import requests from requests.adapters import HTTPAdapter -from flask import Blueprint, request, jsonify, session, current_app, g, Response +from flask import Blueprint, request, jsonify, session, current_app, g, Response, send_file import pymysql from werkzeug.security import generate_password_hash @@ -52,6 +54,8 @@ _column_sort_schema_checked = False _column_sort_schema_lock = threading.Lock() _product_category_schema_checked = False _product_category_schema_lock = threading.Lock() +IMAGE_VIDEO_PERMISSION_KEY = 'admin_image_video_tasks' +IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data' ADMIN_MENU_ACCESS_CONFIG = { 'dedupe-total-data': { @@ -288,10 +292,12 @@ def _image_video_admin_item(row, include_json=False): 'task_id': row.get('id'), 'user_id': row.get('user_id'), 'username': row.get('username') or '', + 'group_name': row.get('group_name') or '', 'mode': _image_video_mode(row.get('request_json')), 'status': row.get('status') or '', 'coze_status': row.get('coze_status') or '', 'coze_execute_id': row.get('coze_execute_id') or '', + 'videos': videos, 'video_url': videos[0]['display_url'] if videos else '', 'debug_url': row.get('debug_url') or '', 'archive_status': row.get('archive_status') or '', @@ -302,7 +308,6 @@ def _image_video_admin_item(row, include_json=False): } if include_json: item.update({ - 'videos': videos, 'error_message': row.get('error_message') or '', 'archived_at': _format_admin_datetime(row.get('archived_at')), 'request': _mask_image_video_secrets(_parse_json_value(row.get('request_json'), {})), @@ -625,17 +630,17 @@ def _load_current_admin_menu_items(): role, current_row = get_current_admin_role() if not role or not current_row: return None, None, None, (jsonify({'success': False, 'error': '需要管理员权限'}), 403) + if role == 'super_admin': + items = [ + _format_permission_item(item) + for item in _load_local_columns('admin') + if (item.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY + ] + return role, current_row, items, None user_id = _get_current_admin_id(current_row) if not user_id: return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400) - result, error_response, status = _proxy_backend_java( - 'GET', - f'/api/admin/permission-users/{user_id}/column-permissions', - params={'menuType': 'admin'}, - ) - if error_response is not None: - return role, current_row, None, (error_response, status) - items = [_format_permission_item(item) for item in (result.get('data') or [])] + items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')] return role, current_row, items, None @@ -643,17 +648,17 @@ def _load_current_backend_menu_items(): role, current_row = get_current_admin_role() if not role or not current_row: return None, None, None, (jsonify({'success': False, 'error': '需要登录'}), 403) + if role == 'super_admin': + items = [ + _format_permission_item(item) + for item in _load_local_columns('admin') + if (item.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY + ] + return role, current_row, items, None user_id = _get_current_admin_id(current_row) if not user_id: return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400) - result, error_response, status = _proxy_backend_java( - 'GET', - f'/api/admin/permission-users/{user_id}/column-permissions', - params={'menuType': 'admin'}, - ) - if error_response is not None: - return role, current_row, None, (error_response, status) - items = [_format_permission_item(item) for item in (result.get('data') or [])] + items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')] return role, current_row, items, None @@ -676,6 +681,30 @@ def _ensure_backend_menu_access(*menu_names): return role, current_row, (jsonify({'success': False, 'error': error_message}), 403) +def _ensure_image_video_data_access(): + role, current_row = get_current_admin_role() + if role == 'super_admin': + return role, current_row, None + if not role or not current_row: + return role, current_row, (jsonify({'success': False, 'error': '需要登录'}), 403) + conn = get_db() + try: + with conn.cursor() as cur: + data_column_id = _image_video_permission_column_id(cur) + cur.execute( + "SELECT 1 FROM user_column_permission ucp " + "WHERE ucp.user_id = %s AND ucp.column_id = %s LIMIT 1", + (int(current_row['id']), data_column_id), + ) + granted = cur.fetchone() + conn.commit() + finally: + conn.close() + if granted: + return role, current_row, None + return role, current_row, (jsonify({'success': False, 'error': '无权查看视频任务数据'}), 403) + + def _ensure_product_category_access(): role, current_row, items, denied = _load_current_backend_menu_items() if denied: @@ -781,7 +810,8 @@ def list_users(): search_username = (request.args.get('username') or request.args.get('search') or '').strip() created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id') created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None - can_view_all_users = role == 'super_admin' or (current_row and (current_row.get('username') or '').strip().lower() == 'admin') + # 用户范围只由角色决定,用户名不能绕过权限模型。 + can_view_all_users = role == 'super_admin' can_view_child_users = role == 'admin' and not can_view_all_users if not can_view_all_users: created_by_id = None @@ -935,10 +965,23 @@ def create_user(): (username, pwd_hash, is_admin, want_role, want_created_by), ) new_uid = cur.lastrowid - _set_user_column_permissions(cur, new_uid, column_ids) + _validate_admin_granted_columns(cur, role, current_row, column_ids) + _set_user_column_permissions( + cur, + new_uid, + column_ids, + preserve_column_keys=(IMAGE_VIDEO_DATA_PERMISSION_KEY,), + ) conn.commit() conn.close() return jsonify({'success': True, 'msg': '用户创建成功'}) + except PermissionError as exc: + try: + conn.rollback() + conn.close() + except Exception: + pass + return jsonify({'success': False, 'error': str(exc)}), 403 except pymysql.IntegrityError: return jsonify({'success': False, 'error': '用户名已存在'}) except Exception as e: @@ -994,10 +1037,23 @@ def update_user(uid): (is_admin, want_role, uid), ) if 'column_ids' in data: - _set_user_column_permissions(cur, uid, data.get('column_ids')) + _validate_admin_granted_columns(cur, role, current_row, data.get('column_ids')) + _set_user_column_permissions( + cur, + uid, + data.get('column_ids'), + preserve_column_keys=(IMAGE_VIDEO_DATA_PERMISSION_KEY,), + ) conn.commit() conn.close() return jsonify({'success': True, 'msg': '更新成功'}) + except PermissionError as exc: + try: + conn.rollback() + conn.close() + except Exception: + pass + return jsonify({'success': False, 'error': str(exc)}), 403 except Exception as e: return jsonify({'success': False, 'error': str(e)}) @@ -1118,7 +1174,15 @@ _IMAGE_VIDEO_ADMIN_COLUMNS = """ t.id, t.user_id, t.status, t.request_json, t.submit_response_json, t.result_json, t.video_urls_json, t.debug_url, t.archived_videos_json, t.archive_status, t.archive_error, t.archive_attempt_count, t.archived_at, t.error_message, - t.coze_execute_id, t.coze_status, t.submitted_at, t.completed_at, u.username + t.coze_execute_id, t.coze_status, t.submitted_at, t.completed_at, u.username, + COALESCE(( + SELECT GROUP_CONCAT(DISTINCT g.group_name ORDER BY g.id SEPARATOR '、') + FROM biz_shop_manage_group g + LEFT JOIN biz_shop_manage_group_member gm ON gm.group_id = g.id + WHERE g.created_by_id = t.user_id + OR g.user_id = t.user_id + OR gm.user_id = t.user_id + ), '') AS group_name """ @@ -1132,10 +1196,220 @@ def _parse_admin_datetime_arg(name): raise ValueError(f'{name} 时间格式无效') from exc +def _image_video_permission_column_id(cur): + cur.execute("SELECT id FROM columns WHERE column_key = %s LIMIT 1", (IMAGE_VIDEO_DATA_PERMISSION_KEY,)) + row = cur.fetchone() + if not row or row.get('id') is None: + # 兼容 V74 尚未执行的旧库:创建数据权限栏目,并继承已有菜单授权。 + cur.execute( + "INSERT INTO columns (name, column_key, menu_type, route_path, sort_order) " + "SELECT %s, %s, 'internal', 'image-video-task-data', 0 " + "WHERE NOT EXISTS (SELECT 1 FROM columns WHERE column_key = %s)", + ('视频任务数据查看', IMAGE_VIDEO_DATA_PERMISSION_KEY, IMAGE_VIDEO_DATA_PERMISSION_KEY), + ) + cur.execute("SELECT id FROM columns WHERE column_key = %s LIMIT 1", (IMAGE_VIDEO_DATA_PERMISSION_KEY,)) + row = cur.fetchone() + if not row or row.get('id') is None: + raise ValueError('视频任务数据权限栏目不存在') + cur.execute( + "INSERT IGNORE INTO user_column_permission (user_id, column_id) " + "SELECT old_perm.user_id, %s FROM user_column_permission old_perm " + "INNER JOIN columns old_col ON old_col.id = old_perm.column_id " + "WHERE old_col.column_key = %s", + (int(row['id']), IMAGE_VIDEO_PERMISSION_KEY), + ) + return int(row['id']) + + +def _is_stored_super_admin_sql(alias='u'): + return ( + f"(LOWER(COALESCE({alias}.role, '')) = 'super_admin' OR " + f"((COALESCE({alias}.role, '') = '') AND {alias}.is_admin = 1 AND {alias}.created_by_id IS NULL))" + ) + + +@admin_api.route('/image-video-task-permissions', methods=['GET', 'PUT']) +@login_required +def manage_image_video_task_permissions(): + role, _ = get_current_admin_role() + if role != 'super_admin': + return jsonify({'success': False, 'error': '仅超级管理员可以配置视频任务权限'}), 403 + + conn = get_db() + try: + with conn.cursor() as cur: + column_id = _image_video_permission_column_id(cur) + if request.method == 'GET': + cur.execute( + "SELECT u.id, u.username, u.role, u.is_admin, u.created_by_id, " + "CASE WHEN ucp.user_id IS NULL THEN 0 ELSE 1 END AS granted " + "FROM users u LEFT JOIN user_column_permission ucp " + "ON ucp.user_id = u.id AND ucp.column_id = %s " + f"WHERE NOT {_is_stored_super_admin_sql('u')} " + "ORDER BY u.username ASC, u.id ASC", + (column_id,), + ) + items = [] + for row in cur.fetchall(): + stored_role = (row.get('role') or '').strip().lower() + effective_role = stored_role or ('admin' if row.get('is_admin') else 'normal') + items.append({ + 'id': int(row['id']), + 'username': row.get('username') or '', + 'role': effective_role, + 'granted': bool(row.get('granted')), + }) + return jsonify({'success': True, 'items': items}) + + data = request.get_json(silent=True) or {} + raw_user_ids = data.get('user_ids') + if not isinstance(raw_user_ids, list): + return jsonify({'success': False, 'error': 'user_ids 必须是数组'}), 400 + try: + user_ids = sorted({int(value) for value in raw_user_ids if not isinstance(value, bool) and int(value) > 0}) + except (TypeError, ValueError): + return jsonify({'success': False, 'error': 'user_ids 包含无效用户 ID'}), 400 + if len(user_ids) != len(raw_user_ids): + return jsonify({'success': False, 'error': 'user_ids 包含重复或无效用户 ID'}), 400 + + if user_ids: + placeholders = ','.join(['%s'] * len(user_ids)) + cur.execute( + f"SELECT id FROM users u WHERE id IN ({placeholders}) AND NOT {_is_stored_super_admin_sql('u')}", + tuple(user_ids), + ) + valid_ids = {int(row['id']) for row in cur.fetchall()} + if valid_ids != set(user_ids): + return jsonify({'success': False, 'error': '包含不存在或不可授权的用户'}), 400 + + cur.execute( + "DELETE ucp FROM user_column_permission ucp " + "INNER JOIN users u ON u.id = ucp.user_id " + f"WHERE ucp.column_id = %s AND NOT {_is_stored_super_admin_sql('u')}", + (column_id,), + ) + for user_id in user_ids: + cur.execute( + "INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)", + (user_id, column_id), + ) + conn.commit() + return jsonify({'success': True, 'granted_count': len(user_ids), 'msg': '视频任务权限已更新'}) + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + except Exception as exc: + if request.method == 'PUT': + conn.rollback() + return jsonify({'success': False, 'error': str(exc)}), 500 + finally: + conn.close() + + +@admin_api.route('/image-video-tasks/download-zip', methods=['POST']) +@login_required +def download_image_video_tasks_zip(): + _, _, denied = _ensure_backend_menu_access('image-video-tasks') + if not denied: + _, _, denied = _ensure_image_video_data_access() + if denied: + return denied + + data = request.get_json(silent=True) or {} + raw_items = data.get('items') + if not isinstance(raw_items, list) or not raw_items: + return jsonify({'success': False, 'error': '请至少选择一个视频'}), 400 + if len(raw_items) > 100: + return jsonify({'success': False, 'error': '单次最多打包 100 个视频'}), 400 + + selections = [] + seen = set() + try: + for item in raw_items: + if not isinstance(item, dict): + raise ValueError + task_id = int(item.get('task_id')) + video_index = int(item.get('video_index')) + if task_id <= 0 or video_index < 0: + raise ValueError + key = (task_id, video_index) + if key not in seen: + seen.add(key) + selections.append(key) + except (TypeError, ValueError): + return jsonify({'success': False, 'error': '视频选择参数无效'}), 400 + + task_ids = sorted({task_id for task_id, _ in selections}) + conn = get_db() + try: + with conn.cursor() as cur: + placeholders = ','.join(['%s'] * len(task_ids)) + cur.execute( + 'SELECT ' + _IMAGE_VIDEO_ADMIN_COLUMNS + + ' FROM biz_image_video_async_task t LEFT JOIN users u ON u.id = t.user_id ' + f"WHERE t.task_type = 'IMAGE_VIDEO_WORKFLOW' " + f"AND t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY) " + f"AND t.id IN ({placeholders})", + tuple(task_ids), + ) + rows = cur.fetchall() + finally: + conn.close() + tasks = {int(row['id']): row for row in rows} + + archive = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024, mode='w+b') + errors = [] + file_count = 0 + try: + with zipfile.ZipFile(archive, mode='w', compression=zipfile.ZIP_STORED, allowZip64=True) as output_zip: + for task_id, video_index in selections: + row = tasks.get(task_id) + videos = _image_video_urls(row) if row else [] + if video_index >= len(videos) or not videos[video_index].get('display_url'): + errors.append(f'task-{task_id}-video-{video_index + 1}: 视频地址不存在') + continue + url = videos[video_index]['display_url'] + extension_match = re.search(r'\.([a-z0-9]{2,5})(?:[?#]|$)', url, re.IGNORECASE) + extension = extension_match.group(1).lower() if extension_match else 'mp4' + filename = f'task-{task_id}-video-{video_index + 1}.{extension}' + remote_response = None + try: + remote_response = requests.get(url, stream=True, timeout=(10, 120)) + remote_response.raise_for_status() + with output_zip.open(filename, mode='w', force_zip64=True) as target: + for chunk in remote_response.iter_content(chunk_size=1024 * 1024): + if chunk: + target.write(chunk) + file_count += 1 + except requests.RequestException as exc: + errors.append(f'{filename}: 下载失败 ({exc})') + finally: + if remote_response is not None: + remote_response.close() + if errors: + output_zip.writestr('download-errors.txt', '\n'.join(errors).encode('utf-8')) + archive.seek(0) + response = send_file( + archive, + mimetype='application/zip', + as_attachment=True, + download_name=f"video-tasks-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip", + max_age=0, + ) + response.headers['X-Archive-File-Count'] = str(file_count) + response.headers['X-Archive-Error-Count'] = str(len(errors)) + response.call_on_close(archive.close) + return response + except Exception: + archive.close() + raise + + @admin_api.route('/image-video-tasks') -@admin_required +@login_required def list_image_video_tasks(): - role, current_row, denied = _ensure_admin_menu_access('image-video-tasks') + _, _, denied = _ensure_backend_menu_access('image-video-tasks') + if not denied: + _, _, denied = _ensure_image_video_data_access() if denied: return denied try: @@ -1148,8 +1422,11 @@ def list_image_video_tasks(): submitted_from = _parse_admin_datetime_arg('submitted_from') submitted_to = _parse_admin_datetime_arg('submitted_to') - access_sql, params = _image_video_access_sql(role, current_row) - conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'"] + params = [] + conditions = [ + "t.task_type = 'IMAGE_VIDEO_WORKFLOW'", + "t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)", + ] if user_id: conditions.append('t.user_id = %s') params.append(user_id) @@ -1203,14 +1480,19 @@ def list_image_video_tasks(): @admin_api.route('/image-video-tasks/') -@admin_required +@login_required def get_image_video_task(task_id): - role, current_row, denied = _ensure_admin_menu_access('image-video-tasks') + _, _, denied = _ensure_backend_menu_access('image-video-tasks') + if not denied: + _, _, denied = _ensure_image_video_data_access() if denied: return denied - access_sql, params = _image_video_access_sql(role, current_row) - conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'", 't.id = %s'] - params.append(task_id) + conditions = [ + "t.task_type = 'IMAGE_VIDEO_WORKFLOW'", + "t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)", + 't.id = %s', + ] + params = [task_id] conn = get_db() try: with conn.cursor() as cur: @@ -1248,6 +1530,7 @@ def list_columns(): 'created_at': str(row.get('created_at') or '')[:16].replace('T', ' '), } for row in local_rows + if (row.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY ] return jsonify({'success': True, 'items': items}) @@ -1405,16 +1688,50 @@ def get_user_column_permissions(uid): return jsonify({'success': True, 'items': [_format_permission_item(item) for item in items]}) -def _set_user_column_permissions(cur, user_id, column_ids): +def _set_user_column_permissions(cur, user_id, column_ids, preserve_column_keys=()): """设置用户栏目权限:先删后插。cur 为已打开的游标。""" - cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,)) - if column_ids: - column_ids = [int(x) for x in column_ids if x] - for cid in column_ids: + requested_ids = {int(x) for x in (column_ids or []) if x} + preserved_ids = set() + if preserve_column_keys: + placeholders = ','.join(['%s'] * len(preserve_column_keys)) + cur.execute( + f"SELECT id FROM columns WHERE column_key IN ({placeholders})", + tuple(preserve_column_keys), + ) + protected_ids = {int(row['id']) for row in cur.fetchall() if row.get('id') is not None} + requested_ids.difference_update(protected_ids) + if protected_ids: + id_placeholders = ','.join(['%s'] * len(protected_ids)) cur.execute( - "INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)", - (user_id, cid), + f"SELECT column_id FROM user_column_permission WHERE user_id = %s AND column_id IN ({id_placeholders})", + (user_id, *sorted(protected_ids)), ) + preserved_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None} + cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,)) + for cid in sorted(requested_ids | preserved_ids): + cur.execute( + "INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)", + (user_id, cid), + ) + + +def _validate_admin_granted_columns(cur, role, current_row, column_ids): + """普通管理员只能把自己已拥有的栏目授予其直接管理的普通用户。""" + if role != 'admin' or not current_row or not column_ids: + return + requested_ids = {int(value) for value in column_ids if str(value).isdigit() and int(value) > 0} + if not requested_ids: + return + placeholders = ','.join(['%s'] * len(requested_ids)) + cur.execute( + f"SELECT column_id FROM user_column_permission " + f"WHERE user_id = %s AND column_id IN ({placeholders})", + (int(current_row['id']), *sorted(requested_ids)), + ) + owned_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None} + denied_ids = requested_ids - owned_ids + if denied_ids: + raise PermissionError('普通管理员只能分配自己已有的菜单权限') # ---------- 版本管理(web_config) ---------- diff --git a/backend/config.py b/backend/config.py index 8e1beb2..32e80cc 100644 --- a/backend/config.py +++ b/backend/config.py @@ -59,8 +59,8 @@ file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" backend_java_base_url, backend_java_base_url_source = _get_env( "java_api_base", "BACKEND_JAVA_BASE_URL", - default="http://api.aishufu.top:18080/", - # default="http://127.0.0.1:18080/", + # default="http://api.aishufu.top:18080/", + default="http://127.0.0.1:18080/", ) backend_java_base_url = backend_java_base_url.rstrip("/") os.environ["OSS_ACCESS_KEY_ID"] = accessKeyId diff --git a/backend/static/admin.js b/backend/static/admin.js index 0693169..c3cf306 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -380,11 +380,30 @@ } var allColumnsList = []; function loadColumnsForPermission() { - fetch('/api/admin/columns') - .then(function (r) { return r.json(); }) + var ownedKeys = {}; + var ownedRoutes = {}; + var ownPermissionRequest = (currentUserRole === 'admin' && currentUserId) + ? fetch('/api/admin/user/' + currentUserId + '/column-permissions') + .then(function (r) { return r.json(); }) + .then(function (res) { + (res.items || []).forEach(function (item) { + if (item.column_key) ownedKeys[String(item.column_key).trim()] = true; + if (item.route_path) ownedRoutes[String(item.route_path).trim()] = true; + }); + }) + .catch(function () { }) + : Promise.resolve(); + ownPermissionRequest.then(function () { + return fetch('/api/admin/columns'); + }).then(function (r) { return r.json(); }) .then(function (res) { if (!res.success) return; - allColumnsList = res.items || []; + allColumnsList = (res.items || []).filter(function (item) { + if (item.column_key === 'admin_image_video_task_data') return false; + if (currentUserRole !== 'admin') return true; + return !!ownedKeys[String(item.column_key || '').trim()] || + !!ownedRoutes[String(item.route_path || '').trim()]; + }); renderColumnPermissionWrap('createColumnPermissionWrap'); renderColumnPermissionWrap('editColumnPermissionWrap'); var selCreate = document.getElementById('createColumnPermissionWrap'); @@ -617,17 +636,161 @@ document.getElementById('editUserModal').classList.remove('show'); }; - // ========== 生成记录 ========== + // ========== 视频任务管理 ========== var imageVideoTaskPage = 1, imageVideoTaskPageSize = 20; + var imageVideoCards = []; + var selectedImageVideoKeys = new Set(); + var imageVideoDownloadInProgress = false; + var imageVideoPermissionUsers = []; + var imageVideoPermissionInitialUserIds = new Set(); + var selectedImageVideoPermissionUserIds = new Set(); + var imageVideoPermissionView = 'granted'; + function updateImageVideoPermissionAccess() { + var button = document.getElementById('btnOpenImageVideoPermissions'); + if (button) button.style.display = currentUserRole === 'super_admin' ? 'inline-flex' : 'none'; + } + function imageVideoPermissionUsersForView() { + if (imageVideoPermissionView === 'granted') { + return imageVideoPermissionUsers.filter(function (user) { + return imageVideoPermissionInitialUserIds.has(Number(user.id)); + }); + } + return imageVideoPermissionUsers; + } + function syncImageVideoPermissionTabs() { + document.getElementById('imageVideoPermissionGrantedCount').textContent = '(' + imageVideoPermissionInitialUserIds.size + ')'; + document.getElementById('imageVideoPermissionAllCount').textContent = '(' + imageVideoPermissionUsers.length + ')'; + document.querySelectorAll('[data-image-video-permission-view]').forEach(function (tab) { + var active = tab.dataset.imageVideoPermissionView === imageVideoPermissionView; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + } + function filteredImageVideoPermissionUsers() { + var users = imageVideoPermissionUsersForView(); + var keyword = (document.getElementById('imageVideoPermissionSearch').value || '').trim().toLowerCase(); + if (!keyword) return users; + return users.filter(function (user) { + return String(user.username || '').toLowerCase().indexOf(keyword) >= 0; + }); + } + function syncImageVideoPermissionSelectAll() { + var visibleUsers = filteredImageVideoPermissionUsers(); + var selectedCount = visibleUsers.filter(function (user) { + return selectedImageVideoPermissionUserIds.has(Number(user.id)); + }).length; + var selectAll = document.getElementById('imageVideoPermissionSelectAll'); + selectAll.checked = visibleUsers.length > 0 && selectedCount === visibleUsers.length; + selectAll.indeterminate = selectedCount > 0 && selectedCount < visibleUsers.length; + selectAll.disabled = visibleUsers.length === 0; + } + function renderImageVideoPermissionUsers() { + var list = document.getElementById('imageVideoPermissionList'); + var summary = document.getElementById('imageVideoPermissionSummary'); + var visibleUsers = filteredImageVideoPermissionUsers(); + syncImageVideoPermissionTabs(); + var pendingCount = imageVideoPermissionUsers.filter(function (user) { + return imageVideoPermissionInitialUserIds.has(Number(user.id)) !== selectedImageVideoPermissionUserIds.has(Number(user.id)); + }).length; + summary.textContent = imageVideoPermissionView === 'granted' + ? '当前显示已保存分配用户,共 ' + visibleUsers.length + ' 人' + (pendingCount ? ' · 待保存变更 ' + pendingCount + ' 项' : '') + : '当前显示全部用户,已保存分配 ' + imageVideoPermissionInitialUserIds.size + ' 人' + (pendingCount ? ' · 待保存变更 ' + pendingCount + ' 项' : ''); + list.innerHTML = visibleUsers.length ? visibleUsers.map(function (user) { + var userId = Number(user.id); + var persistedGranted = imageVideoPermissionInitialUserIds.has(userId); + var pendingGranted = selectedImageVideoPermissionUserIds.has(userId); + var pendingChanged = persistedGranted !== pendingGranted; + var actionText = pendingChanged + ? (pendingGranted ? '待保存分配' : '待保存取消') + : (persistedGranted ? '取消分配' : '分配'); + return '
' + + '' + + '' + + '' + escapeHtml(user.username || '-') + '' + + '' + escapeHtml(roleLabel(user.role)) + '' + + '' + + '' + + '
'; + }).join('') : '
暂无匹配用户
'; + syncImageVideoPermissionSelectAll(); + } + function setImageVideoPermissionLoading(loading) { + document.getElementById('btnSaveImageVideoPermissions').disabled = loading; + document.getElementById('imageVideoPermissionSearch').disabled = loading; + if (loading) document.getElementById('imageVideoPermissionSelectAll').disabled = true; + } + function openImageVideoPermissions() { + if (currentUserRole !== 'super_admin') return; + var modal = document.getElementById('imageVideoPermissionModal'); + var message = document.getElementById('imageVideoPermissionMessage'); + modal.classList.add('show'); + message.textContent = ''; + message.className = 'msg'; + imageVideoPermissionView = 'granted'; + syncImageVideoPermissionTabs(); + document.getElementById('imageVideoPermissionSearch').value = ''; + document.getElementById('imageVideoPermissionList').innerHTML = '
加载中...
'; + setImageVideoPermissionLoading(true); + fetch('/api/admin/image-video-task-permissions') + .then(function (response) { return response.json(); }) + .then(function (res) { + if (!res.success) throw new Error(res.error || '权限加载失败'); + imageVideoPermissionUsers = res.items || []; + imageVideoPermissionInitialUserIds = new Set(imageVideoPermissionUsers.filter(function (user) { + return !!user.granted; + }).map(function (user) { return Number(user.id); })); + selectedImageVideoPermissionUserIds = new Set(imageVideoPermissionInitialUserIds); + renderImageVideoPermissionUsers(); + }) + .catch(function (error) { + imageVideoPermissionUsers = []; + selectedImageVideoPermissionUserIds.clear(); + document.getElementById('imageVideoPermissionList').innerHTML = '
' + escapeHtml(error.message || '权限加载失败') + '
'; + }) + .finally(function () { setImageVideoPermissionLoading(false); }); + } + function closeImageVideoPermissions() { + document.getElementById('imageVideoPermissionModal').classList.remove('show'); + } + function saveImageVideoPermissions() { + var message = document.getElementById('imageVideoPermissionMessage'); + message.textContent = '保存中...'; + message.className = 'msg'; + setImageVideoPermissionLoading(true); + fetch('/api/admin/image-video-task-permissions', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ user_ids: Array.from(selectedImageVideoPermissionUserIds).sort(function (a, b) { return a - b; }) }) + }) + .then(function (response) { return response.json(); }) + .then(function (res) { + if (!res.success) throw new Error(res.error || '权限保存失败'); + imageVideoPermissionUsers.forEach(function (user) { + user.granted = selectedImageVideoPermissionUserIds.has(Number(user.id)); + }); + imageVideoPermissionInitialUserIds = new Set(selectedImageVideoPermissionUserIds); + renderImageVideoPermissionUsers(); + message.textContent = res.msg || '保存成功'; + message.className = 'msg ok'; + }) + .catch(function (error) { + message.textContent = error.message || '权限保存失败'; + message.className = 'msg err'; + }) + .finally(function () { + setImageVideoPermissionLoading(false); + syncImageVideoPermissionSelectAll(); + }); + } function buildImageVideoTaskQuery(page) { var params = new URLSearchParams(); params.set('page', String(page || 1)); params.set('page_size', String(imageVideoTaskPageSize)); var values = { - user_id: document.getElementById('imageVideoFilterUserId').value, username: document.getElementById('imageVideoFilterUsername').value.trim(), - status: document.getElementById('imageVideoFilterStatus').value, - coze_execute_id: document.getElementById('imageVideoFilterExecuteId').value.trim(), submitted_from: document.getElementById('imageVideoFilterFrom').value, submitted_to: document.getElementById('imageVideoFilterTo').value }; @@ -642,107 +805,353 @@ var url = String(value || '').trim(); return /^https?:\/\//i.test(url) ? url : ''; } + function imageVideoDownloadIcon() { + return ''; + } + function imageVideoUnavailableHtml(message) { + return '
' + + '' + + '' + escapeHtml(message || '暂无可用视频') + '' + + '
'; + } + function imageVideoCardKey(taskId, videoIndex) { + return String(taskId) + ':' + String(videoIndex); + } + function flattenImageVideoTasks(items) { + var cards = []; + (items || []).forEach(function (task) { + var videos = Array.isArray(task.videos) ? task.videos : []; + if (!videos.length) { + cards.push({ key: imageVideoCardKey(task.task_id, 'empty'), task: task, video: null, videoIndex: 0 }); + return; + } + videos.forEach(function (video, index) { + cards.push({ + key: imageVideoCardKey(task.task_id, index), + task: task, + video: { + sourceUrl: safeAdminUrl(video.source_url), + archivedUrl: safeAdminUrl(video.archived_url), + displayUrl: safeAdminUrl(video.display_url), + objectKey: String(video.object_key || ''), + archiveStatus: String(video.archive_status || ''), + archiveError: String(video.archive_error || '') + }, + videoIndex: index + }); + }); + }); + return cards; + } + function renderImageVideoCard(card) { + var task = card.task; + var video = card.video; + var hasVideo = !!(video && video.displayUrl); + var debugUrl = safeAdminUrl(task.debug_url); + var generatedAt = task.completed_at || task.submitted_at || '-'; + var mediaHtml = hasVideo + ? '' + + '' + : imageVideoUnavailableHtml(task.status === 'FAILED' ? '任务失败,未生成视频' : '视频生成中或暂无结果'); + return '
' + + '
' + mediaHtml + '
' + + '
' + + '
' + + '
任务 #' + escapeHtml(task.task_id) + (video ? ' · 视频 ' + (card.videoIndex + 1) : '') + '
' + + renderImageVideoStatus(task.status) + + '
' + + '
' + + '
' + escapeHtml(task.username || '-') + '
' + + '
' + escapeHtml(task.group_name || '-') + '
' + + '
' + escapeHtml(task.mode || '-') + '
' + + '
' + escapeHtml(generatedAt) + '
' + + '
' + (debugUrl + ? '' + : '-') + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
'; + } + function syncImageVideoSelectionUi() { + var selectable = imageVideoCards.filter(function (card) { return !!(card.video && card.video.displayUrl); }); + document.querySelectorAll('[data-image-video-card]').forEach(function (cardEl) { + var selected = selectedImageVideoKeys.has(cardEl.dataset.imageVideoCard); + cardEl.classList.toggle('selected', selected); + var checkbox = cardEl.querySelector('[data-image-video-select]'); + if (checkbox) checkbox.checked = selected; + }); + var selectedCount = selectable.filter(function (card) { return selectedImageVideoKeys.has(card.key); }).length; + var selectAll = document.getElementById('imageVideoSelectAll'); + selectAll.checked = selectable.length > 0 && selectedCount === selectable.length; + selectAll.indeterminate = selectedCount > 0 && selectedCount < selectable.length; + selectAll.disabled = imageVideoDownloadInProgress || selectable.length === 0; + var batchButton = document.getElementById('btnBatchDownloadImageVideos'); + batchButton.disabled = imageVideoDownloadInProgress || selectedCount === 0; + batchButton.innerHTML = imageVideoDownloadIcon() + (imageVideoDownloadInProgress ? '处理中' : '批量下载' + (selectedCount ? ' (' + selectedCount + ')' : '')); + document.querySelectorAll('[data-image-video-download]').forEach(function (button) { + var card = imageVideoCards.find(function (item) { return item.key === button.dataset.imageVideoDownload; }); + button.disabled = imageVideoDownloadInProgress || !card || !card.video || !card.video.displayUrl; + }); + } + function renderImageVideoCards() { + var grid = document.getElementById('imageVideoTaskGrid'); + grid.innerHTML = imageVideoCards.length + ? imageVideoCards.map(renderImageVideoCard).join('') + : '
暂无符合条件的视频任务
'; + grid.querySelectorAll('video').forEach(function (video) { + video.addEventListener('error', function () { + var media = video.closest('.image-video-media'); + if (!media || media.querySelector('.image-video-unavailable')) return; + video.remove(); + media.insertAdjacentHTML('beforeend', imageVideoUnavailableHtml('视频加载失败,可尝试下载')); + }, { once: true }); + }); + syncImageVideoSelectionUi(); + } + function resetImageVideoSelection() { + selectedImageVideoKeys.clear(); + document.getElementById('imageVideoDownloadProgress').textContent = ''; + syncImageVideoSelectionUi(); + } function loadImageVideoTasks(page) { imageVideoTaskPage = page || 1; - var tbody = document.getElementById('imageVideoTaskListBody'); - tbody.innerHTML = '加载中...'; + selectedImageVideoKeys.clear(); + var grid = document.getElementById('imageVideoTaskGrid'); + grid.innerHTML = '
加载中...
'; + document.getElementById('imageVideoDownloadProgress').textContent = ''; fetch('/api/admin/image-video-tasks?' + buildImageVideoTaskQuery(imageVideoTaskPage)) .then(function (r) { return r.json(); }) .then(function (res) { if (!res.success) { - tbody.innerHTML = '加载失败:' + escapeHtml(res.error || '') + ''; + imageVideoCards = []; + grid.innerHTML = '
加载失败:' + escapeHtml(res.error || '') + '
'; + document.getElementById('imageVideoTaskTotal').textContent = ''; + syncImageVideoSelectionUi(); return; } var items = res.items || []; - document.getElementById('imageVideoTaskTotal').textContent = '共 ' + (res.total || 0) + ' 条'; - if (!items.length) { - tbody.innerHTML = '暂无视频任务'; - } else { - tbody.innerHTML = items.map(function (item) { - var videoUrl = safeAdminUrl(item.video_url); - var videoLink = videoUrl ? '查看视频' : '-'; - return '' + - '' + escapeHtml(item.task_id) + '' + - '' + escapeHtml(item.username || '-') + '
ID ' + escapeHtml(item.user_id) + '
' + - '' + escapeHtml(item.mode || '-') + '' + - '' + renderImageVideoStatus(item.status) + '' + - '' + escapeHtml(item.coze_status || '-') + '' + - '' + escapeHtml(item.coze_execute_id || '-').slice(0, 18) + '' + - '' + videoLink + '' + - '' + escapeHtml(item.archive_status || '-') + '' + - '' + escapeHtml(item.submitted_at || '-') + '' + - '' + - ''; - }).join(''); - } + imageVideoCards = flattenImageVideoTasks(items); + var videoCount = imageVideoCards.filter(function (card) { return !!card.video; }).length; + document.getElementById('imageVideoTaskTotal').textContent = '共 ' + (res.total || 0) + ' 个任务 · 本页 ' + videoCount + ' 个视频'; + renderImageVideoCards(); renderPagination('imageVideoTaskPagination', res.total, res.page, res.page_size, loadImageVideoTasks); }) .catch(function () { - tbody.innerHTML = '请求失败'; + imageVideoCards = []; + grid.innerHTML = '
请求失败,请稍后重试
'; + document.getElementById('imageVideoTaskTotal').textContent = ''; + syncImageVideoSelectionUi(); }); } - function formatImageVideoJson(value) { - try { return JSON.stringify(value == null ? {} : value, null, 2); } - catch (error) { return String(value || ''); } + function imageVideoFilename(card) { + var url = card.video.displayUrl || ''; + var path = url.split(/[?#]/)[0]; + var match = path.match(/\.([a-z0-9]{2,5})$/i); + return 'task-' + card.task.task_id + '-video-' + (card.videoIndex + 1) + '.' + (match ? match[1].toLowerCase() : 'mp4'); } - function renderImageVideoTaskDetail(item) { - var videos = item.videos || []; - var videoHtml = videos.length ? videos.map(function (video, index) { - var displayUrl = safeAdminUrl(video.display_url); - var sourceUrl = safeAdminUrl(video.source_url); - var archivedUrl = safeAdminUrl(video.archived_url); - return '
' + - '视频 ' + (index + 1) + '' + - (displayUrl ? '' : '

无可用视频地址

') + - '
' + - (archivedUrl ? 'OSS 视频' : '') + - (sourceUrl ? 'Coze 原始视频' : '') + - '
归档:' + escapeHtml(video.archive_status || '-') + - (video.archive_error ? ' · ' + escapeHtml(video.archive_error) : '') + '
'; - }).join('') : '

当前没有解析到视频地址

'; - var debugUrl = safeAdminUrl(item.debug_url); - return '
' + - '
任务 ID' + escapeHtml(item.task_id) + '
' + - '
用户' + escapeHtml(item.username || '-') + '(' + escapeHtml(item.user_id) + ')
' + - '
任务模式' + escapeHtml(item.mode || '-') + '
' + - '
状态' + escapeHtml(item.status || '-') + ' / ' + escapeHtml(item.coze_status || '-') + '
' + - '
Coze 执行 ID' + escapeHtml(item.coze_execute_id || '-') + '
' + - '
提交时间' + escapeHtml(item.submitted_at || '-') + '
' + - '
完成时间' + escapeHtml(item.completed_at || '-') + '
' + - '
归档状态' + escapeHtml(item.archive_status || '-') + '
' + - '
' + - (debugUrl ? '

调试链接:' + escapeHtml(debugUrl) + '

' : '') + - (item.error_message ? '

任务错误:' + escapeHtml(item.error_message) + '

' : '') + - (item.archive_error ? '

归档错误:' + escapeHtml(item.archive_error) + '

' : '') + - '
' + videoHtml + '
' + - '
请求参数(密钥已脱敏)
' + escapeHtml(formatImageVideoJson(item.request)) + '
' + - '
Coze 首次提交响应
' + escapeHtml(formatImageVideoJson(item.submit_response)) + '
' + - '
Coze 最终响应
' + escapeHtml(formatImageVideoJson(item.result)) + '
'; + function triggerImageVideoLink(url, filename, newWindow) { + var link = document.createElement('a'); + link.href = url; + link.download = filename; + if (newWindow) { + link.target = '_blank'; + link.rel = 'noreferrer'; + } + document.body.appendChild(link); + link.click(); + link.remove(); } - function openImageVideoTaskDetail(taskId) { - var content = document.getElementById('imageVideoTaskDetailContent'); - content.innerHTML = '

加载中...

'; - document.getElementById('imageVideoTaskDetailModal').classList.add('show'); - fetch('/api/admin/image-video-tasks/' + encodeURIComponent(taskId)) - .then(function (r) { return r.json(); }) - .then(function (res) { - content.innerHTML = res.success ? renderImageVideoTaskDetail(res.item || {}) : '

' + escapeHtml(res.error || '加载失败') + '

'; + function downloadImageVideo(card) { + var url = card && card.video ? card.video.displayUrl : ''; + if (!url) return Promise.resolve({ fallback: false, skipped: true }); + var filename = imageVideoFilename(card); + return fetch(url, { __skipLoading: true }) + .then(function (response) { + if (!response.ok) throw new Error('HTTP ' + response.status); + return response.blob(); }) - .catch(function () { content.innerHTML = '

请求失败

'; }); + .then(function (blob) { + var objectUrl = URL.createObjectURL(blob); + triggerImageVideoLink(objectUrl, filename, false); + setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 1000); + return { fallback: false, skipped: false }; + }) + .catch(function () { + triggerImageVideoLink(url, filename, true); + return { fallback: true, skipped: false }; + }); + } + function runImageVideoDownloads(cards) { + if (imageVideoDownloadInProgress || !cards.length) return; + imageVideoDownloadInProgress = true; + syncImageVideoSelectionUi(); + var progress = document.getElementById('imageVideoDownloadProgress'); + progress.textContent = '正在下载'; + downloadImageVideo(cards[0]).then(function (result) { + progress.textContent = result.fallback ? '已在新窗口打开视频' : '下载已开始'; + }).finally(function () { + imageVideoDownloadInProgress = false; + syncImageVideoSelectionUi(); + }); + } + function imageVideoZipFilename(response) { + var disposition = response.headers.get('Content-Disposition') || ''; + var encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (encoded) { + try { return decodeURIComponent(encoded[1]); } catch (error) { } + } + var plain = disposition.match(/filename="?([^";]+)"?/i); + return plain ? plain[1] : 'video-tasks.zip'; + } + function downloadImageVideoZip(cards) { + if (imageVideoDownloadInProgress || !cards.length) return; + imageVideoDownloadInProgress = true; + syncImageVideoSelectionUi(); + var progress = document.getElementById('imageVideoDownloadProgress'); + progress.textContent = '正在打包 ' + cards.length + ' 个视频...'; + fetch('/api/admin/image-video-tasks/download-zip', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + items: cards.map(function (card) { + return { task_id: Number(card.task.task_id), video_index: card.videoIndex }; + }) + }), + __skipLoading: true + }).then(function (response) { + if (!response.ok) { + return response.json().catch(function () { return {}; }).then(function (data) { + throw new Error(data.error || '压缩包生成失败'); + }); + } + var filename = imageVideoZipFilename(response); + var errorCount = Number(response.headers.get('X-Archive-Error-Count') || 0); + return response.blob().then(function (blob) { + return { blob: blob, filename: filename, errorCount: errorCount }; + }); + }).then(function (result) { + var objectUrl = URL.createObjectURL(result.blob); + triggerImageVideoLink(objectUrl, result.filename, false); + setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 1000); + progress.textContent = result.errorCount + ? '压缩包已下载,' + result.errorCount + ' 个视频失败,详见包内错误清单' + : '压缩包下载已开始'; + }).catch(function (error) { + progress.textContent = error.message || '压缩包下载失败'; + }).finally(function () { + imageVideoDownloadInProgress = false; + syncImageVideoSelectionUi(); + }); + } + function copyImageVideoDebugUrl(card, button) { + var url = card ? safeAdminUrl(card.task.debug_url) : ''; + if (!url) return; + var copyPromise; + if (navigator.clipboard && navigator.clipboard.writeText) { + copyPromise = navigator.clipboard.writeText(url); + } else { + copyPromise = new Promise(function (resolve, reject) { + var textarea = document.createElement('textarea'); + textarea.value = url; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + var copied = document.execCommand('copy'); + textarea.remove(); + if (copied) resolve(); + else reject(new Error('copy failed')); + }); + } + copyPromise.then(function () { + button.textContent = '已复制'; + button.classList.add('copied'); + setTimeout(function () { + button.textContent = '复制链接'; + button.classList.remove('copied'); + }, 1600); + }).catch(function () { + button.textContent = '复制失败'; + setTimeout(function () { button.textContent = '复制链接'; }, 1600); + }); } document.getElementById('btnFilterImageVideoTasks').onclick = function () { loadImageVideoTasks(1); }; + document.getElementById('btnOpenImageVideoPermissions').onclick = openImageVideoPermissions; + document.getElementById('btnCloseImageVideoPermissions').onclick = closeImageVideoPermissions; + document.getElementById('btnCancelImageVideoPermissions').onclick = closeImageVideoPermissions; + document.getElementById('btnSaveImageVideoPermissions').onclick = saveImageVideoPermissions; + document.querySelectorAll('[data-image-video-permission-view]').forEach(function (tab) { + tab.onclick = function () { + imageVideoPermissionView = tab.dataset.imageVideoPermissionView || 'granted'; + document.getElementById('imageVideoPermissionSearch').value = ''; + renderImageVideoPermissionUsers(); + }; + }); + document.getElementById('imageVideoPermissionSearch').oninput = renderImageVideoPermissionUsers; + document.getElementById('imageVideoPermissionSelectAll').onchange = function (event) { + filteredImageVideoPermissionUsers().forEach(function (user) { + var userId = Number(user.id); + if (event.target.checked) selectedImageVideoPermissionUserIds.add(userId); + else selectedImageVideoPermissionUserIds.delete(userId); + }); + renderImageVideoPermissionUsers(); + }; + document.getElementById('imageVideoPermissionList').onclick = function (event) { + var toggle = event.target.closest('[data-image-video-permission-toggle]'); + if (!toggle) return; + var userId = Number(toggle.dataset.imageVideoPermissionToggle); + if (selectedImageVideoPermissionUserIds.has(userId)) selectedImageVideoPermissionUserIds.delete(userId); + else selectedImageVideoPermissionUserIds.add(userId); + renderImageVideoPermissionUsers(); + }; + document.getElementById('imageVideoPermissionList').onchange = function (event) { + var checkbox = event.target.closest('[data-image-video-permission-user]'); + if (!checkbox) return; + var userId = Number(checkbox.dataset.imageVideoPermissionUser); + if (checkbox.checked) selectedImageVideoPermissionUserIds.add(userId); + else selectedImageVideoPermissionUserIds.delete(userId); + renderImageVideoPermissionUsers(); + }; + document.getElementById('imageVideoPermissionModal').onclick = function (event) { + if (event.target === event.currentTarget) closeImageVideoPermissions(); + }; document.getElementById('btnResetImageVideoTasks').onclick = function () { - ['imageVideoFilterUserId', 'imageVideoFilterUsername', 'imageVideoFilterStatus', 'imageVideoFilterExecuteId', 'imageVideoFilterFrom', 'imageVideoFilterTo'] + ['imageVideoFilterUsername', 'imageVideoFilterFrom', 'imageVideoFilterTo'] .forEach(function (id) { document.getElementById(id).value = ''; }); loadImageVideoTasks(1); }; - document.getElementById('imageVideoTaskListBody').onclick = function (event) { - var button = event.target.closest('[data-image-video-detail]'); - if (button) openImageVideoTaskDetail(button.dataset.imageVideoDetail); + document.getElementById('imageVideoSelectAll').onchange = function (event) { + imageVideoCards.forEach(function (card) { + if (!card.video || !card.video.displayUrl) return; + if (event.target.checked) selectedImageVideoKeys.add(card.key); + else selectedImageVideoKeys.delete(card.key); + }); + syncImageVideoSelectionUi(); }; - document.getElementById('btnCloseImageVideoTaskDetail').onclick = function () { - document.getElementById('imageVideoTaskDetailModal').classList.remove('show'); - document.getElementById('imageVideoTaskDetailContent').innerHTML = ''; + document.getElementById('btnBatchDownloadImageVideos').onclick = function () { + downloadImageVideoZip(imageVideoCards.filter(function (card) { return selectedImageVideoKeys.has(card.key); })); + }; + document.getElementById('imageVideoTaskGrid').onchange = function (event) { + var checkbox = event.target.closest('[data-image-video-select]'); + if (!checkbox) return; + if (checkbox.checked) selectedImageVideoKeys.add(checkbox.dataset.imageVideoSelect); + else selectedImageVideoKeys.delete(checkbox.dataset.imageVideoSelect); + syncImageVideoSelectionUi(); + }; + document.getElementById('imageVideoTaskGrid').onclick = function (event) { + var copyButton = event.target.closest('[data-image-video-copy-debug]'); + if (copyButton) { + var copyCard = imageVideoCards.find(function (item) { return item.key === copyButton.dataset.imageVideoCopyDebug; }); + if (copyCard) copyImageVideoDebugUrl(copyCard, copyButton); + } + var downloadButton = event.target.closest('[data-image-video-download]'); + if (downloadButton) { + var card = imageVideoCards.find(function (item) { return item.key === downloadButton.dataset.imageVideoDownload; }); + if (card) runImageVideoDownloads([card]); + } }; var historyPage = 1, historyPageSize = 15; @@ -4348,6 +4757,7 @@ roleEl.style.display = 'none'; } updateShopManageGroupButtonsAccess(); + updateImageVideoPermissionAccess(); }) .catch(function () { return fetch('/api/auth/check') @@ -4356,11 +4766,13 @@ document.getElementById('adminCurrentUsername').textContent = res && res.logged_in ? '当前用户' : '未登录'; document.getElementById('adminCurrentUserRole').style.display = 'none'; updateShopManageGroupButtonsAccess(); + updateImageVideoPermissionAccess(); }) .catch(function () { document.getElementById('adminCurrentUsername').textContent = '当前用户'; document.getElementById('adminCurrentUserRole').style.display = 'none'; updateShopManageGroupButtonsAccess(); + updateImageVideoPermissionAccess(); }); }) .finally(function () { @@ -4398,7 +4810,6 @@ loadAdminCurrentUser().finally(function () { loadUserOptions(); - loadColumnsForPermission(); loadShopManageGroups(); loadAdminMenus(); }); diff --git a/backend/tests/test_image_video_admin.py b/backend/tests/test_image_video_admin.py index 4359fe0..0dc78c4 100644 --- a/backend/tests/test_image_video_admin.py +++ b/backend/tests/test_image_video_admin.py @@ -1,7 +1,9 @@ +import io import json import unittest +import zipfile from datetime import datetime -from unittest.mock import patch +from unittest.mock import MagicMock, patch from app import app from blueprints import admin_api as admin_module @@ -43,6 +45,23 @@ class FakeConnection: pass +class FakeDownloadResponse: + def __init__(self, content=b'video-bytes', error=None): + self.content = content + self.error = error + self.closed = False + + def raise_for_status(self): + if self.error: + raise self.error + + def iter_content(self, chunk_size): + yield self.content + + def close(self): + self.closed = True + + class ImageVideoAdminTest(unittest.TestCase): def setUp(self): app.config.update(TESTING=True, SECRET_KEY='test') @@ -72,6 +91,7 @@ class ImageVideoAdminTest(unittest.TestCase): 'id': 91, 'user_id': 21, 'username': 'demo', + 'group_name': '测试分组、第二分组', 'status': 'SUCCESS', 'request_json': json.dumps({'parameters': {'video_info': {'mode': '1'}}}), 'submit_response_json': '{}', @@ -99,7 +119,8 @@ class ImageVideoAdminTest(unittest.TestCase): session['user_id'] = 17 with patch('utils.auth.is_session_user_valid', return_value=True), \ patch('utils.auth.get_current_admin_role', return_value=('admin', {'id': 17})), \ - patch.object(admin_module, '_ensure_admin_menu_access', return_value=('admin', {'id': 17}, None)), \ + patch.object(admin_module, '_ensure_backend_menu_access', return_value=('admin', {'id': 17}, None)), \ + patch.object(admin_module, '_ensure_image_video_data_access', return_value=('admin', {'id': 17}, None)), \ patch.object(admin_module, 'get_db', return_value=connection): response = self.client.get('/api/admin/image-video-tasks?page=2&page_size=20&status=success') @@ -107,12 +128,212 @@ class ImageVideoAdminTest(unittest.TestCase): data = response.get_json() self.assertTrue(data['success']) self.assertEqual(2, data['page']) + self.assertEqual('测试分组、第二分组', data['items'][0]['group_name']) self.assertEqual('图生视频', data['items'][0]['mode']) self.assertEqual('https://oss.example/video.mp4', data['items'][0]['video_url']) + self.assertEqual([{ + 'source_url': 'https://coze.example/video.mp4', + 'archived_url': 'https://oss.example/video.mp4', + 'object_key': 'result/image_video/1/video.mp4', + 'archive_status': 'SUCCESS', + 'archive_error': '', + 'display_url': 'https://oss.example/video.mp4', + }], data['items'][0]['videos']) + self.assertNotIn('request', data['items'][0]) + self.assertNotIn('submit_response', data['items'][0]) + self.assertNotIn('result', data['items'][0]) list_sql, list_params = connection.cursor_value.executions[0] self.assertIn("t.task_type = 'IMAGE_VIDEO_WORKFLOW'", list_sql) - self.assertIn('created_by_id = %s', list_sql) - self.assertEqual((17, 17, 'SUCCESS', 20, 20), list_params) + self.assertIn('t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)', list_sql) + self.assertNotIn('created_by_id = %s', list_sql) + self.assertEqual(('SUCCESS', 20, 20), list_params) + + def test_authorized_normal_user_receives_all_video_tasks(self): + connection = FakeConnection([]) + with self.client.session_transaction() as session: + session['user_id'] = 23 + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \ + patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \ + patch.object(admin_module, 'get_db', return_value=connection): + response = self.client.get('/api/admin/image-video-tasks') + + self.assertEqual(200, response.status_code) + list_sql, list_params = connection.cursor_value.executions[0] + self.assertNotIn('t.user_id = %s', list_sql) + self.assertEqual((20, 0), list_params) + + def test_batch_download_returns_single_zip_with_selected_video(self): + row = { + 'id': 91, + 'video_urls_json': json.dumps(['https://coze.example/video.mp4']), + 'archived_videos_json': json.dumps([{'url': 'https://oss.example/video.mp4'}]), + } + connection = FakeConnection([row]) + remote_response = FakeDownloadResponse() + with self.client.session_transaction() as session: + session['user_id'] = 23 + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \ + patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \ + patch.object(admin_module, 'get_db', return_value=connection), \ + patch.object(admin_module.requests, 'get', return_value=remote_response): + response = self.client.post('/api/admin/image-video-tasks/download-zip', json={ + 'items': [{'task_id': 91, 'video_index': 0}], + }) + + self.assertEqual(200, response.status_code) + self.assertEqual('application/zip', response.mimetype) + self.assertEqual('1', response.headers['X-Archive-File-Count']) + with zipfile.ZipFile(io.BytesIO(response.get_data())) as archive: + self.assertEqual(['task-91-video-1.mp4'], archive.namelist()) + self.assertEqual(b'video-bytes', archive.read('task-91-video-1.mp4')) + response.close() + self.assertTrue(remote_response.closed) + + def test_batch_download_writes_failures_into_zip(self): + row = { + 'id': 91, + 'video_urls_json': json.dumps(['https://coze.example/video.mp4']), + 'archived_videos_json': '[]', + } + connection = FakeConnection([row]) + remote_response = FakeDownloadResponse(error=admin_module.requests.RequestException('upstream failed')) + with self.client.session_transaction() as session: + session['user_id'] = 23 + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \ + patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \ + patch.object(admin_module, 'get_db', return_value=connection), \ + patch.object(admin_module.requests, 'get', return_value=remote_response): + response = self.client.post('/api/admin/image-video-tasks/download-zip', json={ + 'items': [{'task_id': 91, 'video_index': 0}], + }) + + self.assertEqual(200, response.status_code) + self.assertEqual('1', response.headers['X-Archive-Error-Count']) + with zipfile.ZipFile(io.BytesIO(response.get_data())) as archive: + self.assertEqual(['download-errors.txt'], archive.namelist()) + self.assertIn('下载失败', archive.read('download-errors.txt').decode('utf-8')) + response.close() + + def test_unassigned_normal_user_is_denied_video_tasks(self): + denied = (app.response_class('{"success":false}', content_type='application/json'), 403) + with self.client.session_transaction() as session: + session['user_id'] = 24 + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 24}, denied)): + response = self.client.get('/api/admin/image-video-tasks') + + self.assertEqual(403, response.status_code) + + def test_only_super_admin_can_manage_image_video_permissions(self): + with self.client.session_transaction() as session: + session['user_id'] = 17 + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, 'get_current_admin_role', return_value=('admin', {'id': 17})): + response = self.client.get('/api/admin/image-video-task-permissions') + + self.assertEqual(403, response.status_code) + + def test_super_admin_lists_image_video_permission_users(self): + cursor = MagicMock() + cursor.__enter__.return_value = cursor + cursor.__exit__.return_value = False + cursor.fetchone.return_value = {'id': 75} + cursor.fetchall.return_value = [ + {'id': 2, 'username': 'admin-a', 'role': 'admin', 'is_admin': 1, 'created_by_id': 1, 'granted': 1}, + {'id': 3, 'username': 'user-a', 'role': 'normal', 'is_admin': 0, 'created_by_id': 2, 'granted': 0}, + ] + connection = MagicMock() + connection.cursor.return_value = cursor + with self.client.session_transaction() as session: + session['user_id'] = 1 + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, 'get_current_admin_role', return_value=('super_admin', {'id': 1})), \ + patch.object(admin_module, 'get_db', return_value=connection): + response = self.client.get('/api/admin/image-video-task-permissions') + + self.assertEqual(200, response.status_code) + self.assertEqual([True, False], [item['granted'] for item in response.get_json()['items']]) + + def test_super_admin_replaces_only_image_video_permissions(self): + cursor = MagicMock() + cursor.__enter__.return_value = cursor + cursor.__exit__.return_value = False + cursor.fetchone.return_value = {'id': 75} + cursor.fetchall.return_value = [{'id': 2}, {'id': 3}] + connection = MagicMock() + connection.cursor.return_value = cursor + with self.client.session_transaction() as session: + session['user_id'] = 1 + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, 'get_current_admin_role', return_value=('super_admin', {'id': 1})), \ + patch.object(admin_module, 'get_db', return_value=connection): + response = self.client.put('/api/admin/image-video-task-permissions', json={'user_ids': [2, 3]}) + + self.assertEqual(200, response.status_code) + self.assertEqual(2, response.get_json()['granted_count']) + statements = [call.args[0] for call in cursor.execute.call_args_list] + self.assertTrue(any('DELETE ucp FROM user_column_permission' in sql for sql in statements)) + self.assertEqual(2, sum('INSERT INTO user_column_permission' in sql for sql in statements)) + connection.commit.assert_called_once() + + def test_generic_permission_update_preserves_video_permission(self): + cursor = MagicMock() + cursor.fetchall.side_effect = [[{'id': 75}], [{'column_id': 75}]] + + admin_module._set_user_column_permissions( + cursor, + 7, + [2, 75], + preserve_column_keys=(admin_module.IMAGE_VIDEO_PERMISSION_KEY,), + ) + + inserts = [call.args[1] for call in cursor.execute.call_args_list if call.args[0].startswith('INSERT INTO')] + self.assertEqual([(7, 2), (7, 75)], inserts) + + def test_video_items_fall_back_to_source_and_handle_empty_values(self): + row = { + 'video_urls_json': json.dumps([ + 'https://coze.example/first.mp4', + 'https://coze.example/second.mp4', + ]), + 'archived_videos_json': json.dumps([{ + 'sourceUrl': 'https://coze.example/first.mp4', + 'status': 'FAILED', + 'error': 'archive failed', + }]), + } + + videos = admin_module._image_video_urls(row) + + self.assertEqual(2, len(videos)) + self.assertEqual('https://coze.example/first.mp4', videos[0]['display_url']) + self.assertEqual('FAILED', videos[0]['archive_status']) + self.assertEqual('archive failed', videos[0]['archive_error']) + self.assertEqual('https://coze.example/second.mp4', videos[1]['display_url']) + self.assertEqual([], admin_module._image_video_urls({ + 'video_urls_json': None, + 'archived_videos_json': 'not-json', + })) + + def test_detail_item_returns_masked_request_and_responses(self): + row = { + 'id': 8, + 'request_json': json.dumps({'token': 'secret', 'name': 'demo'}), + 'submit_response_json': json.dumps({'execute_id': 'exec-8'}), + 'result_json': json.dumps({'status': 'SUCCESS'}), + 'video_urls_json': '[]', + 'archived_videos_json': '[]', + } + + item = admin_module._image_video_admin_item(row, include_json=True) + + self.assertEqual('******', item['request']['token']) + self.assertEqual('demo', item['request']['name']) + self.assertEqual({'execute_id': 'exec-8'}, item['submit_response']) + self.assertEqual({'status': 'SUCCESS'}, item['result']) if __name__ == '__main__': diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 745cf27..6e44b00 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -754,8 +754,9 @@ .column-permission-select { width: 100%; max-width: 100%; - min-height: 80px; - max-height: 140px; + min-height: 120px; + height: 220px; + max-height: 260px; overflow-y: auto; padding: 8px; box-sizing: border-box; @@ -764,21 +765,81 @@ .column-permission-select option { padding: 4px 0; } - .image-video-table-wrap { overflow-x: auto; } - .image-video-table { min-width: 1260px; } - .image-video-status { display:inline-block; padding:3px 8px; border-radius:4px; background:#eef1f4; font-size:12px; } + .image-video-panel-box { padding:0; } + .image-video-results { padding:20px 20px 0; } + .image-video-panel-box > .pagination { padding:0 20px 20px; } + .image-video-toolbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:20px; } + .image-video-toolbar-main { display:flex; align-items:center; gap:18px; flex-wrap:wrap; } + .image-video-batch-btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:36px; padding:8px 14px; background:#27b38b; } + .image-video-batch-btn:disabled { cursor:not-allowed; opacity:.45; } + .image-video-batch-btn svg, + .image-video-card-action svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; } + .image-video-select-all { display:inline-flex; align-items:center; gap:8px; color:#333; font-size:14px; cursor:pointer; } + .image-video-select-all input { width:16px; height:16px; accent-color:#27b38b; } + .image-video-download-progress { min-height:18px; color:#527067; font-size:13px; } + .image-video-summary { color:#666; font-size:13px; text-align:right; } + .image-video-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:20px; } + .image-video-card { min-width:0; overflow:hidden; background:#fff; border:1px solid #e0e0e0; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.05); transition:border-color .2s ease,box-shadow .2s ease,transform .2s ease; } + .image-video-card:hover { border-color:#cbd7d3; box-shadow:0 6px 16px rgba(32,74,62,.1); transform:translateY(-1px); } + .image-video-card.selected { border-color:#27b38b; box-shadow:0 0 0 2px rgba(39,179,139,.12),0 6px 16px rgba(32,74,62,.1); } + .image-video-media { position:relative; width:100%; aspect-ratio:16/9; display:flex; align-items:center; justify-content:center; background:#0b0d0c; overflow:hidden; } + .image-video-media video { display:block; width:100%; height:100%; object-fit:contain; background:#000; } + .image-video-card-check { position:absolute; top:11px; left:11px; z-index:2; width:17px; height:17px; accent-color:#27b38b; cursor:pointer; filter:drop-shadow(0 1px 2px rgba(0,0,0,.55)); } + .image-video-unavailable { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; padding:20px; color:#b9c1be; text-align:center; } + .image-video-unavailable svg { width:34px; height:34px; fill:none; stroke:currentColor; stroke-width:1.5; } + .image-video-unavailable strong { color:#e0e4e2; font-size:14px; font-weight:500; } + .image-video-card-body { padding:15px 16px 16px; } + .image-video-card-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; } + .image-video-card-title { min-width:0; font-size:15px; font-weight:600; color:#2b3532; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + .image-video-status { flex:0 0 auto; display:inline-block; padding:3px 8px; border-radius:4px; background:#eef1f0; color:#5b6461; font-size:11px; font-weight:600; } .image-video-status.SUCCESS { color:#14733b; background:#e5f5eb; } .image-video-status.FAILED { color:#a82d2d; background:#fbe9e9; } - .image-video-detail-modal { width:min(1080px, calc(100vw - 48px)); max-height:88vh; overflow:auto; } - .image-video-detail-meta { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; } - .image-video-detail-meta div { padding:10px; background:#f6f7f8; border-radius:4px; min-width:0; } - .image-video-detail-meta span { display:block; color:#777; font-size:12px; margin-bottom:4px; } - .image-video-video-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:14px; } - .image-video-video-item { border:1px solid #ddd; border-radius:6px; padding:10px; } - .image-video-video-item video { display:block; width:100%; aspect-ratio:16/9; background:#111; } - .image-video-json { margin-top:14px; } - .image-video-json pre { max-height:300px; overflow:auto; padding:12px; background:#172026; color:#e8edf0; border-radius:4px; white-space:pre-wrap; word-break:break-word; font-size:12px; } - @media (max-width: 760px) { .image-video-detail-meta { grid-template-columns:repeat(2,minmax(0,1fr)); } } + .image-video-status.RUNNING, + .image-video-status.POLLING { color:#8a6200; background:#fff3cd; } + .image-video-card-info { display:grid; gap:7px; } + .image-video-info-row { display:grid; grid-template-columns:68px minmax(0,1fr); gap:8px; color:#777; font-size:13px; line-height:1.45; } + .image-video-info-row span, + .image-video-info-row a { min-width:0; color:#333; overflow-wrap:anywhere; } + .image-video-info-row a { color:#218a6d; text-decoration:none; } + .image-video-info-row a:hover { text-decoration:underline; } + .image-video-copy-link { min-width:0; padding:0; border:0; background:none; color:#218a6d; cursor:pointer; font:inherit; text-align:left; } + .image-video-copy-link:hover { text-decoration:underline; } + .image-video-copy-link.copied { color:#14733b; font-weight:600; } + .image-video-card-actions { display:flex; align-items:center; gap:9px; margin-top:15px; } + .image-video-card-action { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:32px; padding:6px 11px; border:1px solid #27b38b; border-radius:4px; background:#fff; color:#218a6d; cursor:pointer; font-size:13px; } + .image-video-card-action:hover { background:#27b38b; color:#fff; } + .image-video-card-action:disabled { cursor:not-allowed; opacity:.45; } + .image-video-empty { grid-column:1/-1; padding:64px 20px; border:1px dashed #d7ddda; border-radius:8px; color:#777; text-align:center; background:#fafbfb; } + .image-video-permission-btn { display:none; min-height:36px; padding:8px 14px; } + .image-video-permission-modal { width:min(620px,calc(100vw - 32px)); } + .image-video-permission-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; } + .image-video-permission-tools { display:flex; align-items:center; gap:12px; margin-bottom:12px; } + .image-video-permission-tabs { display:flex; gap:4px; margin-bottom:12px; padding:3px; border-radius:6px; background:#f1f4f3; } + .image-video-permission-tab { flex:1; min-height:34px; padding:7px 10px; border:0; border-radius:4px; background:transparent; color:#68736f; cursor:pointer; font:inherit; font-size:13px; } + .image-video-permission-tab.active { background:#fff; color:#16835e; box-shadow:0 1px 4px rgba(38,70,58,.12); font-weight:600; } + .image-video-permission-search { flex:1; min-width:0; padding:9px 10px; border:1px solid #ddd; border-radius:6px; font:inherit; } + .image-video-permission-list { max-height:52vh; overflow:auto; border:1px solid #e1e5e3; border-radius:6px; } + .image-video-permission-row { display:grid; grid-template-columns:24px minmax(0,1fr) auto; align-items:center; gap:10px; min-height:48px; padding:9px 12px; border-bottom:1px solid #edf0ef; } + .image-video-permission-row:last-child { border-bottom:0; } + .image-video-permission-row input { width:16px; height:16px; accent-color:#27b38b; } + .image-video-permission-user { min-width:0; display:flex; flex-direction:column; gap:2px; } + .image-video-permission-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#293330; } + .image-video-permission-role { color:#727b78; font-size:12px; } + .image-video-permission-state { min-width:68px; padding:4px 8px; border:1px solid #d8dfdc; border-radius:4px; background:#fff; color:#6d7773; cursor:pointer; font-size:12px; white-space:nowrap; } + .image-video-permission-state.granted { border-color:#a9ddca; background:#ecfaf4; color:#16835e; } + .image-video-permission-state.pending { border-color:#efd39b; background:#fff8e8; color:#a56b00; } + .image-video-permission-summary { margin:0 0 10px; color:#66716d; font-size:13px; } + .image-video-permission-empty { padding:36px 16px; color:#777; text-align:center; } + .image-video-permission-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; } + @media (max-width: 760px) { + .admin-user-box { position:static !important; width:max-content; max-width:100%; margin-bottom:16px; } + .tabs { max-width:100%; overflow-x:auto; overscroll-behavior-x:contain; } + .tab { flex:0 0 auto; padding:10px 14px; } + .image-video-results { padding:16px; } + .image-video-toolbar { align-items:flex-start; flex-direction:column; } + .image-video-summary { text-align:left; } + .image-video-grid { grid-template-columns:minmax(0,1fr); gap:16px; } + } @@ -1521,26 +1582,31 @@

视频任务筛选

-
-
-
-
-
-

视频任务记录

- -
-
- - - -
任务 ID用户模式状态Coze 状态执行 ID视频归档提交时间操作
+
+
+
+
+ + + + +
+ +
+
@@ -1947,16 +2013,33 @@
-