视频任务管理更新
This commit is contained in:
@@ -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());
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", "评分"})
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(紫鸟)")
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 不合法");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,9 +54,18 @@ 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);
|
||||||
|
int groupSize = Math.max(1, details.size());
|
||||||
|
String asin = firstNonBlank(
|
||||||
|
dto == null ? null : dto.getProductAsinSku(),
|
||||||
|
dto == null ? null : dto.getRemoveAsin());
|
||||||
|
for (int offset = 0; offset < groupSize; offset++) {
|
||||||
|
Row row = sheet.createRow(rowIdx + offset);
|
||||||
|
if (offset == 0) {
|
||||||
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
|
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
|
||||||
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
|
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
|
||||||
createTextCell(row, 2, dto == null ? null : dto.getStatus());
|
createTextCell(row, 2, dto == null ? null : dto.getStatus());
|
||||||
@@ -55,6 +73,19 @@ public class ProductRiskExcelAssemblyService {
|
|||||||
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
|
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
|
||||||
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "正常")
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 +1688,52 @@ 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 为已打开的游标。"""
|
||||||
|
requested_ids = {int(x) for x in (column_ids or []) if x}
|
||||||
|
preserved_ids = set()
|
||||||
|
if preserve_column_keys:
|
||||||
|
placeholders = ','.join(['%s'] * len(preserve_column_keys))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id FROM columns WHERE column_key IN ({placeholders})",
|
||||||
|
tuple(preserve_column_keys),
|
||||||
|
)
|
||||||
|
protected_ids = {int(row['id']) for row in cur.fetchall() if row.get('id') is not None}
|
||||||
|
requested_ids.difference_update(protected_ids)
|
||||||
|
if protected_ids:
|
||||||
|
id_placeholders = ','.join(['%s'] * len(protected_ids))
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT column_id FROM user_column_permission WHERE user_id = %s AND column_id IN ({id_placeholders})",
|
||||||
|
(user_id, *sorted(protected_ids)),
|
||||||
|
)
|
||||||
|
preserved_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None}
|
||||||
cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,))
|
cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,))
|
||||||
if column_ids:
|
for cid in sorted(requested_ids | preserved_ids):
|
||||||
column_ids = [int(x) for x in column_ids if x]
|
|
||||||
for cid in column_ids:
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
|
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
|
||||||
(user_id, cid),
|
(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) ----------
|
||||||
|
|
||||||
# ---------- 商品类目 ----------
|
# ---------- 商品类目 ----------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -380,11 +380,30 @@
|
|||||||
}
|
}
|
||||||
var allColumnsList = [];
|
var allColumnsList = [];
|
||||||
function loadColumnsForPermission() {
|
function loadColumnsForPermission() {
|
||||||
fetch('/api/admin/columns')
|
var ownedKeys = {};
|
||||||
|
var ownedRoutes = {};
|
||||||
|
var ownPermissionRequest = (currentUserRole === 'admin' && currentUserId)
|
||||||
|
? fetch('/api/admin/user/' + currentUserId + '/column-permissions')
|
||||||
.then(function (r) { return r.json(); })
|
.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>') +
|
|
||||||
'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap;">' +
|
|
||||||
(archivedUrl ? '<a href="' + escapeHtml(archivedUrl) + '" target="_blank" rel="noreferrer">OSS 视频</a>' : '') +
|
|
||||||
(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) {
|
document.body.appendChild(link);
|
||||||
var content = document.getElementById('imageVideoTaskDetailContent');
|
link.click();
|
||||||
content.innerHTML = '<p class="empty-tip">加载中...</p>';
|
link.remove();
|
||||||
document.getElementById('imageVideoTaskDetailModal').classList.add('show');
|
}
|
||||||
fetch('/api/admin/image-video-tasks/' + encodeURIComponent(taskId))
|
function downloadImageVideo(card) {
|
||||||
.then(function (r) { return r.json(); })
|
var url = card && card.video ? card.video.displayUrl : '';
|
||||||
.then(function (res) {
|
if (!url) return Promise.resolve({ fallback: false, skipped: true });
|
||||||
content.innerHTML = res.success ? renderImageVideoTaskDetail(res.item || {}) : '<p class="msg err">' + escapeHtml(res.error || '加载失败') + '</p>';
|
var filename = imageVideoFilename(card);
|
||||||
|
return fetch(url, { __skipLoading: true })
|
||||||
|
.then(function (response) {
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
return response.blob();
|
||||||
})
|
})
|
||||||
.catch(function () { content.innerHTML = '<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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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__':
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<button class="btn btn-secondary image-video-permission-btn" id="btnOpenImageVideoPermissions" type="button">权限配置</button>
|
||||||
|
<button class="btn image-video-batch-btn" id="btnBatchDownloadImageVideos" type="button" disabled>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"></path></svg>
|
||||||
|
批量下载
|
||||||
|
</button>
|
||||||
|
<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>
|
</div>
|
||||||
<div class="image-video-table-wrap">
|
<span class="image-video-summary" id="imageVideoTaskTotal"></span>
|
||||||
<table class="image-video-table">
|
</div>
|
||||||
<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>
|
<div class="image-video-grid" id="imageVideoTaskGrid" aria-live="polite"></div>
|
||||||
<tbody id="imageVideoTaskListBody"></tbody>
|
|
||||||
</table>
|
|
||||||
</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>
|
||||||
<div id="imageVideoTaskDetailContent"></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>
|
</div>
|
||||||
<script src="/static/admin.js"></script>
|
</div>
|
||||||
|
<script src="/static/admin.js?v=permission-tabs-3"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
BIN
frontend-vue/new_web_source.zip
Normal file
BIN
frontend-vue/new_web_source.zip
Normal file
Binary file not shown.
@@ -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]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>>(
|
||||||
|
|||||||
35
frontend-vue/src/shared/components/ZiniaoVersionSetting.vue
Normal file
35
frontend-vue/src/shared/components/ZiniaoVersionSetting.vue
Normal 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>
|
||||||
20
frontend-vue/src/shared/utils/ziniao-version.ts
Normal file
20
frontend-vue/src/shared/utils/ziniao-version.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user