视频任务管理更新

This commit is contained in:
super
2026-07-19 22:23:26 +08:00
parent 69784b8d32
commit cbde22ec79
62 changed files with 2164 additions and 280 deletions

View File

@@ -676,6 +676,7 @@ public class AppearancePatentCozeClient {
row.setCountry(source.getCountry()); row.setCountry(source.getCountry());
row.setSku(source.getSku()); row.setSku(source.getSku());
row.setBrand(source.getBrand()); row.setBrand(source.getBrand());
row.setPrice(source.getPrice());
row.setUrl(source.getUrl()); row.setUrl(source.getUrl());
row.setTitle(source.getTitle()); row.setTitle(source.getTitle());
row.setError(source.getError()); row.setError(source.getError());

View File

@@ -87,7 +87,7 @@ public class AppearancePatentController {
public ApiResponse<AppearancePatentHistoryVo> history( public ApiResponse<AppearancePatentHistoryVo> history(
@Parameter(description = "当前用户 ID用于查询该用户自己的历史记录。", required = true, example = "1") @Parameter(description = "当前用户 ID用于查询该用户自己的历史记录。", required = true, example = "1")
@RequestParam("user_id") Long userId, @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) { @RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
return ApiResponse.success(service.history(userId, limit)); return ApiResponse.success(service.history(userId, limit));
} }

View File

@@ -33,10 +33,14 @@ public class AppearancePatentResultRowDto {
@JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"}) @JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"})
private String 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", "品牌"}) @JsonAlias({"Brand", "品牌"})
private String 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") @Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
@JsonAlias({ @JsonAlias({
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url", "imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
@@ -58,7 +62,7 @@ public class AppearancePatentResultRowDto {
private Boolean done; private Boolean done;
@JsonAlias({"row_status", "rowStatus", "Status"}) @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; private String status;
@Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度商标”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) @Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度商标”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
@@ -75,15 +79,15 @@ public class AppearancePatentResultRowDto {
private String conclusion; private String conclusion;
@JsonAlias({"title_reason", "titleReason"}) @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; private String titleReason;
@JsonAlias({"appearance_reason", "appearanceReason"}) @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; private String appearanceReason;
@JsonAlias({"patent_reason", "patentReason", "patent reason"}) @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; private String patentReason;
@JsonAlias({"score", "Score", "评分"}) @JsonAlias({"score", "Score", "评分"})

View File

@@ -1036,7 +1036,12 @@ public class AppearancePatentTaskService {
} }
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk); Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
for (AppearancePatentResultRowDto row : rows) { 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 payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "appearance patent chunk payload merge failed");
String oldPayload = chunk.getPayloadJson(); String oldPayload = chunk.getPayloadJson();
@@ -3328,7 +3333,7 @@ public class AppearancePatentTaskService {
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); 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(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name"));
row.createCell(col++).setCellValue(resolveBrand(resultRow, parsedRow)); 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.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.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl()));
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku())); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku()));
@@ -4190,6 +4195,10 @@ public class AppearancePatentTaskService {
return ""; return "";
} }
static String resolvePrice(AppearancePatentResultRowDto resultRow) {
return resultRow == null || resultRow.getPrice() == null ? "" : resultRow.getPrice().trim();
}
private boolean hasInfringingPersistedConclusion(Long taskId) { private boolean hasInfringingPersistedConclusion(Long taskId) {
if (taskId == null) { if (taskId == null) {
return false; return false;

View File

@@ -14,7 +14,7 @@ public class CollectDataSubmitRowDto {
private String brand; private String brand;
@JsonAlias({"ASIN", "asin"}) @JsonAlias({"ASIN", "asin"})
@Schema(description = "ASIN", example = "B0XXXXXXX") @Schema(description = "商品 ASIN", example = "B0XXXXXXX")
private String asin; private String asin;
@JsonAlias({"价格", "price"}) @JsonAlias({"价格", "price"})

View File

@@ -149,7 +149,7 @@ public class DeleteBrandRunController {
} }
@GetMapping("/results/{resultId}/download") @GetMapping("/results/{resultId}/download")
@Operation(summary = "Download one delete-brand result") @Operation(summary = "下载单个删除品牌结果")
public void downloadResult( public void downloadResult(
@PathVariable Long resultId, @PathVariable Long resultId,
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY) @Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)

View File

@@ -100,7 +100,7 @@ public class ImageVideoController {
} }
@GetMapping("/tasks/{taskId}") @GetMapping("/tasks/{taskId}")
@Operation(summary = "Query image video async task") @Operation(summary = "查询图生视频异步任务")
public ApiResponse<ImageVideoAsyncTaskVo> getTask( public ApiResponse<ImageVideoAsyncTaskVo> getTask(
@PathVariable Long taskId, @PathVariable Long taskId,
@RequestParam("user_id") Long userId) { @RequestParam("user_id") Long userId) {

View File

@@ -35,6 +35,8 @@ public class ImageVideoArchiveService {
static final String TASK_TYPE = "IMAGE_VIDEO_WORKFLOW"; static final String TASK_TYPE = "IMAGE_VIDEO_WORKFLOW";
private static final int BATCH_SIZE = 20; 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<String> VIDEO_KEYS = Set.of( private static final Set<String> VIDEO_KEYS = Set.of(
"videourl", "video", "outputvideourl", "resultvideourl" "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<ImageVideoAsyncTaskEntity> expiredTasks = taskMapper.selectList(
new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
.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<String> objectKeys = new LinkedHashSet<>();
for (Map<String, Object> 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) { private void backfillDerivedFields(ImageVideoAsyncTaskEntity task) {
if (task.getArchiveStatus() != null) { if (task.getArchiveStatus() != null) {
return; return;

View File

@@ -32,6 +32,7 @@ public class ImageVideoAsyncTaskService {
private static final int DISPATCH_BATCH_SIZE = 20; private static final int DISPATCH_BATCH_SIZE = 20;
private static final int POLL_BATCH_SIZE = 50; private static final int POLL_BATCH_SIZE = 50;
private static final int FAILED_TASK_RETENTION_MINUTES = 10;
private static final Set<String> TERMINAL_STATUSES = Set.of( private static final Set<String> TERMINAL_STATUSES = Set.of(
"SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED", "SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED",
"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED" "FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED"
@@ -102,7 +103,11 @@ public class ImageVideoAsyncTaskService {
throw new BusinessException("Image video task not found"); throw new BusinessException("Image video task not found");
} }
task = recoverFalseFailedTask(task); 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}") @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()))); 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<ImageVideoAsyncTaskEntity>()
.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) { private ImageVideoAsyncTaskVo submit(TaskType type, Long userId, Object payload) {
if (userId == null || userId <= 0) { if (userId == null || userId <= 0) {
throw new BusinessException("userId is required"); throw new BusinessException("userId is required");

View File

@@ -156,7 +156,7 @@ public class PatrolDeleteController {
public ApiResponse<PatrolDeleteHistoryVo> history( public ApiResponse<PatrolDeleteHistoryVo> history(
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
@RequestParam("user_id") Long userId, @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) { @RequestParam(value = "limit", required = false) Integer limit) {
return ApiResponse.success(patrolDeleteTaskService.listHistory(userId, limit)); return ApiResponse.success(patrolDeleteTaskService.listHistory(userId, limit));
} }

View File

@@ -34,6 +34,7 @@ public class PermissionMenuService {
public static final String MENU_TYPE_APP = "app"; public static final String MENU_TYPE_APP = "app";
public static final String MENU_TYPE_ADMIN = "admin"; 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 PermissionMenuMapper permissionMenuMapper;
private final UserColumnPermissionMapper userColumnPermissionMapper; private final UserColumnPermissionMapper userColumnPermissionMapper;
@@ -166,6 +167,22 @@ public class PermissionMenuService {
} }
} }
PermissionMenuEntity imageVideoDataPermission = permissionMenuMapper.selectOne(
new LambdaQueryWrapper<PermissionMenuEntity>()
.eq(PermissionMenuEntity::getColumnKey, IMAGE_VIDEO_DATA_PERMISSION_KEY)
.last("LIMIT 1"));
if (imageVideoDataPermission != null) {
requestedIds = new ArrayList<>(requestedIds);
Long existingCount = userColumnPermissionMapper.selectCount(
new LambdaQueryWrapper<UserColumnPermissionEntity>()
.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<UserColumnPermissionEntity>() userColumnPermissionMapper.delete(new LambdaUpdateWrapper<UserColumnPermissionEntity>()
.eq(UserColumnPermissionEntity::getUserId, userId)); .eq(UserColumnPermissionEntity::getUserId, userId));
for (Long columnId : requestedIds) { for (Long columnId : requestedIds) {

View File

@@ -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.PriceTrackCandidateAddRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest; 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.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.PriceTrackLoopRunChildFinishedRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest; import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
@@ -145,7 +146,7 @@ public class PriceTrackController {
@PathVariable Long taskId, @PathVariable Long taskId,
@Parameter(description = "国家代码", required = true, example = "DE") @Parameter(description = "国家代码", required = true, example = "DE")
@RequestParam("country") String country, @RequestParam("country") String country,
@Parameter(description = "ASIN", required = true, example = "B0XXXX") @Parameter(description = "商品 ASIN", required = true, example = "B0XXXX")
@RequestParam("asin") String asin) { @RequestParam("asin") String asin) {
return ApiResponse.success(priceTrackTaskService.checkTaskSkipAsin(taskId, country, asin)); return ApiResponse.success(priceTrackTaskService.checkTaskSkipAsin(taskId, country, asin));
} }
@@ -182,6 +183,16 @@ public class PriceTrackController {
return ApiResponse.success(priceTrackTaskService.createTask(request)); return ApiResponse.success(priceTrackTaskService.createTask(request));
} }
@PostMapping("/tasks/{taskId}/dispatch-failed")
@Operation(summary = "标记 Python 入队失败", description = "前端创建任务后若 Python 队列拒绝接收,立即将任务及占位店铺标记为失败。")
public ApiResponse<Void> 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") @PostMapping("/loop-runs")
@Operation(summary = "创建跟价循环任务", description = "创建整批店铺的多轮循环主任务。") @Operation(summary = "创建跟价循环任务", description = "创建整批店铺的多轮循环主任务。")
public ApiResponse<PriceTrackLoopRunVo> createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) { public ApiResponse<PriceTrackLoopRunVo> createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) {

View File

@@ -33,12 +33,12 @@ public class PriceTrackCreateTaskRequest {
@NotEmpty(message = "国家列表不能为空") @NotEmpty(message = "国家列表不能为空")
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]") @Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
private List<String> countryCodes; private List<String> countryCodes;
@Schema(description = "optional loop run id", example = "1") @Schema(description = "可选的循环运行 ID", example = "1")
private Long loopRunId; private Long loopRunId;
@Schema(description = "optional round index, 1-based", example = "2") @Schema(description = "可选的轮次序号,从 1 开始", example = "2")
private Integer roundIndex; private Integer roundIndex;
@Schema(description = "optional shop index, 0-based", example = "0") @Schema(description = "可选的店铺序号,从 0 开始", example = "0")
private Integer shopIndex; private Integer shopIndex;
} }

View File

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

View File

@@ -32,7 +32,7 @@ public class PriceTrackLoopRunCreateRequest {
private List<String> countryCodes = new ArrayList<>(); private List<String> countryCodes = new ArrayList<>();
@NotBlank(message = "executionMode不能为空") @NotBlank(message = "executionMode不能为空")
@Schema(description = "FINITE or INFINITE", example = "FINITE") @Schema(description = "循环模式:FINITE 表示有限次数,INFINITE 表示无限循环", example = "FINITE")
private String executionMode; private String executionMode;
@Schema(description = "固定轮次时必填", example = "3") @Schema(description = "固定轮次时必填", example = "3")

View File

@@ -8,20 +8,20 @@ import lombok.Data;
import java.util.List; import java.util.List;
@Data @Data
@Schema(description = "Price track match shops request") @Schema(description = "跟价店铺匹配请求")
public class PriceTrackMatchShopsRequest { public class PriceTrackMatchShopsRequest {
@NotNull(message = "userId is required") @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; private Long userId;
@NotEmpty(message = "shopNames cannot be empty") @NotEmpty(message = "shopNames cannot be empty")
@Schema(description = "Shop names to match", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "需要匹配的店铺名称", requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> shopNames; private List<String> shopNames;
@Schema(description = "Selected ASIN file local paths") @Schema(description = "已选择的 ASIN 文件本地路径")
private List<String> asinFiles; private List<String> asinFiles;
@Schema(description = "Selected country codes") @Schema(description = "已选择的国家代码")
private List<String> countryCodes; private List<String> countryCodes;
} }

View File

@@ -65,7 +65,7 @@ public class PriceTrackSubmitResultRequest {
@Schema(description = "店铺商城名称", example = "Amazon.de") @Schema(description = "店铺商城名称", example = "Amazon.de")
private String shopMallName; private String shopMallName;
@Schema(description = "ASIN", example = "B0ABCDE123") @Schema(description = "商品 ASIN", example = "B0ABCDE123")
private String asin; private String asin;
@Schema(description = "价格", example = "19.99") @Schema(description = "价格", example = "19.99")

View File

@@ -7,23 +7,23 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@Data @Data
@Schema(description = "price track create task response") @Schema(description = "跟价任务创建响应")
public class PriceTrackCreateTaskVo { public class PriceTrackCreateTaskVo {
@Schema(description = "task id", example = "200") @Schema(description = "任务 ID", example = "200")
private Long taskId; private Long taskId;
@Schema(description = "initial task items") @Schema(description = "初始任务项")
private List<PriceTrackResultItemVo> items; private List<PriceTrackResultItemVo> items;
@Schema(description = "all skip asins grouped by country") @Schema(description = "按国家分组的全部跳过跟价 ASIN")
private Map<String, List<String>> skipAsinsByCountry; private Map<String, List<String>> skipAsinsByCountry;
@Schema(description = "all skip asin details grouped by country code, each item includes asin and minimumPrice") @Schema(description = "按国家代码分组的全部跳过跟价 ASIN 明细,每项包含 asin minimumPrice")
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry; private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry;
@Schema(description = "parsed asin rows by country") @Schema(description = "按国家分组的已解析 ASIN 行")
private Map<String, List<Map<String, String>>> asinRowsByCountry; private Map<String, List<Map<String, String>>> asinRowsByCountry;
@Schema(description = "minimum price grouped by country code and asin") @Schema(description = "按国家代码和 ASIN 分组的最低价格")
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin; private Map<String, Map<String, String>> minimumPriceByCountryAndAsin;
} }

View File

@@ -8,53 +8,53 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@Data @Data
@Schema(description = "Price track shop match response") @Schema(description = "跟价店铺匹配响应")
public class PriceTrackMatchShopsVo { public class PriceTrackMatchShopsVo {
@Schema(description = "Match results in the same order as requested shop names") @Schema(description = "匹配结果,顺序与请求中的店铺名称一致")
private List<PriceTrackShopQueueItem> items = new ArrayList<>(); private List<PriceTrackShopQueueItem> items = new ArrayList<>();
@Schema(description = "All skip ASINs grouped by country code") @Schema(description = "按国家代码分组的全部跳过跟价 ASIN")
private Map<String, List<String>> skipAsinsByCountry; private Map<String, List<String>> skipAsinsByCountry;
@Schema(description = "All skip ASIN details grouped by country code, each item includes asin and minimumPrice") @Schema(description = "按国家代码分组的全部跳过跟价 ASIN 明细,每项包含 asin minimumPrice")
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry; private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry;
@Schema(description = "Parsed asin rows grouped by country code") @Schema(description = "按国家代码分组的已解析 ASIN 行")
private Map<String, List<Map<String, String>>> asinRowsByCountry; private Map<String, List<Map<String, String>>> asinRowsByCountry;
@Schema(description = "Minimum price grouped by country code and asin") @Schema(description = "按国家代码和 ASIN 分组的最低价格")
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin; private Map<String, Map<String, String>> minimumPriceByCountryAndAsin;
@Data @Data
@Schema(description = "Single matched shop item") @Schema(description = "单个店铺匹配结果")
public static class PriceTrackShopQueueItem { public static class PriceTrackShopQueueItem {
@Schema(description = "Shop name", example = "Demo Shop") @Schema(description = "店铺名称", example = "Demo Shop")
private String shopName; private String shopName;
@Schema(description = "Shop mall name", example = "Amazon.de") @Schema(description = "店铺商城名称", example = "Amazon.de")
private String shopMallName; private String shopMallName;
@Schema(description = "Matched shop id") @Schema(description = "匹配到的店铺 ID")
private Object shopId; private Object shopId;
@Schema(description = "Platform", example = "Amazon") @Schema(description = "平台", example = "Amazon")
private String platform; private String platform;
@Schema(description = "Company name") @Schema(description = "公司名称")
private String companyName; private String companyName;
@Schema(description = "Whether the shop matched") @Schema(description = "店铺是否匹配成功")
private Boolean matched; private Boolean matched;
@Schema(description = "Match status, such as MATCHED or PENDING") @Schema(description = "匹配状态,例如 MATCHED PENDING")
private String matchStatus; private String matchStatus;
@Schema(description = "Readable match message") @Schema(description = "可读的匹配结果说明")
private String matchMessage; private String matchMessage;
@Schema(description = "All skip ASINs grouped by country code") @Schema(description = "按国家代码分组的全部跳过跟价 ASIN")
private Map<String, List<String>> skipAsins; private Map<String, List<String>> skipAsins;
} }
} }

View File

@@ -17,7 +17,7 @@ public class PriceTrackResultItemVo {
@Schema(description = "店铺名称,详情页会尽量恢复创建任务时的展示名") @Schema(description = "店铺名称,详情页会尽量恢复创建任务时的展示名")
private String shopName; private String shopName;
@Schema(description = "Shop mall name") @Schema(description = "店铺商城名称")
private String shopMallName; private String shopMallName;
@Schema(description = "店铺 ID紫鸟") @Schema(description = "店铺 ID紫鸟")

View File

@@ -4,18 +4,18 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@Data @Data
@Schema(description = "single skip price asin lookup response") @Schema(description = "单个跳过跟价 ASIN 查询响应")
public class SkipPriceAsinCheckVo { public class SkipPriceAsinCheckVo {
@Schema(description = "country code", example = "DE") @Schema(description = "国家代码", example = "DE")
private String country; private String country;
@Schema(description = "normalized asin", example = "B0XXXX") @Schema(description = "规范化后的 ASIN", example = "B0XXXX")
private String asin; private String asin;
@Schema(description = "whether the asin exists in skip price asin table") @Schema(description = "该 ASIN 是否存在于跳过跟价 ASIN 表中")
private boolean exists; private boolean exists;
@Schema(description = "configured minimum price, empty when not configured or not found") @Schema(description = "已配置的最低价格;未配置或未找到时为空")
private String minimumPrice; private String minimumPrice;
} }

View File

@@ -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<FileResultEntity> results = fileResultMapper.selectList(
new LambdaQueryWrapper<FileResultEntity>()
.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 @Transactional
public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) { public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) {
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");

View File

@@ -4,6 +4,9 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data @Data
@Schema(description = "单国 sheet 内一行数据,与商品风险处理 Excel 模板列一致") @Schema(description = "单国 sheet 内一行数据,与商品风险处理 Excel 模板列一致")
public class ProductRiskRowDto { public class ProductRiskRowDto {
@@ -30,4 +33,19 @@ public class ProductRiskRowDto {
@JsonProperty("removeStatus") @JsonProperty("removeStatus")
@Schema(description = "移除状态") @Schema(description = "移除状态")
private String removeStatus; private String removeStatus;
@JsonProperty("violationDetails")
@Schema(description = "该 ASIN 的逐项违规处理明细")
private List<ViolationDetail> violationDetails = new ArrayList<>();
@Data
@Schema(description = "单项违规处理结果")
public static class ViolationDetail {
@Schema(description = "违规原因标题", example = "商品安全违规")
private String reason;
@Schema(description = "处理状态", allowableValues = {"成功", "失败"})
private String status;
}
} }

View File

@@ -5,8 +5,13 @@ import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskRowDto;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode; import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.Cell; 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.Row;
import org.apache.poi.ss.usermodel.Sheet; 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.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -15,6 +20,7 @@ import java.io.FileOutputStream;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
@Service @Service
@Slf4j @Slf4j
@@ -30,7 +36,10 @@ public class ProductRiskExcelAssemblyService {
"状态", "状态",
"完成", "完成",
"移除ASIN", "移除ASIN",
"移除状态" "移除状态",
"ASIN",
"原因",
"状态"
}; };
public void writeWorkbook(File outputXlsx, String shopDisplayName, Map<String, List<ProductRiskRowDto>> countries) { public void writeWorkbook(File outputXlsx, String shopDisplayName, Map<String, List<ProductRiskRowDto>> countries) {
@@ -45,16 +54,38 @@ public class ProductRiskExcelAssemblyService {
headerRow.createCell(c).setCellValue(HEADER[c]); headerRow.createCell(c).setCellValue(HEADER[c]);
} }
List<ProductRiskRowDto> rows = safe.getOrDefault(sheetName, List.of()); List<ProductRiskRowDto> rows = safe.getOrDefault(sheetName, List.of());
CellStyle detailStyle = createDetailStyle(workbook);
CellStyle detailAsinStyle = createDetailAsinStyle(workbook);
int rowIdx = 1; int rowIdx = 1;
for (ProductRiskRowDto dto : rows) { for (ProductRiskRowDto dto : rows) {
Row row = sheet.createRow(rowIdx++); List<ProductRiskRowDto.ViolationDetail> details = violationDetails(dto);
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName)); int groupSize = Math.max(1, details.size());
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku()); String asin = firstNonBlank(
createTextCell(row, 2, dto == null ? null : dto.getStatus()); dto == null ? null : dto.getProductAsinSku(),
createTextCell(row, 3, completionDisplayValue(dto)); dto == null ? null : dto.getRemoveAsin());
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin()); for (int offset = 0; offset < groupSize; offset++) {
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus()); 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); applyDefaultColumnWidths(sheet);
} }
workbook.write(fos); workbook.write(fos);
@@ -104,6 +135,41 @@ public class ProductRiskExcelAssemblyService {
cell.setCellValue(value == null ? "" : value); 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<ProductRiskRowDto.ViolationDetail> 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) { private static String firstNonBlank(String primary, String fallback) {
if (primary != null && !primary.isBlank()) { if (primary != null && !primary.isBlank()) {
return primary; return primary;
@@ -150,7 +216,7 @@ public class ProductRiskExcelAssemblyService {
} }
private void applyDefaultColumnWidths(Sheet sheet) { 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++) { for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i] * 256); sheet.setColumnWidth(i, widths[i] * 256);
} }

View File

@@ -1245,6 +1245,8 @@ public class ProductRiskTaskService {
merged.setDone(incoming.getDone() != null ? incoming.getDone() : merged.getDone()); merged.setDone(incoming.getDone() != null ? incoming.getDone() : merged.getDone());
merged.setRemoveAsin(firstNonBlank(incoming.getRemoveAsin(), merged.getRemoveAsin())); merged.setRemoveAsin(firstNonBlank(incoming.getRemoveAsin(), merged.getRemoveAsin()));
merged.setRemoveStatus(firstNonBlank(incoming.getRemoveStatus(), merged.getRemoveStatus())); merged.setRemoveStatus(firstNonBlank(incoming.getRemoveStatus(), merged.getRemoveStatus()));
merged.setViolationDetails(mergeViolationDetails(
merged.getViolationDetails(), incoming.getViolationDetails()));
return merged; return merged;
} }
@@ -1259,6 +1261,31 @@ public class ProductRiskTaskService {
out.setDone(row.getDone()); out.setDone(row.getDone());
out.setRemoveAsin(row.getRemoveAsin()); out.setRemoveAsin(row.getRemoveAsin());
out.setRemoveStatus(row.getRemoveStatus()); out.setRemoveStatus(row.getRemoveStatus());
out.setViolationDetails(cloneViolationDetails(row.getViolationDetails()));
return out;
}
static List<ProductRiskRowDto.ViolationDetail> mergeViolationDetails(
List<ProductRiskRowDto.ViolationDetail> existing,
List<ProductRiskRowDto.ViolationDetail> incoming) {
return cloneViolationDetails(incoming == null || incoming.isEmpty() ? existing : incoming);
}
private static List<ProductRiskRowDto.ViolationDetail> cloneViolationDetails(
List<ProductRiskRowDto.ViolationDetail> details) {
if (details == null || details.isEmpty()) {
return new ArrayList<>();
}
List<ProductRiskRowDto.ViolationDetail> 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; return out;
} }

View File

@@ -7,7 +7,7 @@ import lombok.Data;
@Schema(description = "查询 ASIN 单条结果") @Schema(description = "查询 ASIN 单条结果")
public class QueryAsinAsinStatusDto { public class QueryAsinAsinStatusDto {
@Schema(description = "ASIN", example = "B0BRZZR3N2") @Schema(description = "商品 ASIN", example = "B0BRZZR3N2")
private String asin; private String asin;
@Schema(description = "Python 查询后返回的状态", example = "正常") @Schema(description = "Python 查询后返回的状态", example = "正常")

View File

@@ -48,7 +48,7 @@ public class QueryAsinController {
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize, @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName, @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 = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success(queryAsinService.page( return ApiResponse.success(queryAsinService.page(
@@ -66,7 +66,7 @@ public class QueryAsinController {
public ResponseEntity<byte[]> export( public ResponseEntity<byte[]> export(
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName, @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 = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
byte[] bytes = queryAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin)); byte[] bytes = queryAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));

View File

@@ -48,7 +48,7 @@ public class SkipPriceAsinController {
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize, @Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName, @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 = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
return ApiResponse.success(skipPriceAsinService.page( return ApiResponse.success(skipPriceAsinService.page(
@@ -66,7 +66,7 @@ public class SkipPriceAsinController {
public ResponseEntity<byte[]> export( public ResponseEntity<byte[]> export(
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName, @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 = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin)); byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));

View File

@@ -7,7 +7,7 @@ import lombok.Data;
@Data @Data
public class QueryAsinCountryUpdateRequest { public class QueryAsinCountryUpdateRequest {
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "商品 ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "ASIN 不能为空") @NotBlank(message = "ASIN 不能为空")
private String asin; private String asin;
} }

View File

@@ -9,7 +9,7 @@ import java.math.BigDecimal;
@Data @Data
public class SkipPriceAsinCountryUpdateRequest { public class SkipPriceAsinCountryUpdateRequest {
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED) @Schema(description = "商品 ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "ASIN 不能为空") @NotBlank(message = "ASIN 不能为空")
private String asin; private String asin;

View File

@@ -8,7 +8,7 @@ import lombok.Data;
@Schema(description = "跳过 ASIN 明细") @Schema(description = "跳过 ASIN 明细")
public class SkipPriceAsinDetailDto { public class SkipPriceAsinDetailDto {
@Schema(description = "ASIN") @Schema(description = "商品 ASIN")
private String asin; private String asin;
@JsonProperty("minimumPrice") @JsonProperty("minimumPrice")

View File

@@ -9,7 +9,7 @@ import lombok.Data;
@Schema(description = "匹配店铺单行结果") @Schema(description = "匹配店铺单行结果")
public class ShopMatchRowDto { public class ShopMatchRowDto {
@Schema(description = "ASIN") @Schema(description = "商品 ASIN")
private String asin; private String asin;
@JsonAlias("minimum_price") @JsonAlias("minimum_price")

View File

@@ -109,7 +109,7 @@ public class SimilarAsinController {
public ApiResponse<SimilarAsinHistoryVo> history( public ApiResponse<SimilarAsinHistoryVo> history(
@Parameter(description = "当前用户 ID", required = true, example = "1") @Parameter(description = "当前用户 ID", required = true, example = "1")
@RequestParam("user_id") Long userId, @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) { @RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
return ApiResponse.success(service.history(userId, limit)); return ApiResponse.success(service.history(userId, limit));
} }

View File

@@ -45,7 +45,7 @@ public class SimilarAsinResultRowDto {
private String price; private String price;
@JsonAlias({"alibaba", "alibaba_items", "alibabaItems", "alibaba_products", "alibabaProducts"}) @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<AlibabaItem> alibaba = new ArrayList<>(); private List<AlibabaItem> alibaba = new ArrayList<>();
@Schema(description = "商品主图 URL。Python 端解析的代表图。允许传字符串或数组(数组取首个非空),与 urls 互不影响。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg") @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; private Boolean done;
@JsonAlias({"row_status", "rowStatus", "Status"}) @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; private String status;
@Schema(description = "兼容旧版 Coze 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) @Schema(description = "兼容旧版 Coze 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
@@ -102,15 +102,15 @@ public class SimilarAsinResultRowDto {
private String conclusion; private String conclusion;
@JsonAlias({"title_reason", "titleReason"}) @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; private String titleReason;
@JsonAlias({"appearance_reason", "appearanceReason"}) @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; private String appearanceReason;
@JsonAlias({"patent_reason", "patentReason", "patent reason"}) @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; private String patentReason;
@JsonAlias({"main_url", "mainUrl", "main_image_url", "mainImageUrl", "main_img", "mainImg", "主图URL", "主图链接"}) @JsonAlias({"main_url", "mainUrl", "main_image_url", "mainImageUrl", "main_img", "mainImg", "主图URL", "主图链接"})
@@ -270,15 +270,15 @@ public class SimilarAsinResultRowDto {
} }
@Data @Data
@Schema(description = "Alibaba candidate") @Schema(description = "阿里巴巴候选商品")
public static class AlibabaItem { public static class AlibabaItem {
@JsonAlias({"url", "image_url", "imageUrl", "img_url", "imgUrl", "link"}) @JsonAlias({"url", "image_url", "imageUrl", "img_url", "imgUrl", "link"})
@Schema(description = "Alibaba image or product URL") @Schema(description = "阿里巴巴商品图片或商品 URL")
private String url; private String url;
@JsonAlias({"price", "浠锋牸"}) @JsonAlias({"price", "浠锋牸"})
@Schema(description = "Alibaba candidate price") @Schema(description = "阿里巴巴候选商品价格")
private Object price; private Object price;
public String getUrl() { public String getUrl() {

View File

@@ -18,17 +18,17 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequiredArgsConstructor @RequiredArgsConstructor
@RequestMapping("/api/tasks") @RequestMapping("/api/tasks")
@Tag(name = "Task heartbeat", description = "Unified heartbeat API for Python task workers") @Tag(name = "任务心跳", description = "供 Python 任务执行器统一上报运行状态和进度的心跳接口")
public class TaskHeartbeatController { public class TaskHeartbeatController {
private final TaskHeartbeatService taskHeartbeatService; private final TaskHeartbeatService taskHeartbeatService;
@PostMapping("/{taskId}/heartbeat") @PostMapping("/{taskId}/heartbeat")
@Operation( @Operation(
summary = "Task heartbeat", summary = "上报任务心跳",
description = "Python calls this during execution. The backend resolves biz_file_task or brand task by taskId only.") description = "Python 在任务执行期间调用。后端仅根据 taskId 识别文件任务或品牌任务并更新心跳。")
public ApiResponse<TaskHeartbeatVo> heartbeat( public ApiResponse<TaskHeartbeatVo> heartbeat(
@Parameter(description = "Task ID", required = true, example = "200") @Parameter(description = "任务 ID", required = true, example = "200")
@PathVariable Long taskId, @PathVariable Long taskId,
@Valid @RequestBody(required = false) TaskHeartbeatRequest request) { @Valid @RequestBody(required = false) TaskHeartbeatRequest request) {
return ApiResponse.success(taskHeartbeatService.heartbeat(taskId, request)); return ApiResponse.success(taskHeartbeatService.heartbeat(taskId, request));

View File

@@ -6,15 +6,15 @@ import lombok.Data;
@Data @Data
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "Task heartbeat request") @Schema(description = "任务心跳请求")
public class TaskHeartbeatRequest { public class TaskHeartbeatRequest {
@Schema(description = "Optional phase label", example = "crawling") @Schema(description = "可选的任务阶段标识", example = "crawling")
private String phase; private String phase;
@Schema(description = "Optional current progress", example = "42") @Schema(description = "可选的当前进度", example = "42")
private Integer current; private Integer current;
@Schema(description = "Optional total progress", example = "1100") @Schema(description = "可选的总进度", example = "1100")
private Integer total; private Integer total;
} }

View File

@@ -4,19 +4,19 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@Data @Data
@Schema(description = "Task heartbeat response") @Schema(description = "任务心跳响应")
public class TaskHeartbeatVo { public class TaskHeartbeatVo {
@Schema(description = "Whether the task can continue", example = "true") @Schema(description = "任务是否可以继续执行", example = "true")
private boolean alive; private boolean alive;
@Schema(description = "Task status", example = "RUNNING") @Schema(description = "任务状态", example = "RUNNING")
private String status; private String status;
@Schema(description = "Resolved task module", example = "PRODUCT_RISK_RESOLVE") @Schema(description = "识别出的任务模块", example = "PRODUCT_RISK_RESOLVE")
private String moduleType; private String moduleType;
@Schema(description = "Message", example = "ok") @Schema(description = "提示信息", example = "ok")
private String message; private String message;
public static TaskHeartbeatVo alive(String moduleType, String status) { public static TaskHeartbeatVo alive(String moduleType, String status) {

View File

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

View File

@@ -149,4 +149,19 @@ class AppearancePatentCozeClientTest {
assertThat(merged.get(0).getAppearanceRisk()).isNullOrEmpty(); assertThat(merged.get(0).getAppearanceRisk()).isNullOrEmpty();
verify(brandCheckClient, never()).checkTitleText(anyString(), anyString()); 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<AppearancePatentResultRowDto> merged = client.mergeRowsFromDataText(List.of(row), dataText);
assertThat(merged).hasSize(1);
assertThat(merged.get(0).getPrice()).isEqualTo("12.99");
}
} }

View File

@@ -34,4 +34,16 @@ class AppearancePatentTaskServiceTest {
parsedRow.getValues().clear(); parsedRow.getValues().clear();
assertEquals("", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow)); 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));
}
} }

View File

@@ -13,10 +13,53 @@ import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.assertj.core.api.Assertions.assertThat; 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.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ImageVideoArchiveServiceTest { 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 @Test
void extractsNestedAndMultipleVideoUrlsWithoutDuplicates() { void extractsNestedAndMultipleVideoUrlsWithoutDuplicates() {
Map<String, Object> result = Map.of( Map<String, Object> result = Map.of(

View File

@@ -139,6 +139,69 @@ class ImageVideoAsyncTaskServiceTest {
verify(taskMapper).updateById(task); 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() { private ImageVideoAsyncTaskEntity waitingDouyinTask() {
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity(); ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
task.setId(79L); task.setId(79L);

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
package com.nanri.aiimage.modules.productrisk.service; 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.dto.ProductRiskRowDto;
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode; import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals; 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 { 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<ProductRiskRowDto.ViolationDetail> original = List.of(detail("旧原因", "成功"));
List<ProductRiskRowDto.ViolationDetail> replacement = List.of(
detail("商品安全违规", "成功"),
detail("商品安全违规", "失败"));
List<ProductRiskRowDto.ViolationDetail> kept =
ProductRiskTaskService.mergeViolationDetails(original, List.of());
List<ProductRiskRowDto.ViolationDetail> 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) { private static ProductRiskRowDto row(String productAsinSku, String status, Boolean done) {
ProductRiskRowDto row = new ProductRiskRowDto(); ProductRiskRowDto row = new ProductRiskRowDto();
row.setShopName("店铺A"); row.setShopName("店铺A");
@@ -55,4 +130,11 @@ class ProductRiskExcelAssemblyServiceTest {
row.setDone(done); row.setDone(done);
return row; 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;
}
} }

View File

@@ -4,13 +4,15 @@
import json import json
import os import os
import re import re
import tempfile
import threading import threading
import zipfile
from datetime import datetime from datetime import datetime
from urllib.parse import quote from urllib.parse import quote
import requests import requests
from requests.adapters import HTTPAdapter 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 import pymysql
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
@@ -52,6 +54,8 @@ _column_sort_schema_checked = False
_column_sort_schema_lock = threading.Lock() _column_sort_schema_lock = threading.Lock()
_product_category_schema_checked = False _product_category_schema_checked = False
_product_category_schema_lock = threading.Lock() _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 = { ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': { 'dedupe-total-data': {
@@ -288,10 +292,12 @@ def _image_video_admin_item(row, include_json=False):
'task_id': row.get('id'), 'task_id': row.get('id'),
'user_id': row.get('user_id'), 'user_id': row.get('user_id'),
'username': row.get('username') or '', 'username': row.get('username') or '',
'group_name': row.get('group_name') or '',
'mode': _image_video_mode(row.get('request_json')), 'mode': _image_video_mode(row.get('request_json')),
'status': row.get('status') or '', 'status': row.get('status') or '',
'coze_status': row.get('coze_status') or '', 'coze_status': row.get('coze_status') or '',
'coze_execute_id': row.get('coze_execute_id') or '', 'coze_execute_id': row.get('coze_execute_id') or '',
'videos': videos,
'video_url': videos[0]['display_url'] if videos else '', 'video_url': videos[0]['display_url'] if videos else '',
'debug_url': row.get('debug_url') or '', 'debug_url': row.get('debug_url') or '',
'archive_status': row.get('archive_status') or '', 'archive_status': row.get('archive_status') or '',
@@ -302,7 +308,6 @@ def _image_video_admin_item(row, include_json=False):
} }
if include_json: if include_json:
item.update({ item.update({
'videos': videos,
'error_message': row.get('error_message') or '', 'error_message': row.get('error_message') or '',
'archived_at': _format_admin_datetime(row.get('archived_at')), 'archived_at': _format_admin_datetime(row.get('archived_at')),
'request': _mask_image_video_secrets(_parse_json_value(row.get('request_json'), {})), '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() role, current_row = get_current_admin_role()
if not role or not current_row: if not role or not current_row:
return None, None, None, (jsonify({'success': False, 'error': '需要管理员权限'}), 403) 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) user_id = _get_current_admin_id(current_row)
if not user_id: if not user_id:
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400) return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
result, error_response, status = _proxy_backend_java( items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')]
'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 [])]
return role, current_row, items, None return role, current_row, items, None
@@ -643,17 +648,17 @@ def _load_current_backend_menu_items():
role, current_row = get_current_admin_role() role, current_row = get_current_admin_role()
if not role or not current_row: if not role or not current_row:
return None, None, None, (jsonify({'success': False, 'error': '需要登录'}), 403) 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) user_id = _get_current_admin_id(current_row)
if not user_id: if not user_id:
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400) return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
result, error_response, status = _proxy_backend_java( items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')]
'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 [])]
return role, current_row, items, None 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) 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(): def _ensure_product_category_access():
role, current_row, items, denied = _load_current_backend_menu_items() role, current_row, items, denied = _load_current_backend_menu_items()
if denied: if denied:
@@ -781,7 +810,8 @@ def list_users():
search_username = (request.args.get('username') or request.args.get('search') or '').strip() 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_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 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 can_view_child_users = role == 'admin' and not can_view_all_users
if not can_view_all_users: if not can_view_all_users:
created_by_id = None created_by_id = None
@@ -935,10 +965,23 @@ def create_user():
(username, pwd_hash, is_admin, want_role, want_created_by), (username, pwd_hash, is_admin, want_role, want_created_by),
) )
new_uid = cur.lastrowid 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.commit()
conn.close() conn.close()
return jsonify({'success': True, 'msg': '用户创建成功'}) 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: except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'}) return jsonify({'success': False, 'error': '用户名已存在'})
except Exception as e: except Exception as e:
@@ -994,10 +1037,23 @@ def update_user(uid):
(is_admin, want_role, uid), (is_admin, want_role, uid),
) )
if 'column_ids' in data: 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.commit()
conn.close() conn.close()
return jsonify({'success': True, 'msg': '更新成功'}) 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: except Exception as e:
return jsonify({'success': False, 'error': str(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.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.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.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 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_api.route('/image-video-tasks')
@admin_required @login_required
def list_image_video_tasks(): 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: if denied:
return denied return denied
try: try:
@@ -1148,8 +1422,11 @@ def list_image_video_tasks():
submitted_from = _parse_admin_datetime_arg('submitted_from') submitted_from = _parse_admin_datetime_arg('submitted_from')
submitted_to = _parse_admin_datetime_arg('submitted_to') submitted_to = _parse_admin_datetime_arg('submitted_to')
access_sql, params = _image_video_access_sql(role, current_row) params = []
conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'"] conditions = [
"t.task_type = 'IMAGE_VIDEO_WORKFLOW'",
"t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)",
]
if user_id: if user_id:
conditions.append('t.user_id = %s') conditions.append('t.user_id = %s')
params.append(user_id) params.append(user_id)
@@ -1203,14 +1480,19 @@ def list_image_video_tasks():
@admin_api.route('/image-video-tasks/<int:task_id>') @admin_api.route('/image-video-tasks/<int:task_id>')
@admin_required @login_required
def get_image_video_task(task_id): 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: if denied:
return denied return denied
access_sql, params = _image_video_access_sql(role, current_row) conditions = [
conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'", 't.id = %s'] "t.task_type = 'IMAGE_VIDEO_WORKFLOW'",
params.append(task_id) "t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)",
't.id = %s',
]
params = [task_id]
conn = get_db() conn = get_db()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -1248,6 +1530,7 @@ def list_columns():
'created_at': str(row.get('created_at') or '')[:16].replace('T', ' '), 'created_at': str(row.get('created_at') or '')[:16].replace('T', ' '),
} }
for row in local_rows for row in local_rows
if (row.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
] ]
return jsonify({'success': True, 'items': items}) 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]}) 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 为已打开的游标。"""
cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,)) requested_ids = {int(x) for x in (column_ids or []) if x}
if column_ids: preserved_ids = set()
column_ids = [int(x) for x in column_ids if x] if preserve_column_keys:
for cid in column_ids: 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( cur.execute(
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)", f"SELECT column_id FROM user_column_permission WHERE user_id = %s AND column_id IN ({id_placeholders})",
(user_id, cid), (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 ---------- # ---------- 版本管理web_config ----------

View File

@@ -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( backend_java_base_url, backend_java_base_url_source = _get_env(
"java_api_base", "java_api_base",
"BACKEND_JAVA_BASE_URL", "BACKEND_JAVA_BASE_URL",
default="http://api.aishufu.top:18080/", # default="http://api.aishufu.top:18080/",
# default="http://127.0.0.1:18080/", default="http://127.0.0.1:18080/",
) )
backend_java_base_url = backend_java_base_url.rstrip("/") backend_java_base_url = backend_java_base_url.rstrip("/")
os.environ["OSS_ACCESS_KEY_ID"] = accessKeyId os.environ["OSS_ACCESS_KEY_ID"] = accessKeyId

View File

@@ -380,11 +380,30 @@
} }
var allColumnsList = []; var allColumnsList = [];
function loadColumnsForPermission() { function loadColumnsForPermission() {
fetch('/api/admin/columns') var ownedKeys = {};
.then(function (r) { return r.json(); }) 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) { .then(function (res) {
if (!res.success) return; 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('createColumnPermissionWrap');
renderColumnPermissionWrap('editColumnPermissionWrap'); renderColumnPermissionWrap('editColumnPermissionWrap');
var selCreate = document.getElementById('createColumnPermissionWrap'); var selCreate = document.getElementById('createColumnPermissionWrap');
@@ -617,17 +636,161 @@
document.getElementById('editUserModal').classList.remove('show'); document.getElementById('editUserModal').classList.remove('show');
}; };
// ========== 生成记录 ========== // ========== 视频任务管理 ==========
var imageVideoTaskPage = 1, imageVideoTaskPageSize = 20; 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 '<div class="image-video-permission-row">' +
'<input type="checkbox" aria-label="' + escapeHtml(user.username || '-') + (persistedGranted ? ' 已保存分配' : ' 未分配') + '" data-image-video-permission-user="' + userId + '"' +
(pendingGranted ? ' checked' : '') + '>' +
'<span class="image-video-permission-user">' +
'<span class="image-video-permission-name" title="' + escapeHtml(user.username || '') + '">' + escapeHtml(user.username || '-') + '</span>' +
'<span class="image-video-permission-role">' + escapeHtml(roleLabel(user.role)) + '</span>' +
'</span>' +
'<button class="image-video-permission-state' + (pendingChanged ? ' pending' : (persistedGranted ? ' granted' : '')) + '" type="button" data-image-video-permission-toggle="' + userId + '">' +
actionText +
'</button>' +
'</div>';
}).join('') : '<div class="image-video-permission-empty">暂无匹配用户</div>';
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 = '<div class="image-video-permission-empty">加载中...</div>';
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 = '<div class="image-video-permission-empty">' + escapeHtml(error.message || '权限加载失败') + '</div>';
})
.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) { function buildImageVideoTaskQuery(page) {
var params = new URLSearchParams(); var params = new URLSearchParams();
params.set('page', String(page || 1)); params.set('page', String(page || 1));
params.set('page_size', String(imageVideoTaskPageSize)); params.set('page_size', String(imageVideoTaskPageSize));
var values = { var values = {
user_id: document.getElementById('imageVideoFilterUserId').value,
username: document.getElementById('imageVideoFilterUsername').value.trim(), 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_from: document.getElementById('imageVideoFilterFrom').value,
submitted_to: document.getElementById('imageVideoFilterTo').value submitted_to: document.getElementById('imageVideoFilterTo').value
}; };
@@ -642,107 +805,353 @@
var url = String(value || '').trim(); var url = String(value || '').trim();
return /^https?:\/\//i.test(url) ? url : ''; return /^https?:\/\//i.test(url) ? url : '';
} }
function imageVideoDownloadIcon() {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"></path></svg>';
}
function imageVideoUnavailableHtml(message) {
return '<div class="image-video-unavailable">' +
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 10 4.5-2.5v9L15 14"></path><rect x="3" y="6" width="12" height="12" rx="2"></rect><path d="m4 4 16 16"></path></svg>' +
'<strong>' + escapeHtml(message || '暂无可用视频') + '</strong>' +
'</div>';
}
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
? '<input class="image-video-card-check" type="checkbox" data-image-video-select="' + escapeHtml(card.key) + '" aria-label="选择任务 ' + escapeHtml(task.task_id) + ' 的视频 ' + (card.videoIndex + 1) + '">' +
'<video src="' + escapeHtml(video.displayUrl) + '" controls playsinline preload="metadata"></video>'
: imageVideoUnavailableHtml(task.status === 'FAILED' ? '任务失败,未生成视频' : '视频生成中或暂无结果');
return '<article class="image-video-card" data-image-video-card="' + escapeHtml(card.key) + '">' +
'<div class="image-video-media">' + mediaHtml + '</div>' +
'<div class="image-video-card-body">' +
'<div class="image-video-card-head">' +
'<div class="image-video-card-title" title="任务 ' + escapeHtml(task.task_id) + '">任务 #' + escapeHtml(task.task_id) + (video ? ' · 视频 ' + (card.videoIndex + 1) : '') + '</div>' +
renderImageVideoStatus(task.status) +
'</div>' +
'<div class="image-video-card-info">' +
'<div class="image-video-info-row"><label>用户名</label><span>' + escapeHtml(task.username || '-') + '</span></div>' +
'<div class="image-video-info-row"><label>所属分组</label><span>' + escapeHtml(task.group_name || '-') + '</span></div>' +
'<div class="image-video-info-row"><label>任务模式</label><span>' + escapeHtml(task.mode || '-') + '</span></div>' +
'<div class="image-video-info-row"><label>生成时间</label><span>' + escapeHtml(generatedAt) + '</span></div>' +
'<div class="image-video-info-row"><label>调试链接</label>' + (debugUrl
? '<button class="image-video-copy-link" type="button" data-image-video-copy-debug="' + escapeHtml(card.key) + '">复制链接</button>'
: '<span>-</span>') + '</div>' +
'</div>' +
'<div class="image-video-card-actions">' +
'<button class="image-video-card-action" type="button" data-image-video-download="' + escapeHtml(card.key) + '"' + (hasVideo ? '' : ' disabled') + '>' + imageVideoDownloadIcon() + '下载</button>' +
'</div>' +
'</div>' +
'</article>';
}
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('')
: '<div class="image-video-empty">暂无符合条件的视频任务</div>';
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) { function loadImageVideoTasks(page) {
imageVideoTaskPage = page || 1; imageVideoTaskPage = page || 1;
var tbody = document.getElementById('imageVideoTaskListBody'); selectedImageVideoKeys.clear();
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">加载中...</td></tr>'; var grid = document.getElementById('imageVideoTaskGrid');
grid.innerHTML = '<div class="image-video-empty">加载中...</div>';
document.getElementById('imageVideoDownloadProgress').textContent = '';
fetch('/api/admin/image-video-tasks?' + buildImageVideoTaskQuery(imageVideoTaskPage)) fetch('/api/admin/image-video-tasks?' + buildImageVideoTaskQuery(imageVideoTaskPage))
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (res) { .then(function (res) {
if (!res.success) { if (!res.success) {
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">加载失败:' + escapeHtml(res.error || '') + '</td></tr>'; imageVideoCards = [];
grid.innerHTML = '<div class="image-video-empty">加载失败:' + escapeHtml(res.error || '') + '</div>';
document.getElementById('imageVideoTaskTotal').textContent = '';
syncImageVideoSelectionUi();
return; return;
} }
var items = res.items || []; var items = res.items || [];
document.getElementById('imageVideoTaskTotal').textContent = '共 ' + (res.total || 0) + ' 条'; imageVideoCards = flattenImageVideoTasks(items);
if (!items.length) { var videoCount = imageVideoCards.filter(function (card) { return !!card.video; }).length;
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">暂无视频任务</td></tr>'; document.getElementById('imageVideoTaskTotal').textContent = '共 ' + (res.total || 0) + ' 个任务 · 本页 ' + videoCount + ' 个视频';
} else { renderImageVideoCards();
tbody.innerHTML = items.map(function (item) {
var videoUrl = safeAdminUrl(item.video_url);
var videoLink = videoUrl ? '<a href="' + escapeHtml(videoUrl) + '" target="_blank" rel="noreferrer">查看视频</a>' : '-';
return '<tr>' +
'<td>' + escapeHtml(item.task_id) + '</td>' +
'<td>' + escapeHtml(item.username || '-') + '<div style="color:#888;font-size:12px;">ID ' + escapeHtml(item.user_id) + '</div></td>' +
'<td>' + escapeHtml(item.mode || '-') + '</td>' +
'<td>' + renderImageVideoStatus(item.status) + '</td>' +
'<td>' + escapeHtml(item.coze_status || '-') + '</td>' +
'<td title="' + escapeHtml(item.coze_execute_id || '') + '">' + escapeHtml(item.coze_execute_id || '-').slice(0, 18) + '</td>' +
'<td>' + videoLink + '</td>' +
'<td>' + escapeHtml(item.archive_status || '-') + '</td>' +
'<td>' + escapeHtml(item.submitted_at || '-') + '</td>' +
'<td><button class="btn btn-sm" data-image-video-detail="' + escapeHtml(item.task_id) + '">详情</button></td>' +
'</tr>';
}).join('');
}
renderPagination('imageVideoTaskPagination', res.total, res.page, res.page_size, loadImageVideoTasks); renderPagination('imageVideoTaskPagination', res.total, res.page, res.page_size, loadImageVideoTasks);
}) })
.catch(function () { .catch(function () {
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">请求失败</td></tr>'; imageVideoCards = [];
grid.innerHTML = '<div class="image-video-empty">请求失败,请稍后重试</div>';
document.getElementById('imageVideoTaskTotal').textContent = '';
syncImageVideoSelectionUi();
}); });
} }
function formatImageVideoJson(value) { function imageVideoFilename(card) {
try { return JSON.stringify(value == null ? {} : value, null, 2); } var url = card.video.displayUrl || '';
catch (error) { return String(value || ''); } 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) { function triggerImageVideoLink(url, filename, newWindow) {
var videos = item.videos || []; var link = document.createElement('a');
var videoHtml = videos.length ? videos.map(function (video, index) { link.href = url;
var displayUrl = safeAdminUrl(video.display_url); link.download = filename;
var sourceUrl = safeAdminUrl(video.source_url); if (newWindow) {
var archivedUrl = safeAdminUrl(video.archived_url); link.target = '_blank';
return '<div class="image-video-video-item">' + link.rel = 'noreferrer';
'<strong>视频 ' + (index + 1) + '</strong>' + }
(displayUrl ? '<video src="' + escapeHtml(displayUrl) + '" controls playsinline preload="metadata"></video>' : '<p class="empty-tip">无可用视频地址</p>') + document.body.appendChild(link);
'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap;">' + link.click();
(archivedUrl ? '<a href="' + escapeHtml(archivedUrl) + '" target="_blank" rel="noreferrer">OSS 视频</a>' : '') + link.remove();
(sourceUrl ? '<a href="' + escapeHtml(sourceUrl) + '" target="_blank" rel="noreferrer">Coze 原始视频</a>' : '') +
'</div><div style="font-size:12px;color:#777;margin-top:6px;">归档:' + escapeHtml(video.archive_status || '-') +
(video.archive_error ? ' · ' + escapeHtml(video.archive_error) : '') + '</div></div>';
}).join('') : '<p class="empty-tip">当前没有解析到视频地址</p>';
var debugUrl = safeAdminUrl(item.debug_url);
return '<div class="image-video-detail-meta">' +
'<div><span>任务 ID</span>' + escapeHtml(item.task_id) + '</div>' +
'<div><span>用户</span>' + escapeHtml(item.username || '-') + '' + escapeHtml(item.user_id) + '</div>' +
'<div><span>任务模式</span>' + escapeHtml(item.mode || '-') + '</div>' +
'<div><span>状态</span>' + escapeHtml(item.status || '-') + ' / ' + escapeHtml(item.coze_status || '-') + '</div>' +
'<div><span>Coze 执行 ID</span>' + escapeHtml(item.coze_execute_id || '-') + '</div>' +
'<div><span>提交时间</span>' + escapeHtml(item.submitted_at || '-') + '</div>' +
'<div><span>完成时间</span>' + escapeHtml(item.completed_at || '-') + '</div>' +
'<div><span>归档状态</span>' + escapeHtml(item.archive_status || '-') + '</div>' +
'</div>' +
(debugUrl ? '<p><strong>调试链接:</strong><a href="' + escapeHtml(debugUrl) + '" target="_blank" rel="noreferrer">' + escapeHtml(debugUrl) + '</a></p>' : '') +
(item.error_message ? '<p class="msg err">任务错误:' + escapeHtml(item.error_message) + '</p>' : '') +
(item.archive_error ? '<p class="msg err">归档错误:' + escapeHtml(item.archive_error) + '</p>' : '') +
'<div class="image-video-video-grid">' + videoHtml + '</div>' +
'<details class="image-video-json"><summary>请求参数(密钥已脱敏)</summary><pre>' + escapeHtml(formatImageVideoJson(item.request)) + '</pre></details>' +
'<details class="image-video-json"><summary>Coze 首次提交响应</summary><pre>' + escapeHtml(formatImageVideoJson(item.submit_response)) + '</pre></details>' +
'<details class="image-video-json"><summary>Coze 最终响应</summary><pre>' + escapeHtml(formatImageVideoJson(item.result)) + '</pre></details>';
} }
function openImageVideoTaskDetail(taskId) { function downloadImageVideo(card) {
var content = document.getElementById('imageVideoTaskDetailContent'); var url = card && card.video ? card.video.displayUrl : '';
content.innerHTML = '<p class="empty-tip">加载中...</p>'; if (!url) return Promise.resolve({ fallback: false, skipped: true });
document.getElementById('imageVideoTaskDetailModal').classList.add('show'); var filename = imageVideoFilename(card);
fetch('/api/admin/image-video-tasks/' + encodeURIComponent(taskId)) return fetch(url, { __skipLoading: true })
.then(function (r) { return r.json(); }) .then(function (response) {
.then(function (res) { if (!response.ok) throw new Error('HTTP ' + response.status);
content.innerHTML = res.success ? renderImageVideoTaskDetail(res.item || {}) : '<p class="msg err">' + escapeHtml(res.error || '加载失败') + '</p>'; return response.blob();
}) })
.catch(function () { content.innerHTML = '<p class="msg err">请求失败</p>'; }); .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('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 () { document.getElementById('btnResetImageVideoTasks').onclick = function () {
['imageVideoFilterUserId', 'imageVideoFilterUsername', 'imageVideoFilterStatus', 'imageVideoFilterExecuteId', 'imageVideoFilterFrom', 'imageVideoFilterTo'] ['imageVideoFilterUsername', 'imageVideoFilterFrom', 'imageVideoFilterTo']
.forEach(function (id) { document.getElementById(id).value = ''; }); .forEach(function (id) { document.getElementById(id).value = ''; });
loadImageVideoTasks(1); loadImageVideoTasks(1);
}; };
document.getElementById('imageVideoTaskListBody').onclick = function (event) { document.getElementById('imageVideoSelectAll').onchange = function (event) {
var button = event.target.closest('[data-image-video-detail]'); imageVideoCards.forEach(function (card) {
if (button) openImageVideoTaskDetail(button.dataset.imageVideoDetail); 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('btnBatchDownloadImageVideos').onclick = function () {
document.getElementById('imageVideoTaskDetailModal').classList.remove('show'); downloadImageVideoZip(imageVideoCards.filter(function (card) { return selectedImageVideoKeys.has(card.key); }));
document.getElementById('imageVideoTaskDetailContent').innerHTML = ''; };
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; var historyPage = 1, historyPageSize = 15;
@@ -4348,6 +4757,7 @@
roleEl.style.display = 'none'; roleEl.style.display = 'none';
} }
updateShopManageGroupButtonsAccess(); updateShopManageGroupButtonsAccess();
updateImageVideoPermissionAccess();
}) })
.catch(function () { .catch(function () {
return fetch('/api/auth/check') return fetch('/api/auth/check')
@@ -4356,11 +4766,13 @@
document.getElementById('adminCurrentUsername').textContent = res && res.logged_in ? '当前用户' : '未登录'; document.getElementById('adminCurrentUsername').textContent = res && res.logged_in ? '当前用户' : '未登录';
document.getElementById('adminCurrentUserRole').style.display = 'none'; document.getElementById('adminCurrentUserRole').style.display = 'none';
updateShopManageGroupButtonsAccess(); updateShopManageGroupButtonsAccess();
updateImageVideoPermissionAccess();
}) })
.catch(function () { .catch(function () {
document.getElementById('adminCurrentUsername').textContent = '当前用户'; document.getElementById('adminCurrentUsername').textContent = '当前用户';
document.getElementById('adminCurrentUserRole').style.display = 'none'; document.getElementById('adminCurrentUserRole').style.display = 'none';
updateShopManageGroupButtonsAccess(); updateShopManageGroupButtonsAccess();
updateImageVideoPermissionAccess();
}); });
}) })
.finally(function () { .finally(function () {
@@ -4398,7 +4810,6 @@
loadAdminCurrentUser().finally(function () { loadAdminCurrentUser().finally(function () {
loadUserOptions(); loadUserOptions();
loadColumnsForPermission();
loadShopManageGroups(); loadShopManageGroups();
loadAdminMenus(); loadAdminMenus();
}); });

View File

@@ -1,7 +1,9 @@
import io
import json import json
import unittest import unittest
import zipfile
from datetime import datetime from datetime import datetime
from unittest.mock import patch from unittest.mock import MagicMock, patch
from app import app from app import app
from blueprints import admin_api as admin_module from blueprints import admin_api as admin_module
@@ -43,6 +45,23 @@ class FakeConnection:
pass 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): class ImageVideoAdminTest(unittest.TestCase):
def setUp(self): def setUp(self):
app.config.update(TESTING=True, SECRET_KEY='test') app.config.update(TESTING=True, SECRET_KEY='test')
@@ -72,6 +91,7 @@ class ImageVideoAdminTest(unittest.TestCase):
'id': 91, 'id': 91,
'user_id': 21, 'user_id': 21,
'username': 'demo', 'username': 'demo',
'group_name': '测试分组、第二分组',
'status': 'SUCCESS', 'status': 'SUCCESS',
'request_json': json.dumps({'parameters': {'video_info': {'mode': '1'}}}), 'request_json': json.dumps({'parameters': {'video_info': {'mode': '1'}}}),
'submit_response_json': '{}', 'submit_response_json': '{}',
@@ -99,7 +119,8 @@ class ImageVideoAdminTest(unittest.TestCase):
session['user_id'] = 17 session['user_id'] = 17
with patch('utils.auth.is_session_user_valid', return_value=True), \ with patch('utils.auth.is_session_user_valid', return_value=True), \
patch('utils.auth.get_current_admin_role', return_value=('admin', {'id': 17})), \ 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): 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') 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() data = response.get_json()
self.assertTrue(data['success']) self.assertTrue(data['success'])
self.assertEqual(2, data['page']) self.assertEqual(2, data['page'])
self.assertEqual('测试分组、第二分组', data['items'][0]['group_name'])
self.assertEqual('图生视频', data['items'][0]['mode']) self.assertEqual('图生视频', data['items'][0]['mode'])
self.assertEqual('https://oss.example/video.mp4', data['items'][0]['video_url']) 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] list_sql, list_params = connection.cursor_value.executions[0]
self.assertIn("t.task_type = 'IMAGE_VIDEO_WORKFLOW'", list_sql) self.assertIn("t.task_type = 'IMAGE_VIDEO_WORKFLOW'", list_sql)
self.assertIn('created_by_id = %s', list_sql) self.assertIn('t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)', list_sql)
self.assertEqual((17, 17, 'SUCCESS', 20, 20), list_params) 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__': if __name__ == '__main__':

View File

@@ -754,8 +754,9 @@
.column-permission-select { .column-permission-select {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-height: 80px; min-height: 120px;
max-height: 140px; height: 220px;
max-height: 260px;
overflow-y: auto; overflow-y: auto;
padding: 8px; padding: 8px;
box-sizing: border-box; box-sizing: border-box;
@@ -764,21 +765,81 @@
.column-permission-select option { .column-permission-select option {
padding: 4px 0; padding: 4px 0;
} }
.image-video-table-wrap { overflow-x: auto; } .image-video-panel-box { padding:0; }
.image-video-table { min-width: 1260px; } .image-video-results { padding:20px 20px 0; }
.image-video-status { display:inline-block; padding:3px 8px; border-radius:4px; background:#eef1f4; font-size:12px; } .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.SUCCESS { color:#14733b; background:#e5f5eb; }
.image-video-status.FAILED { color:#a82d2d; background:#fbe9e9; } .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-status.RUNNING,
.image-video-detail-meta { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; } .image-video-status.POLLING { color:#8a6200; background:#fff3cd; }
.image-video-detail-meta div { padding:10px; background:#f6f7f8; border-radius:4px; min-width:0; } .image-video-card-info { display:grid; gap:7px; }
.image-video-detail-meta span { display:block; color:#777; font-size:12px; margin-bottom:4px; } .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-video-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:14px; } .image-video-info-row span,
.image-video-video-item { border:1px solid #ddd; border-radius:6px; padding:10px; } .image-video-info-row a { min-width:0; color:#333; overflow-wrap:anywhere; }
.image-video-video-item video { display:block; width:100%; aspect-ratio:16/9; background:#111; } .image-video-info-row a { color:#218a6d; text-decoration:none; }
.image-video-json { margin-top:14px; } .image-video-info-row a:hover { text-decoration:underline; }
.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; } .image-video-copy-link { min-width:0; padding:0; border:0; background:none; color:#218a6d; cursor:pointer; font:inherit; text-align:left; }
@media (max-width: 760px) { .image-video-detail-meta { grid-template-columns:repeat(2,minmax(0,1fr)); } } .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; }
}
</style> </style>
</head> </head>
@@ -1521,26 +1582,31 @@
<div class="form-box"> <div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">视频任务筛选</h3> <h3 style="margin-bottom:16px;font-size:15px;">视频任务筛选</h3>
<div class="form-row"> <div class="form-row">
<div class="form-group" style="min-width:130px;"><label>用户 ID</label><input type="number" id="imageVideoFilterUserId" min="1"></div>
<div class="form-group" style="min-width:150px;"><label>用户名</label><input type="text" id="imageVideoFilterUsername" placeholder="模糊搜索"></div> <div class="form-group" style="min-width:150px;"><label>用户名</label><input type="text" id="imageVideoFilterUsername" placeholder="模糊搜索"></div>
<div class="form-group" style="min-width:130px;"><label>任务状态</label><select id="imageVideoFilterStatus"><option value="">全部</option><option value="PENDING">PENDING</option><option value="RUNNING">RUNNING</option><option value="WAITING">WAITING</option><option value="POLLING">POLLING</option><option value="SUCCESS">SUCCESS</option><option value="FAILED">FAILED</option></select></div>
<div class="form-group" style="min-width:180px;"><label>Coze 执行 ID</label><input type="text" id="imageVideoFilterExecuteId"></div>
<div class="form-group" style="min-width:170px;"><label>提交开始</label><input type="datetime-local" id="imageVideoFilterFrom"></div> <div class="form-group" style="min-width:170px;"><label>提交开始</label><input type="datetime-local" id="imageVideoFilterFrom"></div>
<div class="form-group" style="min-width:170px;"><label>提交结束</label><input type="datetime-local" id="imageVideoFilterTo"></div> <div class="form-group" style="min-width:170px;"><label>提交结束</label><input type="datetime-local" id="imageVideoFilterTo"></div>
<button class="btn" id="btnFilterImageVideoTasks" type="button">查询</button> <button class="btn" id="btnFilterImageVideoTasks" type="button">查询</button>
<button class="btn btn-secondary" id="btnResetImageVideoTasks" type="button">重置</button> <button class="btn btn-secondary" id="btnResetImageVideoTasks" type="button">重置</button>
</div> </div>
</div> </div>
<div class="panel-box"> <div class="panel-box image-video-panel-box">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;gap:12px;"> <div class="image-video-results">
<h3 style="font-size:15px;">视频任务记录</h3> <div class="image-video-toolbar">
<span id="imageVideoTaskTotal" style="font-size:13px;color:#666;"></span> <div class="image-video-toolbar-main">
</div> <button class="btn btn-secondary image-video-permission-btn" id="btnOpenImageVideoPermissions" type="button">权限配置</button>
<div class="image-video-table-wrap"> <button class="btn image-video-batch-btn" id="btnBatchDownloadImageVideos" type="button" disabled>
<table class="image-video-table"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"></path></svg>
<thead><tr><th>任务 ID</th><th>用户</th><th>模式</th><th>状态</th><th>Coze 状态</th><th>执行 ID</th><th>视频</th><th>归档</th><th>提交时间</th><th>操作</th></tr></thead> 批量下载
<tbody id="imageVideoTaskListBody"></tbody> </button>
</table> <label class="image-video-select-all">
<input type="checkbox" id="imageVideoSelectAll">
全选当前页
</label>
<span class="image-video-download-progress" id="imageVideoDownloadProgress" aria-live="polite"></span>
</div>
<span class="image-video-summary" id="imageVideoTaskTotal"></span>
</div>
<div class="image-video-grid" id="imageVideoTaskGrid" aria-live="polite"></div>
</div> </div>
<div class="pagination" id="imageVideoTaskPagination"></div> <div class="pagination" id="imageVideoTaskPagination"></div>
</div> </div>
@@ -1947,16 +2013,33 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal-mask" id="imageVideoTaskDetailModal"> <div class="modal-mask" id="imageVideoPermissionModal">
<div class="modal image-video-detail-modal"> <div class="modal image-video-permission-modal" role="dialog" aria-modal="true" aria-labelledby="imageVideoPermissionTitle">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;"> <div class="image-video-permission-head">
<h3>视频任务详情</h3> <h3 id="imageVideoPermissionTitle">视频任务权限配置</h3>
<button class="btn btn-secondary btn-sm" id="btnCloseImageVideoTaskDetail" type="button">关闭</button> <button class="btn btn-secondary btn-sm" id="btnCloseImageVideoPermissions" type="button">关闭</button>
</div>
<div class="image-video-permission-tabs" role="tablist" aria-label="视频权限用户范围">
<button class="image-video-permission-tab active" type="button" role="tab" aria-selected="true" data-image-video-permission-view="granted">已分配用户 <span id="imageVideoPermissionGrantedCount">(0)</span></button>
<button class="image-video-permission-tab" type="button" role="tab" aria-selected="false" data-image-video-permission-view="all">全部用户 <span id="imageVideoPermissionAllCount">(0)</span></button>
</div>
<div class="image-video-permission-tools">
<input class="image-video-permission-search" id="imageVideoPermissionSearch" type="search" placeholder="搜索用户名">
<label class="image-video-select-all">
<input type="checkbox" id="imageVideoPermissionSelectAll">
全选搜索结果
</label>
</div>
<p class="image-video-permission-summary" id="imageVideoPermissionSummary"></p>
<div class="image-video-permission-list" id="imageVideoPermissionList" aria-live="polite"></div>
<p class="msg" id="imageVideoPermissionMessage" aria-live="polite"></p>
<div class="image-video-permission-actions">
<button class="btn btn-secondary" id="btnCancelImageVideoPermissions" type="button">取消</button>
<button class="btn" id="btnSaveImageVideoPermissions" type="button">保存</button>
</div> </div>
<div id="imageVideoTaskDetailContent"></div>
</div> </div>
</div> </div>
<script src="/static/admin.js"></script> <script src="/static/admin.js?v=permission-tabs-3"></script>
</body> </body>
</html> </html>

Binary file not shown.

View File

@@ -50,6 +50,8 @@
</label> </label>
</div> </div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row"> <div class="run-row">
<button type="button" class="btn-run" :disabled="running" @click="submitRun"> <button type="button" class="btn-run" :disabled="running" @click="submitRun">
{{ running ? '解析中...' : '开始解析' }} {{ running ? '解析中...' : '开始解析' }}
@@ -211,6 +213,7 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import BrandTopBar from './BrandTopBar.vue' import BrandTopBar from './BrandTopBar.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand' import { expandBrandFolderRecursive, type BrandExpandFolderItem } from '@/shared/api/brand'
import { import {
deleteDeleteBrandHistory, deleteDeleteBrandHistory,
@@ -229,12 +232,15 @@ import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebvi
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers' import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress' import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
interface SessionDeleteBrandItem extends DeleteBrandResultItem { interface SessionDeleteBrandItem extends DeleteBrandResultItem {
_pushed?: boolean _pushed?: boolean
_completed?: boolean _completed?: boolean
} }
const ziniaoVersion = useZiniaoVersion()
interface StoredCurrentTask { interface StoredCurrentTask {
taskId: number taskId: number
items: SessionDeleteBrandItem[] items: SessionDeleteBrandItem[]
@@ -1307,6 +1313,7 @@ async function runItem(item: DeleteBrandResultItem, options?: { auto?: boolean }
ts: Date.now(), ts: Date.now(),
data: { data: {
taskId: taskId, taskId: taskId,
ziniao_version: ziniaoVersion.value,
items: [item] items: [item]
}, },
} }

View File

@@ -113,6 +113,8 @@
</ul> </ul>
</div> </div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row"> <div class="run-row">
<button <button
type="button" type="button"
@@ -393,9 +395,12 @@ import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config"; import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers"; import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
import { saveUrlWithProgress } from "@/shared/utils/download-progress"; import { saveUrlWithProgress } from "@/shared/utils/download-progress";
import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue";
import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const; const COUNTRY_TEMPLATE = ["德国", "英国", "法国", "意大利", "西班牙"] as const;
const MAX_TRANSIENT_ERRORS = 30; const MAX_TRANSIENT_ERRORS = 30;
const ziniaoVersion = useZiniaoVersion();
const shopInput = ref(""); const shopInput = ref("");
const conditionInput = ref(""); const conditionInput = ref("");
@@ -999,6 +1004,7 @@ function buildQueuePayload(taskId: number, items: PatrolDeleteHistoryItem[]) {
ts: Date.now(), ts: Date.now(),
data: { data: {
taskId, taskId,
ziniao_version: ziniaoVersion.value,
user_id: Number(uidForStorage()) || 0, user_id: Number(uidForStorage()) || 0,
source: "frontend-vue-patrol-delete", source: "frontend-vue-patrol-delete",
delete_conditions: deleteConditionsForTask, delete_conditions: deleteConditionsForTask,

View File

@@ -114,6 +114,8 @@
<div v-if="countryPrefSaving" class="country-pref-status">保存中</div> <div v-if="countryPrefSaving" class="country-pref-status">保存中</div>
</template> </template>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<!-- 状态指示 --> <!-- 状态指示 -->
<div v-if="statusModeEnabled" class="mode-status-bar"> <div v-if="statusModeEnabled" class="mode-status-bar">
<span class="mode-status-dot active"></span> <span class="mode-status-dot active"></span>
@@ -261,12 +263,14 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue' import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters' import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { expandBrandFolderRecursive } from '@/shared/api/brand' import { expandBrandFolderRecursive } from '@/shared/api/brand'
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview' import { getPywebviewApi, type PywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers' import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress' import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
import { import {
addPriceTrackCandidate, addPriceTrackCandidate,
completePriceTrackLoopChild, completePriceTrackLoopChild,
@@ -287,6 +291,7 @@ import {
getTaskSkipPriceAsinsPaginated, getTaskSkipPriceAsinsPaginated,
listPriceTrackCandidates, listPriceTrackCandidates,
matchPriceTrackShops, matchPriceTrackShops,
markPriceTrackDispatchFailed,
putPriceTrackCountryPreference, putPriceTrackCountryPreference,
stopPriceTrackLoopRun, stopPriceTrackLoopRun,
type PriceTrackAsinParsedRow, type PriceTrackAsinParsedRow,
@@ -300,6 +305,8 @@ import {
type PriceTrackTaskDetailVo, type PriceTrackTaskDetailVo,
} from '@/shared/api/java-modules' } from '@/shared/api/java-modules'
const ziniaoVersion = useZiniaoVersion()
// ========== 类型定义 ========== // ========== 类型定义 ==========
const COUNTRY_OPTIONS = [ const COUNTRY_OPTIONS = [
{ code: 'DE', label: '德国' }, { code: 'DE', label: '德国' },
@@ -1011,6 +1018,7 @@ async function pushToPythonQueueLegacy() {
ts: Date.now(), ts: Date.now(),
data: { data: {
task_id: taskVo.taskId, task_id: taskVo.taskId,
ziniao_version: ziniaoVersion.value,
shop_name: (firstRow.shopName || '').trim(), shop_name: (firstRow.shopName || '').trim(),
shopName: (firstRow.shopName || '').trim(), shopName: (firstRow.shopName || '').trim(),
companyName: firstRow.companyName || '', companyName: firstRow.companyName || '',
@@ -1023,16 +1031,11 @@ async function pushToPythonQueueLegacy() {
} }
queuePayloadText.value = JSON.stringify(queuePayload, null, 2) queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
const pushResult = await api.enqueue_json(queuePayload) await enqueueCreatedTask(api, taskVo.taskId, queuePayload)
if (pushResult?.success) { queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...`
queuePushResult.value = `任务 ${taskVo.taskId} 已入队,等待执行完成...` addPollingTask(taskVo.taskId)
addPollingTask(taskVo.taskId) scheduleNextPoll(true)
scheduleNextPoll(true) ElMessage.success('已推送到 Python 队列')
ElMessage.success('已推送到 Python 队列')
} else {
queuePushResult.value = `推送失败:${pushResult?.error || '未知错误'}`
ElMessage.error(queuePushResult.value)
}
} catch (e) { } catch (e) {
queuePushResult.value = e instanceof Error ? e.message : '推送异常' queuePushResult.value = e instanceof Error ? e.message : '推送异常'
ElMessage.error(queuePushResult.value) ElMessage.error(queuePushResult.value)
@@ -1052,6 +1055,7 @@ async function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrack
ts: Date.now(), ts: Date.now(),
data: { data: {
task_id: taskVo.taskId, task_id: taskVo.taskId,
ziniao_version: ziniaoVersion.value,
shop_name: shopName, shop_name: shopName,
country_codes: resolveCountryCodesForRequest(), country_codes: resolveCountryCodesForRequest(),
mode: priceTrackModeForAppClient(), mode: priceTrackModeForAppClient(),
@@ -1064,6 +1068,42 @@ async function buildQueuePayload(taskVo: PriceTrackCreateTaskVo, row: PriceTrack
} }
} }
async function enqueueCreatedTask(api: PywebviewApi, taskId: number, queuePayload: unknown) {
if (!api.enqueue_json) throw new Error('当前环境未启用 pywebview enqueue_json')
let pushResult: Awaited<ReturnType<NonNullable<PywebviewApi['enqueue_json']>>>
try {
pushResult = await api.enqueue_json(queuePayload)
} catch (error) {
const reason = `Python 队列调用异常:${error instanceof Error ? error.message : String(error || '未知错误')}`
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
if (!pushResult?.success) {
const reason = pushResult?.error || 'Python 队列拒绝接收任务'
await compensateDispatchFailure(taskId, reason)
throw new Error(reason)
}
return pushResult
}
async function compensateDispatchFailure(taskId: number, reason: string) {
try {
await markPriceTrackDispatchFailed(taskId, reason)
removePollingTask(taskId)
taskDetails.value = { ...taskDetails.value, [taskId]: 'FAILED' }
saveTaskDetailsToStorage()
await loadHistory()
await loadDashboard()
syncPollingIdsWithHistory()
if (activeLoopRunId.value) await syncActiveLoopRun()
} catch (error) {
addPollingTask(taskId)
scheduleNextPoll(true)
const syncError = error instanceof Error ? error.message : String(error || '未知错误')
throw new Error(`${reason};后端失败状态同步失败:${syncError}`)
}
}
async function waitForTaskTerminal(taskId: number) { async function waitForTaskTerminal(taskId: number) {
let transientErrorCount = 0 let transientErrorCount = 0
const maxTransientErrors = 30 const maxTransientErrors = 30
@@ -1203,14 +1243,7 @@ async function processMatchedQueue() {
saveTaskDetailsToStorage() saveTaskDetailsToStorage()
const queuePayload = await buildQueuePayload(taskVo, row) const queuePayload = await buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2) queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
const pushResult = await api.enqueue_json(queuePayload) await enqueueCreatedTask(api, taskVo.taskId, queuePayload)
if (!pushResult?.success) {
failedCount += 1
queuePushResult.value = '任务 ' + taskVo.taskId + ' 推送失败,已自动继续下一条:' + (pushResult?.error || '未知错误')
ElMessage.error(queuePushResult.value)
removeMatchedRowsLocally([row])
continue
}
addPollingTask(taskVo.taskId) addPollingTask(taskVo.taskId)
scheduleNextPoll(true) scheduleNextPoll(true)
removeMatchedRowsLocally([row]) removeMatchedRowsLocally([row])
@@ -1404,10 +1437,7 @@ async function runLoopExecution(loopId?: number) {
recordCreatedTask(taskVo) recordCreatedTask(taskVo)
const queuePayload = await buildQueuePayload(taskVo, row) const queuePayload = await buildQueuePayload(taskVo, row)
queuePayloadText.value = JSON.stringify(queuePayload, null, 2) queuePayloadText.value = JSON.stringify(queuePayload, null, 2)
const pushResult = await api.enqueue_json(queuePayload) await enqueueCreatedTask(api, taskVo.taskId, queuePayload)
if (!pushResult?.success) {
throw new Error(pushResult?.error || `任务 ${taskVo.taskId} 推送失败`)
}
addPollingTask(taskVo.taskId) addPollingTask(taskVo.taskId)
scheduleNextPoll(true) scheduleNextPoll(true)
removeMatchedRowsLocally([row]) removeMatchedRowsLocally([row])

View File

@@ -64,6 +64,8 @@
</el-select> </el-select>
</div> </div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row"> <div class="run-row">
<button type="button" class="btn-run" :disabled="matching" @click="runMatch"> <button type="button" class="btn-run" :disabled="matching" @click="runMatch">
{{ matching ? '匹配中' : '匹配店铺' }} {{ matching ? '匹配中' : '匹配店铺' }}
@@ -222,6 +224,8 @@ import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers' import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress' import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
const shopInput = ref('') const shopInput = ref('')
const candidates = ref<ProductRiskCandidateVo[]>([]) const candidates = ref<ProductRiskCandidateVo[]>([])
@@ -234,6 +238,7 @@ const queueWorkerRunning = ref(false)
const autoQueueEnabled = ref(false) const autoQueueEnabled = ref(false)
const queuePushResult = ref('') const queuePushResult = ref('')
const queuePayloadText = ref('') const queuePayloadText = ref('')
const ziniaoVersion = useZiniaoVersion()
const PRODUCT_RISK_LISTING_FILTER_OPTIONS = [ const PRODUCT_RISK_LISTING_FILTER_OPTIONS = [
...LISTING_FILTER_OPTIONS, ...LISTING_FILTER_OPTIONS,
@@ -1078,6 +1083,7 @@ async function processMatchedBatchQueue(toPush: ProductRiskShopQueueItem[]) {
ts: Date.now(), ts: Date.now(),
data: { data: {
taskId, taskId,
ziniao_version: ziniaoVersion.value,
items: toPush, items: toPush,
country_codes: [...orderedCountryCodes.value], country_codes: [...orderedCountryCodes.value],
risk_listing_filter: productRiskListingFilter.value, risk_listing_filter: productRiskListingFilter.value,
@@ -1155,6 +1161,7 @@ async function processMatchedQueue() {
ts: Date.now(), ts: Date.now(),
data: { data: {
taskId, taskId,
ziniao_version: ziniaoVersion.value,
items: [item], items: [item],
country_codes: [...orderedCountryCodes.value], country_codes: [...orderedCountryCodes.value],
risk_listing_filter: productRiskListingFilter.value, risk_listing_filter: productRiskListingFilter.value,

View File

@@ -60,6 +60,8 @@
</el-table> </el-table>
</div> </div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row"> <div class="run-row">
<button <button
type="button" type="button"
@@ -341,8 +343,11 @@ import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config"; import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers"; import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
import { saveUrlWithProgress } from "@/shared/utils/download-progress"; import { saveUrlWithProgress } from "@/shared/utils/download-progress";
import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue";
import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
const MAX_TRANSIENT_ERRORS = 30; const MAX_TRANSIENT_ERRORS = 30;
const ziniaoVersion = useZiniaoVersion();
const shopInput = ref(""); const shopInput = ref("");
const candidates = ref<QueryAsinCandidateVo[]>([]); const candidates = ref<QueryAsinCandidateVo[]>([]);
@@ -937,6 +942,7 @@ function buildQueuePayload(taskId: number, item: QueryAsinHistoryItem) {
ts: Date.now(), ts: Date.now(),
data: { data: {
taskId, taskId,
ziniao_version: ziniaoVersion.value,
user_id: Number(uidForStorage()) || 0, user_id: Number(uidForStorage()) || 0,
source: "frontend-vue-query-asin", source: "frontend-vue-query-asin",
submissionId, submissionId,

View File

@@ -55,6 +55,8 @@
<button type="button" class="schedule-add" :disabled="pushing" @click="addScheduleValue">添加时间点</button> <button type="button" class="schedule-add" :disabled="pushing" @click="addScheduleValue">添加时间点</button>
<p class="hint schedule-hint">默认开启定时支持按 `MM-DD HH:mm` 配置多个时间点前端不再限制选择更早时间实际是否可执行以后端校验为准</p> <p class="hint schedule-hint">默认开启定时支持按 `MM-DD HH:mm` 配置多个时间点前端不再限制选择更早时间实际是否可执行以后端校验为准</p>
</div> </div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row"> <div class="run-row">
<button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button> <button type="button" class="btn-run" :disabled="matching" @click="runMatch">{{ matching ? '匹配中...' : '匹配店铺' }}</button>
<button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '推送中...' : scheduleEnabled ? '创建定时任务' : '推送到 Python 队列' }}</button> <button type="button" class="btn-run btn-queue" :disabled="pushing || !matchedItems.length" @click="pushToPythonQueue">{{ pushing ? '推送中...' : scheduleEnabled ? '创建定时任务' : '推送到 Python 队列' }}</button>
@@ -107,12 +109,14 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue' import BrandTopBar from '@/pages/brand/components/BrandTopBar.vue'
import ZiniaoVersionSetting from '@/shared/components/ZiniaoVersionSetting.vue'
import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters' import { LISTING_FILTER_OPTIONS, type ListingFilterValue } from '@/pages/brand/components/listingFilters'
import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules' import { activateShopMatchTask, addShopMatchCandidate, createShopMatchTask, deleteShopMatchCandidate, deleteShopMatchHistory, deleteShopMatchTask, getShopMatchCountryPreference, getShopMatchDashboard, getShopMatchHistory, getShopMatchResultDownloadUrl, getShopMatchTaskProgressBatch, getShopMatchTasksBatch, listShopMatchCandidates, matchShopMatchShops, putShopMatchCountryPreference, type ShopMatchCandidateVo, type ShopMatchCreateTaskItem, type ShopMatchDashboardVo, type ShopMatchHistoryItem, type ShopMatchShopQueueItem, type ShopMatchTaskDetailVo } from '@/shared/api/java-modules'
import { getPywebviewApi } from '@/shared/bridges/pywebview' import { getPywebviewApi } from '@/shared/bridges/pywebview'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config' import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers' import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import { saveUrlWithProgress } from '@/shared/utils/download-progress' import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import { useZiniaoVersion } from '@/shared/utils/ziniao-version'
const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const const COUNTRY_OPTIONS = [{ code: 'DE', label: '德国' }, { code: 'UK', label: '英国' }, { code: 'FR', label: '法国' }, { code: 'IT', label: '意大利' }, { code: 'ES', label: '西班牙' }] as const
const shopInput = ref('') const shopInput = ref('')
@@ -127,6 +131,7 @@ const queueWorkerRunning = ref(false)
const autoQueueEnabled = ref(false) const autoQueueEnabled = ref(false)
const queuePushResult = ref('') const queuePushResult = ref('')
const queuePayloadText = ref('') const queuePayloadText = ref('')
const ziniaoVersion = useZiniaoVersion()
const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES']) const orderedCountryCodes = ref<string[]>(['DE', 'UK', 'FR', 'IT', 'ES'])
const dragCountryIndex = ref<number | null>(null) const dragCountryIndex = ref<number | null>(null)
const countryPrefSaving = ref(false) const countryPrefSaving = ref(false)
@@ -325,6 +330,7 @@ function buildQueuePayload(taskId: number, item: Pick<ShopMatchHistoryItem, 'sho
ts: Date.now(), ts: Date.now(),
data: { data: {
taskId, taskId,
ziniao_version: ziniaoVersion.value,
user_id: currentUserId(), user_id: currentUserId(),
items: [{ items: [{
shopName: item.shopName, shopName: item.shopName,

View File

@@ -71,6 +71,8 @@
</el-table> </el-table>
</div> </div>
<ZiniaoVersionSetting v-model="ziniaoVersion" />
<div class="run-row"> <div class="run-row">
<button <button
type="button" type="button"
@@ -354,8 +356,11 @@ import { getPywebviewApi } from "@/shared/bridges/pywebview";
import { getTaskPollIntervalMs } from "@/shared/task-progress-config"; import { getTaskPollIntervalMs } from "@/shared/task-progress-config";
import { createCategorizedTimers } from "@/shared/utils/categorized-timers"; import { createCategorizedTimers } from "@/shared/utils/categorized-timers";
import { saveUrlWithProgress } from "@/shared/utils/download-progress"; import { saveUrlWithProgress } from "@/shared/utils/download-progress";
import ZiniaoVersionSetting from "@/shared/components/ZiniaoVersionSetting.vue";
import { useZiniaoVersion } from "@/shared/utils/ziniao-version";
const MAX_TRANSIENT_ERRORS = 30; const MAX_TRANSIENT_ERRORS = 30;
const ziniaoVersion = useZiniaoVersion();
type WithdrawTaskBatchQueueItem = { type WithdrawTaskBatchQueueItem = {
batchId: string; batchId: string;
@@ -1048,6 +1053,7 @@ function buildQueuePayload(taskId: number, batch: WithdrawTaskBatchQueueItem) {
ts: Date.now(), ts: Date.now(),
data: { data: {
task_id: taskId, task_id: taskId,
ziniao_version: ziniaoVersion.value,
user_id: Number(uidForStorage()) || 0, user_id: Number(uidForStorage()) || 0,
source: "frontend-vue-withdraw", source: "frontend-vue-withdraw",
reserved_amount: batch.reservedAmount, reserved_amount: batch.reservedAmount,

View File

@@ -2501,6 +2501,15 @@ export function getTaskSkipPriceAsinsPaginated(
); );
} }
export function markPriceTrackDispatchFailed(taskId: number, errorMessage: string) {
return unwrapJavaResponse(
post<JavaApiResponse<null>, { errorMessage: string }>(
`${JAVA_API_PREFIX}/price-track/tasks/${taskId}/dispatch-failed?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
{ errorMessage },
),
);
}
export function checkTaskSkipPriceAsin(taskId: number, country: string, asin: string) { export function checkTaskSkipPriceAsin(taskId: number, country: string, asin: string) {
return unwrapJavaResponse( return unwrapJavaResponse(
get<JavaApiResponse<SkipPriceAsinCheckVo>>( get<JavaApiResponse<SkipPriceAsinCheckVo>>(

View File

@@ -0,0 +1,35 @@
<template>
<section class="ziniao-version-setting">
<div class="setting-title">紫鸟版本</div>
<div class="version-options" role="radiogroup" aria-label="紫鸟版本">
<label v-for="option in options" :key="option.value" class="version-option"
:class="{ active: modelValue === option.value }">
<input :checked="modelValue === option.value" type="radio" :name="radioName" :value="option.value"
@change="emit('update:modelValue', option.value)" />
<span>{{ option.label }}</span>
</label>
</div>
</section>
</template>
<script setup lang="ts">
import type { ZiniaoVersion } from '@/shared/utils/ziniao-version'
defineProps<{ modelValue: ZiniaoVersion }>()
const emit = defineEmits<{ 'update:modelValue': [value: ZiniaoVersion] }>()
const radioName = `ziniao-version-${Math.random().toString(36).slice(2)}`
const options: Array<{ label: string; value: ZiniaoVersion }> = [
{ label: '新版', value: 'new' },
{ label: '旧版', value: 'old' },
]
</script>
<style scoped>
.ziniao-version-setting { margin: 16px 0; }
.setting-title { margin-bottom: 10px; color: #bbb; font-size: 13px; }
.version-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; min-height: 40px; padding: 3px; border: 1px solid #3a3a3a; border-radius: 6px; background: #202020; }
.version-option { display: flex; min-width: 0; align-items: center; justify-content: center; border-radius: 4px; color: #aaa; cursor: pointer; font-size: 13px; transition: background-color .15s ease, color .15s ease; }
.version-option:hover { color: #eee; }
.version-option.active { background: #409eff; color: #fff; }
.version-option input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
</style>

View File

@@ -0,0 +1,20 @@
import { ref, watch } from 'vue'
export type ZiniaoVersion = 'new' | 'old'
const STORAGE_KEY = 'ziniao:version'
function loadZiniaoVersion(): ZiniaoVersion {
if (typeof window === 'undefined') return 'new'
return window.localStorage.getItem(STORAGE_KEY) === 'old' ? 'old' : 'new'
}
export function useZiniaoVersion() {
const ziniaoVersion = ref<ZiniaoVersion>(loadZiniaoVersion())
watch(ziniaoVersion, (value) => {
if (typeof window !== 'undefined') window.localStorage.setItem(STORAGE_KEY, value)
})
return ziniaoVersion
}

View File

@@ -39,6 +39,70 @@ html {
.page-shell { .page-shell {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
}
/*
* The desktop client uses a resizable WebView. Brand modules have a dynamic
* navigation header, so their content must consume the remaining space
* instead of subtracting the old fixed 56px header height.
*/
@media (min-width: 1101px) {
html:has(.module-page),
body:has(.module-page) {
height: 100%;
overflow: hidden;
background: #1a1a1a;
}
#app:has(> .module-page) {
height: 100vh;
height: 100dvh;
min-height: 0;
overflow: hidden;
background: #1a1a1a;
}
.module-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
min-height: 0 !important;
overflow: hidden;
}
.module-page > .top-bar {
flex: 0 0 auto;
}
.module-page > .main-content {
flex: 1 1 auto;
height: auto !important;
min-height: 0 !important;
overflow: hidden;
}
.module-page > .main-content > .left-panel,
.module-page > .main-content > .right-panel,
.module-page .task-list-wrap {
min-height: 0;
}
.module-page > .main-content > .left-panel,
.module-page .task-list-wrap {
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
}
@media (max-width: 1100px) {
body:has(.module-page),
#app:has(> .module-page) {
min-height: 100vh;
min-height: 100dvh;
background: #1a1a1a;
}
} }
.theme-light { .theme-light {