From 92d3fc965b5d48ea62d8d6de33468e653c48a5b8 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Fri, 20 Mar 2026 14:26:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=96=B0=E9=9C=80?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 15 + backend-java/pom.xml | 131 ++++ .../com/nanri/aiimage/AiImageApplication.java | 12 + .../nanri/aiimage/common/api/ApiResponse.java | 34 + .../common/exception/BusinessException.java | 8 + .../exception/GlobalExceptionHandler.java | 37 + .../nanri/aiimage/config/OpenApiConfig.java | 44 ++ .../nanri/aiimage/config/OssProperties.java | 14 + .../aiimage/config/PropertiesConfig.java | 9 + .../nanri/aiimage/config/SecurityConfig.java | 20 + .../aiimage/config/StorageProperties.java | 10 + .../controller/ConvertRunController.java | 84 +++ .../controller/ConvertTemplateController.java | 53 ++ .../convert/mapper/ConvertTemplateMapper.java | 9 + .../convert/model/dto/ConvertRunRequest.java | 23 + .../dto/ConvertTemplateImportRequest.java | 15 + .../model/dto/UploadedSourceFileDto.java | 17 + .../model/entity/ConvertTemplateEntity.java | 30 + .../convert/model/vo/ConvertHistoryVo.java | 14 + .../convert/model/vo/ConvertResultItemVo.java | 27 + .../convert/model/vo/ConvertRunVo.java | 23 + .../convert/model/vo/ConvertTemplateVo.java | 27 + .../convert/service/ConvertRunService.java | 317 ++++++++ .../service/ConvertTemplateService.java | 99 +++ .../controller/DedupeRunController.java | 74 ++ .../dedupe/model/dto/DedupeRunRequest.java | 28 + .../dedupe/model/dto/DedupeSourceFileDto.java | 17 + .../dedupe/model/vo/DedupeHistoryVo.java | 14 + .../dedupe/model/vo/DedupeResultItemVo.java | 27 + .../modules/dedupe/model/vo/DedupeRunVo.java | 23 + .../dedupe/service/DedupeRunService.java | 303 ++++++++ .../file/controller/FileUploadController.java | 37 + .../modules/file/model/vo/UploadFileVo.java | 21 + .../file/service/LocalFileStorageService.java | 39 + .../file/service/oss/OssStorageService.java | 49 ++ .../split/controller/SplitRunController.java | 75 ++ .../split/model/dto/SplitRunRequest.java | 33 + .../split/model/dto/SplitSourceFileDto.java | 17 + .../split/model/vo/SplitHistoryVo.java | 14 + .../split/model/vo/SplitResultItemVo.java | 30 + .../modules/split/model/vo/SplitRunVo.java | 23 + .../split/service/SplitRunService.java | 333 +++++++++ .../modules/task/mapper/FileResultMapper.java | 9 + .../modules/task/mapper/FileTaskMapper.java | 9 + .../task/model/entity/FileResultEntity.java | 28 + .../task/model/entity/FileTaskEntity.java | 30 + .../resources/application-local.example.yml | 16 + .../src/main/resources/application.yml | 49 ++ .../src/main/resources/db/V1__init.sql | 76 ++ .../db/V2__sync_uk_offer_template.sql | 17 + desktop/__pycache__/app.cpython-312.pyc | Bin 44478 -> 51068 bytes desktop/app.py | 703 +++++++++--------- desktop/run_desktop_dev.ps1 | 2 +- desktop/txt_templates.json | 45 ++ frontend-vue/index.html | 2 +- .../brand/components/BrandConvertTab.vue | 139 +++- .../pages/brand/components/BrandDedupeTab.vue | 95 ++- .../pages/brand/components/BrandSplitTab.vue | 97 ++- frontend-vue/src/pages/brand/index.vue | 88 +-- frontend-vue/src/pages/home/index.vue | 9 +- frontend-vue/src/pages/image/index.vue | 4 +- frontend-vue/src/pages/login/index.vue | 19 +- frontend-vue/src/router.ts | 10 +- frontend-vue/src/shared/api/convert.ts | 63 ++ frontend-vue/src/shared/api/dedupe.ts | 22 + frontend-vue/src/shared/bridges/pywebview.ts | 26 + web_source/templates_backup/admin.html | 2 +- web_source/templates_backup/brand.html | 4 +- web_source/templates_backup/home.html | 4 +- web_source/templates_backup/index.html | 4 +- web_source/templates_backup/login.html | 4 +- 71 files changed, 3265 insertions(+), 540 deletions(-) create mode 100644 backend-java/pom.xml create mode 100644 backend-java/src/main/java/com/nanri/aiimage/AiImageApplication.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/common/api/ApiResponse.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/common/exception/BusinessException.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/OpenApiConfig.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/config/StorageProperties.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/mapper/ConvertTemplateMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/ConvertRunRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/ConvertTemplateImportRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/UploadedSourceFileDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/entity/ConvertTemplateEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertHistoryVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertResultItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertRunVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertTemplateVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/dto/DedupeRunRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/dto/DedupeSourceFileDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeHistoryVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeResultItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeRunVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/model/vo/UploadFileVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/split/model/dto/SplitRunRequest.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/split/model/dto/SplitSourceFileDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitHistoryVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitResultItemVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitRunVo.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileResultMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileTaskMapper.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileResultEntity.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileTaskEntity.java create mode 100644 backend-java/src/main/resources/application-local.example.yml create mode 100644 backend-java/src/main/resources/application.yml create mode 100644 backend-java/src/main/resources/db/V1__init.sql create mode 100644 backend-java/src/main/resources/db/V2__sync_uk_offer_template.sql create mode 100644 frontend-vue/src/shared/api/convert.ts create mode 100644 frontend-vue/src/shared/api/dedupe.ts diff --git a/.gitignore b/.gitignore index b324bde..ae2c06c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,18 @@ frontend-vue/npm-debug.log* frontend-vue/yarn-debug.log* frontend-vue/yarn-error.log* frontend-vue/pnpm-debug.log* + +# backend-java build output +backend-java/target/ + +# backend-java runtime data +backend-java/data/ +backend-java/*.log +backend-java/logs/ + +# backend-java local config +backend-java/src/main/resources/application-local.yml + +# backend-java IDE files +backend-java/.idea/ +backend-java/*.iml diff --git a/backend-java/pom.xml b/backend-java/pom.xml new file mode 100644 index 0000000..7f2ff65 --- /dev/null +++ b/backend-java/pom.xml @@ -0,0 +1,131 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.9 + + + + com.nanri + aiimage-backend + 0.0.1-SNAPSHOT + aiimage-backend + AI image backend service + + + 21 + 2.6.0 + 4.5.0 + 3.5.7 + 1.5.5.Final + 4.0.3 + 3.17.4 + 5.8.36 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-security + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + com.mysql + mysql-connector-j + runtime + + + com.alibaba + easyexcel + ${easyexcel.version} + + + com.aliyun.oss + aliyun-sdk-oss + ${aliyun.oss.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + ${knife4j.version} + + + cn.hutool + hutool-all + ${hutool.version} + + + org.projectlombok + lombok + true + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + + + org.projectlombok + lombok + 1.18.36 + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + + diff --git a/backend-java/src/main/java/com/nanri/aiimage/AiImageApplication.java b/backend-java/src/main/java/com/nanri/aiimage/AiImageApplication.java new file mode 100644 index 0000000..a85d792 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/AiImageApplication.java @@ -0,0 +1,12 @@ +package com.nanri.aiimage; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AiImageApplication { + + public static void main(String[] args) { + SpringApplication.run(AiImageApplication.class, args); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/api/ApiResponse.java b/backend-java/src/main/java/com/nanri/aiimage/common/api/ApiResponse.java new file mode 100644 index 0000000..a2db9aa --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/common/api/ApiResponse.java @@ -0,0 +1,34 @@ +package com.nanri.aiimage.common.api; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "统一响应体") +public class ApiResponse { + + @Schema(description = "是否成功") + private boolean success; + + @Schema(description = "提示信息") + private String message; + + @Schema(description = "响应数据") + private T data; + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, "操作成功", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(true, message, data); + } + + public static ApiResponse fail(String message) { + return new ApiResponse<>(false, message, null); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/exception/BusinessException.java b/backend-java/src/main/java/com/nanri/aiimage/common/exception/BusinessException.java new file mode 100644 index 0000000..0240697 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/common/exception/BusinessException.java @@ -0,0 +1,8 @@ +package com.nanri.aiimage.common.exception; + +public class BusinessException extends RuntimeException { + + public BusinessException(String message) { + super(message); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java b/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7b1e109 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,37 @@ +package com.nanri.aiimage.common.exception; + +import com.nanri.aiimage.common.api.ApiResponse; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ApiResponse handleBusinessException(BusinessException ex) { + return ApiResponse.fail(ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ApiResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldError() != null + ? ex.getBindingResult().getFieldError().getDefaultMessage() + : "参数校验失败"; + return ApiResponse.fail(message); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ApiResponse handleConstraintViolationException(ConstraintViolationException ex) { + return ApiResponse.fail(ex.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ApiResponse handleException(Exception ex) { + log.error("Unhandled exception", ex); + return ApiResponse.fail("服务异常:" + ex.getMessage()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/OpenApiConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/OpenApiConfig.java new file mode 100644 index 0000000..6c6adf8 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/OpenApiConfig.java @@ -0,0 +1,44 @@ +package com.nanri.aiimage.config; + +import io.swagger.v3.oas.models.ExternalDocumentation; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("AI Image Backend API") + .description(""" + 文件处理后端接口文档。 + + 当前迁移范围:仅迁移数据去重(dedupe)、格式转换(convert)、数据拆分(split)三块处理逻辑到 Java; + Python 桌面端壳、pywebview 调用方式、前端页面交互保持不变。 + + 建议联调顺序: + 1. 先启动 Java 后端; + 2. 在 Python 桌面端中选择文件并执行 dedupe/convert/split; + 3. Python 壳会先调用 /api/files/upload 上传临时文件; + 4. 再调用对应的 /api/dedupe/run、/api/convert/run、/api/split/run; + 5. Java 生成结果文件后,Python 壳再通过下载接口取回并保存到用户本地目录。 + + 本地启动说明: + - 默认配置读取 application.yml; + - 建议复制 application-local.example.yml 为本地配置,并在 IDE 中通过环境变量覆盖数据库与 OSS 参数; + - Knife4j 地址:/doc.html + """) + .version("v0.0.1") + .contact(new Contact().name("Nanri AI")) + .license(new License().name("Internal Use"))) + .externalDocs(new ExternalDocumentation() + .description("Knife4j 文档") + .url("/doc.html")); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java new file mode 100644 index 0000000..2e00956 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "aiimage.oss") +public class OssProperties { + private String region; + private String endpoint; + private String bucket; + private String accessKeyId; + private String accessKeySecret; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java new file mode 100644 index 0000000..2b77762 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/PropertiesConfig.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({OssProperties.class, StorageProperties.class}) +public class PropertiesConfig { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java new file mode 100644 index 0000000..968fadf --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SecurityConfig.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .httpBasic(Customizer.withDefaults()) + .build(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/StorageProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/StorageProperties.java new file mode 100644 index 0000000..da584bf --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/config/StorageProperties.java @@ -0,0 +1,10 @@ +package com.nanri.aiimage.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Data +@ConfigurationProperties(prefix = "aiimage.storage") +public class StorageProperties { + private String localTempDir; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java new file mode 100644 index 0000000..49ed3c7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java @@ -0,0 +1,84 @@ +package com.nanri.aiimage.modules.convert.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.convert.model.dto.ConvertRunRequest; +import com.nanri.aiimage.modules.convert.model.vo.ConvertHistoryVo; +import com.nanri.aiimage.modules.convert.model.vo.ConvertRunVo; +import com.nanri.aiimage.modules.convert.service.ConvertRunService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.File; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/convert") +@Tag(name = "格式转换执行", description = "负责根据已上传文件和模板执行格式转换,并返回结果下载地址。当前先打通接口链路,下一步再把旧 Python 的模板生成逻辑完整迁移到 Java。") +public class ConvertRunController { + + private final ConvertRunService convertRunService; + + @PostMapping("/run") + @Operation( + summary = "执行格式转换", + description = """ + 使用上传接口返回的 fileKey 和选中的模板执行格式转换。 + + 当前已按旧 Python 桌面端规则迁移以下逻辑: + - required_source_columns 缺失校验; + - preamble_lines 输出; + - header_columns 输出; + - blank_header_rows_after_schema 空白行补齐; + - field_mapping 字段映射; + - defaults 默认值填充; + - ASIN 为空的行跳过; + - sku 按 时间戳-序号 生成。 + + 输出文件名遵循模板配置的 outputFilename,若临时目录中重名则自动追加序号。 + """) + public ApiResponse run(@Valid @RequestBody ConvertRunRequest request) { + return ApiResponse.success(convertRunService.run(request)); + } + + @GetMapping("/history") + @Operation(summary = "查询格式转换历史", description = "查询最近的格式转换成功记录,用于页面右侧历史下载列表展示。") + public ApiResponse history() { + ConvertHistoryVo vo = new ConvertHistoryVo(); + vo.setItems(convertRunService.listHistory()); + return ApiResponse.success(vo); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除格式转换历史", description = "删除一条格式转换历史记录。") + public ApiResponse deleteHistory(@PathVariable Long resultId) { + convertRunService.deleteHistory(resultId); + return ApiResponse.success(null); + } + + @GetMapping("/results/{resultId}/download") + @Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载已生成的转换结果文件。桌面端前端可继续通过 save_file_from_url 选择保存位置。") + public ResponseEntity download(@PathVariable Long resultId) { + File resultFile = convertRunService.getResultFile(resultId); + String encodedFilename = URLEncoder.encode(resultFile.getName(), StandardCharsets.UTF_8).replaceAll("\\+", "%20"); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename) + .body(new FileSystemResource(resultFile)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java new file mode 100644 index 0000000..370dc97 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java @@ -0,0 +1,53 @@ +package com.nanri.aiimage.modules.convert.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.convert.model.dto.ConvertTemplateImportRequest; +import com.nanri.aiimage.modules.convert.model.vo.ConvertTemplateVo; +import com.nanri.aiimage.modules.convert.service.ConvertTemplateService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/convert/templates") +@Tag(name = "格式转换模板", description = "用于维护和查询格式转换模板,支持查询、导入、设默认、删除。") +public class ConvertTemplateController { + + private final ConvertTemplateService convertTemplateService; + + @GetMapping + @Operation(summary = "查询启用模板", description = "返回当前启用的格式转换模板列表,供前端‘格式转换’模块展示。") + public ApiResponse> listTemplates() { + return ApiResponse.success(convertTemplateService.listEnabledTemplates()); + } + + @PostMapping("/import") + @Operation(summary = "导入模板", description = "导入自定义 txt 模板,成功后返回新模板并可立即刷新模板列表。") + public ApiResponse importTemplate(@RequestBody ConvertTemplateImportRequest request) { + return ApiResponse.success(convertTemplateService.importTemplate(request)); + } + + @PostMapping("/{templateCode}/default") + @Operation(summary = "设为默认模板", description = "将指定模板设为默认模板。") + public ApiResponse setDefault(@PathVariable String templateCode) { + convertTemplateService.setDefaultTemplate(templateCode); + return ApiResponse.success(null); + } + + @DeleteMapping("/{templateCode}") + @Operation(summary = "删除模板", description = "删除指定模板。内置模板不允许删除。") + public ApiResponse delete(@PathVariable String templateCode) { + convertTemplateService.deleteTemplate(templateCode); + return ApiResponse.success(null); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/mapper/ConvertTemplateMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/mapper/ConvertTemplateMapper.java new file mode 100644 index 0000000..f47665c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/mapper/ConvertTemplateMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.convert.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.convert.model.entity.ConvertTemplateEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ConvertTemplateMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/ConvertRunRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/ConvertRunRequest.java new file mode 100644 index 0000000..5aecfa2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/ConvertRunRequest.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.convert.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "格式转换执行请求") +public class ConvertRunRequest { + + @Valid + @NotEmpty(message = "请先上传待转换文件") + @Schema(description = "已上传的源文件列表") + private List files; + + @NotBlank(message = "请选择模板") + @Schema(description = "模板编码,例如 uk_offer") + private String templateId; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/ConvertTemplateImportRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/ConvertTemplateImportRequest.java new file mode 100644 index 0000000..e07fb2f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/ConvertTemplateImportRequest.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.convert.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "格式转换模板导入请求") +public class ConvertTemplateImportRequest { + + @Schema(description = "模板显示名称") + private String templateName; + + @Schema(description = "模板文本内容") + private String templateContent; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/UploadedSourceFileDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/UploadedSourceFileDto.java new file mode 100644 index 0000000..f4205c6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/dto/UploadedSourceFileDto.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.convert.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "已上传源文件") +public class UploadedSourceFileDto { + + @NotBlank(message = "fileKey 不能为空") + @Schema(description = "上传接口返回的临时文件键") + private String fileKey; + + @Schema(description = "原始文件名") + private String originalFilename; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/entity/ConvertTemplateEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/entity/ConvertTemplateEntity.java new file mode 100644 index 0000000..0cb2be5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/entity/ConvertTemplateEntity.java @@ -0,0 +1,30 @@ +package com.nanri.aiimage.modules.convert.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_convert_template") +public class ConvertTemplateEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private String templateCode; + private String templateName; + private String outputFilename; + private String requiredSourceColumnsJson; + private String headerColumnsJson; + private String fieldMappingJson; + private String defaultsJson; + private String preambleLinesJson; + private Integer blankHeaderRowsAfterSchema; + private Integer isDefault; + private Integer builtIn; + private Integer enabled; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertHistoryVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertHistoryVo.java new file mode 100644 index 0000000..caf338e --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertHistoryVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.convert.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "格式转换历史列表") +public class ConvertHistoryVo { + + @Schema(description = "历史记录") + private List items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertResultItemVo.java new file mode 100644 index 0000000..a6f0393 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertResultItemVo.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.modules.convert.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "格式转换结果项") +public class ConvertResultItemVo { + + @Schema(description = "结果记录ID") + private Long resultId; + + @Schema(description = "源文件名") + private String sourceFilename; + + @Schema(description = "结果文件名") + private String outputFilename; + + @Schema(description = "是否成功") + private boolean success; + + @Schema(description = "错误信息") + private String error; + + @Schema(description = "下载地址") + private String downloadUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertRunVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertRunVo.java new file mode 100644 index 0000000..4215265 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertRunVo.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.convert.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "格式转换执行结果") +public class ConvertRunVo { + + @Schema(description = "处理文件总数") + private Integer total; + + @Schema(description = "成功文件数") + private Integer successCount; + + @Schema(description = "失败文件数") + private Integer failedCount; + + @Schema(description = "结果列表") + private List items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertTemplateVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertTemplateVo.java new file mode 100644 index 0000000..153fd41 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/model/vo/ConvertTemplateVo.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.modules.convert.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "格式转换模板") +public class ConvertTemplateVo { + + @Schema(description = "模板编码") + private String id; + + @Schema(description = "模板编码") + private String templateCode; + + @Schema(description = "模板名称") + private String templateName; + + @Schema(description = "输出文件名") + private String outputFilename; + + @Schema(description = "是否默认模板") + private Boolean isDefault; + + @Schema(description = "是否内置模板") + private Boolean builtIn; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java new file mode 100644 index 0000000..64e3d97 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertRunService.java @@ -0,0 +1,317 @@ +package com.nanri.aiimage.modules.convert.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.json.JSONUtil; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.StorageProperties; +import com.nanri.aiimage.modules.convert.model.dto.ConvertRunRequest; +import com.nanri.aiimage.modules.convert.model.dto.UploadedSourceFileDto; +import com.nanri.aiimage.modules.convert.model.entity.ConvertTemplateEntity; +import com.nanri.aiimage.modules.convert.model.vo.ConvertResultItemVo; +import com.nanri.aiimage.modules.convert.model.vo.ConvertRunVo; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; + +@Service +@RequiredArgsConstructor +public class ConvertRunService { + + private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; + private final StorageProperties storageProperties; + private final ConvertTemplateService convertTemplateService; + private final ObjectMapper objectMapper; + private final OssStorageService ossStorageService; + + public ConvertRunVo run(ConvertRunRequest request) { + ConvertTemplateEntity template = convertTemplateService.getByCode(request.getTemplateId()); + + FileTaskEntity task = new FileTaskEntity(); + task.setTaskNo("CONVERT-" + IdUtil.getSnowflakeNextIdStr()); + task.setModuleType("CONVERT"); + task.setTaskMode("IMMEDIATE"); + task.setStatus("SUCCESS"); + task.setSourceFileCount(request.getFiles().size()); + task.setCreatedBy("system"); + task.setRequestJson(JSONUtil.toJsonStr(request)); + task.setCreatedAt(LocalDateTime.now()); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.insert(task); + + List items = new ArrayList<>(); + int successCount = 0; + int failedCount = 0; + + for (UploadedSourceFileDto sourceFile : request.getFiles()) { + ConvertResultItemVo item = new ConvertResultItemVo(); + item.setSourceFilename(sourceFile.getOriginalFilename()); + try { + File inputFile = findLocalSourceFile(sourceFile.getFileKey()); + if (inputFile == null || !inputFile.exists()) { + throw new BusinessException("上传文件不存在,请重新上传"); + } + + String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename(); + String outputFilename = template.getOutputFilename() == null || template.getOutputFilename().isBlank() + ? "结果.txt" + : template.getOutputFilename(); + File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result")); + File outputFile = buildNamedOutputFile(outputDir, outputFilename); + + writeTxtByLegacyRules(inputFile, outputFile, template); + + String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "CONVERT"); + String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); + + item.setSuccess(true); + item.setOutputFilename(outputFilename); + item.setDownloadUrl(downloadUrl); + successCount++; + + FileResultEntity resultEntity = new FileResultEntity(); + resultEntity.setTaskId(task.getId()); + resultEntity.setModuleType("CONVERT"); + resultEntity.setSourceFilename(inputName); + resultEntity.setResultFilename(outputFilename); + resultEntity.setResultFileUrl(downloadUrl); + resultEntity.setResultFileSize(outputFile.length()); + resultEntity.setResultContentType("text/plain"); + resultEntity.setSuccess(1); + resultEntity.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(resultEntity); + item.setResultId(resultEntity.getId()); + } catch (Exception ex) { + item.setSuccess(false); + item.setError(ex.getMessage()); + failedCount++; + + FileResultEntity resultEntity = new FileResultEntity(); + resultEntity.setTaskId(task.getId()); + resultEntity.setModuleType("CONVERT"); + resultEntity.setSourceFilename(sourceFile.getOriginalFilename()); + resultEntity.setSuccess(0); + resultEntity.setErrorMessage(ex.getMessage()); + resultEntity.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(resultEntity); + } + items.add(item); + } + + task.setSuccessFileCount(successCount); + task.setFailedFileCount(failedCount); + task.setResultJson(JSONUtil.toJsonStr(items)); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + + ConvertRunVo vo = new ConvertRunVo(); + vo.setTotal(request.getFiles().size()); + vo.setSuccessCount(successCount); + vo.setFailedCount(failedCount); + vo.setItems(items); + return vo; + } + + public List listHistory() { + return fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, "CONVERT") + .eq(FileResultEntity::getSuccess, 1) + .orderByDesc(FileResultEntity::getCreatedAt) + .last("limit 50")) + .stream() + .map(entity -> { + ConvertResultItemVo vo = new ConvertResultItemVo(); + vo.setResultId(entity.getId()); + vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", "")); + vo.setOutputFilename(entity.getResultFilename()); + vo.setDownloadUrl(entity.getResultFileUrl()); + vo.setSuccess(true); + return vo; + }) + .toList(); + } + + public void deleteHistory(Long resultId) { + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || !"CONVERT".equals(entity.getModuleType())) { + throw new BusinessException("记录不存在"); + } + fileResultMapper.deleteById(resultId); + } + + public File getResultFile(Long resultId) { + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || entity.getResultFileUrl() == null || !"CONVERT".equals(entity.getModuleType())) { + throw new BusinessException("结果文件不存在"); + } + File file = new File(entity.getResultFileUrl()); + if (!file.exists()) { + throw new BusinessException("结果文件不存在"); + } + return file; + } + + private void writeTxtByLegacyRules(File inputFile, File outputFile, ConvertTemplateEntity templateEntity) throws IOException { + List requiredColumns = readStringList(templateEntity.getRequiredSourceColumnsJson()); + List headerColumns = readStringList(templateEntity.getHeaderColumnsJson()); + Map fieldMapping = readStringMap(templateEntity.getFieldMappingJson()); + Map defaults = readStringMap(templateEntity.getDefaultsJson()); + List preambleLines = readStringList(templateEntity.getPreambleLinesJson()); + int blankHeaderRowsAfterSchema = templateEntity.getBlankHeaderRowsAfterSchema() == null ? 0 : templateEntity.getBlankHeaderRowsAfterSchema(); + + DataFormatter formatter = new DataFormatter(); + try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) { + Sheet sheet = workbook.getSheetAt(0); + Row headerRow = sheet.getRow(0); + if (headerRow == null) { + throw new BusinessException("Excel 表头为空"); + } + + Map headerMap = new HashMap<>(); + for (int i = 0; i < headerRow.getLastCellNum(); i++) { + String value = normalizeCellText(formatter.formatCellValue(headerRow.getCell(i))); + if (!value.isBlank() && !headerMap.containsKey(value)) { + headerMap.put(value, i); + } + } + + List missing = requiredColumns.stream().filter(column -> !headerMap.containsKey(column)).toList(); + if (!missing.isEmpty()) { + throw new BusinessException("缺少列:" + String.join("、", missing)); + } + + List rows = new ArrayList<>(); + rows.addAll(preambleLines); + rows.add(String.join("\t", headerColumns)); + for (int i = 0; i < blankHeaderRowsAfterSchema; i++) { + rows.add("\t".repeat(Math.max(0, headerColumns.size() - 1))); + } + + long batchId = System.currentTimeMillis(); + int rowIndex = 1; + int asinIndex = headerMap.getOrDefault("ASIN", -1); + for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) { + Row row = sheet.getRow(rowNum); + if (row == null) { + continue; + } + String asin = asinIndex >= 0 ? normalizeCellText(formatter.formatCellValue(row.getCell(asinIndex))) : ""; + if (asin.isBlank()) { + continue; + } + + List lineValues = new ArrayList<>(); + for (String column : headerColumns) { + if ("sku".equals(column)) { + lineValues.add(batchId + "-" + rowIndex); + continue; + } + if (fieldMapping.containsKey(column)) { + String sourceColumn = fieldMapping.get(column); + Integer sourceIndex = headerMap.get(sourceColumn); + String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex))); + lineValues.add(value); + continue; + } + if (defaults.containsKey(column)) { + lineValues.add(defaults.get(column)); + continue; + } + lineValues.add(""); + } + rows.add(String.join("\t", lineValues)); + rowIndex++; + } + FileUtil.writeLines(rows, outputFile, StandardCharsets.UTF_8); + } + } + + private List readStringList(String json) { + try { + if (json == null || json.isBlank()) { + return new ArrayList<>(); + } + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception ex) { + throw new BusinessException("模板配置解析失败"); + } + } + + private Map readStringMap(String json) { + try { + if (json == null || json.isBlank()) { + return new HashMap<>(); + } + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception ex) { + throw new BusinessException("模板配置解析失败"); + } + } + + private File findLocalSourceFile(String fileKey) { + File baseDir = FileUtil.file(storageProperties.getLocalTempDir()); + if (!baseDir.exists()) { + return null; + } + List matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)); + return matchedFiles.isEmpty() ? null : matchedFiles.getFirst(); + } + + private File buildNamedOutputFile(File outputDir, String filename) { + File candidate = FileUtil.file(outputDir, filename); + if (!candidate.exists()) { + return candidate; + } + String mainName = FileUtil.mainName(filename); + String extName = FileUtil.extName(filename); + int index = 2; + while (true) { + String nextFilename = extName == null || extName.isBlank() + ? mainName + "_" + index + : mainName + "_" + index + "." + extName; + File nextCandidate = FileUtil.file(outputDir, nextFilename); + if (!nextCandidate.exists()) { + return nextCandidate; + } + index++; + } + } + + private String normalizeCellText(String value) { + if (value == null) { + return ""; + } + return value.replace("\ufeff", "") + .replace("\u3000", " ") + .replace("\r\n", " ") + .replace("\r", " ") + .replace("\n", " ") + .replace("\t", " ") + .trim() + .replaceAll("\\s+", " "); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateService.java new file mode 100644 index 0000000..778e720 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/service/ConvertTemplateService.java @@ -0,0 +1,99 @@ +package com.nanri.aiimage.modules.convert.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.convert.mapper.ConvertTemplateMapper; +import com.nanri.aiimage.modules.convert.model.dto.ConvertTemplateImportRequest; +import com.nanri.aiimage.modules.convert.model.entity.ConvertTemplateEntity; +import com.nanri.aiimage.modules.convert.model.vo.ConvertTemplateVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ConvertTemplateService { + + private final ConvertTemplateMapper convertTemplateMapper; + + public List listEnabledTemplates() { + return convertTemplateMapper.selectList(new LambdaQueryWrapper() + .eq(ConvertTemplateEntity::getEnabled, 1) + .orderByDesc(ConvertTemplateEntity::getIsDefault) + .orderByAsc(ConvertTemplateEntity::getId)) + .stream() + .map(this::toVo) + .toList(); + } + + public ConvertTemplateEntity getByCode(String templateCode) { + ConvertTemplateEntity entity = convertTemplateMapper.selectOne(new LambdaQueryWrapper() + .eq(ConvertTemplateEntity::getTemplateCode, templateCode) + .eq(ConvertTemplateEntity::getEnabled, 1) + .last("limit 1")); + if (entity == null) { + throw new BusinessException("模板不存在或已禁用"); + } + return entity; + } + + @Transactional + public ConvertTemplateVo importTemplate(ConvertTemplateImportRequest request) { + if (request.getTemplateContent() == null || request.getTemplateContent().isBlank()) { + throw new BusinessException("模板内容不能为空"); + } + ConvertTemplateEntity entity = new ConvertTemplateEntity(); + entity.setTemplateCode("user_" + System.currentTimeMillis()); + entity.setTemplateName((request.getTemplateName() == null || request.getTemplateName().isBlank()) ? "自定义 Offer 模板" : request.getTemplateName().trim()); + entity.setOutputFilename(entity.getTemplateName().endsWith(".txt") ? entity.getTemplateName() : entity.getTemplateName() + ".txt"); + entity.setRequiredSourceColumnsJson("[\"ASIN\",\"价格\"]"); + entity.setHeaderColumnsJson("[\"sku\",\"price\",\"quantity\",\"product-id\",\"product-id-type\",\"condition-type\",\"condition-note\",\"ASIN-hint\",\"title\",\"product-tax-code\",\"operation-type\",\"sale-price\",\"sale-start-date\",\"sale-end-date\",\"leadtime-to-ship\",\"launch-date\",\"is-giftwrap-available\",\"is-gift-message-available\"]"); + entity.setFieldMappingJson("{\"price\":\"价格\",\"product-id\":\"ASIN\"}"); + entity.setDefaultsJson("{\"quantity\":\"100\",\"product-id-type\":\"ASIN\",\"condition-type\":\"new\",\"leadtime-to-ship\":\"4\"}"); + entity.setPreambleLinesJson("[\"TemplateType=Offer\\tVersion=1.4\"]"); + entity.setBlankHeaderRowsAfterSchema(1); + entity.setIsDefault(0); + entity.setBuiltIn(0); + entity.setEnabled(1); + entity.setCreatedAt(LocalDateTime.now()); + entity.setUpdatedAt(LocalDateTime.now()); + convertTemplateMapper.insert(entity); + return toVo(entity); + } + + @Transactional + public void setDefaultTemplate(String templateCode) { + ConvertTemplateEntity entity = getByCode(templateCode); + convertTemplateMapper.selectList(new LambdaQueryWrapper().eq(ConvertTemplateEntity::getEnabled, 1)) + .forEach(item -> { + item.setIsDefault(item.getId().equals(entity.getId()) ? 1 : 0); + item.setUpdatedAt(LocalDateTime.now()); + convertTemplateMapper.updateById(item); + }); + } + + @Transactional + public void deleteTemplate(String templateCode) { + ConvertTemplateEntity entity = getByCode(templateCode); + if (Integer.valueOf(1).equals(entity.getBuiltIn())) { + throw new BusinessException("内置模板不允许删除"); + } + entity.setEnabled(0); + entity.setUpdatedAt(LocalDateTime.now()); + convertTemplateMapper.updateById(entity); + } + + private ConvertTemplateVo toVo(ConvertTemplateEntity entity) { + ConvertTemplateVo vo = new ConvertTemplateVo(); + vo.setId(entity.getTemplateCode()); + vo.setTemplateCode(entity.getTemplateCode()); + vo.setTemplateName(entity.getTemplateName()); + vo.setOutputFilename(entity.getOutputFilename()); + vo.setIsDefault(Integer.valueOf(1).equals(entity.getIsDefault())); + vo.setBuiltIn(Integer.valueOf(1).equals(entity.getBuiltIn())); + return vo; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java new file mode 100644 index 0000000..6cf2987 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java @@ -0,0 +1,74 @@ +package com.nanri.aiimage.modules.dedupe.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.dedupe.model.dto.DedupeRunRequest; +import com.nanri.aiimage.modules.dedupe.model.vo.DedupeHistoryVo; +import com.nanri.aiimage.modules.dedupe.model.vo.DedupeRunVo; +import com.nanri.aiimage.modules.dedupe.service.DedupeRunService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/dedupe") +@Tag(name = "数据去重", description = "按照旧 Python 桌面端规则执行列保留和 ID 过滤。桌面端交互不变,只将处理逻辑迁到 Java。") +public class DedupeRunController { + + private final DedupeRunService dedupeRunService; + + @PostMapping("/run") + @Operation( + summary = "执行数据去重", + description = """ + 根据上传文件、保留列、ID 保留规则执行数据去重,生成清洗后的 Excel 结果文件。 + + 规则与旧 Python 桌面端保持一致: + - 表头会先做文本清洗; + - 遇到“缩略图地址8”停止继续识别表头; + - 仅保留 selectedColumns 中指定的列; + - keepIntegerIds=true 时保留纯数字 ID; + - keepUnderscoreIds=true 时保留类似 1_1 的 ID; + - 输出文件命名遵循 原文件名_cleaned.xlsx,若重名自动追加序号。 + """) + public ApiResponse run(@Valid @RequestBody DedupeRunRequest request) { + return ApiResponse.success(dedupeRunService.run(request)); + } + + @GetMapping("/history") + @Operation(summary = "查询去重历史", description = "查询最近的去重成功记录,用于页面右侧历史下载列表展示。") + public ApiResponse history() { + DedupeHistoryVo vo = new DedupeHistoryVo(); + vo.setItems(dedupeRunService.listHistory()); + return ApiResponse.success(vo); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除去重历史", description = "删除一条去重历史记录。") + public ApiResponse deleteHistory(@PathVariable Long resultId) { + dedupeRunService.deleteHistory(resultId); + return ApiResponse.success(null); + } + + @GetMapping("/results/{resultId}/download") + @Operation(summary = "下载数据去重结果", description = "根据结果记录 ID 下载清洗后的 Excel 文件。") + public ResponseEntity download(@PathVariable Long resultId) { + Resource resource = dedupeRunService.getResultFile(resultId); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment") + .body(resource); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/dto/DedupeRunRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/dto/DedupeRunRequest.java new file mode 100644 index 0000000..63baff1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/dto/DedupeRunRequest.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.dedupe.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "数据去重执行请求") +public class DedupeRunRequest { + + @Valid + @NotEmpty(message = "请先上传待处理文件") + @Schema(description = "已上传源文件列表") + private List files; + + @NotEmpty(message = "请至少选择一列") + @Schema(description = "保留列") + private List selectedColumns; + + @Schema(description = "是否保留纯数字 ID") + private boolean keepIntegerIds = true; + + @Schema(description = "是否保留带下划线 ID") + private boolean keepUnderscoreIds = true; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/dto/DedupeSourceFileDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/dto/DedupeSourceFileDto.java new file mode 100644 index 0000000..48d1720 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/dto/DedupeSourceFileDto.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.dedupe.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "数据去重源文件") +public class DedupeSourceFileDto { + + @NotBlank(message = "fileKey 不能为空") + @Schema(description = "上传接口返回的临时文件键") + private String fileKey; + + @Schema(description = "原始文件名") + private String originalFilename; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeHistoryVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeHistoryVo.java new file mode 100644 index 0000000..6c1de57 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeHistoryVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.dedupe.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "数据去重历史列表") +public class DedupeHistoryVo { + + @Schema(description = "历史记录") + private List items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeResultItemVo.java new file mode 100644 index 0000000..bb1c4dd --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeResultItemVo.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.modules.dedupe.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "数据去重结果项") +public class DedupeResultItemVo { + + @Schema(description = "结果记录ID") + private Long resultId; + + @Schema(description = "源文件名") + private String sourceFilename; + + @Schema(description = "结果文件名") + private String outputFilename; + + @Schema(description = "是否成功") + private boolean success; + + @Schema(description = "错误信息") + private String error; + + @Schema(description = "下载地址") + private String downloadUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeRunVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeRunVo.java new file mode 100644 index 0000000..afd3191 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeRunVo.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.dedupe.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "数据去重执行结果") +public class DedupeRunVo { + + @Schema(description = "处理文件总数") + private Integer total; + + @Schema(description = "成功文件数") + private Integer successCount; + + @Schema(description = "失败文件数") + private Integer failedCount; + + @Schema(description = "结果列表") + private List items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java new file mode 100644 index 0000000..f2d8755 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java @@ -0,0 +1,303 @@ +package com.nanri.aiimage.modules.dedupe.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.json.JSONUtil; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.StorageProperties; +import com.nanri.aiimage.modules.dedupe.model.dto.DedupeRunRequest; +import com.nanri.aiimage.modules.dedupe.model.dto.DedupeSourceFileDto; +import com.nanri.aiimage.modules.dedupe.model.vo.DedupeResultItemVo; +import com.nanri.aiimage.modules.dedupe.model.vo.DedupeRunVo; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; + +@Service +@RequiredArgsConstructor +public class DedupeRunService { + + private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; + private final StorageProperties storageProperties; + private final OssStorageService ossStorageService; + + public DedupeRunVo run(DedupeRunRequest request) { + if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds()) { + throw new BusinessException("请至少选择一种 ID 保留规则"); + } + + FileTaskEntity task = new FileTaskEntity(); + task.setTaskNo("DEDUPE-" + IdUtil.getSnowflakeNextIdStr()); + task.setModuleType("DEDUPE"); + task.setTaskMode("IMMEDIATE"); + task.setStatus("SUCCESS"); + task.setSourceFileCount(request.getFiles().size()); + task.setCreatedBy("system"); + task.setRequestJson(JSONUtil.toJsonStr(request)); + task.setCreatedAt(LocalDateTime.now()); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.insert(task); + + List items = new ArrayList<>(); + int successCount = 0; + int failedCount = 0; + + for (DedupeSourceFileDto sourceFile : request.getFiles()) { + DedupeResultItemVo item = new DedupeResultItemVo(); + item.setSourceFilename(sourceFile.getOriginalFilename()); + try { + File inputFile = findLocalSourceFile(sourceFile.getFileKey()); + if (inputFile == null || !inputFile.exists()) { + throw new BusinessException("上传文件不存在,请重新上传"); + } + + String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename(); + String outputFilename = buildOutputFilename(inputName); + File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "dedupe-result")); + File outputFile = buildNamedOutputFile(outputDir, outputFilename); + + cleanExcelByLegacyRules(inputFile, outputFile, request.getSelectedColumns(), request.isKeepIntegerIds(), request.isKeepUnderscoreIds()); + + String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "DEDUPE"); + String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); + + item.setSuccess(true); + item.setOutputFilename(outputFile.getName()); + item.setDownloadUrl(downloadUrl); + successCount++; + + FileResultEntity resultEntity = new FileResultEntity(); + resultEntity.setTaskId(task.getId()); + resultEntity.setModuleType("DEDUPE"); + resultEntity.setSourceFilename(inputName); + resultEntity.setResultFilename(outputFile.getName()); + resultEntity.setResultFileUrl(downloadUrl); + resultEntity.setResultFileSize(outputFile.length()); + resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + resultEntity.setSuccess(1); + resultEntity.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(resultEntity); + } catch (Exception ex) { + item.setSuccess(false); + item.setError(ex.getMessage()); + failedCount++; + + FileResultEntity resultEntity = new FileResultEntity(); + resultEntity.setTaskId(task.getId()); + resultEntity.setModuleType("DEDUPE"); + resultEntity.setSourceFilename(sourceFile.getOriginalFilename()); + resultEntity.setSuccess(0); + resultEntity.setErrorMessage(ex.getMessage()); + resultEntity.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(resultEntity); + } + items.add(item); + } + + task.setSuccessFileCount(successCount); + task.setFailedFileCount(failedCount); + task.setResultJson(JSONUtil.toJsonStr(items)); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + + DedupeRunVo vo = new DedupeRunVo(); + vo.setTotal(request.getFiles().size()); + vo.setSuccessCount(successCount); + vo.setFailedCount(failedCount); + vo.setItems(items); + return vo; + } + + public List listHistory() { + return fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, "DEDUPE") + .eq(FileResultEntity::getSuccess, 1) + .orderByDesc(FileResultEntity::getCreatedAt) + .last("limit 50")) + .stream() + .map(entity -> { + DedupeResultItemVo vo = new DedupeResultItemVo(); + vo.setResultId(entity.getId()); + vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", "")); + vo.setOutputFilename(entity.getResultFilename()); + vo.setDownloadUrl(entity.getResultFileUrl()); + vo.setSuccess(true); + return vo; + }) + .toList(); + } + + public void deleteHistory(Long resultId) { + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || !"DEDUPE".equals(entity.getModuleType())) { + throw new BusinessException("记录不存在"); + } + fileResultMapper.deleteById(resultId); + } + + public Resource getResultFile(Long resultId) { + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType())) { + throw new BusinessException("结果文件不存在"); + } + File file = new File(entity.getResultFileUrl()); + if (!file.exists()) { + throw new BusinessException("结果文件不存在"); + } + return new FileSystemResource(file); + } + + private void cleanExcelByLegacyRules(File inputFile, File outputFile, List selectedColumns, + boolean keepIntegerIds, boolean keepUnderscoreIds) throws Exception { + DataFormatter formatter = new DataFormatter(); + try (FileInputStream fis = new FileInputStream(inputFile); + Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis); + XSSFWorkbook outputWorkbook = new XSSFWorkbook()) { + Sheet sheet = workbook.getSheetAt(0); + Row headerRow = sheet.getRow(0); + if (headerRow == null) { + throw new BusinessException("Excel 表头为空"); + } + + Map headerMap = new HashMap<>(); + for (int i = 0; i < headerRow.getLastCellNum(); i++) { + Cell cell = headerRow.getCell(i); + String value = normalizeCellText(cell == null ? null : formatter.formatCellValue(cell)); + if (value.isBlank()) { + continue; + } + value = value.split("idASIN国家状态价格变体数量", 2)[0].trim().isEmpty() + ? value + : value.split("idASIN国家状态价格变体数量", 2)[0].trim(); + if (headerMap.containsKey(value)) { + continue; + } + headerMap.put(value, i); + if ("缩略图地址8".equals(value)) { + break; + } + } + + List missing = selectedColumns.stream().filter(column -> !headerMap.containsKey(column)).toList(); + if (!missing.isEmpty()) { + throw new BusinessException("缺少列:" + String.join("、", missing)); + } + + Sheet outputSheet = outputWorkbook.createSheet(sheet.getSheetName()); + Row outputHeaderRow = outputSheet.createRow(0); + for (int i = 0; i < selectedColumns.size(); i++) { + outputHeaderRow.createCell(i).setCellValue(selectedColumns.get(i)); + } + + Integer idColumnIndex = headerMap.get("id"); + int outputRowIndex = 1; + for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) { + Row row = sheet.getRow(rowNum); + if (row == null) { + continue; + } + if (idColumnIndex != null) { + String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex))); + if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds)) { + continue; + } + } + + Row outputRow = outputSheet.createRow(outputRowIndex++); + for (int i = 0; i < selectedColumns.size(); i++) { + Integer sourceIndex = headerMap.get(selectedColumns.get(i)); + String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex))); + outputRow.createCell(i).setCellValue(value); + } + } + + try (FileOutputStream fos = new FileOutputStream(outputFile)) { + outputWorkbook.write(fos); + } + } + } + + private boolean shouldKeepId(String text, boolean keepIntegerIds, boolean keepUnderscoreIds) { + if (text == null || text.isBlank()) { + return false; + } + if (keepIntegerIds && text.matches("\\d+")) { + return true; + } + return keepUnderscoreIds && text.matches("\\d+_\\d+"); + } + + private File findLocalSourceFile(String fileKey) { + File baseDir = FileUtil.file(storageProperties.getLocalTempDir()); + if (!baseDir.exists()) { + return null; + } + List matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)); + return matchedFiles.isEmpty() ? null : matchedFiles.getFirst(); + } + + private File buildNamedOutputFile(File outputDir, String filename) { + File candidate = FileUtil.file(outputDir, filename); + if (!candidate.exists()) { + return candidate; + } + String mainName = FileUtil.mainName(filename); + String extName = FileUtil.extName(filename); + int index = 2; + while (true) { + String nextFilename = extName == null || extName.isBlank() + ? mainName + "_" + index + : mainName + "_" + index + "." + extName; + File nextCandidate = FileUtil.file(outputDir, nextFilename); + if (!nextCandidate.exists()) { + return nextCandidate; + } + index++; + } + } + + private String buildOutputFilename(String originalFilename) { + String mainName = originalFilename == null || originalFilename.isBlank() ? "source" : FileUtil.mainName(originalFilename); + String extName = originalFilename == null || originalFilename.isBlank() ? "xlsx" : FileUtil.extName(originalFilename); + return mainName + "_cleaned." + (extName == null || extName.isBlank() ? "xlsx" : extName); + } + + private String normalizeCellText(String value) { + if (value == null) { + return ""; + } + return value.replace("\ufeff", "") + .replace("\u3000", " ") + .replace("\r\n", " ") + .replace("\r", " ") + .replace("\n", " ") + .replace("\t", " ") + .trim() + .replaceAll("\\s+", " "); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java new file mode 100644 index 0000000..19d14a0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java @@ -0,0 +1,37 @@ +package com.nanri.aiimage.modules.file.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.file.model.vo.UploadFileVo; +import com.nanri.aiimage.modules.file.service.LocalFileStorageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/files") +@Tag(name = "文件上传", description = "用于前端将本地文件上传到后端临时目录,后续任务接口基于 fileKey 进行处理") +public class FileUploadController { + + private final LocalFileStorageService localFileStorageService; + + @PostMapping("/upload") + @Operation( + summary = "上传临时文件", + description = """ + 上传本地 Excel 文件到后端临时目录,返回 fileKey。 + + 该接口不是给浏览器页面直接使用的最终业务接口,而是给 Python 桌面端壳做文件中转: + - 桌面端先选本地文件; + - 再调用本接口上传到 Java 临时目录; + - 然后把 fileKey 传给 dedupe/convert/split 执行接口; + - 处理完成后,桌面端再调用下载接口把结果文件保存回用户本地目录。 + """) + public ApiResponse upload(MultipartFile file) throws Exception { + return ApiResponse.success(localFileStorageService.saveTempFile(file)); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/model/vo/UploadFileVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/model/vo/UploadFileVo.java new file mode 100644 index 0000000..de83fe0 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/model/vo/UploadFileVo.java @@ -0,0 +1,21 @@ +package com.nanri.aiimage.modules.file.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "上传文件返回信息") +public class UploadFileVo { + + @Schema(description = "临时文件键") + private String fileKey; + + @Schema(description = "原始文件名") + private String originalFilename; + + @Schema(description = "本地暂存路径") + private String localPath; + + @Schema(description = "文件大小") + private Long size; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java new file mode 100644 index 0000000..6236bff --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/LocalFileStorageService.java @@ -0,0 +1,39 @@ +package com.nanri.aiimage.modules.file.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import com.nanri.aiimage.config.StorageProperties; +import com.nanri.aiimage.modules.file.model.vo.UploadFileVo; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; + +@Service +@RequiredArgsConstructor +public class LocalFileStorageService { + + private final StorageProperties storageProperties; + + public UploadFileVo saveTempFile(MultipartFile file) throws IOException { + File tempDir = FileUtil.file(storageProperties.getLocalTempDir()); + File parentDir = tempDir.getParentFile(); + if (parentDir != null) { + FileUtil.mkdir(parentDir); + } + FileUtil.mkdir(tempDir); + String fileKey = IdUtil.fastSimpleUUID(); + String extName = FileUtil.extName(file.getOriginalFilename()); + File target = FileUtil.file(tempDir, fileKey + (extName.isEmpty() ? "" : "." + extName)); + file.transferTo(target); + + UploadFileVo vo = new UploadFileVo(); + vo.setFileKey(fileKey); + vo.setOriginalFilename(file.getOriginalFilename()); + vo.setLocalPath(target.getAbsolutePath()); + vo.setSize(file.getSize()); + return vo; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java new file mode 100644 index 0000000..bc75374 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java @@ -0,0 +1,49 @@ +package com.nanri.aiimage.modules.file.service.oss; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.nanri.aiimage.config.OssProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.net.URL; +import java.util.Date; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OssStorageService { + + private final OssProperties ossProperties; + + public String uploadResultFile(File file, String moduleType) { + String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName()); + OSS ossClient = new OSSClientBuilder().build( + "https://" + ossProperties.getEndpoint(), + ossProperties.getAccessKeyId(), + ossProperties.getAccessKeySecret() + ); + try { + ossClient.putObject(ossProperties.getBucket(), objectKey, file); + return objectKey; + } finally { + ossClient.shutdown(); + } + } + + public String generateDownloadUrl(String objectKey) { + OSS ossClient = new OSSClientBuilder().build( + "https://" + ossProperties.getEndpoint(), + ossProperties.getAccessKeyId(), + ossProperties.getAccessKeySecret() + ); + try { + Date expiration = new Date(System.currentTimeMillis() + 3600_000L); + URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration); + return url.toString(); + } finally { + ossClient.shutdown(); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java new file mode 100644 index 0000000..2057e1a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java @@ -0,0 +1,75 @@ +package com.nanri.aiimage.modules.split.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.split.model.dto.SplitRunRequest; +import com.nanri.aiimage.modules.split.model.vo.SplitHistoryVo; +import com.nanri.aiimage.modules.split.model.vo.SplitRunVo; +import com.nanri.aiimage.modules.split.service.SplitRunService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/split") +@Tag(name = "数据拆分", description = "按照旧 Python 桌面端规则执行数据拆分。桌面端交互不变,只将处理逻辑迁到 Java。") +public class SplitRunController { + + private final SplitRunService splitRunService; + + @PostMapping("/run") + @Operation( + summary = "执行数据拆分", + description = """ + 根据上传文件、保留列、拆分模式和参数执行 Excel 拆分。 + + 规则与旧 Python 桌面端保持一致: + - 表头做文本清洗并兼容历史脏串; + - 遇到“缩略图地址8”停止继续识别表头; + - 仅保留 selectedColumns 中指定的列; + - splitMode=rows_per_file 时按固定条数切分; + - splitMode=parts 时按总条数尽量均分; + - 输出文件命名遵循 原文件名_split_序号.xlsx; + - 返回结果中 rowCount 表示该结果文件包含的数据行数。 + """) + public ApiResponse run(@Valid @RequestBody SplitRunRequest request) { + return ApiResponse.success(splitRunService.run(request)); + } + + @GetMapping("/history") + @Operation(summary = "查询数据拆分历史", description = "查询最近的数据拆分成功记录,用于页面右侧历史下载列表展示。") + public ApiResponse history() { + SplitHistoryVo vo = new SplitHistoryVo(); + vo.setItems(splitRunService.listHistory()); + return ApiResponse.success(vo); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除数据拆分历史", description = "删除一条数据拆分历史记录。") + public ApiResponse deleteHistory(@PathVariable Long resultId) { + splitRunService.deleteHistory(resultId); + return ApiResponse.success(null); + } + + @GetMapping("/results/{resultId}/download") + @Operation(summary = "下载数据拆分结果", description = "根据结果记录 ID 下载拆分后的 Excel 文件。") + public ResponseEntity download(@PathVariable Long resultId) { + Resource resource = splitRunService.getResultFile(resultId); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment") + .body(resource); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/dto/SplitRunRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/dto/SplitRunRequest.java new file mode 100644 index 0000000..de8fefb --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/dto/SplitRunRequest.java @@ -0,0 +1,33 @@ +package com.nanri.aiimage.modules.split.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "数据拆分执行请求") +public class SplitRunRequest { + + @Valid + @NotEmpty(message = "请先上传待拆分文件") + @Schema(description = "已上传源文件列表") + private List files; + + @NotEmpty(message = "请至少选择一列") + @Schema(description = "保留列") + private List selectedColumns; + + @NotBlank(message = "拆分模式不能为空") + @Schema(description = "拆分模式:rows_per_file / parts") + private String splitMode; + + @Schema(description = "按每份条数拆分时的条数") + private Integer rowsPerFile; + + @Schema(description = "按份数拆分时的份数") + private Integer parts; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/dto/SplitSourceFileDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/dto/SplitSourceFileDto.java new file mode 100644 index 0000000..2316ba4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/dto/SplitSourceFileDto.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.split.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "数据拆分源文件") +public class SplitSourceFileDto { + + @NotBlank(message = "fileKey 不能为空") + @Schema(description = "上传接口返回的临时文件键") + private String fileKey; + + @Schema(description = "原始文件名") + private String originalFilename; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitHistoryVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitHistoryVo.java new file mode 100644 index 0000000..158808f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitHistoryVo.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.split.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "数据拆分历史列表") +public class SplitHistoryVo { + + @Schema(description = "历史记录") + private List items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitResultItemVo.java new file mode 100644 index 0000000..95e1c3d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitResultItemVo.java @@ -0,0 +1,30 @@ +package com.nanri.aiimage.modules.split.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "数据拆分结果项") +public class SplitResultItemVo { + + @Schema(description = "结果记录ID") + private Long resultId; + + @Schema(description = "源文件名") + private String sourceFilename; + + @Schema(description = "结果文件名") + private String outputFilename; + + @Schema(description = "是否成功") + private boolean success; + + @Schema(description = "错误信息") + private String error; + + @Schema(description = "结果行数") + private Integer rowCount; + + @Schema(description = "下载地址") + private String downloadUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitRunVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitRunVo.java new file mode 100644 index 0000000..613f30a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/model/vo/SplitRunVo.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.split.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +@Schema(description = "数据拆分执行结果") +public class SplitRunVo { + + @Schema(description = "处理文件总数") + private Integer total; + + @Schema(description = "成功文件数") + private Integer successCount; + + @Schema(description = "失败文件数") + private Integer failedCount; + + @Schema(description = "结果列表") + private List items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java new file mode 100644 index 0000000..555b546 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/service/SplitRunService.java @@ -0,0 +1,333 @@ +package com.nanri.aiimage.modules.split.service; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.json.JSONUtil; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.StorageProperties; +import com.nanri.aiimage.modules.split.model.dto.SplitRunRequest; +import com.nanri.aiimage.modules.split.model.dto.SplitSourceFileDto; +import com.nanri.aiimage.modules.split.model.vo.SplitResultItemVo; +import com.nanri.aiimage.modules.split.model.vo.SplitRunVo; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; + +@Service +@RequiredArgsConstructor +public class SplitRunService { + + private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; + private final StorageProperties storageProperties; + private final OssStorageService ossStorageService; + + public SplitRunVo run(SplitRunRequest request) { + validateRequest(request); + + FileTaskEntity task = new FileTaskEntity(); + task.setTaskNo("SPLIT-" + IdUtil.getSnowflakeNextIdStr()); + task.setModuleType("SPLIT"); + task.setTaskMode("IMMEDIATE"); + task.setStatus("SUCCESS"); + task.setSourceFileCount(request.getFiles().size()); + task.setCreatedBy("system"); + task.setRequestJson(JSONUtil.toJsonStr(request)); + task.setCreatedAt(LocalDateTime.now()); + task.setUpdatedAt(LocalDateTime.now()); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.insert(task); + + List items = new ArrayList<>(); + int successCount = 0; + int failedCount = 0; + + for (SplitSourceFileDto sourceFile : request.getFiles()) { + try { + File inputFile = findLocalSourceFile(sourceFile.getFileKey()); + if (inputFile == null || !inputFile.exists()) { + throw new BusinessException("上传文件不存在,请重新上传"); + } + String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename(); + List chunkResults = splitExcelByLegacyRules(inputFile, inputName, request); + for (SplitChunkResult chunkResult : chunkResults) { + SplitResultItemVo item = new SplitResultItemVo(); + item.setSourceFilename(inputName); + item.setOutputFilename(chunkResult.outputFile().getName()); + item.setSuccess(true); + item.setRowCount(chunkResult.rowCount()); + + String ossObjectKey = ossStorageService.uploadResultFile(chunkResult.outputFile(), "SPLIT"); + String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey); + + FileResultEntity resultEntity = new FileResultEntity(); + resultEntity.setTaskId(task.getId()); + resultEntity.setModuleType("SPLIT"); + resultEntity.setSourceFilename(inputName); + resultEntity.setResultFilename(chunkResult.outputFile().getName()); + resultEntity.setResultFileUrl(downloadUrl); + resultEntity.setResultFileSize(chunkResult.outputFile().length()); + resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + resultEntity.setRowCount(chunkResult.rowCount()); + resultEntity.setSuccess(1); + resultEntity.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(resultEntity); + item.setDownloadUrl(downloadUrl); + item.setResultId(resultEntity.getId()); + items.add(item); + } + successCount++; + } catch (Exception ex) { + SplitResultItemVo item = new SplitResultItemVo(); + item.setSourceFilename(sourceFile.getOriginalFilename()); + item.setSuccess(false); + item.setError(ex.getMessage()); + items.add(item); + failedCount++; + + FileResultEntity resultEntity = new FileResultEntity(); + resultEntity.setTaskId(task.getId()); + resultEntity.setModuleType("SPLIT"); + resultEntity.setSourceFilename(sourceFile.getOriginalFilename()); + resultEntity.setSuccess(0); + resultEntity.setErrorMessage(ex.getMessage()); + resultEntity.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(resultEntity); + } + } + + task.setSuccessFileCount(successCount); + task.setFailedFileCount(failedCount); + task.setResultJson(JSONUtil.toJsonStr(items)); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + + SplitRunVo vo = new SplitRunVo(); + vo.setTotal(request.getFiles().size()); + vo.setSuccessCount(successCount); + vo.setFailedCount(failedCount); + vo.setItems(items); + return vo; + } + + public List listHistory() { + return fileResultMapper.selectList(new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, "SPLIT") + .eq(FileResultEntity::getSuccess, 1) + .orderByDesc(FileResultEntity::getCreatedAt) + .last("limit 100")) + .stream() + .map(entity -> { + SplitResultItemVo vo = new SplitResultItemVo(); + vo.setResultId(entity.getId()); + vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", "")); + vo.setOutputFilename(entity.getResultFilename()); + vo.setDownloadUrl(entity.getResultFileUrl()); + vo.setRowCount(entity.getRowCount()); + vo.setSuccess(true); + return vo; + }) + .toList(); + } + + public void deleteHistory(Long resultId) { + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || !"SPLIT".equals(entity.getModuleType())) { + throw new BusinessException("记录不存在"); + } + fileResultMapper.deleteById(resultId); + } + + public Resource getResultFile(Long resultId) { + FileResultEntity entity = fileResultMapper.selectById(resultId); + if (entity == null || entity.getResultFileUrl() == null || !"SPLIT".equals(entity.getModuleType())) { + throw new BusinessException("结果文件不存在"); + } + File file = new File(entity.getResultFileUrl()); + if (!file.exists()) { + throw new BusinessException("结果文件不存在"); + } + return new FileSystemResource(file); + } + + private List splitExcelByLegacyRules(File inputFile, String originalFilename, SplitRunRequest request) throws Exception { + DataFormatter formatter = new DataFormatter(); + try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) { + Sheet sheet = workbook.getSheetAt(0); + Row headerRow = sheet.getRow(0); + if (headerRow == null) { + throw new BusinessException("Excel 表头为空"); + } + + Map headerMap = new HashMap<>(); + for (int i = 0; i < headerRow.getLastCellNum(); i++) { + Cell cell = headerRow.getCell(i); + String value = normalizeCellText(cell == null ? null : formatter.formatCellValue(cell)); + if (value.isBlank()) { + continue; + } + value = value.split("idASIN国家状态价格变体数量", 2)[0].trim().isEmpty() + ? value + : value.split("idASIN国家状态价格变体数量", 2)[0].trim(); + if (headerMap.containsKey(value)) { + continue; + } + headerMap.put(value, i); + if ("缩略图地址8".equals(value)) { + break; + } + } + + List missing = request.getSelectedColumns().stream().filter(column -> !headerMap.containsKey(column)).toList(); + if (!missing.isEmpty()) { + throw new BusinessException("缺少列:" + String.join("、", missing)); + } + + List> rowsData = new ArrayList<>(); + for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) { + Row row = sheet.getRow(rowNum); + if (row == null) { + continue; + } + List projected = new ArrayList<>(); + for (String column : request.getSelectedColumns()) { + Integer sourceIndex = headerMap.get(column); + String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex))); + projected.add(value); + } + rowsData.add(projected); + } + + int totalRows = rowsData.size(); + if (totalRows == 0) { + throw new BusinessException("没有可拆分的数据行"); + } + + List>> chunks = new ArrayList<>(); + if ("rows_per_file".equals(request.getSplitMode())) { + int chunkSize = request.getRowsPerFile(); + for (int i = 0; i < totalRows; i += chunkSize) { + chunks.add(rowsData.subList(i, Math.min(i + chunkSize, totalRows))); + } + } else { + int actualParts = Math.min(request.getParts(), totalRows); + int base = totalRows / actualParts; + int remainder = totalRows % actualParts; + int start = 0; + for (int i = 0; i < actualParts; i++) { + int size = base + (i < remainder ? 1 : 0); + int end = start + size; + chunks.add(rowsData.subList(start, end)); + start = end; + } + } + + File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "split-result")); + String sourceStem = FileUtil.mainName(originalFilename); + List results = new ArrayList<>(); + for (int i = 0; i < chunks.size(); i++) { + File outputFile = buildNamedOutputFile(outputDir, sourceStem + "_split_" + (i + 1) + ".xlsx"); + try (XSSFWorkbook outputWorkbook = new XSSFWorkbook()) { + Sheet outputSheet = outputWorkbook.createSheet(sheet.getSheetName()); + Row outputHeaderRow = outputSheet.createRow(0); + for (int colIndex = 0; colIndex < request.getSelectedColumns().size(); colIndex++) { + outputHeaderRow.createCell(colIndex).setCellValue(request.getSelectedColumns().get(colIndex)); + } + int outputRowIndex = 1; + for (List rowValues : chunks.get(i)) { + Row outputRow = outputSheet.createRow(outputRowIndex++); + for (int colIndex = 0; colIndex < rowValues.size(); colIndex++) { + outputRow.createCell(colIndex).setCellValue(rowValues.get(colIndex)); + } + } + try (FileOutputStream fos = new FileOutputStream(outputFile)) { + outputWorkbook.write(fos); + } + } + results.add(new SplitChunkResult(outputFile, chunks.get(i).size())); + } + return results; + } + } + + private void validateRequest(SplitRunRequest request) { + if (!List.of("rows_per_file", "parts").contains(request.getSplitMode())) { + throw new BusinessException("拆分模式不合法"); + } + if ("rows_per_file".equals(request.getSplitMode()) && (request.getRowsPerFile() == null || request.getRowsPerFile() <= 0)) { + throw new BusinessException("每份条数不合法"); + } + if ("parts".equals(request.getSplitMode()) && (request.getParts() == null || request.getParts() <= 0)) { + throw new BusinessException("拆分份数不合法"); + } + } + + private File findLocalSourceFile(String fileKey) { + File baseDir = FileUtil.file(storageProperties.getLocalTempDir()); + if (!baseDir.exists()) { + return null; + } + List matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)); + return matchedFiles.isEmpty() ? null : matchedFiles.getFirst(); + } + + private File buildNamedOutputFile(File outputDir, String filename) { + File candidate = FileUtil.file(outputDir, filename); + if (!candidate.exists()) { + return candidate; + } + String mainName = FileUtil.mainName(filename); + String extName = FileUtil.extName(filename); + int index = 2; + while (true) { + String nextFilename = extName == null || extName.isBlank() + ? mainName + "_" + index + : mainName + "_" + index + "." + extName; + File nextCandidate = FileUtil.file(outputDir, nextFilename); + if (!nextCandidate.exists()) { + return nextCandidate; + } + index++; + } + } + + private String normalizeCellText(String value) { + if (value == null) { + return ""; + } + return value.replace("\ufeff", "") + .replace("\u3000", " ") + .replace("\r\n", " ") + .replace("\r", " ") + .replace("\n", " ") + .replace("\t", " ") + .trim() + .replaceAll("\\s+", " "); + } + + private record SplitChunkResult(File outputFile, int rowCount) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileResultMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileResultMapper.java new file mode 100644 index 0000000..8df360d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileResultMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FileResultMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileTaskMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileTaskMapper.java new file mode 100644 index 0000000..7c3c133 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/mapper/FileTaskMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.task.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FileTaskMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileResultEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileResultEntity.java new file mode 100644 index 0000000..e6c8359 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileResultEntity.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.task.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_file_result") +public class FileResultEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private Long taskId; + private String moduleType; + private String sourceFilename; + private String sourceFileUrl; + private String resultFilename; + private String resultFileUrl; + private Long resultFileSize; + private String resultContentType; + private Integer rowCount; + private Integer success; + private String errorMessage; + private LocalDateTime createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileTaskEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileTaskEntity.java new file mode 100644 index 0000000..ae3fdc1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/FileTaskEntity.java @@ -0,0 +1,30 @@ +package com.nanri.aiimage.modules.task.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("biz_file_task") +public class FileTaskEntity { + + @TableId(type = IdType.AUTO) + private Long id; + private String taskNo; + private String moduleType; + private String taskMode; + private String status; + private Integer sourceFileCount; + private Integer successFileCount; + private Integer failedFileCount; + private String requestJson; + private String resultJson; + private String errorMessage; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private LocalDateTime finishedAt; +} diff --git a/backend-java/src/main/resources/application-local.example.yml b/backend-java/src/main/resources/application-local.example.yml new file mode 100644 index 0000000..274c75c --- /dev/null +++ b/backend-java/src/main/resources/application-local.example.yml @@ -0,0 +1,16 @@ +# 这个文件只是给你对照看的“环境变量配置示例”,不是 Spring Boot 自动加载的配置文件。 +# 你现在最简单的做法:把下面这些键值复制到 IDEA 的 Run/Debug Configuration -> Environment variables。 + +AIIMAGE_SERVER_PORT=18080 + +AIIMAGE_DB_URL=jdbc:mysql://159.75.121.33:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false +AIIMAGE_DB_USERNAME=AIimage +AIIMAGE_DB_PASSWORD=WTFrb5y6hNLz6hNy + +AIIMAGE_OSS_REGION=cn-hangzhou +AIIMAGE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +AIIMAGE_OSS_BUCKET=nanri-ai-images +AIIMAGE_OSS_ACCESS_KEY_ID=LTAI5tNpyvzMNz9f2dHarsm8 +AIIMAGE_OSS_ACCESS_KEY_SECRET=bQSZnFH455i8tzyOgeahJmUzwmhynz + +AIIMAGE_STORAGE_LOCAL_TEMP_DIR=F:/project/crawler-plugin/backend-java/data/tmp diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml new file mode 100644 index 0000000..362a417 --- /dev/null +++ b/backend-java/src/main/resources/application.yml @@ -0,0 +1,49 @@ +server: + port: ${AIIMAGE_SERVER_PORT:18080} + +spring: + application: + name: aiimage-backend + config: + import: optional:classpath:application-local.yml + servlet: + multipart: + max-file-size: 200MB + max-request-size: 500MB + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${AIIMAGE_DB_URL:jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false} + username: ${AIIMAGE_DB_USERNAME:root} + password: ${AIIMAGE_DB_PASSWORD:change-me} + +management: + health: + db: + enabled: false + +mybatis-plus: + mapper-locations: classpath*:mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + +springdoc: + api-docs: + enabled: true + swagger-ui: + path: /swagger-ui.html + +knife4j: + enable: true + setting: + language: zh_cn + +aiimage: + oss: + region: ${AIIMAGE_OSS_REGION:cn-hangzhou} + endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} + bucket: ${AIIMAGE_OSS_BUCKET:change-me} + access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:change-me} + access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:change-me} + storage: + local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp} diff --git a/backend-java/src/main/resources/db/V1__init.sql b/backend-java/src/main/resources/db/V1__init.sql new file mode 100644 index 0000000..5b3efb0 --- /dev/null +++ b/backend-java/src/main/resources/db/V1__init.sql @@ -0,0 +1,76 @@ +CREATE TABLE IF NOT EXISTS biz_file_task ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + task_no VARCHAR(64) NOT NULL COMMENT '任务编号', + module_type VARCHAR(32) NOT NULL COMMENT '模块类型:DEDUPE/CONVERT/SPLIT', + task_mode VARCHAR(32) DEFAULT 'IMMEDIATE' COMMENT '任务模式:IMMEDIATE/TASK', + status VARCHAR(32) NOT NULL COMMENT '任务状态:PENDING/RUNNING/SUCCESS/FAILED/CANCELLED', + source_file_count INT DEFAULT 0 COMMENT '源文件数量', + success_file_count INT DEFAULT 0 COMMENT '成功文件数量', + failed_file_count INT DEFAULT 0 COMMENT '失败文件数量', + request_json JSON NULL COMMENT '请求参数快照', + result_json JSON NULL COMMENT '结果快照', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + created_by VARCHAR(64) DEFAULT 'system' COMMENT '创建人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + finished_at DATETIME NULL COMMENT '完成时间', + UNIQUE KEY uk_task_no (task_no), + KEY idx_module_status (module_type, status), + KEY idx_created_at (created_at) +) COMMENT='文件处理任务表'; + +CREATE TABLE IF NOT EXISTS biz_file_result ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + task_id BIGINT NOT NULL COMMENT '任务ID', + module_type VARCHAR(32) NOT NULL COMMENT '模块类型:DEDUPE/CONVERT/SPLIT', + source_filename VARCHAR(255) NULL COMMENT '源文件名', + source_file_url VARCHAR(1000) NULL COMMENT '源文件地址', + result_filename VARCHAR(255) NULL COMMENT '结果文件名', + result_file_url VARCHAR(1000) NULL COMMENT '结果文件地址', + result_file_size BIGINT NULL COMMENT '结果文件大小', + result_content_type VARCHAR(128) NULL COMMENT '结果文件类型', + row_count INT NULL COMMENT '结果行数', + success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功', + error_message VARCHAR(1000) NULL COMMENT '错误信息', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + KEY idx_task_id (task_id), + KEY idx_module_type (module_type) +) COMMENT='文件处理结果表'; + +CREATE TABLE IF NOT EXISTS biz_convert_template ( + id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', + template_code VARCHAR(64) NOT NULL COMMENT '模板编码', + template_name VARCHAR(128) NOT NULL COMMENT '模板名称', + output_filename VARCHAR(255) NOT NULL COMMENT '输出文件名', + required_source_columns_json JSON NOT NULL COMMENT '源文件必需列', + header_columns_json JSON NOT NULL COMMENT '输出表头列', + field_mapping_json JSON NOT NULL COMMENT '源字段映射', + defaults_json JSON NULL COMMENT '默认值配置', + preamble_lines_json JSON NULL COMMENT '前导说明行', + blank_header_rows_after_schema INT DEFAULT 0 COMMENT '表头后空白行数', + is_default TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认模板', + built_in TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否内置模板', + enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_template_code (template_code) +) COMMENT='格式转换模板表'; + +INSERT INTO biz_convert_template ( + template_code, template_name, output_filename, + required_source_columns_json, header_columns_json, field_mapping_json, + defaults_json, preamble_lines_json, blank_header_rows_after_schema, + is_default, built_in, enabled +) +SELECT + 'uk_offer', '英国模板', '英国.txt', + JSON_ARRAY('ASIN', '价格'), + JSON_ARRAY('sku', 'price', 'quantity', 'product-id', 'product-id-type', 'condition-type', 'condition-note', 'ASIN-hint', 'title', 'product-tax-code', 'operation-type', 'sale-price', 'sale-start-date', 'sale-end-date', 'leadtime-to-ship', 'launch-date', 'is-giftwrap-available', 'is-gift-message-available'), + JSON_OBJECT('price', '价格', 'product-id', 'ASIN'), + JSON_OBJECT('quantity', '100', 'product-id-type', 'ASIN', 'condition-type', 'new', 'leadtime-to-ship', '4'), + JSON_ARRAY('TemplateType=Offer\tVersion=1.4'), + 1, + 1, 1, 1 +WHERE NOT EXISTS ( + SELECT 1 FROM biz_convert_template WHERE template_code = 'uk_offer' +); diff --git a/backend-java/src/main/resources/db/V2__sync_uk_offer_template.sql b/backend-java/src/main/resources/db/V2__sync_uk_offer_template.sql new file mode 100644 index 0000000..b8395a8 --- /dev/null +++ b/backend-java/src/main/resources/db/V2__sync_uk_offer_template.sql @@ -0,0 +1,17 @@ +UPDATE biz_convert_template +SET required_source_columns_json = JSON_ARRAY('ASIN', '价格'), + header_columns_json = JSON_ARRAY( + 'sku', 'price', 'quantity', 'product-id', 'product-id-type', 'condition-type', 'condition-note', + 'ASIN-hint', 'title', 'product-tax-code', 'operation-type', 'sale-price', 'sale-start-date', + 'sale-end-date', 'leadtime-to-ship', 'launch-date', 'is-giftwrap-available', 'is-gift-message-available' + ), + field_mapping_json = JSON_OBJECT('price', '价格', 'product-id', 'ASIN'), + defaults_json = JSON_OBJECT('quantity', '100', 'product-id-type', 'ASIN', 'condition-type', 'new', 'leadtime-to-ship', '4'), + preamble_lines_json = JSON_ARRAY('TemplateType=Offer\tVersion=1.4'), + blank_header_rows_after_schema = 1, + output_filename = '英国.txt', + template_name = '英国模板', + built_in = 1, + enabled = 1, + updated_at = CURRENT_TIMESTAMP +WHERE template_code = 'uk_offer'; diff --git a/desktop/__pycache__/app.cpython-312.pyc b/desktop/__pycache__/app.cpython-312.pyc index ba952b6961a76c74150af981ddcfbac5f71bcbe2..6ec347af519426930e271e6cf7b65bb61a2d5284 100644 GIT binary patch delta 18310 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 delta 14470 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 FKLL(n7OemP diff --git a/desktop/app.py b/desktop/app.py index e2b1c52..b05d9d8 100644 --- a/desktop/app.py +++ b/desktop/app.py @@ -250,8 +250,8 @@ class DesktopConfig: class DesktopApi: - def __init__(self, app_base_url: str) -> None: - self.app_base_url = app_base_url.rstrip("/") + def __init__(self, backend_base_url: str) -> None: + self.app_base_url = backend_base_url.rstrip("/") self.session = requests.Session() def select_brand_xlsx_files(self) -> list[str]: @@ -347,17 +347,38 @@ class DesktopApi: return {"success": False, "error": str(exc)} def list_txt_templates(self) -> list[dict]: - templates, _default_template_id = build_effective_templates() - return [ - { - "id": template["id"], - "label": template["label"], - "output_filename": template["output_filename"], - "is_default": template.get("is_default", False), - "built_in": template.get("built_in", False), - } - for template in templates.values() - ] + try: + response = self.session.get( + resolve_url(self.app_base_url, "/api/convert/templates"), + timeout=60, + ) + response.raise_for_status() + payload = response.json() + if not payload.get("success"): + return [] + items = payload.get("data") or [] + return [ + { + "id": str(item.get("templateCode") or item.get("id") or ""), + "label": item.get("templateName") or "", + "output_filename": item.get("outputFilename") or "", + "is_default": bool(item.get("isDefault")), + "built_in": bool(item.get("builtIn")), + } + for item in items + ] + except Exception: + templates, _default_template_id = build_effective_templates() + return [ + { + "id": template["id"], + "label": template["label"], + "output_filename": template["output_filename"], + "is_default": template.get("is_default", False), + "built_in": template.get("built_in", False), + } + for template in templates.values() + ] def import_txt_template(self, name: str | None = None) -> dict: file_path = run_file_dialog( @@ -370,39 +391,60 @@ class DesktopApi: return {"success": False, "error": "用户取消"} try: - template_text = Path(file_path).read_text(encoding="utf-8") - except UnicodeDecodeError: - template_text = Path(file_path).read_text(encoding="utf-8-sig") + try: + template_text = Path(file_path).read_text(encoding="utf-8") + except UnicodeDecodeError: + template_text = Path(file_path).read_text(encoding="utf-8-sig") + + response = self.session.post( + resolve_url(self.app_base_url, "/api/convert/templates/import"), + json={ + "templateName": (name or "").strip() or Path(file_path).stem, + "templateContent": template_text, + }, + timeout=120, + ) + response.raise_for_status() + payload = response.json() + if not payload.get("success") or not payload.get("data"): + return {"success": False, "error": payload.get("message") or "导入失败"} + item = payload.get("data") or {} + return { + "success": True, + "template": { + "id": str(item.get("templateCode") or item.get("id") or ""), + "label": item.get("templateName") or "", + "output_filename": item.get("outputFilename") or "", + "is_default": bool(item.get("isDefault")), + "built_in": bool(item.get("builtIn")), + }, + } except Exception as exc: # noqa: BLE001 return {"success": False, "error": str(exc)} - template = recognize_txt_template(template_text, (name or "").strip() or None) - if not template: - return {"success": False, "error": "无法识别模板"} - - user_templates, default_template_id = load_user_templates() - user_templates[template["id"]] = template - save_user_templates(user_templates, default_template_id) - - return { - "success": True, - "template": { - "id": template["id"], - "label": template["label"], - "output_filename": template["output_filename"], - "is_default": False, - "built_in": False, - }, - } - def set_default_txt_template(self, template_id: str) -> dict: - templates, _default_template_id = build_effective_templates() - if template_id not in templates: - return {"success": False, "error": "模板不存在"} + try: + response = self.session.post( + resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}/default"), + timeout=60, + ) + response.raise_for_status() + payload = response.json() + return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} - user_templates, _ = load_user_templates() - save_user_templates(user_templates, template_id) - return {"success": True} + def delete_txt_template(self, template_id: str) -> dict: + try: + response = self.session.delete( + resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}"), + timeout=60, + ) + response.raise_for_status() + payload = response.json() + return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} def split_excel_files(self, payload: dict) -> dict: paths = payload.get("paths") or [] @@ -410,7 +452,6 @@ class DesktopApi: split_mode = payload.get("split_mode") or "rows_per_file" rows_per_file = payload.get("rows_per_file") parts = payload.get("parts") - output_dir = payload.get("output_dir") or "" if not isinstance(paths, list) or not paths: return {"success": False, "error": "未选择待拆分文件"} @@ -422,128 +463,171 @@ class DesktopApi: return {"success": False, "error": "每份条数不合法"} if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0): return {"success": False, "error": "拆分份数不合法"} - if not output_dir: - return {"success": False, "error": "未选择输出目录"} - output_dir_path = Path(output_dir) - if not output_dir_path.exists() or not output_dir_path.is_dir(): - return {"success": False, "error": "输出目录不存在"} - - items: list[dict] = [] - success_count = 0 - failed_count = 0 - - for path in paths: - try: - workbook = load_workbook(path) - sheet = workbook[workbook.sheetnames[0]] - header_cells = next(sheet.iter_rows(min_row=1, max_row=1)) - header_map: dict[str, int] = {} - for index, cell in enumerate(header_cells): - value = normalize_cell_text(cell.value) - if not value: - continue - value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value - if value in header_map: - continue - header_map[value] = index - if value == "缩略图地址8": - break - - missing = [column for column in selected_columns if column not in header_map] - if missing: - workbook.close() - items.append({ - "source_path": path, - "success": False, - "error": f"缺少列:{'、'.join(missing)}", - }) - failed_count += 1 - continue - - rows_data: list[list[str]] = [] - for row in sheet.iter_rows(min_row=2, values_only=True): - projected = [ - normalize_cell_text(row[header_map[column]]) if header_map[column] < len(row) else "" - for column in selected_columns - ] - rows_data.append(projected) - - total_rows = len(rows_data) - if total_rows == 0: - workbook.close() - items.append({ - "source_path": path, - "success": False, - "error": "没有可拆分的数据行", - }) - failed_count += 1 - continue - - if split_mode == "rows_per_file": - chunk_size = rows_per_file - chunks = [rows_data[i : i + chunk_size] for i in range(0, total_rows, chunk_size)] - else: - actual_parts = min(parts, total_rows) - base = total_rows // actual_parts - remainder = total_rows % actual_parts - chunks = [] - start = 0 - for idx in range(actual_parts): - size = base + (1 if idx < remainder else 0) - end = start + size - chunks.append(rows_data[start:end]) - start = end - - from openpyxl import Workbook - - source = Path(path) - for index, chunk in enumerate(chunks, start=1): - output_workbook = Workbook() - output_sheet = output_workbook.active - output_sheet.title = sheet.title - output_sheet.append(selected_columns) - for row_values in chunk: - output_sheet.append(row_values) - - output_path = build_named_output_path(output_dir, f"{source.stem}_split_{index}.xlsx") - output_workbook.save(output_path) - output_workbook.close() - - items.append({ - "source_path": path, - "output_path": output_path, - "success": True, - "row_count": len(chunk), - }) - - workbook.close() - success_count += 1 - except Exception as exc: # noqa: BLE001 - items.append({ - "source_path": path, - "success": False, - "error": str(exc), + try: + uploaded_files = [] + for path in paths: + with open(path, "rb") as file_obj: + response = self.session.post( + resolve_url(self.app_base_url, "/api/files/upload"), + files={"file": (Path(path).name, file_obj)}, + timeout=180, + ) + response.raise_for_status() + upload_payload = response.json() + if not upload_payload.get("success") or not upload_payload.get("data"): + return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} + upload_data = upload_payload.get("data") or {} + uploaded_files.append({ + "fileKey": upload_data.get("fileKey"), + "originalFilename": upload_data.get("originalFilename") or Path(path).name, }) - failed_count += 1 - return { - "success": True, - "summary": { - "total": len(paths), - "success_count": success_count, - "failed_count": failed_count, - "output_dir": str(output_dir_path), - }, - "items": items, - } + run_response = self.session.post( + resolve_url(self.app_base_url, "/api/split/run"), + json={ + "files": uploaded_files, + "selectedColumns": selected_columns, + "splitMode": split_mode, + "rowsPerFile": rows_per_file, + "parts": parts, + }, + timeout=600, + ) + run_response.raise_for_status() + run_payload = run_response.json() + if not run_payload.get("success") or not run_payload.get("data"): + return {"success": False, "error": run_payload.get("message") or "数据拆分失败"} + + data = run_payload.get("data") or {} + result_items = [] + for item in data.get("items") or []: + if item.get("success") and item.get("downloadUrl"): + result_items.append({ + "result_id": item.get("resultId"), + "source_path": item.get("sourceFilename") or "", + "output_path": item.get("downloadUrl") or "", + "success": True, + "row_count": item.get("rowCount"), + }) + else: + result_items.append({ + "source_path": item.get("sourceFilename") or "", + "success": False, + "error": item.get("error") or "数据拆分失败", + }) + + return { + "success": True, + "summary": { + "total": data.get("total") or len(paths), + "success_count": data.get("successCount") or 0, + "failed_count": data.get("failedCount") or 0, + }, + "items": result_items, + } + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} + + def delete_history_item(self, module_type: str, result_id: int) -> dict: + try: + response = self.session.delete( + resolve_url(self.app_base_url, f"/api/{module_type}/history/{result_id}"), + timeout=60, + ) + response.raise_for_status() + payload = response.json() + return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None} + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} + + def get_convert_history(self) -> dict: + try: + response = self.session.get( + resolve_url(self.app_base_url, "/api/convert/history"), + timeout=60, + ) + response.raise_for_status() + payload = response.json() + if not payload.get("success") or not payload.get("data"): + return {"success": False, "error": payload.get("message") or "获取历史失败"} + + items = payload.get("data", {}).get("items") or [] + return { + "success": True, + "items": [ + { + "result_id": item.get("resultId"), + "source_path": item.get("sourceFilename") or "", + "output_path": item.get("downloadUrl") or "", + "success": bool(item.get("success", True)), + } + for item in items + ], + } + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} + + def get_split_history(self) -> dict: + try: + response = self.session.get( + resolve_url(self.app_base_url, "/api/split/history"), + timeout=60, + ) + response.raise_for_status() + payload = response.json() + if not payload.get("success") or not payload.get("data"): + return {"success": False, "error": payload.get("message") or "获取历史失败"} + + items = payload.get("data", {}).get("items") or [] + return { + "success": True, + "items": [ + { + "result_id": item.get("resultId"), + "source_path": item.get("sourceFilename") or "", + "output_path": item.get("downloadUrl") or "", + "success": bool(item.get("success", True)), + "row_count": item.get("rowCount"), + } + for item in items + ], + } + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} + + def get_dedupe_history(self) -> dict: + try: + response = self.session.get( + resolve_url(self.app_base_url, "/api/dedupe/history"), + timeout=60, + ) + response.raise_for_status() + payload = response.json() + if not payload.get("success") or not payload.get("data"): + return {"success": False, "error": payload.get("message") or "获取历史失败"} + + items = payload.get("data", {}).get("items") or [] + return { + "success": True, + "items": [ + { + "result_id": item.get("resultId"), + "source_path": item.get("sourceFilename") or "", + "output_path": item.get("downloadUrl") or "", + "success": bool(item.get("success", True)), + } + for item in items + ], + } + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} def clean_excel_files(self, payload: dict) -> dict: paths = payload.get("paths") or [] selected_columns = payload.get("selected_columns") or [] keep_integer_ids = bool(payload.get("keep_integer_ids", True)) keep_underscore_ids = bool(payload.get("keep_underscore_ids", True)) - output_dir = payload.get("output_dir") or "" if not isinstance(paths, list) or not paths: return {"success": False, "error": "未选择待处理文件"} @@ -551,213 +635,140 @@ class DesktopApi: return {"success": False, "error": "请至少选择一列"} if not keep_integer_ids and not keep_underscore_ids: return {"success": False, "error": "请至少选择一种 ID 保留规则"} - if not output_dir: - return {"success": False, "error": "未选择输出目录"} - output_dir_path = Path(output_dir) - if not output_dir_path.exists() or not output_dir_path.is_dir(): - return {"success": False, "error": "输出目录不存在"} + try: + uploaded_files = [] + for path in paths: + with open(path, "rb") as file_obj: + response = self.session.post( + resolve_url(self.app_base_url, "/api/files/upload"), + files={"file": (Path(path).name, file_obj)}, + timeout=180, + ) + response.raise_for_status() + upload_payload = response.json() + if not upload_payload.get("success") or not upload_payload.get("data"): + return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} + upload_data = upload_payload.get("data") or {} + uploaded_files.append({ + "fileKey": upload_data.get("fileKey"), + "originalFilename": upload_data.get("originalFilename") or Path(path).name, + }) - items: list[dict] = [] - success_count = 0 - failed_count = 0 + run_response = self.session.post( + resolve_url(self.app_base_url, "/api/dedupe/run"), + json={ + "files": uploaded_files, + "selectedColumns": selected_columns, + "keepIntegerIds": keep_integer_ids, + "keepUnderscoreIds": keep_underscore_ids, + }, + timeout=600, + ) + run_response.raise_for_status() + run_payload = run_response.json() + if not run_payload.get("success") or not run_payload.get("data"): + return {"success": False, "error": run_payload.get("message") or "去重失败"} - for path in paths: - try: - workbook = load_workbook(path) - sheet = workbook[workbook.sheetnames[0]] - header_cells = next(sheet.iter_rows(min_row=1, max_row=1)) - headers: list[str] = [] - header_map: dict[str, int] = {} - for index, cell in enumerate(header_cells): - value = normalize_cell_text(cell.value) - if not value: - continue - value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value - if value in header_map: - continue - header_map[value] = index - headers.append(value) - if value == "缩略图地址8": - break - - missing = [column for column in selected_columns if column not in header_map] - if missing: - workbook.close() - items.append({ - "source_path": path, - "success": False, - "error": f"缺少列:{'、'.join(missing)}", + data = run_payload.get("data") or {} + result_items = [] + for item in data.get("items") or []: + if item.get("success") and item.get("downloadUrl"): + result_items.append({ + "source_path": item.get("sourceFilename") or "", + "output_path": item.get("downloadUrl") or "", + "success": True, + }) + else: + result_items.append({ + "source_path": item.get("sourceFilename") or "", + "success": False, + "error": item.get("error") or "去重失败", }) - failed_count += 1 - continue - from openpyxl import Workbook - - output_workbook = Workbook() - output_sheet = output_workbook.active - output_sheet.title = sheet.title - output_sheet.append(selected_columns) - - id_column_index = header_map.get("id") - - for row in sheet.iter_rows(min_row=2, values_only=True): - original_values = list(row) - if id_column_index is not None: - id_value = original_values[id_column_index] if id_column_index < len(original_values) else None - if not should_keep_id(id_value, keep_integer_ids, keep_underscore_ids): - continue - - cleaned_values = [ - normalize_cell_text(original_values[header_map[column]]) - if header_map[column] < len(original_values) - else "" - for column in selected_columns - ] - output_sheet.append(cleaned_values) - - output_path = build_output_path(output_dir, path) - output_workbook.save(output_path) - output_workbook.close() - workbook.close() - - items.append({ - "source_path": path, - "output_path": output_path, - "success": True, - }) - success_count += 1 - except Exception as exc: # noqa: BLE001 - items.append({ - "source_path": path, - "success": False, - "error": str(exc), - }) - failed_count += 1 - - return { - "success": True, - "summary": { - "total": len(paths), - "success_count": success_count, - "failed_count": failed_count, - "output_dir": str(output_dir_path), - }, - "items": items, - } + return { + "success": True, + "summary": { + "total": data.get("total") or len(paths), + "success_count": data.get("successCount") or 0, + "failed_count": data.get("failedCount") or 0, + }, + "items": result_items, + } + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} def convert_excel_to_uk_txt(self, payload: dict) -> dict: paths = payload.get("paths") or [] - output_dir = payload.get("output_dir") or "" template_id = payload.get("template_id") or "uk_offer" if not isinstance(paths, list) or not paths: return {"success": False, "error": "未选择待转换文件"} - if not output_dir: - return {"success": False, "error": "未选择输出目录"} - templates, _default_template_id = build_effective_templates() - template = templates.get(template_id) - if not template: - return {"success": False, "error": "模板不存在"} + try: + templates = self.list_txt_templates() + selected_template = next((item for item in templates if item.get("id") == template_id), None) + if not selected_template: + return {"success": False, "error": "模板不存在"} - output_dir_path = Path(output_dir) - if not output_dir_path.exists() or not output_dir_path.is_dir(): - return {"success": False, "error": "输出目录不存在"} + uploaded_files = [] + for path in paths: + with open(path, "rb") as file_obj: + response = self.session.post( + resolve_url(self.app_base_url, "/api/files/upload"), + files={"file": (Path(path).name, file_obj)}, + timeout=180, + ) + response.raise_for_status() + upload_payload = response.json() + if not upload_payload.get("success") or not upload_payload.get("data"): + return {"success": False, "error": upload_payload.get("message") or "上传文件失败"} + upload_data = upload_payload.get("data") or {} + uploaded_files.append({ + "fileKey": upload_data.get("fileKey"), + "originalFilename": upload_data.get("originalFilename") or Path(path).name, + }) - items: list[dict] = [] - success_count = 0 - failed_count = 0 + run_response = self.session.post( + resolve_url(self.app_base_url, "/api/convert/run"), + json={ + "files": uploaded_files, + "templateId": str(template_id), + }, + timeout=600, + ) + run_response.raise_for_status() + run_payload = run_response.json() + if not run_payload.get("success") or not run_payload.get("data"): + return {"success": False, "error": run_payload.get("message") or "格式转换失败"} - for path in paths: - try: - workbook = load_workbook(path, read_only=True, data_only=True) - sheet = workbook[workbook.sheetnames[0]] - header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None) - if not header_row: - workbook.close() - items.append({"source_path": path, "success": False, "error": 'Excel 表头为空'}) - failed_count += 1 - continue - - header_map: dict[str, int] = {} - for index, cell in enumerate(header_row): - value = normalize_cell_text(cell) - if not value or value in header_map: - continue - header_map[value] = index - - required = template["required_source_columns"] - missing = [column for column in required if column not in header_map] - if missing: - workbook.close() - items.append({ - "source_path": path, - "success": False, - "error": f"缺少列:{'、'.join(missing)}", + data = run_payload.get("data") or {} + result_items = [] + for item in data.get("items") or []: + if item.get("success") and item.get("downloadUrl"): + result_items.append({ + "source_path": item.get("sourceFilename") or "", + "output_path": item.get("downloadUrl") or "", + "success": True, + }) + else: + result_items.append({ + "source_path": item.get("sourceFilename") or "", + "success": False, + "error": item.get("error") or "格式转换失败", }) - failed_count += 1 - continue - rows: list[str] = [] - rows.extend(template["preamble_lines"]) - rows.append('\t'.join(template["header_columns"])) - for _ in range(template.get("blank_header_rows_after_schema", 0)): - rows.append('\t' * (len(template["header_columns"]) - 1)) - - batch_id = generate_snowflake_like_id() - row_index = 1 - for row in sheet.iter_rows(min_row=2, values_only=True): - line_values: list[str] = [] - asin = normalize_cell_text(row[header_map['ASIN']] if header_map['ASIN'] < len(row) else '') - if not asin: - continue - - for column in template["header_columns"]: - if column == 'sku': - line_values.append(f'{batch_id}-{row_index}') - continue - if column in template["field_mapping"]: - source_column = template["field_mapping"][column] - value = normalize_cell_text(row[header_map[source_column]] if header_map[source_column] < len(row) else '') - line_values.append(value) - continue - if column in template["defaults"]: - line_values.append(str(template["defaults"][column])) - continue - line_values.append('') - - rows.append('\t'.join(line_values)) - row_index += 1 - - output_path = build_named_output_path(output_dir, template["output_filename"]) - Path(output_path).write_text('\n'.join(rows), encoding='utf-8') - workbook.close() - - items.append({ - "source_path": path, - "output_path": output_path, - "success": True, - }) - success_count += 1 - except Exception as exc: # noqa: BLE001 - items.append({ - "source_path": path, - "success": False, - "error": str(exc), - }) - failed_count += 1 - - return { - "success": True, - "summary": { - "total": len(paths), - "success_count": success_count, - "failed_count": failed_count, - "output_dir": str(output_dir_path), - }, - "items": items, - } + return { + "success": True, + "summary": { + "total": data.get("total") or len(paths), + "success_count": data.get("successCount") or 0, + "failed_count": data.get("failedCount") or 0, + }, + "items": result_items, + } + except Exception as exc: # noqa: BLE001 + return {"success": False, "error": str(exc)} def open_path(self, path: str) -> dict: try: @@ -939,7 +950,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--port", type=int, default=0) parser.add_argument("--width", type=int, default=1440) parser.add_argument("--height", type=int, default=960) - parser.add_argument("--title", default="南日AI") + parser.add_argument("--title", default="数富AI") return parser @@ -985,7 +996,7 @@ def main() -> None: ensure_dev_server_ready(config.frontend_url) app_url = config.frontend_url - api = DesktopApi(app_url) + api = DesktopApi(config.backend_url) window = webview.create_window( title=config.title, url=app_url, diff --git a/desktop/run_desktop_dev.ps1 b/desktop/run_desktop_dev.ps1 index 9610368..eda8ff8 100644 --- a/desktop/run_desktop_dev.ps1 +++ b/desktop/run_desktop_dev.ps1 @@ -64,4 +64,4 @@ Write-Host "Ensuring desktop dependencies..." Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt") Write-Host "Starting desktop app..." -Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:8000") +Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:18080") diff --git a/desktop/txt_templates.json b/desktop/txt_templates.json index 8873222..da5deed 100644 --- a/desktop/txt_templates.json +++ b/desktop/txt_templates.json @@ -90,6 +90,51 @@ "price": "价格", "product-id": "ASIN" } + }, + "user_1773982097249": { + "id": "user_1773982097249", + "label": "默认", + "output_filename": "默认.txt", + "is_default": false, + "built_in": false, + "preamble_lines": [ + "TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t" + ], + "header_columns": [ + "sku", + "price", + "quantity", + "product-id", + "product-id-type", + "condition-type", + "condition-note", + "ASIN-hint", + "title", + "product-tax-code", + "operation-type", + "sale-price", + "sale-start-date", + "sale-end-date", + "leadtime-to-ship", + "launch-date", + "is-giftwrap-available", + "is-gift-message-available" + ], + "blank_header_rows_after_schema": 1, + "required_source_columns": [ + "ASIN", + "价格" + ], + "defaults": { + "quantity": "100", + "product-id-type": "ASIN", + "condition-type": "new", + "leadtime-to-ship": "4" + }, + "field_mapping": { + "price": "价格", + "product-id": "ASIN" + } } } } \ No newline at end of file diff --git a/frontend-vue/index.html b/frontend-vue/index.html index ab51280..707eae4 100644 --- a/frontend-vue/index.html +++ b/frontend-vue/index.html @@ -3,7 +3,7 @@ - 南日AI + 数富AI
diff --git a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue index cfc2de6..8bbda4b 100644 --- a/frontend-vue/src/pages/brand/components/BrandConvertTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandConvertTab.vue @@ -41,13 +41,23 @@ :class="{ active: convertTemplateId === template.id }" > -
- - {{ template.label }} - 默认 - 内置 - -
输出文件:{{ template.output_filename }}
+
+
+ + {{ template.label }} + 默认 + 内置 + +
输出文件:{{ template.output_filename }}
+
+
@@ -81,7 +91,7 @@ {{ convertRunning ? '转换中...' : '开始转换' }} - {{ convertRunning ? '正在生成英国.txt,请稍候…' : '选择文件后即可执行格式转换' }} + {{ convertRunning ? '正在生成并上传结果,请稍候…' : '选择文件后即可执行格式转换,结果会显示在右侧供下载' }} @@ -108,9 +118,6 @@
生成的 txt 列表 -
@@ -120,12 +127,12 @@
  • - {{ shorten(item.source_path, 42) }} -
    输出文件:{{ item.output_path }}
    + {{ item.source_path || '-' }} +
    结果下载地址已生成
    错误信息:{{ item.error }}
    @@ -137,9 +144,17 @@ v-if="item.success && item.output_path" type="button" class="download" - @click="openConvertResult(item)" + @click="downloadConvertResult(item)" > - 打开文件 + 下载文件 + +
@@ -175,7 +190,6 @@ function shorten(value: string, maxLength: number) { } function resetConvertResults() { - convertResultItems.value = [] convertSummary.value = { total: 0, success_count: 0, failed_count: 0 } convertOutputDir.value = '' } @@ -217,6 +231,26 @@ async function importConvertTemplate() { ElMessage.success('模板导入成功') } +async function deleteConvertTemplate(templateId: string) { + const api = getPywebviewApi() + if (!api?.delete_txt_template) { + ElMessage.warning('当前环境不支持删除模板') + return + } + + const result = await api.delete_txt_template(templateId) + if (!result.success) { + ElMessage.error(result.error || '删除模板失败') + return + } + + if (convertTemplateId.value === templateId) { + convertTemplateId.value = '' + } + await loadConvertTemplates() + ElMessage.success('模板已删除') +} + async function setConvertDefaultTemplate() { const api = getPywebviewApi() if (!api?.set_default_txt_template || !convertTemplateId.value) { @@ -279,7 +313,7 @@ async function selectConvertFolder() { async function submitConvertRun() { const api = getPywebviewApi() - if (!api?.convert_excel_to_uk_txt || !api?.select_folder) { + if (!api?.convert_excel_to_uk_txt) { ElMessage.warning('当前环境不支持格式转换,请在桌面端打开') return } @@ -289,13 +323,9 @@ async function submitConvertRun() { } try { - const outputDir = await api.select_folder() - if (!outputDir) return - convertRunning.value = true const result = await api.convert_excel_to_uk_txt({ paths: convertSelectedPaths.value, - output_dir: outputDir, template_id: convertTemplateId.value, }) @@ -304,13 +334,13 @@ async function submitConvertRun() { return } - convertOutputDir.value = result.summary?.output_dir || outputDir + convertOutputDir.value = '' convertSummary.value = { total: result.summary?.total || 0, success_count: result.summary?.success_count || 0, failed_count: result.summary?.failed_count || 0, } - convertResultItems.value = result.items || [] + await loadConvertHistory() ElMessage.success('格式转换完成') } catch (error) { ElMessage.error(error instanceof Error ? error.message : '格式转换失败') @@ -319,34 +349,68 @@ async function submitConvertRun() { } } -async function openConvertOutputDir() { +async function deleteConvertHistory(resultId: number) { const api = getPywebviewApi() - if (!api?.open_path || !convertOutputDir.value) { - ElMessage.warning('当前环境不支持打开输出目录') + if (!api?.delete_history_item) { + ElMessage.warning('当前环境不支持删除历史') return } - const result = await api.open_path(convertOutputDir.value) + const result = await api.delete_history_item('convert', resultId) if (!result.success) { - ElMessage.error(result.error || '打开目录失败') + ElMessage.error(result.error || '删除失败') + return } + await loadConvertHistory() + ElMessage.success('已删除') } -async function openConvertResult(item: ConvertTxtResultItem) { +async function downloadConvertResult(item: ConvertTxtResultItem) { const api = getPywebviewApi() - if (!api?.open_path || !item.output_path) { - ElMessage.warning('当前环境不支持打开结果文件') + if (!item.output_path) { + ElMessage.warning('当前结果没有下载地址') return } - const result = await api.open_path(item.output_path) - if (!result.success) { - ElMessage.error(result.error || '打开文件失败') + const today = new Date() + const yyyy = String(today.getFullYear()) + const mm = String(today.getMonth() + 1).padStart(2, '0') + const dd = String(today.getDate()).padStart(2, '0') + const filename = `${yyyy}${mm}${dd}.txt` + if (api?.save_file_from_url) { + const result = await api.save_file_from_url(item.output_path, filename) + if (result.success) { + ElMessage.success(`已保存:${result.path || filename}`) + } else if (result.error && result.error !== '用户取消') { + ElMessage.error(result.error) + } + return + } + + window.open(item.output_path, '_blank') +} + +async function loadConvertHistory() { + const api = getPywebviewApi() + if (!api?.get_convert_history) return + + try { + const response = await api.get_convert_history() + if (!response.success || !response.items) return + convertResultItems.value = response.items + convertSummary.value = { + total: response.items.length, + success_count: response.items.filter((item) => item.success).length, + failed_count: response.items.filter((item) => !item.success).length, + } + } catch { + // ignore history load errors } } onMounted(() => { loadConvertTemplates().catch(() => undefined) + loadConvertHistory().catch(() => undefined) }) @@ -374,7 +438,10 @@ onMounted(() => { .template-tag { margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #8fd0ff; background: rgba(52, 152, 219, 0.14); } .template-tag.muted { color: #b8b8b8; background: rgba(255, 255, 255, 0.08); } .compact-row { margin-top: 4px; } -.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; } + .radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; } +.template-item-content { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; } +.template-delete-btn { padding: 6px 10px; border: 1px solid #5c2f2f; border-radius: 6px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; font-size: 12px; cursor: pointer; } +.template-delete-btn:hover { background: rgba(231, 76, 60, 0.2); } .radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; } .radio-item input { margin-top: 3px; } .label { font-weight: 500; color: #e0e0e0; font-size: 13px; } diff --git a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue index 5c35ef3..a6d83c8 100644 --- a/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandDedupeTab.vue @@ -79,7 +79,7 @@ {{ cleanRunning ? '清洗中...' : '开始清洗' }} - {{ cleanRunning ? '正在处理文件,请稍候…' : '选择文件、列和 ID 规则后即可执行本地清洗' }} + {{ cleanRunning ? '正在处理文件并上传结果,请稍候…' : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
@@ -106,9 +106,6 @@
处理后的 Excel 列表 -
@@ -118,8 +115,8 @@
  • - {{ shorten(item.source_path, 42) }} -
    输出文件:{{ item.output_path }}
    + {{ item.source_path || '-' }} +
    结果下载地址已生成
    错误信息:{{ item.error }}
    @@ -131,9 +128,17 @@ v-if="item.success && item.output_path" type="button" class="download" - @click="openCleanResult(item)" + @click="downloadCleanResult(item)" > - 打开文件 + 下载文件 + +
@@ -145,7 +150,7 @@ diff --git a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue index 93c3382..b1debca 100644 --- a/frontend-vue/src/pages/brand/components/BrandSplitTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandSplitTab.vue @@ -90,7 +90,7 @@ {{ splitRunning ? '拆分中...' : '开始拆分' }} - {{ splitRunning ? '正在拆分文件,请稍候…' : '选择文件、保留列和拆分规则后即可执行本地拆分' }} + {{ splitRunning ? '正在拆分并上传结果,请稍候…' : '选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载' }}
@@ -116,9 +116,6 @@
拆分后的 Excel 列表 -
@@ -128,8 +125,8 @@
  • - {{ shorten(item.source_path, 42) }} -
    输出文件:{{ item.output_path }}
    + {{ item.source_path || '-' }} +
    结果下载地址已生成
    包含 {{ item.row_count }} 条数据
    错误信息:{{ item.error }}
    @@ -142,9 +139,17 @@ v-if="item.success && item.output_path" type="button" class="download" - @click="openSplitResult(item)" + @click="downloadSplitResult(item)" > - 打开文件 + 下载文件 + +
@@ -156,7 +161,7 @@ diff --git a/frontend-vue/src/pages/brand/index.vue b/frontend-vue/src/pages/brand/index.vue index aa23520..3e3f025 100644 --- a/frontend-vue/src/pages/brand/index.vue +++ b/frontend-vue/src/pages/brand/index.vue @@ -3,42 +3,26 @@
- 南日AI-亚马逊 + 数富AI-亚马逊 返回首页
- +
diff --git a/frontend-vue/src/pages/brand/index.vue b/frontend-vue/src/pages/brand/index.vue deleted file mode 100644 index 3e3f025..0000000 --- a/frontend-vue/src/pages/brand/index.vue +++ /dev/null @@ -1,1069 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend-vue/src/pages/home/index.vue b/frontend-vue/src/pages/home/index.vue deleted file mode 100644 index 2dafe26..0000000 --- a/frontend-vue/src/pages/home/index.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - - - diff --git a/frontend-vue/src/pages/image/index.vue b/frontend-vue/src/pages/image/index.vue deleted file mode 100644 index 9b44488..0000000 --- a/frontend-vue/src/pages/image/index.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - - - diff --git a/frontend-vue/src/pages/login/index.vue b/frontend-vue/src/pages/login/index.vue deleted file mode 100644 index 5756671..0000000 --- a/frontend-vue/src/pages/login/index.vue +++ /dev/null @@ -1,137 +0,0 @@ - - - - - diff --git a/frontend-vue/src/router.ts b/frontend-vue/src/router.ts deleted file mode 100644 index 5b336fc..0000000 --- a/frontend-vue/src/router.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createRouter, createWebHistory } from 'vue-router' - -const routes = [ - { - path: '/', - redirect: '/home', - }, - { - path: '/login', - component: () => import('@/pages/login/index.vue'), - meta: { title: '登录 - 数富AI' }, - }, - { - path: '/home', - component: () => import('@/pages/home/index.vue'), - meta: { title: '首页 - 数富AI' }, - }, - { - path: '/brand', - component: () => import('@/pages/brand/index.vue'), - meta: { title: '亚马逊 - 数富AI' }, - }, - { - path: '/admin', - component: () => import('@/pages/admin/index.vue'), - meta: { title: '管理后台 - 数富AI' }, - }, - { - path: '/image', - component: () => import('@/pages/image/index.vue'), - meta: { title: '图片 - 数富AI' }, - }, -] - -const router = createRouter({ - history: createWebHistory(), - routes, -}) - -router.afterEach((to) => { - if (to.meta.title) { - document.title = String(to.meta.title) - } -}) - -export default router diff --git a/frontend-vue/src/shared/api/admin.ts b/frontend-vue/src/shared/api/admin.ts deleted file mode 100644 index c5c65e6..0000000 --- a/frontend-vue/src/shared/api/admin.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { requestDeleteJson, requestGetJson, requestPostJson, requestPutJson } from '@/shared/api/http' - -export interface AdminUserItem { - id: number - username?: string - role?: 'super_admin' | 'admin' | 'normal' | string - creator_username?: string - created_at?: string -} - -export interface AdminSimpleUser { - id: number - username: string -} - -export interface AdminUsersResponse { - success: boolean - current_user_role?: 'super_admin' | 'admin' | 'normal' | string - admins?: AdminSimpleUser[] - items?: AdminUserItem[] - total?: number - page?: number - page_size?: number - error?: string -} - -export interface AdminMutationResponse { - success: boolean - msg?: string - error?: string -} - -export interface AdminHistoryItem { - id: number - username?: string - panel_type?: string - created_at?: string - long_image_url?: string | null - result_urls?: string[] -} - -export interface AdminHistoryResponse { - success: boolean - items?: AdminHistoryItem[] - total?: number - page?: number - page_size?: number - error?: string -} - -export interface AdminColumnPermissionItem { - column_key?: string -} - -export interface AdminColumnPermissionResponse { - success: boolean - items?: AdminColumnPermissionItem[] - error?: string -} - -export function getAdminUsers(query = '') { - const suffix = query ? `?${query}` : '' - return requestGetJson(`/api/admin/users${suffix}`) -} - -export function createAdminUser(payload: { - username: string - password: string - role: string - created_by_id?: number -}) { - return requestPostJson('/api/admin/user', payload) -} - -export function updateAdminUser( - userId: number | string, - payload: { - password?: string - role?: string - }, -) { - return requestPutJson(`/api/admin/user/${userId}`, payload) -} - -export function deleteAdminUser(userId: number | string) { - return requestDeleteJson(`/api/admin/user/${userId}`) -} - -export function getAdminHistory(query = '') { - const suffix = query ? `?${query}` : '' - return requestGetJson(`/api/admin/history${suffix}`) -} - -export function getUserColumnPermissions(userId: string | number) { - return requestGetJson(`/api/admin/user/${userId}/column-permissions`) -} diff --git a/frontend-vue/src/shared/api/auth.ts b/frontend-vue/src/shared/api/auth.ts deleted file mode 100644 index da7da51..0000000 --- a/frontend-vue/src/shared/api/auth.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { requestGetJson, requestPostJson } from '@/shared/api/http' - -export interface LoginResponse { - success: boolean - redirect?: string - error?: string -} - -export interface AuthCheckResponse { - logged_in: boolean - redirect?: string -} - -export async function checkAuth() { - return requestGetJson('/api/auth/check') -} - -export async function login(username: string, password: string) { - const formData = new FormData() - formData.set('username', username) - formData.set('password', password) - - return requestPostJson('/login', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }) -} - -export async function logout() { - try { - await requestGetJson('/logout', { - validateStatus: () => true, - }) - } catch { - // logout should still continue on the client even if backend responds abnormally - } -} diff --git a/frontend-vue/src/shared/api/convert.ts b/frontend-vue/src/shared/api/convert.ts deleted file mode 100644 index ae3ebea..0000000 --- a/frontend-vue/src/shared/api/convert.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { requestGetJson, requestPostJson } from '@/shared/api/http' - -export interface UploadFileVo { - fileKey: string - originalFilename?: string - localPath?: string - size?: number -} - -export interface ConvertTemplateVo { - id: number - templateCode: string - templateName: string - outputFilename: string - isDefault?: boolean - builtIn?: boolean -} - -export interface UploadedSourceFileDto { - fileKey: string - originalFilename?: string -} - -export interface ConvertResultItemVo { - resultId?: number - sourceFilename?: string - outputFilename?: string - success: boolean - error?: string - downloadUrl?: string -} - -export interface ConvertRunVo { - total: number - successCount: number - failedCount: number - items: ConvertResultItemVo[] -} - -export interface StandardApiResponse { - success: boolean - message?: string - data?: T -} - -export function getConvertTemplates() { - return requestGetJson>('/api/convert/templates') -} - -export function uploadTempFile(file: File) { - const formData = new FormData() - formData.append('file', file) - return requestPostJson, FormData>('/api/files/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) -} - -export function runConvert(files: UploadedSourceFileDto[], templateId: number) { - return requestPostJson>('/api/convert/run', { - files, - templateId, - }) -} diff --git a/frontend-vue/src/shared/api/dedupe.ts b/frontend-vue/src/shared/api/dedupe.ts deleted file mode 100644 index 7622020..0000000 --- a/frontend-vue/src/shared/api/dedupe.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { requestGetJson } from '@/shared/api/http' - -export interface DedupeHistoryItem { - sourceFilename?: string - outputFilename?: string - downloadUrl?: string - success: boolean -} - -export interface DedupeHistoryVo { - items: DedupeHistoryItem[] -} - -export interface StandardApiResponse { - success: boolean - message?: string - data?: T -} - -export function getDedupeHistory() { - return requestGetJson>('/api/dedupe/history') -} diff --git a/frontend-vue/src/shared/api/update.ts b/frontend-vue/src/shared/api/update.ts deleted file mode 100644 index 099ed55..0000000 --- a/frontend-vue/src/shared/api/update.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { requestGetJson, requestPostJson } from '@/shared/api/http' - -export interface VersionResponse { - version?: string - has_update?: boolean - latest_version?: string - desc?: string - file_url?: string -} - -export interface UpdateStartResponse { - success: boolean - error?: string -} - -export function fetchVersion() { - return requestGetJson('/api/version') -} - -export function startUpdate(fileUrl: string) { - return requestPostJson('/api/update/do', { file_url: fileUrl }) -} diff --git a/frontend-vue/src/shared/bridges/pywebview.ts b/frontend-vue/src/shared/bridges/pywebview.ts index f941311..5aa0109 100644 --- a/frontend-vue/src/shared/bridges/pywebview.ts +++ b/frontend-vue/src/shared/bridges/pywebview.ts @@ -4,7 +4,7 @@ export interface SplitExcelPayload { split_mode: 'rows_per_file' | 'parts' rows_per_file?: number parts?: number - output_dir: string + output_dir?: string } export interface SplitExcelResultItem { @@ -37,6 +37,14 @@ export interface ExcelInfoResponse { error?: string } +export interface CleanExcelPayload { + paths: string[] + selected_columns: string[] + keep_integer_ids?: boolean + keep_underscore_ids?: boolean + output_dir?: string +} + export interface CleanExcelResultItem { result_id?: number source_path: string @@ -47,7 +55,7 @@ export interface CleanExcelResultItem { export interface ConvertTxtPayload { paths: string[] - output_dir: string + output_dir?: string template_id: string } diff --git a/frontend-vue/src/shared/composables/useUpdateCheck.ts b/frontend-vue/src/shared/composables/useUpdateCheck.ts deleted file mode 100644 index ca03bf4..0000000 --- a/frontend-vue/src/shared/composables/useUpdateCheck.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ref } from 'vue' -import { fetchVersion, startUpdate } from '@/shared/api/update' - -export function useUpdateCheck() { - const loading = ref(false) - const version = ref('1.0.0') - const hint = ref('') - const downloadVisible = ref(false) - const downloadLoading = ref(false) - const fileUrl = ref('') - - const check = async () => { - loading.value = true - hint.value = '正在检测更新...' - downloadVisible.value = false - - try { - const result = await fetchVersion() - version.value = (result.version || version.value).replace(/^v/i, '') - - if (result.has_update && result.latest_version) { - hint.value = `发现新版本 v${result.latest_version}${result.desc ? `:${result.desc}` : ''}` - fileUrl.value = result.file_url || '' - downloadVisible.value = true - return - } - - hint.value = '已是最新版本' - } catch (error) { - hint.value = error instanceof Error ? error.message : '检测更新失败' - } finally { - loading.value = false - } - } - - const runUpdate = async () => { - if (!fileUrl.value) { - hint.value = '暂无下载地址,请关注官方渠道。' - return false - } - - downloadLoading.value = true - hint.value = '正在下载并准备更新,程序将自动退出...' - - try { - const result = await startUpdate(fileUrl.value) - if (result.success) { - hint.value = '更新已启动,程序即将退出...' - return true - } - - hint.value = result.error || '更新启动失败' - return false - } catch (error) { - hint.value = error instanceof Error ? error.message : '请求更新失败,请重试' - return false - } finally { - downloadLoading.value = false - } - } - - return { - loading, - version, - hint, - downloadVisible, - downloadLoading, - check, - runUpdate, - } -} diff --git a/frontend-vue/src/main.ts b/frontend-vue/src/split-main.ts similarity index 51% rename from frontend-vue/src/main.ts rename to frontend-vue/src/split-main.ts index 34be832..b74c843 100644 --- a/frontend-vue/src/main.ts +++ b/frontend-vue/src/split-main.ts @@ -2,7 +2,6 @@ import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import '@/styles/main.css' -import App from '@/App.vue' -import router from '@/router' +import BrandSplitTab from '@/pages/brand/components/BrandSplitTab.vue' -createApp(App).use(ElementPlus).use(router).mount('#app') +createApp(BrandSplitTab).use(ElementPlus).mount('#app') diff --git a/frontend-vue/vite.config.ts b/frontend-vue/vite.config.ts index cd05be6..cb1dcd7 100644 --- a/frontend-vue/vite.config.ts +++ b/frontend-vue/vite.config.ts @@ -1,4 +1,5 @@ import { fileURLToPath, URL } from 'node:url' +import { resolve } from 'node:path' import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' @@ -6,6 +7,7 @@ import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ + base: './', plugins: [ vue(), AutoImport({ @@ -47,5 +49,19 @@ export default defineConfig({ }, build: { outDir: 'dist', + emptyOutDir: true, + cssCodeSplit: true, + rollupOptions: { + input: { + dedupe: resolve(__dirname, 'dedupe.html'), + convert: resolve(__dirname, 'convert.html'), + split: resolve(__dirname, 'split.html'), + }, + output: { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', + }, + }, }, }) diff --git a/ali_oss.py b/source_code/ali_oss.py similarity index 97% rename from ali_oss.py rename to source_code/ali_oss.py index 179fadd..0cb6b40 100644 --- a/ali_oss.py +++ b/source_code/ali_oss.py @@ -1,73 +1,73 @@ -import argparse -import base64 -import re -import time -import alibabacloud_oss_v2 as oss -import requests - -from config import region, endpoint, bucket, file_url_pre, bucket_path - - -def upload_file(file_content: bytes, key: str): - credentials_provider = oss.credentials.EnvironmentVariableCredentialsProvider() - cfg = oss.config.load_default() - cfg.credentials_provider = credentials_provider - cfg.region = region - cfg.endpoint = endpoint - cfg.retry_max_attempts = 3 - client = oss.Client(cfg) - - result = client.put_object( - oss.PutObjectRequest( - bucket=bucket, # 存储空间名称 - key=key, # 对象名称 - body=file_content # 读取文件内容 - ) - ) - # print(result) - return file_url_pre + key - - -def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str: - """ - 将 base64 data URL 上传到 OSS,返回图片链接 - data_url: data:image/png;base64,xxxx 或 data:image/jpeg;base64,xxxx - prefix: OSS key 前缀 - key_hint: 可选后缀避免重名,如 "_0", "_1" - """ - match = re.match(r'data:image/(\w+);base64,(.+)', data_url) - if not match: - raise ValueError('无效的 data URL 格式') - ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg' - file_content = base64.b64decode(match.group(2)) - ts = int(time.time() * 1000) - key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}" - return upload_file(file_content, key) - - -def upload_data_urls(data_urls: list, prefix: str = "history") -> list: - """批量上传 base64 图片到 OSS,返回图片链接列表""" - urls = [] - ts = int(time.time() * 1000) - for i, data_url in enumerate(data_urls or []): - if not data_url or not isinstance(data_url, str): - continue - if not data_url.startswith("http"): - match = re.match(r'data:image/(\w+);base64,(.+)', data_url) - if not match: - continue - ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg' - file_content = base64.b64decode(match.group(2)) - else: - file_content = requests.get(data_url).content - ext = "png" - key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}" - urls.append(upload_file(file_content, key)) - return urls - - -# 脚本入口,当文件被直接运行时调用main函数 -if __name__ == "__main__": - with open("测试图片数据/IMG_2685.JPG", "rb") as f: - file_content = f.read() +import argparse +import base64 +import re +import time +import alibabacloud_oss_v2 as oss +import requests + +from config import region, endpoint, bucket, file_url_pre, bucket_path + + +def upload_file(file_content: bytes, key: str): + credentials_provider = oss.credentials.EnvironmentVariableCredentialsProvider() + cfg = oss.config.load_default() + cfg.credentials_provider = credentials_provider + cfg.region = region + cfg.endpoint = endpoint + cfg.retry_max_attempts = 3 + client = oss.Client(cfg) + + result = client.put_object( + oss.PutObjectRequest( + bucket=bucket, # 存储空间名称 + key=key, # 对象名称 + body=file_content # 读取文件内容 + ) + ) + # print(result) + return file_url_pre + key + + +def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str: + """ + 将 base64 data URL 上传到 OSS,返回图片链接 + data_url: data:image/png;base64,xxxx 或 data:image/jpeg;base64,xxxx + prefix: OSS key 前缀 + key_hint: 可选后缀避免重名,如 "_0", "_1" + """ + match = re.match(r'data:image/(\w+);base64,(.+)', data_url) + if not match: + raise ValueError('无效的 data URL 格式') + ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg' + file_content = base64.b64decode(match.group(2)) + ts = int(time.time() * 1000) + key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}" + return upload_file(file_content, key) + + +def upload_data_urls(data_urls: list, prefix: str = "history") -> list: + """批量上传 base64 图片到 OSS,返回图片链接列表""" + urls = [] + ts = int(time.time() * 1000) + for i, data_url in enumerate(data_urls or []): + if not data_url or not isinstance(data_url, str): + continue + if not data_url.startswith("http"): + match = re.match(r'data:image/(\w+);base64,(.+)', data_url) + if not match: + continue + ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg' + file_content = base64.b64decode(match.group(2)) + else: + file_content = requests.get(data_url).content + ext = "png" + key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}" + urls.append(upload_file(file_content, key)) + return urls + + +# 脚本入口,当文件被直接运行时调用main函数 +if __name__ == "__main__": + with open("测试图片数据/IMG_2685.JPG", "rb") as f: + file_content = f.read() upload_file(file_content,key=bucket_path+"test.png") \ No newline at end of file diff --git a/app.py b/source_code/app.py similarity index 100% rename from app.py rename to source_code/app.py diff --git a/app_common.py b/source_code/app_common.py similarity index 97% rename from app_common.py rename to source_code/app_common.py index 67169e0..6928c14 100644 --- a/app_common.py +++ b/source_code/app_common.py @@ -1,261 +1,261 @@ -""" -公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染 -供各蓝图复用 -""" -import os -import secrets -from datetime import timedelta -from functools import wraps - -import pymysql -from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string - -from config import mysql_host, mysql_user, mysql_password, mysql_database - -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -STATIC_DIR = os.path.join(BASE_DIR, 'static') - - -def get_db(): - return pymysql.connect( - host=mysql_host, - user=mysql_user, - password=mysql_password, - database=mysql_database, - charset='utf8mb4', - cursorclass=pymysql.cursors.DictCursor - ) - - -def _render_html(template_name: str, **context): - """读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。""" - path = os.path.join(BASE_DIR, "web_source", template_name) - if not os.path.isfile(path): - return render_template(template_name, **context) - with open(path, "rb") as f: - raw = f.read() - try: - from html_crypto import decrypt - content = decrypt(raw).decode("utf-8") - except Exception: - content = raw.decode("utf-8", errors="replace") - return render_template_string(content, **context) - - -def _is_session_user_valid(): - """校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False""" - uid = session.get('user_id') - if not uid: - return False - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT id FROM users WHERE id = %s", (uid,)) - row = cur.fetchone() - conn.close() - if not row: - session.clear() - return False - return True - except Exception: - session.clear() - return False - - -def _get_current_admin_role(): - """获取当前登录用户的管理角色:super_admin / admin / None(非管理员)""" - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute( - "SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s", - (session['user_id'],) - ) - row = cur.fetchone() - conn.close() - if not row or not row.get('is_admin'): - return None, None - return row.get('role') or ('super_admin' if row.get('created_by_id') is None else 'admin'), row - except Exception: - return None, None - - -def login_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if not session.get('user_id') or not _is_session_user_valid(): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'error': '未登录'}), 401 - return redirect(url_for('auth.login')) - return f(*args, **kwargs) - return decorated - - -def admin_required(f): - @wraps(f) - def decorated(*args, **kwargs): - if not session.get('user_id'): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'error': '未登录'}), 401 - return redirect(url_for('auth.login')) - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],)) - row = cur.fetchone() - conn.close() - if not row or not row.get('is_admin'): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 - return redirect(url_for('main.home')) - except Exception as e: - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'error': str(e)}), 500 - return redirect(url_for('main.home')) - return f(*args, **kwargs) - return decorated - - -def init_db(): - """初始化数据库表,若不存在则创建""" - from werkzeug.security import generate_password_hash - conn = pymysql.connect( - host=mysql_host, - user=mysql_user, - password=mysql_password, - charset='utf8mb4' - ) - try: - with conn.cursor() as cur: - cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4") - cur.execute(f"USE `{mysql_database}`") - cur.execute(""" - CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(64) NOT NULL UNIQUE, - password_hash VARCHAR(256) NOT NULL, - is_admin TINYINT(1) DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - """) - cur.execute(""" - CREATE TABLE IF NOT EXISTS image_history ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - panel_type VARCHAR(64) DEFAULT '', - original_urls JSON, - params JSON, - result_urls JSON, - long_image_url VARCHAR(1024) DEFAULT NULL, - INDEX idx_user_created (user_id, created_at DESC) - ) - """) - try: - cur.execute("ALTER TABLE image_history ADD COLUMN long_image_url VARCHAR(1024) DEFAULT NULL") - except Exception: - pass - try: - cur.execute("ALTER TABLE users ADD COLUMN machine VARCHAR(64) DEFAULT NULL") - except Exception: - pass - try: - cur.execute("ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'normal'") - except Exception: - pass - try: - cur.execute("ALTER TABLE users ADD COLUMN created_by_id INT NULL") - except Exception: - pass - cur.execute(""" - CREATE TABLE IF NOT EXISTS brand_crawl_tasks ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - file_paths JSON NOT NULL, - status VARCHAR(20) DEFAULT 'pending', - task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行', - result_paths JSON NULL, - error_message TEXT NULL, - progress_current INT DEFAULT 0, - progress_total INT DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_user_status (user_id, status) - ) - """) - try: - cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_current INT DEFAULT 0") - except Exception: - pass - try: - cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_total INT DEFAULT 0") - except Exception: - pass - try: - cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN `desc` VARCHAR(500) NULL COMMENT '上传文件名描述'") - except Exception: - pass - try: - cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN strategy VARCHAR(20) DEFAULT 'Terms' COMMENT '品牌匹配方式: Terms/Simple'") - except Exception: - pass - try: - cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行'") - except Exception: - pass - cur.execute(""" - CREATE TABLE IF NOT EXISTS columns ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(128) NOT NULL COMMENT '栏目名', - column_key VARCHAR(64) NOT NULL COMMENT '栏目标识', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uk_column_key (column_key) - ) - """) - cur.execute(""" - CREATE TABLE IF NOT EXISTS user_column_permission ( - user_id INT NOT NULL, - column_id INT NOT NULL, - PRIMARY KEY (user_id, column_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE - ) - """) - try: - cur.execute("UPDATE users SET role = 'normal' WHERE (role IS NULL OR role = '') AND (is_admin = 0 OR is_admin IS NULL)") - cur.execute("SELECT MIN(id) AS mid FROM users WHERE is_admin = 1") - row = cur.fetchone() - if row and row.get('mid'): - mid = row['mid'] - cur.execute("UPDATE users SET role = 'super_admin' WHERE id = %s", (mid,)) - cur.execute("UPDATE users SET role = 'admin' WHERE is_admin = 1 AND id != %s", (mid,)) - cur.execute("UPDATE users SET created_by_id = %s WHERE role = 'admin' AND (created_by_id IS NULL)", (mid,)) - except Exception: - pass - conn.commit() - finally: - conn.close() - _create_initial_admin() - - -def _create_initial_admin(): - """若没有任何管理员,则创建默认超级管理员(首次启动时,仅一个)""" - from werkzeug.security import generate_password_hash - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1") - if cur.fetchone(): - conn.close() - return - admin_user = os.environ.get('ADMIN_USER', 'admin') - admin_pwd = os.environ.get('ADMIN_PASSWORD', 'admin123') - pwd_hash = generate_password_hash(admin_pwd, method='pbkdf2:sha256') - cur.execute( - "INSERT INTO users (username, password_hash, is_admin, role) VALUES (%s, %s, 1, 'super_admin')", - (admin_user, pwd_hash) - ) - conn.commit() - conn.close() - except Exception: - pass +""" +公共模块:数据库连接、初始化、会话校验、装饰器、模板渲染 +供各蓝图复用 +""" +import os +import secrets +from datetime import timedelta +from functools import wraps + +import pymysql +from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string + +from config import mysql_host, mysql_user, mysql_password, mysql_database + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +STATIC_DIR = os.path.join(BASE_DIR, 'static') + + +def get_db(): + return pymysql.connect( + host=mysql_host, + user=mysql_user, + password=mysql_password, + database=mysql_database, + charset='utf8mb4', + cursorclass=pymysql.cursors.DictCursor + ) + + +def _render_html(template_name: str, **context): + """读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。""" + path = os.path.join(BASE_DIR, "web_source", template_name) + if not os.path.isfile(path): + return render_template(template_name, **context) + with open(path, "rb") as f: + raw = f.read() + try: + from html_crypto import decrypt + content = decrypt(raw).decode("utf-8") + except Exception: + content = raw.decode("utf-8", errors="replace") + return render_template_string(content, **context) + + +def _is_session_user_valid(): + """校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False""" + uid = session.get('user_id') + if not uid: + return False + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT id FROM users WHERE id = %s", (uid,)) + row = cur.fetchone() + conn.close() + if not row: + session.clear() + return False + return True + except Exception: + session.clear() + return False + + +def _get_current_admin_role(): + """获取当前登录用户的管理角色:super_admin / admin / None(非管理员)""" + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute( + "SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s", + (session['user_id'],) + ) + row = cur.fetchone() + conn.close() + if not row or not row.get('is_admin'): + return None, None + return row.get('role') or ('super_admin' if row.get('created_by_id') is None else 'admin'), row + except Exception: + return None, None + + +def login_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get('user_id') or not _is_session_user_valid(): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': '未登录'}), 401 + return redirect(url_for('auth.login')) + return f(*args, **kwargs) + return decorated + + +def admin_required(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get('user_id'): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': '未登录'}), 401 + return redirect(url_for('auth.login')) + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],)) + row = cur.fetchone() + conn.close() + if not row or not row.get('is_admin'): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': '需要管理员权限'}), 403 + return redirect(url_for('main.home')) + except Exception as e: + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return jsonify({'success': False, 'error': str(e)}), 500 + return redirect(url_for('main.home')) + return f(*args, **kwargs) + return decorated + + +def init_db(): + """初始化数据库表,若不存在则创建""" + from werkzeug.security import generate_password_hash + conn = pymysql.connect( + host=mysql_host, + user=mysql_user, + password=mysql_password, + charset='utf8mb4' + ) + try: + with conn.cursor() as cur: + cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4") + cur.execute(f"USE `{mysql_database}`") + cur.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(256) NOT NULL, + is_admin TINYINT(1) DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS image_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + panel_type VARCHAR(64) DEFAULT '', + original_urls JSON, + params JSON, + result_urls JSON, + long_image_url VARCHAR(1024) DEFAULT NULL, + INDEX idx_user_created (user_id, created_at DESC) + ) + """) + try: + cur.execute("ALTER TABLE image_history ADD COLUMN long_image_url VARCHAR(1024) DEFAULT NULL") + except Exception: + pass + try: + cur.execute("ALTER TABLE users ADD COLUMN machine VARCHAR(64) DEFAULT NULL") + except Exception: + pass + try: + cur.execute("ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'normal'") + except Exception: + pass + try: + cur.execute("ALTER TABLE users ADD COLUMN created_by_id INT NULL") + except Exception: + pass + cur.execute(""" + CREATE TABLE IF NOT EXISTS brand_crawl_tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + file_paths JSON NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行', + result_paths JSON NULL, + error_message TEXT NULL, + progress_current INT DEFAULT 0, + progress_total INT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user_status (user_id, status) + ) + """) + try: + cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_current INT DEFAULT 0") + except Exception: + pass + try: + cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_total INT DEFAULT 0") + except Exception: + pass + try: + cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN `desc` VARCHAR(500) NULL COMMENT '上传文件名描述'") + except Exception: + pass + try: + cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN strategy VARCHAR(20) DEFAULT 'Terms' COMMENT '品牌匹配方式: Terms/Simple'") + except Exception: + pass + try: + cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行'") + except Exception: + pass + cur.execute(""" + CREATE TABLE IF NOT EXISTS columns ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(128) NOT NULL COMMENT '栏目名', + column_key VARCHAR(64) NOT NULL COMMENT '栏目标识', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_column_key (column_key) + ) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS user_column_permission ( + user_id INT NOT NULL, + column_id INT NOT NULL, + PRIMARY KEY (user_id, column_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE + ) + """) + try: + cur.execute("UPDATE users SET role = 'normal' WHERE (role IS NULL OR role = '') AND (is_admin = 0 OR is_admin IS NULL)") + cur.execute("SELECT MIN(id) AS mid FROM users WHERE is_admin = 1") + row = cur.fetchone() + if row and row.get('mid'): + mid = row['mid'] + cur.execute("UPDATE users SET role = 'super_admin' WHERE id = %s", (mid,)) + cur.execute("UPDATE users SET role = 'admin' WHERE is_admin = 1 AND id != %s", (mid,)) + cur.execute("UPDATE users SET created_by_id = %s WHERE role = 'admin' AND (created_by_id IS NULL)", (mid,)) + except Exception: + pass + conn.commit() + finally: + conn.close() + _create_initial_admin() + + +def _create_initial_admin(): + """若没有任何管理员,则创建默认超级管理员(首次启动时,仅一个)""" + from werkzeug.security import generate_password_hash + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1") + if cur.fetchone(): + conn.close() + return + admin_user = os.environ.get('ADMIN_USER', 'admin') + admin_pwd = os.environ.get('ADMIN_PASSWORD', 'admin123') + pwd_hash = generate_password_hash(admin_pwd, method='pbkdf2:sha256') + cur.execute( + "INSERT INTO users (username, password_hash, is_admin, role) VALUES (%s, %s, 1, 'super_admin')", + (admin_user, pwd_hash) + ) + conn.commit() + conn.close() + except Exception: + pass diff --git a/blueprints/__init__.py b/source_code/blueprints/__init__.py similarity index 92% rename from blueprints/__init__.py rename to source_code/blueprints/__init__.py index 283385b..c4c4c58 100644 --- a/blueprints/__init__.py +++ b/source_code/blueprints/__init__.py @@ -1 +1 @@ -# 蓝图包 +# 蓝图包 diff --git a/blueprints/__pycache__/__init__.cpython-311.pyc b/source_code/blueprints/__pycache__/__init__.cpython-311.pyc similarity index 100% rename from blueprints/__pycache__/__init__.cpython-311.pyc rename to source_code/blueprints/__pycache__/__init__.cpython-311.pyc diff --git a/blueprints/__pycache__/__init__.cpython-39.pyc b/source_code/blueprints/__pycache__/__init__.cpython-39.pyc similarity index 100% rename from blueprints/__pycache__/__init__.cpython-39.pyc rename to source_code/blueprints/__pycache__/__init__.cpython-39.pyc diff --git a/blueprints/__pycache__/admin.cpython-311.pyc b/source_code/blueprints/__pycache__/admin.cpython-311.pyc similarity index 100% rename from blueprints/__pycache__/admin.cpython-311.pyc rename to source_code/blueprints/__pycache__/admin.cpython-311.pyc diff --git a/blueprints/__pycache__/admin.cpython-39.pyc b/source_code/blueprints/__pycache__/admin.cpython-39.pyc similarity index 100% rename from blueprints/__pycache__/admin.cpython-39.pyc rename to source_code/blueprints/__pycache__/admin.cpython-39.pyc diff --git a/blueprints/__pycache__/auth.cpython-311.pyc b/source_code/blueprints/__pycache__/auth.cpython-311.pyc similarity index 100% rename from blueprints/__pycache__/auth.cpython-311.pyc rename to source_code/blueprints/__pycache__/auth.cpython-311.pyc diff --git a/blueprints/__pycache__/auth.cpython-39.pyc b/source_code/blueprints/__pycache__/auth.cpython-39.pyc similarity index 100% rename from blueprints/__pycache__/auth.cpython-39.pyc rename to source_code/blueprints/__pycache__/auth.cpython-39.pyc diff --git a/blueprints/__pycache__/brand.cpython-311.pyc b/source_code/blueprints/__pycache__/brand.cpython-311.pyc similarity index 100% rename from blueprints/__pycache__/brand.cpython-311.pyc rename to source_code/blueprints/__pycache__/brand.cpython-311.pyc diff --git a/blueprints/__pycache__/brand.cpython-39.pyc b/source_code/blueprints/__pycache__/brand.cpython-39.pyc similarity index 100% rename from blueprints/__pycache__/brand.cpython-39.pyc rename to source_code/blueprints/__pycache__/brand.cpython-39.pyc diff --git a/blueprints/__pycache__/image.cpython-311.pyc b/source_code/blueprints/__pycache__/image.cpython-311.pyc similarity index 100% rename from blueprints/__pycache__/image.cpython-311.pyc rename to source_code/blueprints/__pycache__/image.cpython-311.pyc diff --git a/blueprints/__pycache__/image.cpython-39.pyc b/source_code/blueprints/__pycache__/image.cpython-39.pyc similarity index 100% rename from blueprints/__pycache__/image.cpython-39.pyc rename to source_code/blueprints/__pycache__/image.cpython-39.pyc diff --git a/blueprints/__pycache__/main.cpython-311.pyc b/source_code/blueprints/__pycache__/main.cpython-311.pyc similarity index 100% rename from blueprints/__pycache__/main.cpython-311.pyc rename to source_code/blueprints/__pycache__/main.cpython-311.pyc diff --git a/blueprints/__pycache__/main.cpython-39.pyc b/source_code/blueprints/__pycache__/main.cpython-39.pyc similarity index 100% rename from blueprints/__pycache__/main.cpython-39.pyc rename to source_code/blueprints/__pycache__/main.cpython-39.pyc diff --git a/blueprints/admin.py b/source_code/blueprints/admin.py similarity index 97% rename from blueprints/admin.py rename to source_code/blueprints/admin.py index ab64fc9..cac93ec 100644 --- a/blueprints/admin.py +++ b/source_code/blueprints/admin.py @@ -1,362 +1,362 @@ -""" -管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页 -""" -import json -import pymysql -from flask import Blueprint, request, jsonify -from werkzeug.security import generate_password_hash - -from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required -from flask import session - -admin_bp = Blueprint('admin', __name__) - - -def _parse_json(val, default=None): - if val is None: - return default if default is not None else [] - if isinstance(val, (list, dict)): - return val - try: - return json.loads(val) - except Exception: - return default if default is not None else [] - - -@admin_bp.route('/admin') -@login_required -@admin_required -def admin_page(): - return _render_html('admin.html') - - -@admin_bp.route('/api/admin/users') -@admin_required -def admin_list_users(): - """分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选""" - role, current_row = _get_current_admin_role() - if not role: - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 - page = max(1, int(request.args.get('page', 1))) - page_size = min(50, max(5, int(request.args.get('page_size', 15)))) - offset = (page - 1) * page_size - search_username = (request.args.get('username') or request.args.get('search') or '').strip() - created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id') - created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None - if role != 'super_admin': - created_by_id = None - try: - conn = get_db() - with conn.cursor() as cur: - if role == 'super_admin': - where_parts = ["1=1"] - params = [] - if search_username: - where_parts.append("u.username LIKE %s") - params.append("%" + search_username + "%") - if created_by_id is not None: - where_parts.append("u.created_by_id = %s") - params.append(created_by_id) - where_sql = " AND ".join(where_parts) - cur.execute( - """SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id, - creator.username AS creator_username - FROM users u - LEFT JOIN users creator ON creator.id = u.created_by_id - WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""", - tuple(params) + (page_size, offset), - ) - rows = cur.fetchall() - cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params)) - total = cur.fetchone()['total'] - cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id") - admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()] - else: - admin_id = current_row['id'] - where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"] - params = [admin_id, admin_id] - if search_username: - where_parts.append("u.username LIKE %s") - params.append("%" + search_username + "%") - where_sql = " AND ".join(where_parts) - cur.execute( - """SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id, - creator.username AS creator_username - FROM users u - LEFT JOIN users creator ON creator.id = u.created_by_id - WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""", - tuple(params) + (page_size, offset), - ) - rows = cur.fetchall() - cur.execute( - "SELECT COUNT(*) as total FROM users u WHERE " + where_sql, - tuple(params), - ) - total = cur.fetchone()['total'] - admins = [] - items = [ - { - 'id': r['id'], - 'username': r['username'], - 'is_admin': bool(r.get('is_admin')), - 'role': r.get('role') or 'normal', - 'created_by_id': r.get('created_by_id'), - 'creator_username': r.get('creator_username') or '', - 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', - } - for r in rows - ] - conn.close() - payload = { - 'success': True, - 'items': items, - 'total': total, - 'page': page, - 'page_size': page_size, - 'current_user_role': role, - 'admins': admins, - } - return jsonify(payload) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - -@admin_bp.route('/api/admin/user', methods=['POST']) -@admin_required -def admin_create_user(): - data = request.get_json() or {} - username = (data.get('username') or '').strip() - password = data.get('password') or '' - role, current_row = _get_current_admin_role() - if not role: - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 - want_role = (data.get('role') or 'normal').strip() or 'normal' - if want_role not in ('admin', 'normal'): - want_role = 'normal' - if role == 'admin' and want_role == 'admin': - return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'}) - if want_role == 'admin': - want_created_by = current_row['id'] - elif role == 'super_admin': - want_created_by = data.get('created_by_id') - else: - want_created_by = current_row['id'] - if not username or not password: - return jsonify({'success': False, 'error': '用户名和密码不能为空'}) - if len(username) < 2: - return jsonify({'success': False, 'error': '用户名至少2个字符'}) - if len(password) < 6: - return jsonify({'success': False, 'error': '密码至少6个字符'}) - if want_role == 'normal' and role == 'super_admin' and want_created_by is None: - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1") - r = cur.fetchone() - conn.close() - want_created_by = r['id'] if r else current_row['id'] - except Exception: - want_created_by = current_row['id'] - if want_role == 'normal' and want_created_by is None: - want_created_by = current_row['id'] - is_admin = 1 if want_role in ('super_admin', 'admin') else 0 - pwd_hash = generate_password_hash(password, method='pbkdf2:sha256') - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute( - "INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)", - (username, pwd_hash, is_admin, want_role, want_created_by), - ) - conn.commit() - conn.close() - return jsonify({'success': True, 'msg': '用户创建成功'}) - except pymysql.IntegrityError: - return jsonify({'success': False, 'error': '用户名已存在'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - -@admin_bp.route('/api/admin/user/', methods=['PUT']) -@admin_required -def admin_update_user(uid): - data = request.get_json() or {} - password = data.get('password') - want_role = (data.get('role') or '').strip() or data.get('role') - role, current_row = _get_current_admin_role() - if not role: - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 - if want_role is None and not password: - return jsonify({'success': False, 'error': '请提供要修改的内容'}) - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,)) - target = cur.fetchone() - if not target: - conn.close() - return jsonify({'success': False, 'error': '用户不存在'}) - if role == 'admin': - if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']: - conn.close() - return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403 - want_role = None - else: - if target.get('role') == 'super_admin': - conn.close() - return jsonify({'success': False, 'error': '不能修改超级管理员'}) - if want_role == 'super_admin': - return jsonify({'success': False, 'error': '不能将用户设为超级管理员'}) - if want_role not in ('admin', 'normal', None, ''): - want_role = None - if password: - if len(password) < 6: - conn.close() - return jsonify({'success': False, 'error': '密码至少6个字符'}) - pwd_hash = generate_password_hash(password, method='pbkdf2:sha256') - cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid)) - if want_role is not None and want_role != '': - is_admin = 1 if want_role == 'admin' else 0 - cur.execute( - "UPDATE users SET is_admin = %s, role = %s WHERE id = %s", - (is_admin, want_role, uid), - ) - conn.commit() - conn.close() - return jsonify({'success': True, 'msg': '更新成功'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - -@admin_bp.route('/api/admin/user/', methods=['DELETE']) -@admin_required -def admin_delete_user(uid): - from flask import session - if session.get('user_id') == uid: - return jsonify({'success': False, 'error': '不能删除当前登录账号'}) - role, current_row = _get_current_admin_role() - if not role: - return jsonify({'success': False, 'error': '需要管理员权限'}), 403 - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,)) - target = cur.fetchone() - if not target: - conn.close() - return jsonify({'success': False, 'error': '用户不存在'}) - if target.get('role') == 'super_admin': - conn.close() - return jsonify({'success': False, 'error': '不能删除超级管理员'}) - if role == 'admin': - if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']: - conn.close() - return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403 - cur.execute("DELETE FROM users WHERE id = %s", (uid,)) - affected = cur.rowcount - conn.commit() - conn.close() - if affected == 0: - return jsonify({'success': False, 'error': '用户不存在'}) - return jsonify({'success': True, 'msg': '删除成功'}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - -@admin_bp.route('/api/admin/user//column-permissions') -@login_required -def admin_user_column_permissions(uid): - """获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。""" - current_uid = session.get('user_id') - if current_uid != uid: - role, _ = _get_current_admin_role() - if not role: - return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403 - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT role FROM users WHERE id = %s", (uid,)) - user_row = cur.fetchone() - if user_row and (user_row.get('role') or '').strip() == 'super_admin': - cur.execute(""" - SELECT id, name, column_key, created_at FROM columns ORDER BY id - """) - rows = cur.fetchall() - else: - cur.execute(""" - SELECT c.id, c.name, c.column_key, c.created_at - FROM columns c - INNER JOIN user_column_permission ucp ON ucp.column_id = c.id - WHERE ucp.user_id = %s - ORDER BY c.id - """, (uid,)) - rows = cur.fetchall() - conn.close() - items = [ - { - 'id': r['id'], - 'name': r['name'], - 'column_key': r['column_key'], - 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', - } - for r in rows - ] - return jsonify({'success': True, 'items': items}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - -@admin_bp.route('/api/admin/history') -@admin_required -def admin_history(): - """管理员分页获取所有生成记录,支持按用户和时间筛选""" - page = max(1, int(request.args.get('page', 1))) - page_size = min(50, max(10, int(request.args.get('page_size', 15)))) - offset = (page - 1) * page_size - user_id = request.args.get('user_id', type=int) - time_start = (request.args.get('time_start') or '').strip() - time_end = (request.args.get('time_end') or '').strip() - conditions, params = [], [] - if user_id: - conditions.append("h.user_id = %s") - params.append(user_id) - if time_start: - conditions.append("h.created_at >= %s") - params.append(time_start) - if time_end: - conditions.append("h.created_at <= %s") - params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end) - where_clause = " AND ".join(conditions) if conditions else "1=1" - params_count = params[:] - params.extend([page_size, offset]) - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute( - """SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls, - h.long_image_url, u.username - FROM image_history h - LEFT JOIN users u ON h.user_id = u.id - WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""", - params, - ) - rows = cur.fetchall() - cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count) - total = cur.fetchone()['total'] - conn.close() - items = [] - for r in rows: - items.append({ - 'id': r['id'], - 'user_id': r['user_id'], - 'username': r.get('username') or '-', - 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '', - 'panel_type': r['panel_type'] or '', - 'original_urls': _parse_json(r['original_urls'], []), - 'params': _parse_json(r['params'], {}), - 'result_urls': _parse_json(r['result_urls'], []), - 'long_image_url': (r.get('long_image_url') or '').strip() or None, - }) - return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) +""" +管理员蓝图:用户管理(列表/创建/更新/删除)、生成历史、管理页 +""" +import json +import pymysql +from flask import Blueprint, request, jsonify +from werkzeug.security import generate_password_hash + +from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required +from flask import session + +admin_bp = Blueprint('admin', __name__) + + +def _parse_json(val, default=None): + if val is None: + return default if default is not None else [] + if isinstance(val, (list, dict)): + return val + try: + return json.loads(val) + except Exception: + return default if default is not None else [] + + +@admin_bp.route('/admin') +@login_required +@admin_required +def admin_page(): + return _render_html('admin.html') + + +@admin_bp.route('/api/admin/users') +@admin_required +def admin_list_users(): + """分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选""" + role, current_row = _get_current_admin_role() + if not role: + return jsonify({'success': False, 'error': '需要管理员权限'}), 403 + page = max(1, int(request.args.get('page', 1))) + page_size = min(50, max(5, int(request.args.get('page_size', 15)))) + offset = (page - 1) * page_size + search_username = (request.args.get('username') or request.args.get('search') or '').strip() + created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id') + created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None + if role != 'super_admin': + created_by_id = None + try: + conn = get_db() + with conn.cursor() as cur: + if role == 'super_admin': + where_parts = ["1=1"] + params = [] + if search_username: + where_parts.append("u.username LIKE %s") + params.append("%" + search_username + "%") + if created_by_id is not None: + where_parts.append("u.created_by_id = %s") + params.append(created_by_id) + where_sql = " AND ".join(where_parts) + cur.execute( + """SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id, + creator.username AS creator_username + FROM users u + LEFT JOIN users creator ON creator.id = u.created_by_id + WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""", + tuple(params) + (page_size, offset), + ) + rows = cur.fetchall() + cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params)) + total = cur.fetchone()['total'] + cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id") + admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()] + else: + admin_id = current_row['id'] + where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"] + params = [admin_id, admin_id] + if search_username: + where_parts.append("u.username LIKE %s") + params.append("%" + search_username + "%") + where_sql = " AND ".join(where_parts) + cur.execute( + """SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id, + creator.username AS creator_username + FROM users u + LEFT JOIN users creator ON creator.id = u.created_by_id + WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""", + tuple(params) + (page_size, offset), + ) + rows = cur.fetchall() + cur.execute( + "SELECT COUNT(*) as total FROM users u WHERE " + where_sql, + tuple(params), + ) + total = cur.fetchone()['total'] + admins = [] + items = [ + { + 'id': r['id'], + 'username': r['username'], + 'is_admin': bool(r.get('is_admin')), + 'role': r.get('role') or 'normal', + 'created_by_id': r.get('created_by_id'), + 'creator_username': r.get('creator_username') or '', + 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', + } + for r in rows + ] + conn.close() + payload = { + 'success': True, + 'items': items, + 'total': total, + 'page': page, + 'page_size': page_size, + 'current_user_role': role, + 'admins': admins, + } + return jsonify(payload) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +@admin_bp.route('/api/admin/user', methods=['POST']) +@admin_required +def admin_create_user(): + data = request.get_json() or {} + username = (data.get('username') or '').strip() + password = data.get('password') or '' + role, current_row = _get_current_admin_role() + if not role: + return jsonify({'success': False, 'error': '需要管理员权限'}), 403 + want_role = (data.get('role') or 'normal').strip() or 'normal' + if want_role not in ('admin', 'normal'): + want_role = 'normal' + if role == 'admin' and want_role == 'admin': + return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'}) + if want_role == 'admin': + want_created_by = current_row['id'] + elif role == 'super_admin': + want_created_by = data.get('created_by_id') + else: + want_created_by = current_row['id'] + if not username or not password: + return jsonify({'success': False, 'error': '用户名和密码不能为空'}) + if len(username) < 2: + return jsonify({'success': False, 'error': '用户名至少2个字符'}) + if len(password) < 6: + return jsonify({'success': False, 'error': '密码至少6个字符'}) + if want_role == 'normal' and role == 'super_admin' and want_created_by is None: + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1") + r = cur.fetchone() + conn.close() + want_created_by = r['id'] if r else current_row['id'] + except Exception: + want_created_by = current_row['id'] + if want_role == 'normal' and want_created_by is None: + want_created_by = current_row['id'] + is_admin = 1 if want_role in ('super_admin', 'admin') else 0 + pwd_hash = generate_password_hash(password, method='pbkdf2:sha256') + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute( + "INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)", + (username, pwd_hash, is_admin, want_role, want_created_by), + ) + conn.commit() + conn.close() + return jsonify({'success': True, 'msg': '用户创建成功'}) + except pymysql.IntegrityError: + return jsonify({'success': False, 'error': '用户名已存在'}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +@admin_bp.route('/api/admin/user/', methods=['PUT']) +@admin_required +def admin_update_user(uid): + data = request.get_json() or {} + password = data.get('password') + want_role = (data.get('role') or '').strip() or data.get('role') + role, current_row = _get_current_admin_role() + if not role: + return jsonify({'success': False, 'error': '需要管理员权限'}), 403 + if want_role is None and not password: + return jsonify({'success': False, 'error': '请提供要修改的内容'}) + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,)) + target = cur.fetchone() + if not target: + conn.close() + return jsonify({'success': False, 'error': '用户不存在'}) + if role == 'admin': + if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']: + conn.close() + return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403 + want_role = None + else: + if target.get('role') == 'super_admin': + conn.close() + return jsonify({'success': False, 'error': '不能修改超级管理员'}) + if want_role == 'super_admin': + return jsonify({'success': False, 'error': '不能将用户设为超级管理员'}) + if want_role not in ('admin', 'normal', None, ''): + want_role = None + if password: + if len(password) < 6: + conn.close() + return jsonify({'success': False, 'error': '密码至少6个字符'}) + pwd_hash = generate_password_hash(password, method='pbkdf2:sha256') + cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid)) + if want_role is not None and want_role != '': + is_admin = 1 if want_role == 'admin' else 0 + cur.execute( + "UPDATE users SET is_admin = %s, role = %s WHERE id = %s", + (is_admin, want_role, uid), + ) + conn.commit() + conn.close() + return jsonify({'success': True, 'msg': '更新成功'}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +@admin_bp.route('/api/admin/user/', methods=['DELETE']) +@admin_required +def admin_delete_user(uid): + from flask import session + if session.get('user_id') == uid: + return jsonify({'success': False, 'error': '不能删除当前登录账号'}) + role, current_row = _get_current_admin_role() + if not role: + return jsonify({'success': False, 'error': '需要管理员权限'}), 403 + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,)) + target = cur.fetchone() + if not target: + conn.close() + return jsonify({'success': False, 'error': '用户不存在'}) + if target.get('role') == 'super_admin': + conn.close() + return jsonify({'success': False, 'error': '不能删除超级管理员'}) + if role == 'admin': + if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']: + conn.close() + return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403 + cur.execute("DELETE FROM users WHERE id = %s", (uid,)) + affected = cur.rowcount + conn.commit() + conn.close() + if affected == 0: + return jsonify({'success': False, 'error': '用户不存在'}) + return jsonify({'success': True, 'msg': '删除成功'}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +@admin_bp.route('/api/admin/user//column-permissions') +@login_required +def admin_user_column_permissions(uid): + """获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。""" + current_uid = session.get('user_id') + if current_uid != uid: + role, _ = _get_current_admin_role() + if not role: + return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403 + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT role FROM users WHERE id = %s", (uid,)) + user_row = cur.fetchone() + if user_row and (user_row.get('role') or '').strip() == 'super_admin': + cur.execute(""" + SELECT id, name, column_key, created_at FROM columns ORDER BY id + """) + rows = cur.fetchall() + else: + cur.execute(""" + SELECT c.id, c.name, c.column_key, c.created_at + FROM columns c + INNER JOIN user_column_permission ucp ON ucp.column_id = c.id + WHERE ucp.user_id = %s + ORDER BY c.id + """, (uid,)) + rows = cur.fetchall() + conn.close() + items = [ + { + 'id': r['id'], + 'name': r['name'], + 'column_key': r['column_key'], + 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', + } + for r in rows + ] + return jsonify({'success': True, 'items': items}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + +@admin_bp.route('/api/admin/history') +@admin_required +def admin_history(): + """管理员分页获取所有生成记录,支持按用户和时间筛选""" + page = max(1, int(request.args.get('page', 1))) + page_size = min(50, max(10, int(request.args.get('page_size', 15)))) + offset = (page - 1) * page_size + user_id = request.args.get('user_id', type=int) + time_start = (request.args.get('time_start') or '').strip() + time_end = (request.args.get('time_end') or '').strip() + conditions, params = [], [] + if user_id: + conditions.append("h.user_id = %s") + params.append(user_id) + if time_start: + conditions.append("h.created_at >= %s") + params.append(time_start) + if time_end: + conditions.append("h.created_at <= %s") + params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end) + where_clause = " AND ".join(conditions) if conditions else "1=1" + params_count = params[:] + params.extend([page_size, offset]) + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute( + """SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls, + h.long_image_url, u.username + FROM image_history h + LEFT JOIN users u ON h.user_id = u.id + WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""", + params, + ) + rows = cur.fetchall() + cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count) + total = cur.fetchone()['total'] + conn.close() + items = [] + for r in rows: + items.append({ + 'id': r['id'], + 'user_id': r['user_id'], + 'username': r.get('username') or '-', + 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '', + 'panel_type': r['panel_type'] or '', + 'original_urls': _parse_json(r['original_urls'], []), + 'params': _parse_json(r['params'], {}), + 'result_urls': _parse_json(r['result_urls'], []), + 'long_image_url': (r.get('long_image_url') or '').strip() or None, + }) + return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) diff --git a/blueprints/auth.py b/source_code/blueprints/auth.py similarity index 97% rename from blueprints/auth.py rename to source_code/blueprints/auth.py index 7b003e0..6bf5896 100644 --- a/blueprints/auth.py +++ b/source_code/blueprints/auth.py @@ -1,107 +1,107 @@ -""" -认证蓝图:登录、登出、登录状态校验 -""" -from flask import Blueprint, request, redirect, url_for, session, jsonify -from werkzeug.security import check_password_hash - -from app_common import ( - get_db, - _render_html, - _is_session_user_valid, - login_required, - BASE_DIR, -) -from tool.devices import DeviceIDGenerator - -auth_bp = Blueprint('auth', __name__) - - -@auth_bp.route('/login', methods=['GET', 'POST']) -def login(): - if session.get('user_id') and _is_session_user_valid(): - return redirect(url_for('main.home')) - if request.method == 'POST': - data = request.get_json() if request.is_json else request.form - username = (data.get('username') or '').strip() - password = data.get('password') or '' - if not username or not password: - if request.is_json: - return jsonify({'success': False, 'error': '请输入用户名和密码'}) - return _render_html('login.html', error='请输入用户名和密码') - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute( - "SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s", - (username,) - ) - row = cur.fetchone() - if row and check_password_hash(row['password_hash'], password): - current_machine = DeviceIDGenerator().get_device_id() - stored_machine = (row.get('machine') or '').strip() - if not stored_machine: - with conn.cursor() as cur: - cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id'])) - conn.commit() - conn.close() - session.permanent = True - session['user_id'] = row['id'] - session['username'] = username - if request.is_json: - return jsonify({'success': True, 'redirect': url_for('main.home')}) - return redirect(url_for('main.home')) - print("验证设备",stored_machine) - print("当前设备",current_machine) - if stored_machine != current_machine and row.get("is_admin") != 1: - conn.close() - err_msg = '当前设备与首次登录设备不一致,请在原设备上登录' - if request.is_json: - return jsonify({'success': False, 'error': err_msg}) - return _render_html('login.html', error=err_msg) - conn.close() - session.permanent = True - session['user_id'] = row['id'] - session['username'] = username - if request.is_json: - return jsonify({'success': True, 'redirect': url_for('main.home')}) - return redirect(url_for('main.home')) - conn.close() - except Exception as e: - if request.is_json: - return jsonify({'success': False, 'error': str(e)}) - return _render_html('login.html', error='登录失败,请稍后重试') - if request.is_json: - return jsonify({'success': False, 'error': '用户名或密码错误'}) - return _render_html('login.html', error='用户名或密码错误') - return _render_html('login.html') - - -@auth_bp.route('/api/auth/check') -@login_required -def api_auth_check(): - """校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致""" - if not session.get('user_id'): - return jsonify({'logged_in': False}) - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],)) - row = cur.fetchone() - conn.close() - if not row: - return jsonify({'logged_in': False}) - stored_machine = (row.get('machine') or '').strip() - if stored_machine: - current_machine = DeviceIDGenerator().get_device_id() - if stored_machine != current_machine and row.get("is_admin") != 1: - session.clear() - return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'}) - except Exception: - return jsonify({'logged_in': False}) - return jsonify({'logged_in': True, 'redirect': url_for('main.home')}) - - -@auth_bp.route('/logout') -def logout(): - session.clear() - return redirect(url_for('auth.login')) +""" +认证蓝图:登录、登出、登录状态校验 +""" +from flask import Blueprint, request, redirect, url_for, session, jsonify +from werkzeug.security import check_password_hash + +from app_common import ( + get_db, + _render_html, + _is_session_user_valid, + login_required, + BASE_DIR, +) +from tool.devices import DeviceIDGenerator + +auth_bp = Blueprint('auth', __name__) + + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if session.get('user_id') and _is_session_user_valid(): + return redirect(url_for('main.home')) + if request.method == 'POST': + data = request.get_json() if request.is_json else request.form + username = (data.get('username') or '').strip() + password = data.get('password') or '' + if not username or not password: + if request.is_json: + return jsonify({'success': False, 'error': '请输入用户名和密码'}) + return _render_html('login.html', error='请输入用户名和密码') + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute( + "SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s", + (username,) + ) + row = cur.fetchone() + if row and check_password_hash(row['password_hash'], password): + current_machine = DeviceIDGenerator().get_device_id() + stored_machine = (row.get('machine') or '').strip() + if not stored_machine: + with conn.cursor() as cur: + cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id'])) + conn.commit() + conn.close() + session.permanent = True + session['user_id'] = row['id'] + session['username'] = username + if request.is_json: + return jsonify({'success': True, 'redirect': url_for('main.home')}) + return redirect(url_for('main.home')) + print("验证设备",stored_machine) + print("当前设备",current_machine) + if stored_machine != current_machine and row.get("is_admin") != 1: + conn.close() + err_msg = '当前设备与首次登录设备不一致,请在原设备上登录' + if request.is_json: + return jsonify({'success': False, 'error': err_msg}) + return _render_html('login.html', error=err_msg) + conn.close() + session.permanent = True + session['user_id'] = row['id'] + session['username'] = username + if request.is_json: + return jsonify({'success': True, 'redirect': url_for('main.home')}) + return redirect(url_for('main.home')) + conn.close() + except Exception as e: + if request.is_json: + return jsonify({'success': False, 'error': str(e)}) + return _render_html('login.html', error='登录失败,请稍后重试') + if request.is_json: + return jsonify({'success': False, 'error': '用户名或密码错误'}) + return _render_html('login.html', error='用户名或密码错误') + return _render_html('login.html') + + +@auth_bp.route('/api/auth/check') +@login_required +def api_auth_check(): + """校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致""" + if not session.get('user_id'): + return jsonify({'logged_in': False}) + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],)) + row = cur.fetchone() + conn.close() + if not row: + return jsonify({'logged_in': False}) + stored_machine = (row.get('machine') or '').strip() + if stored_machine: + current_machine = DeviceIDGenerator().get_device_id() + if stored_machine != current_machine and row.get("is_admin") != 1: + session.clear() + return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'}) + except Exception: + return jsonify({'logged_in': False}) + return jsonify({'logged_in': True, 'redirect': url_for('main.home')}) + + +@auth_bp.route('/logout') +def logout(): + session.clear() + return redirect(url_for('auth.login')) diff --git a/blueprints/brand.py b/source_code/blueprints/brand.py similarity index 100% rename from blueprints/brand.py rename to source_code/blueprints/brand.py diff --git a/blueprints/image.py b/source_code/blueprints/image.py similarity index 97% rename from blueprints/image.py rename to source_code/blueprints/image.py index c454d54..ae07cbe 100644 --- a/blueprints/image.py +++ b/source_code/blueprints/image.py @@ -1,411 +1,411 @@ -""" -图片生成蓝图:生成、历史、下载、拼接、版本 -""" -import os -import sys -import json -import re -import io -import base64 -import tempfile -import threading -import subprocess -import requests -from urllib.parse import urlparse, quote - -from flask import Blueprint, request, jsonify, Response, session, send_file -from PIL import Image - -from app_common import get_db, login_required, BASE_DIR -from config import STITCH_WORKFLOW_ID,client_name - -image_bp = Blueprint('image', __name__) - - -def _load_image_from_url_or_data(url_or_data): - """从 http(s) URL 或 data URL 加载为 PIL Image,失败返回 None""" - if not url_or_data or not isinstance(url_or_data, str): - return None - try: - if url_or_data.startswith('data:'): - m = re.match(r'data:image/[^;]+;base64,(.+)', url_or_data, re.DOTALL) - if not m: - return None - raw = base64.b64decode(m.group(1).strip()) - img = Image.open(io.BytesIO(raw)) - elif url_or_data.startswith(('http://', 'https://')): - resp = requests.get(url_or_data, timeout=15) - resp.raise_for_status() - img = Image.open(io.BytesIO(resp.content)) - else: - return None - if img.mode != 'RGB': - img = img.convert('RGB') - return img - except Exception: - return None - - -def _stitch_and_upload_long_image(urls): - """将多张图片 URL 先上传获取 file_id,再调用 workflow_run 拼接长图,返回 data.merged_image_url;失败返回 None。""" - if not urls or not isinstance(urls, (list, tuple)): - return None - from coze import upload_file as coze_upload_file, workflow_run - file_ids = [] - temp_paths = [] - try: - for u in urls: - img = _load_image_from_url_or_data(u) - if img is None: - continue - fd, path = tempfile.mkstemp(suffix='.png') - try: - os.close(fd) - img.save(path) - temp_paths.append(path) - resp = coze_upload_file(path) - if resp.get('code') == 0 and resp.get('data', {}).get('id'): - file_ids.append(resp['data']['id']) - except Exception: - pass - if not file_ids: - return None - parameters = {"images": [{"file_id": fid} for fid in file_ids]} - resp = workflow_run(STITCH_WORKFLOW_ID, parameters, is_async=False) - if resp.get('code') != 0: - return None - data = resp.get('data') or {} - data = json.loads(data) - merged_image_url = data.get('merged_image_url') - if merged_image_url: - return merged_image_url - output_str = data.get('output') or '' - if output_str: - try: - outer = json.loads(output_str) - inner_str = outer.get('Output', '{}') - inner = json.loads(inner_str) - data_str = inner.get('data', '[]') - inner_data = json.loads(data_str) - merged_image_url = inner_data.get('merged_image_url') - return merged_image_url - except Exception: - pass - return None - except Exception: - return None - finally: - for p in temp_paths: - try: - os.unlink(p) - except Exception: - pass - - -def _sanitize_params_for_history(params): - """移除 base64 大字段及敏感字段,仅保留可存储的请求参数""" - exclude = ('ref_images', 'proc_images', 'layout_image') - out = {} - for k, v in (params or {}).items(): - if k == 'api_key': - continue - if k in exclude: - if isinstance(v, list): - out[f'{k}_count'] = len(v) - else: - out[f'{k}_count'] = 1 if v else 0 - elif isinstance(v, (str, int, float, bool, type(None))): - out[k] = v - elif isinstance(v, list) and not v: - out[k] = [] - elif isinstance(v, list) and isinstance(v[0], (str, int, float, bool)): - out[k] = v - else: - out[k] = str(v)[:200] if v else None - return out - - -@image_bp.route('/api/generate', methods=['POST']) -@login_required -def api_generate(): - """生成图片:调用 generate_api,上传原图到 OSS,保存历史记录""" - try: - params = request.get_json() or {} - from generate_api import generate - result = generate(params) - if result.get('success') and result.get('urls'): - long_image_url = result.get("long_image_url") - result["long_image_url"] = long_image_url - import json as _json - history_id = None - try: - hid = params.get('history_id') - if hid is not None: - try: - hid = int(hid) - except (TypeError, ValueError): - hid = None - conn = get_db() - with conn.cursor() as cur: - if hid is not None and hid > 0: - cur.execute( - "SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s", - (hid, session['user_id']), - ) - row = cur.fetchone() - existing_urls = [] - if row and row.get('result_urls'): - try: - existing_urls = _json.loads(row['result_urls']) - except Exception: - existing_urls = [] - new_urls = result.get('urls') or [] - new_url = new_urls[0] if new_urls else None - idx = params.get('history_index', 0) - try: - idx = int(idx) - except (TypeError, ValueError): - idx = 0 - if new_url: - if not isinstance(existing_urls, list): - existing_urls = [] - while len(existing_urls) <= idx: - existing_urls.append(existing_urls[-1] if existing_urls else new_url) - existing_urls[idx] = new_url - merged_result_urls = existing_urls or new_urls - cur.execute( - """UPDATE image_history SET panel_type=%s, original_urls=%s, params=%s, result_urls=%s - WHERE id=%s AND user_id=%s""", - ( - params.get('panel_type', ''), - _json.dumps(result.get('original_urls') or []), - _json.dumps(_sanitize_params_for_history(params)), - _json.dumps(merged_result_urls), - hid, - session['user_id'], - ), - ) - if cur.rowcount > 0: - history_id = hid - else: - cur.execute( - """INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url) - VALUES (%s, %s, %s, %s, %s, %s)""", - ( - session['user_id'], - params.get('panel_type', ''), - _json.dumps(result.get('original_urls') or []), - _json.dumps(_sanitize_params_for_history(params)), - _json.dumps(result.get('urls') or []), - long_image_url, - ), - ) - history_id = cur.lastrowid - conn.commit() - conn.close() - except Exception: - pass - if history_id is not None: - result['history_id'] = history_id - return jsonify(result) - except Exception as e: - import traceback - traceback.print_exc() - return jsonify({'success': False, 'urls': [], 'error': str(e)}) - - -@image_bp.route('/api/version') -def api_version(): - """检测更新:请求 APP_UPDATE_URL 获取最新版本信息,与当前版本比较""" - current_version = (os.environ.get('APP_VERSION', '1.0.0') or '1.0.0').strip() - update_url = (os.environ.get('APP_UPDATE_URL', '') or '').strip() - result = { - 'version': current_version, - 'desc': '', - 'url': '', - 'has_update': False, - 'latest_version': current_version, - 'file_url': '', - } - if not update_url: - return jsonify(result) - try: - resp = requests.get(update_url, timeout=10) - resp.raise_for_status() - data = resp.json() or {} - latest_version = (data.get('version') or '').strip() - file_url = (data.get('file_url') or '').strip() - result['latest_version'] = latest_version - result['file_url'] = file_url - result['url'] = file_url - # 版本不一致则视为有更新 - if latest_version and latest_version != current_version: - result['has_update'] = True - except Exception: - pass - return jsonify(result) - - -def _run_update_and_exit(zip_path, target_dir): - """在后台延迟后启动 update.exe(脱离当前进程),然后退出当前程序""" - def _do(): - import time - time.sleep(1.5) # 确保 HTTP 响应已发送 - # exe_dir = target_dir - # exe_dir = "D:\\pack\\nanri\\update_exe\\main.dist" - exe_dir = os.path.join(BASE_DIR,"update") - update_exe = os.path.join(exe_dir, 'update.exe') - if not os.path.isfile(update_exe): - return - try: - creationflags = 0 - if sys.platform == 'win32': - creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP - subprocess.Popen( - [update_exe, '--zip', zip_path, '--target', target_dir, '--process',client_name], - cwd=exe_dir, - creationflags=creationflags, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - close_fds=True, - ) - except Exception: - pass - os._exit(0) - t = threading.Thread(target=_do, daemon=False) - t.start() - - -@image_bp.route('/api/update/do', methods=['POST']) -def api_update_do(): - """执行更新:下载 zip 到 tmp,启动 update.exe 后退出程序""" - data = request.get_json() or {} - file_url = (data.get('file_url') or '').strip() - if not file_url: - return jsonify({'success': False, 'error': '缺少 file_url'}), 400 - parsed = urlparse(file_url) - if parsed.scheme not in ('http', 'https'): - return jsonify({'success': False, 'error': '无效的下载地址'}), 400 - tmp_dir = os.path.join(BASE_DIR, 'tmp') - try: - os.makedirs(tmp_dir, exist_ok=True) - except Exception as e: - return jsonify({'success': False, 'error': f'创建 tmp 目录失败: {e}'}), 500 - # 使用 URL 中的文件名或默认版本名 - filename = os.path.basename(parsed.path) or 'update.zip' - zip_path = os.path.join(tmp_dir, filename) - try: - resp = requests.get(file_url, timeout=300, stream=True) - resp.raise_for_status() - with open(zip_path, 'wb') as f: - for chunk in resp.iter_content(chunk_size=65536): - if chunk: - f.write(chunk) - except requests.RequestException as e: - return jsonify({'success': False, 'error': f'下载失败: {e}'}), 502 - _run_update_and_exit(zip_path, BASE_DIR) - return jsonify({'success': True, 'message': '更新已启动,程序即将退出'}) - - -@image_bp.route('/api/download') -@login_required -def api_download(): - """代理下载图片,解决跨域 fetch 无法下载的问题""" - url = request.args.get('url', '').strip() - filename = request.args.get('filename', 'image.png') - if not url: - return jsonify({'success': False, 'error': '缺少 url 参数'}), 400 - parsed = urlparse(url) - if parsed.scheme not in ('http', 'https'): - return jsonify({'success': False, 'error': '仅支持 http/https 链接'}), 400 - try: - resp = requests.get(url, timeout=30, stream=True) - resp.raise_for_status() - content_type = resp.headers.get('Content-Type', 'image/png') - encoded = quote(filename, safe='') - disposition = f"attachment; filename*=UTF-8''{encoded}" - return Response( - resp.iter_content(chunk_size=8192), - mimetype=content_type, - headers={'Content-Disposition': disposition} - ) - except requests.RequestException as e: - return jsonify({'success': False, 'error': str(e)}), 502 - - -@image_bp.route('/api/stitch/save', methods=['POST']) -@login_required -def api_stitch_save(): - """手动拼接:接收图片 URL 列表(支持 http 或 data URL),拼接并上传,返回长图 URL""" - try: - data = request.get_json() or {} - urls = data.get('urls') - if not urls or not isinstance(urls, list): - return jsonify({'success': False, 'error': '请提供 urls 数组'}), 400 - urls = [u for u in urls if u and isinstance(u, str)] - if not urls: - return jsonify({'success': False, 'error': '没有有效的图片'}), 400 - long_image_url = _stitch_and_upload_long_image(urls) - if not long_image_url: - return jsonify({'success': False, 'error': '拼接或上传失败'}), 500 - return jsonify({'success': True, 'long_image_url': long_image_url}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}), 500 - - -@image_bp.route('/api/history') -@login_required -def api_history(): - """分页获取当前用户的历史图库,支持按 panel_type 栏目筛选""" - page = max(1, int(request.args.get('page', 1))) - page_size = min(50, max(10, int(request.args.get('page_size', 20)))) - panel_type = (request.args.get('panel_type') or '').strip() - offset = (page - 1) * page_size - try: - conn = get_db() - with conn.cursor() as cur: - where_user = "user_id = %s" - params_where = [session['user_id']] - if panel_type: - where_user += " AND panel_type = %s" - params_where.append(panel_type) - cur.execute( - """SELECT id, created_at, panel_type, original_urls, params, result_urls, long_image_url - FROM image_history WHERE """ + where_user + """ ORDER BY created_at DESC LIMIT %s OFFSET %s""", - params_where + [page_size, offset], - ) - rows = cur.fetchall() - cur.execute("SELECT COUNT(*) as total FROM image_history WHERE " + where_user, params_where) - total = cur.fetchone()['total'] - conn.close() - - def _parse_json(val, default=None): - if val is None: - return default if default is not None else [] - if isinstance(val, (list, dict)): - return val - try: - return json.loads(val) - except Exception: - return default if default is not None else [] - - items = [] - for r in rows: - items.append({ - 'id': r['id'], - 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '', - 'panel_type': r['panel_type'] or '', - 'original_urls': _parse_json(r['original_urls'], []), - 'params': _parse_json(r['params'], {}), - 'result_urls': _parse_json(r['result_urls'], []), - 'long_image_url': (r.get('long_image_url') or '').strip() or None, - }) - return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size}) - except Exception as e: - return jsonify({'success': False, 'error': str(e)}) - - - - - +""" +图片生成蓝图:生成、历史、下载、拼接、版本 +""" +import os +import sys +import json +import re +import io +import base64 +import tempfile +import threading +import subprocess +import requests +from urllib.parse import urlparse, quote + +from flask import Blueprint, request, jsonify, Response, session, send_file +from PIL import Image + +from app_common import get_db, login_required, BASE_DIR +from config import STITCH_WORKFLOW_ID,client_name + +image_bp = Blueprint('image', __name__) + + +def _load_image_from_url_or_data(url_or_data): + """从 http(s) URL 或 data URL 加载为 PIL Image,失败返回 None""" + if not url_or_data or not isinstance(url_or_data, str): + return None + try: + if url_or_data.startswith('data:'): + m = re.match(r'data:image/[^;]+;base64,(.+)', url_or_data, re.DOTALL) + if not m: + return None + raw = base64.b64decode(m.group(1).strip()) + img = Image.open(io.BytesIO(raw)) + elif url_or_data.startswith(('http://', 'https://')): + resp = requests.get(url_or_data, timeout=15) + resp.raise_for_status() + img = Image.open(io.BytesIO(resp.content)) + else: + return None + if img.mode != 'RGB': + img = img.convert('RGB') + return img + except Exception: + return None + + +def _stitch_and_upload_long_image(urls): + """将多张图片 URL 先上传获取 file_id,再调用 workflow_run 拼接长图,返回 data.merged_image_url;失败返回 None。""" + if not urls or not isinstance(urls, (list, tuple)): + return None + from coze import upload_file as coze_upload_file, workflow_run + file_ids = [] + temp_paths = [] + try: + for u in urls: + img = _load_image_from_url_or_data(u) + if img is None: + continue + fd, path = tempfile.mkstemp(suffix='.png') + try: + os.close(fd) + img.save(path) + temp_paths.append(path) + resp = coze_upload_file(path) + if resp.get('code') == 0 and resp.get('data', {}).get('id'): + file_ids.append(resp['data']['id']) + except Exception: + pass + if not file_ids: + return None + parameters = {"images": [{"file_id": fid} for fid in file_ids]} + resp = workflow_run(STITCH_WORKFLOW_ID, parameters, is_async=False) + if resp.get('code') != 0: + return None + data = resp.get('data') or {} + data = json.loads(data) + merged_image_url = data.get('merged_image_url') + if merged_image_url: + return merged_image_url + output_str = data.get('output') or '' + if output_str: + try: + outer = json.loads(output_str) + inner_str = outer.get('Output', '{}') + inner = json.loads(inner_str) + data_str = inner.get('data', '[]') + inner_data = json.loads(data_str) + merged_image_url = inner_data.get('merged_image_url') + return merged_image_url + except Exception: + pass + return None + except Exception: + return None + finally: + for p in temp_paths: + try: + os.unlink(p) + except Exception: + pass + + +def _sanitize_params_for_history(params): + """移除 base64 大字段及敏感字段,仅保留可存储的请求参数""" + exclude = ('ref_images', 'proc_images', 'layout_image') + out = {} + for k, v in (params or {}).items(): + if k == 'api_key': + continue + if k in exclude: + if isinstance(v, list): + out[f'{k}_count'] = len(v) + else: + out[f'{k}_count'] = 1 if v else 0 + elif isinstance(v, (str, int, float, bool, type(None))): + out[k] = v + elif isinstance(v, list) and not v: + out[k] = [] + elif isinstance(v, list) and isinstance(v[0], (str, int, float, bool)): + out[k] = v + else: + out[k] = str(v)[:200] if v else None + return out + + +@image_bp.route('/api/generate', methods=['POST']) +@login_required +def api_generate(): + """生成图片:调用 generate_api,上传原图到 OSS,保存历史记录""" + try: + params = request.get_json() or {} + from generate_api import generate + result = generate(params) + if result.get('success') and result.get('urls'): + long_image_url = result.get("long_image_url") + result["long_image_url"] = long_image_url + import json as _json + history_id = None + try: + hid = params.get('history_id') + if hid is not None: + try: + hid = int(hid) + except (TypeError, ValueError): + hid = None + conn = get_db() + with conn.cursor() as cur: + if hid is not None and hid > 0: + cur.execute( + "SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s", + (hid, session['user_id']), + ) + row = cur.fetchone() + existing_urls = [] + if row and row.get('result_urls'): + try: + existing_urls = _json.loads(row['result_urls']) + except Exception: + existing_urls = [] + new_urls = result.get('urls') or [] + new_url = new_urls[0] if new_urls else None + idx = params.get('history_index', 0) + try: + idx = int(idx) + except (TypeError, ValueError): + idx = 0 + if new_url: + if not isinstance(existing_urls, list): + existing_urls = [] + while len(existing_urls) <= idx: + existing_urls.append(existing_urls[-1] if existing_urls else new_url) + existing_urls[idx] = new_url + merged_result_urls = existing_urls or new_urls + cur.execute( + """UPDATE image_history SET panel_type=%s, original_urls=%s, params=%s, result_urls=%s + WHERE id=%s AND user_id=%s""", + ( + params.get('panel_type', ''), + _json.dumps(result.get('original_urls') or []), + _json.dumps(_sanitize_params_for_history(params)), + _json.dumps(merged_result_urls), + hid, + session['user_id'], + ), + ) + if cur.rowcount > 0: + history_id = hid + else: + cur.execute( + """INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url) + VALUES (%s, %s, %s, %s, %s, %s)""", + ( + session['user_id'], + params.get('panel_type', ''), + _json.dumps(result.get('original_urls') or []), + _json.dumps(_sanitize_params_for_history(params)), + _json.dumps(result.get('urls') or []), + long_image_url, + ), + ) + history_id = cur.lastrowid + conn.commit() + conn.close() + except Exception: + pass + if history_id is not None: + result['history_id'] = history_id + return jsonify(result) + except Exception as e: + import traceback + traceback.print_exc() + return jsonify({'success': False, 'urls': [], 'error': str(e)}) + + +@image_bp.route('/api/version') +def api_version(): + """检测更新:请求 APP_UPDATE_URL 获取最新版本信息,与当前版本比较""" + current_version = (os.environ.get('APP_VERSION', '1.0.0') or '1.0.0').strip() + update_url = (os.environ.get('APP_UPDATE_URL', '') or '').strip() + result = { + 'version': current_version, + 'desc': '', + 'url': '', + 'has_update': False, + 'latest_version': current_version, + 'file_url': '', + } + if not update_url: + return jsonify(result) + try: + resp = requests.get(update_url, timeout=10) + resp.raise_for_status() + data = resp.json() or {} + latest_version = (data.get('version') or '').strip() + file_url = (data.get('file_url') or '').strip() + result['latest_version'] = latest_version + result['file_url'] = file_url + result['url'] = file_url + # 版本不一致则视为有更新 + if latest_version and latest_version != current_version: + result['has_update'] = True + except Exception: + pass + return jsonify(result) + + +def _run_update_and_exit(zip_path, target_dir): + """在后台延迟后启动 update.exe(脱离当前进程),然后退出当前程序""" + def _do(): + import time + time.sleep(1.5) # 确保 HTTP 响应已发送 + # exe_dir = target_dir + # exe_dir = "D:\\pack\\nanri\\update_exe\\main.dist" + exe_dir = os.path.join(BASE_DIR,"update") + update_exe = os.path.join(exe_dir, 'update.exe') + if not os.path.isfile(update_exe): + return + try: + creationflags = 0 + if sys.platform == 'win32': + creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + subprocess.Popen( + [update_exe, '--zip', zip_path, '--target', target_dir, '--process',client_name], + cwd=exe_dir, + creationflags=creationflags, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ) + except Exception: + pass + os._exit(0) + t = threading.Thread(target=_do, daemon=False) + t.start() + + +@image_bp.route('/api/update/do', methods=['POST']) +def api_update_do(): + """执行更新:下载 zip 到 tmp,启动 update.exe 后退出程序""" + data = request.get_json() or {} + file_url = (data.get('file_url') or '').strip() + if not file_url: + return jsonify({'success': False, 'error': '缺少 file_url'}), 400 + parsed = urlparse(file_url) + if parsed.scheme not in ('http', 'https'): + return jsonify({'success': False, 'error': '无效的下载地址'}), 400 + tmp_dir = os.path.join(BASE_DIR, 'tmp') + try: + os.makedirs(tmp_dir, exist_ok=True) + except Exception as e: + return jsonify({'success': False, 'error': f'创建 tmp 目录失败: {e}'}), 500 + # 使用 URL 中的文件名或默认版本名 + filename = os.path.basename(parsed.path) or 'update.zip' + zip_path = os.path.join(tmp_dir, filename) + try: + resp = requests.get(file_url, timeout=300, stream=True) + resp.raise_for_status() + with open(zip_path, 'wb') as f: + for chunk in resp.iter_content(chunk_size=65536): + if chunk: + f.write(chunk) + except requests.RequestException as e: + return jsonify({'success': False, 'error': f'下载失败: {e}'}), 502 + _run_update_and_exit(zip_path, BASE_DIR) + return jsonify({'success': True, 'message': '更新已启动,程序即将退出'}) + + +@image_bp.route('/api/download') +@login_required +def api_download(): + """代理下载图片,解决跨域 fetch 无法下载的问题""" + url = request.args.get('url', '').strip() + filename = request.args.get('filename', 'image.png') + if not url: + return jsonify({'success': False, 'error': '缺少 url 参数'}), 400 + parsed = urlparse(url) + if parsed.scheme not in ('http', 'https'): + return jsonify({'success': False, 'error': '仅支持 http/https 链接'}), 400 + try: + resp = requests.get(url, timeout=30, stream=True) + resp.raise_for_status() + content_type = resp.headers.get('Content-Type', 'image/png') + encoded = quote(filename, safe='') + disposition = f"attachment; filename*=UTF-8''{encoded}" + return Response( + resp.iter_content(chunk_size=8192), + mimetype=content_type, + headers={'Content-Disposition': disposition} + ) + except requests.RequestException as e: + return jsonify({'success': False, 'error': str(e)}), 502 + + +@image_bp.route('/api/stitch/save', methods=['POST']) +@login_required +def api_stitch_save(): + """手动拼接:接收图片 URL 列表(支持 http 或 data URL),拼接并上传,返回长图 URL""" + try: + data = request.get_json() or {} + urls = data.get('urls') + if not urls or not isinstance(urls, list): + return jsonify({'success': False, 'error': '请提供 urls 数组'}), 400 + urls = [u for u in urls if u and isinstance(u, str)] + if not urls: + return jsonify({'success': False, 'error': '没有有效的图片'}), 400 + long_image_url = _stitch_and_upload_long_image(urls) + if not long_image_url: + return jsonify({'success': False, 'error': '拼接或上传失败'}), 500 + return jsonify({'success': True, 'long_image_url': long_image_url}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@image_bp.route('/api/history') +@login_required +def api_history(): + """分页获取当前用户的历史图库,支持按 panel_type 栏目筛选""" + page = max(1, int(request.args.get('page', 1))) + page_size = min(50, max(10, int(request.args.get('page_size', 20)))) + panel_type = (request.args.get('panel_type') or '').strip() + offset = (page - 1) * page_size + try: + conn = get_db() + with conn.cursor() as cur: + where_user = "user_id = %s" + params_where = [session['user_id']] + if panel_type: + where_user += " AND panel_type = %s" + params_where.append(panel_type) + cur.execute( + """SELECT id, created_at, panel_type, original_urls, params, result_urls, long_image_url + FROM image_history WHERE """ + where_user + """ ORDER BY created_at DESC LIMIT %s OFFSET %s""", + params_where + [page_size, offset], + ) + rows = cur.fetchall() + cur.execute("SELECT COUNT(*) as total FROM image_history WHERE " + where_user, params_where) + total = cur.fetchone()['total'] + conn.close() + + def _parse_json(val, default=None): + if val is None: + return default if default is not None else [] + if isinstance(val, (list, dict)): + return val + try: + return json.loads(val) + except Exception: + return default if default is not None else [] + + items = [] + for r in rows: + items.append({ + 'id': r['id'], + 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '', + 'panel_type': r['panel_type'] or '', + 'original_urls': _parse_json(r['original_urls'], []), + 'params': _parse_json(r['params'], {}), + 'result_urls': _parse_json(r['result_urls'], []), + 'long_image_url': (r.get('long_image_url') or '').strip() or None, + }) + return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}) + + + + + diff --git a/blueprints/main.py b/source_code/blueprints/main.py similarity index 88% rename from blueprints/main.py rename to source_code/blueprints/main.py index c6ec5a2..cec6167 100644 --- a/blueprints/main.py +++ b/source_code/blueprints/main.py @@ -1,69 +1,78 @@ -""" -主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo -""" -import os -from flask import Blueprint, send_file - -from app_common import ( - get_db, - _render_html, - _is_session_user_valid, - login_required, - admin_required, - STATIC_DIR, - BASE_DIR, -) -from flask import redirect, url_for, session - -from config import base_url,version - -main_bp = Blueprint('main', __name__) - - -@main_bp.route('/') -def index(): - if session.get('user_id') and _is_session_user_valid(): - return redirect(url_for('main.home')) - return redirect(url_for('auth.login')) - - -@main_bp.route('/home') -@login_required -def home(): - try: - conn = get_db() - with conn.cursor() as cur: - cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],)) - row = cur.fetchone() - conn.close() - return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version) - except Exception: - return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version) - - -@main_bp.route('/image') -@login_required -def wb(): - return _render_html('index.html') - - -@main_bp.route('/brand') -@login_required -def brand_page(): - return _render_html('brand.html') - - -@main_bp.route('/static/') -def serve_static(filename): - """提供 static 目录及子目录下的静态文件访问。""" - filepath = os.path.normpath(os.path.join(STATIC_DIR, filename)) - static_abs = os.path.abspath(STATIC_DIR) - file_abs = os.path.abspath(filepath) - if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): - return '', 404 - return send_file(file_abs, as_attachment=False) - - -@main_bp.route('/logo.jpg', methods=['GET']) -def get_logo_image(): - return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg') +""" +主页面蓝图:首页、home、图片工作台、品牌页、静态文件、Logo +""" +import os +from flask import Blueprint, send_file + +from app_common import ( + get_db, + _render_html, + _is_session_user_valid, + login_required, + admin_required, + STATIC_DIR, + BASE_DIR, +) +from flask import redirect, url_for, session + +from config import base_url,version + +main_bp = Blueprint('main', __name__) + + +@main_bp.route('/') +def index(): + if session.get('user_id') and _is_session_user_valid(): + return redirect(url_for('main.home')) + return redirect(url_for('auth.login')) + + +@main_bp.route('/home') +@login_required +def home(): + try: + conn = get_db() + with conn.cursor() as cur: + cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],)) + row = cur.fetchone() + conn.close() + return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version) + except Exception: + return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version) + + +@main_bp.route('/image') +@login_required +def wb(): + return _render_html('index.html') + + +@main_bp.route('/brand') +@login_required +def brand_page(): + return _render_html('brand.html') + + +@main_bp.route('/brand-tools') +@login_required +def brand_tools_page(): + html_path = os.path.join(STATIC_DIR, 'brand-tools', 'brand-tools.html') + if os.path.isfile(html_path): + return send_file(html_path) + return _render_html('brand_tools.html') + + +@main_bp.route('/static/') +def serve_static(filename): + """提供 static 目录及子目录下的静态文件访问。""" + filepath = os.path.normpath(os.path.join(STATIC_DIR, filename)) + static_abs = os.path.abspath(STATIC_DIR) + file_abs = os.path.abspath(filepath) + if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs): + return '', 404 + return send_file(file_abs, as_attachment=False) + + +@main_bp.route('/logo.jpg', methods=['GET']) +def get_logo_image(): + return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg') diff --git a/brand_spider/__pycache__/main.cpython-39.pyc b/source_code/brand_spider/__pycache__/main.cpython-39.pyc similarity index 100% rename from brand_spider/__pycache__/main.cpython-39.pyc rename to source_code/brand_spider/__pycache__/main.cpython-39.pyc diff --git a/brand_spider/__pycache__/web_dec.cpython-39.pyc b/source_code/brand_spider/__pycache__/web_dec.cpython-39.pyc similarity index 100% rename from brand_spider/__pycache__/web_dec.cpython-39.pyc rename to source_code/brand_spider/__pycache__/web_dec.cpython-39.pyc diff --git a/brand_spider/main.py b/source_code/brand_spider/main.py similarity index 100% rename from brand_spider/main.py rename to source_code/brand_spider/main.py diff --git a/brand_spider/web_dec.py b/source_code/brand_spider/web_dec.py similarity index 97% rename from brand_spider/web_dec.py rename to source_code/brand_spider/web_dec.py index 7610c86..09fa965 100644 --- a/brand_spider/web_dec.py +++ b/source_code/brand_spider/web_dec.py @@ -1,126 +1,126 @@ -""" -通过 pywebview 加载本地 HTML,执行 JS 实现解密与 GUID 生成。 -""" -import json -import threading -import traceback - -import webview - -# 内嵌 HTML:加载 CryptoJS 并定义 decryptWithHashSearches、guid -_DECRYPT_HTML = """ - - - - - - - - - - -""" - -_window = None -_window_ready = threading.Event() -_init_lock = threading.Lock() - - -def _ensure_window(): - """首次调用时在后台线程启动 pywebview 并等待页面加载完成。""" - global _window - with _init_lock: - if _window is not None: - return - _window = webview.create_window( - "", - html=_DECRYPT_HTML, - width=1, - height=1, - hidden=True, - ) - - def run(): - webview.start(debug=False) - - t = threading.Thread(target=run, daemon=True) - t.start() - _window.events.loaded.wait() - _window_ready.set() - _window_ready.wait() - - -def decrypt_via_service(hash_searches, ciphertext): - """ - 通过 pywebview 执行 JS decryptWithHashSearches 进行 AES-ECB 解密。 - - :param hash_searches: 对应 JS 的 hashSearches(密钥后缀,可为空串) - :param ciphertext: Base64 密文 - :return: 解密后的 UTF-8 字符串,失败返回空串 - """ - _ensure_window() - # 将参数安全注入 JS(避免注入与引号问题) - ciphertext_js = json.dumps(ciphertext) - hash_searches_js = json.dumps(hash_searches or "") - js = f"(function(){{ return decryptWithHashSearches({ciphertext_js}, {hash_searches_js}); }})();" - try: - result = _window.evaluate_js(js) - return result if result is not None else "" - except Exception as e: - raise RuntimeError(f"解密失败: {e}") from e - - -def get_guid(): - """通过 pywebview 执行 JS guid() 生成 UUID 格式字符串。""" - _ensure_window() - try: - result = _window.evaluate_js("(function(){ return guid(); })();") - if result is None: - raise RuntimeError("guid() 返回为空") - return result - except Exception as e: - raise RuntimeError(f"获取 GUID 失败: {e}") from e - - -# 使用示例 -if __name__ == "__main__": - # 参数含义:hash_searches 对应原 key,ciphertext 对应原 data - hash_searches = "a6efb809-b714-7efd-4b64-8c650b9030f0" - ciphertext = "SEsfpOGa8B+sWk0ncknTNh/HNHbGiEVi/RNKxBvyHmAE5VjHonPi202c6VicC/GKfA8mLsIC5mGEpSaH2DdCaEJKeOTWD9SBHHbgtyS1O60VqjgAaptYe9LivvWKc/BU8sZOqhxPMzGUHDcKUts7d0p+hCc80XCXyM2ZT80smM1twndcDfpGkLDk2kJbzl2bGzIc60sl9MzKWfZA2sE0ztFjQ9wD2uhn5LrwoN8NnpiPLNbviMMGtHh4N6Dc0xtPzkzgfkiuxBfWnN1SeM9XVgujHvAGea/dqUWIJqLo26fZIOEFJ0MYL4c8CGLYIeP/70cT2IqJdf+IPBWsCiP29zSyAiQd3yLNvd8+xBLky7lR4ng3MZizn/vhW/5BSg1FtVglbAmNbKgHOIbtpP197Lv+uahx2IpsSPqpy4j2O2VV25YzBk9JpJ4WGG/SvF3f2ZYKKepEQ+kGmBxAfG/5Z8DTNIwnOpXhyjFpJb+fUdPV6LTCK/yFc8g31bNFAJkLW7y/VjewjFravZ3VfQjNAHCvifnrIxGG22MZ3TLVnlbh6ye6vTnd+v9GzqXu+ISR7vQGL/ZSM/bJIjuVkP2XnNUPf+NFdt75gyDmTylFmmbpg7WHaBjinPcyVjeZ38+quJhT8yEk66BOjBM47mVdfU8JLK3ToghxQ54dKWGUbc9HRzYIAQ0rIBBExcOcPM4J9DpufvajmEygoDaws4CxOQDlFoFLwNBYorJsKzAoDe1Cu8oFtk4x190Vd+leRKq6DQSNJwEmyrVkWyFpiuywSMaixFlSqve0lRs50FclXfV7gkBAAvz/DJp90i9yCJdMaihP5ZCvbhKxFGMowkU+tx5Ptnxd9hq6tUxHmuiwDI1eyY8EyjB+3MbrC3H/GY39Z5Qhkj5tkSBm4oGA/h6YjeunBlfMU/QGP3hyZIYALh/YmBlZxAcglWwcqlISUhcx1L3Dbw6ZQbpAmYHmA421xIlRJkdJuPmGoopcoFEVdIO0Ov3uP2JL5ybvUgCBMnbwcYRK9qRmvn2b8jHKch55YXerHBDCEMHz88rfgNIEL8DIAp+wMuIyJ0VrV0BwAXioLYNvMcggBp06gghq2NykGiXRZ/dz8nffFqPVkDNjzcPSGrml9fpKdO8x/GannMNBsA+oHlO2/d4dyo+Q2dZCarQ5UB/N1p+UsTW90kvITKwCbZfV/YY2NRm7HrXq+wLS1vY4mChY82Ybc3cYQT653Q==" - - try: - decrypted_text = decrypt_via_service(hash_searches, ciphertext) - print("解密结果:", decrypted_text) - except Exception as e: - print("错误:", e) - - try: - g = get_guid() - print("GUID:", g) - except Exception as e: - print("GUID 错误:", e) +""" +通过 pywebview 加载本地 HTML,执行 JS 实现解密与 GUID 生成。 +""" +import json +import threading +import traceback + +import webview + +# 内嵌 HTML:加载 CryptoJS 并定义 decryptWithHashSearches、guid +_DECRYPT_HTML = """ + + + + + + + + + + +""" + +_window = None +_window_ready = threading.Event() +_init_lock = threading.Lock() + + +def _ensure_window(): + """首次调用时在后台线程启动 pywebview 并等待页面加载完成。""" + global _window + with _init_lock: + if _window is not None: + return + _window = webview.create_window( + "", + html=_DECRYPT_HTML, + width=1, + height=1, + hidden=True, + ) + + def run(): + webview.start(debug=False) + + t = threading.Thread(target=run, daemon=True) + t.start() + _window.events.loaded.wait() + _window_ready.set() + _window_ready.wait() + + +def decrypt_via_service(hash_searches, ciphertext): + """ + 通过 pywebview 执行 JS decryptWithHashSearches 进行 AES-ECB 解密。 + + :param hash_searches: 对应 JS 的 hashSearches(密钥后缀,可为空串) + :param ciphertext: Base64 密文 + :return: 解密后的 UTF-8 字符串,失败返回空串 + """ + _ensure_window() + # 将参数安全注入 JS(避免注入与引号问题) + ciphertext_js = json.dumps(ciphertext) + hash_searches_js = json.dumps(hash_searches or "") + js = f"(function(){{ return decryptWithHashSearches({ciphertext_js}, {hash_searches_js}); }})();" + try: + result = _window.evaluate_js(js) + return result if result is not None else "" + except Exception as e: + raise RuntimeError(f"解密失败: {e}") from e + + +def get_guid(): + """通过 pywebview 执行 JS guid() 生成 UUID 格式字符串。""" + _ensure_window() + try: + result = _window.evaluate_js("(function(){ return guid(); })();") + if result is None: + raise RuntimeError("guid() 返回为空") + return result + except Exception as e: + raise RuntimeError(f"获取 GUID 失败: {e}") from e + + +# 使用示例 +if __name__ == "__main__": + # 参数含义:hash_searches 对应原 key,ciphertext 对应原 data + hash_searches = "a6efb809-b714-7efd-4b64-8c650b9030f0" + ciphertext = "SEsfpOGa8B+sWk0ncknTNh/HNHbGiEVi/RNKxBvyHmAE5VjHonPi202c6VicC/GKfA8mLsIC5mGEpSaH2DdCaEJKeOTWD9SBHHbgtyS1O60VqjgAaptYe9LivvWKc/BU8sZOqhxPMzGUHDcKUts7d0p+hCc80XCXyM2ZT80smM1twndcDfpGkLDk2kJbzl2bGzIc60sl9MzKWfZA2sE0ztFjQ9wD2uhn5LrwoN8NnpiPLNbviMMGtHh4N6Dc0xtPzkzgfkiuxBfWnN1SeM9XVgujHvAGea/dqUWIJqLo26fZIOEFJ0MYL4c8CGLYIeP/70cT2IqJdf+IPBWsCiP29zSyAiQd3yLNvd8+xBLky7lR4ng3MZizn/vhW/5BSg1FtVglbAmNbKgHOIbtpP197Lv+uahx2IpsSPqpy4j2O2VV25YzBk9JpJ4WGG/SvF3f2ZYKKepEQ+kGmBxAfG/5Z8DTNIwnOpXhyjFpJb+fUdPV6LTCK/yFc8g31bNFAJkLW7y/VjewjFravZ3VfQjNAHCvifnrIxGG22MZ3TLVnlbh6ye6vTnd+v9GzqXu+ISR7vQGL/ZSM/bJIjuVkP2XnNUPf+NFdt75gyDmTylFmmbpg7WHaBjinPcyVjeZ38+quJhT8yEk66BOjBM47mVdfU8JLK3ToghxQ54dKWGUbc9HRzYIAQ0rIBBExcOcPM4J9DpufvajmEygoDaws4CxOQDlFoFLwNBYorJsKzAoDe1Cu8oFtk4x190Vd+leRKq6DQSNJwEmyrVkWyFpiuywSMaixFlSqve0lRs50FclXfV7gkBAAvz/DJp90i9yCJdMaihP5ZCvbhKxFGMowkU+tx5Ptnxd9hq6tUxHmuiwDI1eyY8EyjB+3MbrC3H/GY39Z5Qhkj5tkSBm4oGA/h6YjeunBlfMU/QGP3hyZIYALh/YmBlZxAcglWwcqlISUhcx1L3Dbw6ZQbpAmYHmA421xIlRJkdJuPmGoopcoFEVdIO0Ov3uP2JL5ybvUgCBMnbwcYRK9qRmvn2b8jHKch55YXerHBDCEMHz88rfgNIEL8DIAp+wMuIyJ0VrV0BwAXioLYNvMcggBp06gghq2NykGiXRZ/dz8nffFqPVkDNjzcPSGrml9fpKdO8x/GannMNBsA+oHlO2/d4dyo+Q2dZCarQ5UB/N1p+UsTW90kvITKwCbZfV/YY2NRm7HrXq+wLS1vY4mChY82Ybc3cYQT653Q==" + + try: + decrypted_text = decrypt_via_service(hash_searches, ciphertext) + print("解密结果:", decrypted_text) + except Exception as e: + print("错误:", e) + + try: + g = get_guid() + print("GUID:", g) + except Exception as e: + print("GUID 错误:", e) diff --git a/config.py b/source_code/config.py similarity index 96% rename from config.py rename to source_code/config.py index 56b7ef8..84ad94a 100644 --- a/config.py +++ b/source_code/config.py @@ -1,46 +1,46 @@ -base_url = "https://api.coze.cn/v1" -coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5" -workflow_id = "7608812635877900322" -STITCH_WORKFLOW_ID = "7608813873483300907" - -import os -from dotenv import load_dotenv -load_dotenv() -_base_url = os.getenv("base_url","http://159.75.121.33:15124") - -# MySQL 配置 -mysql_host = os.getenv("mysql_host") -mysql_user = os.getenv("mysql_user") -mysql_password = os.getenv("mysql_password","WTFrb5y6hNLz6hNy") -mysql_database = os.getenv("mysql_database","aiimage") -proxy_url = os.getenv("proxy_url") -proxy_mode = int(os.getenv("proxy_mode",1)) - -client_name=os.getenv("client_name") + ".exe" - -cache_path = "./user_data" - -region = "cn-hangzhou" -endpoint = "oss-cn-hangzhou.aliyuncs.com" -bucket = "nanri-ai-images" -accessKeyId = "LTAI5tNpyvzMNz9f2dHarsm8" -accessKeySecret = "bQSZnFH455i8tzyOgeahJmUzwmhynz" -bucket_path = "nanri-image/" - -file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" - - - -os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId -os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret -os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" - - -debug = True -version = "1.0.3" -APP_UPDATE_URL = f"{_base_url}/api/version/latest" # 检测更新接口地址,返回 { "file_url": "...", "version": "x.x.x" } -os.environ['APP_VERSION'] = version -os.environ['APP_UPDATE_URL'] = APP_UPDATE_URL - - - +base_url = "https://api.coze.cn/v1" +coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5" +workflow_id = "7608812635877900322" +STITCH_WORKFLOW_ID = "7608813873483300907" + +import os +from dotenv import load_dotenv +load_dotenv() +_base_url = os.getenv("base_url","http://159.75.121.33:15124") + +# MySQL 配置 +mysql_host = os.getenv("mysql_host") +mysql_user = os.getenv("mysql_user") +mysql_password = os.getenv("mysql_password","WTFrb5y6hNLz6hNy") +mysql_database = os.getenv("mysql_database","aiimage") +proxy_url = os.getenv("proxy_url") +proxy_mode = int(os.getenv("proxy_mode",1)) + +client_name=os.getenv("client_name") + ".exe" + +cache_path = "./user_data" + +region = "cn-hangzhou" +endpoint = "oss-cn-hangzhou.aliyuncs.com" +bucket = "nanri-ai-images" +accessKeyId = "LTAI5tNpyvzMNz9f2dHarsm8" +accessKeySecret = "bQSZnFH455i8tzyOgeahJmUzwmhynz" +bucket_path = "nanri-image/" + +file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" + + + +os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId +os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret +os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" + + +debug = True +version = "1.0.3" +APP_UPDATE_URL = f"{_base_url}/api/version/latest" # 检测更新接口地址,返回 { "file_url": "...", "version": "x.x.x" } +os.environ['APP_VERSION'] = version +os.environ['APP_UPDATE_URL'] = APP_UPDATE_URL + + + diff --git a/coze.py b/source_code/coze.py similarity index 100% rename from coze.py rename to source_code/coze.py diff --git a/generate_api.py b/source_code/generate_api.py similarity index 100% rename from generate_api.py rename to source_code/generate_api.py diff --git a/html_crypto.py b/source_code/html_crypto.py similarity index 96% rename from html_crypto.py rename to source_code/html_crypto.py index cb089d9..482ff20 100644 --- a/html_crypto.py +++ b/source_code/html_crypto.py @@ -1,68 +1,68 @@ -""" -HTML 模板加密/解密模块 -使用 AES (Fernet) 对 HTML 资源进行加密存储,运行时解密后渲染 -""" - -import base64 -import os - -from cryptography.fernet import Fernet -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC - - -def _get_fernet_key() -> bytes: - """从环境变量 HTML_ENCRYPT_KEY 派生 Fernet 密钥,若未设置则使用默认开发密钥""" - raw = os.environ.get( - "HTML_ENCRYPT_KEY", - "maixiang_html_encrypt_default_key_change_in_production", - ) - # 使用 PBKDF2 派生 32 字节密钥,再转为 Fernet 所需的 base64url - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=b"maixiang_html_salt", - iterations=100000, - ) - key_bytes = kdf.derive(raw.encode("utf-8")) - return base64.urlsafe_b64encode(key_bytes) - - -def encrypt(plain_data: bytes) -> bytes: - """加密原始数据,返回密文(bytes)""" - key = _get_fernet_key() - f = Fernet(key) - return f.encrypt(plain_data) - - -def decrypt(encrypted_data: bytes) -> bytes: - """解密数据,返回明文(bytes)""" - key = _get_fernet_key() - f = Fernet(key) - return f.decrypt(encrypted_data) - - -def encrypt_file(path: str) -> None: - """就地加密文件:读取 UTF-8 内容,加密后写回同一路径""" - with open(path, "rb") as f: - plain = f.read() - encrypted = encrypt(plain) - with open(path, "wb") as f: - f.write(encrypted) - - -def decrypt_file(path: str) -> None: - """就地解密文件:读取密文,解密后以 UTF-8 写回同一路径""" - with open(path, "rb") as f: - encrypted = f.read() - plain = decrypt(encrypted) - with open(path, "wb") as f: - f.write(plain) - - -def is_encrypted(data: bytes) -> bool: - """简单启发式:Fernet 密文以 b'gAAAAA' 开头(base64 编码后)""" - try: - return data[:7] == b"gAAAAAB" - except Exception: - return False +""" +HTML 模板加密/解密模块 +使用 AES (Fernet) 对 HTML 资源进行加密存储,运行时解密后渲染 +""" + +import base64 +import os + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + + +def _get_fernet_key() -> bytes: + """从环境变量 HTML_ENCRYPT_KEY 派生 Fernet 密钥,若未设置则使用默认开发密钥""" + raw = os.environ.get( + "HTML_ENCRYPT_KEY", + "maixiang_html_encrypt_default_key_change_in_production", + ) + # 使用 PBKDF2 派生 32 字节密钥,再转为 Fernet 所需的 base64url + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b"maixiang_html_salt", + iterations=100000, + ) + key_bytes = kdf.derive(raw.encode("utf-8")) + return base64.urlsafe_b64encode(key_bytes) + + +def encrypt(plain_data: bytes) -> bytes: + """加密原始数据,返回密文(bytes)""" + key = _get_fernet_key() + f = Fernet(key) + return f.encrypt(plain_data) + + +def decrypt(encrypted_data: bytes) -> bytes: + """解密数据,返回明文(bytes)""" + key = _get_fernet_key() + f = Fernet(key) + return f.decrypt(encrypted_data) + + +def encrypt_file(path: str) -> None: + """就地加密文件:读取 UTF-8 内容,加密后写回同一路径""" + with open(path, "rb") as f: + plain = f.read() + encrypted = encrypt(plain) + with open(path, "wb") as f: + f.write(encrypted) + + +def decrypt_file(path: str) -> None: + """就地解密文件:读取密文,解密后以 UTF-8 写回同一路径""" + with open(path, "rb") as f: + encrypted = f.read() + plain = decrypt(encrypted) + with open(path, "wb") as f: + f.write(plain) + + +def is_encrypted(data: bytes) -> bool: + """简单启发式:Fernet 密文以 b'gAAAAA' 开头(base64 编码后)""" + try: + return data[:7] == b"gAAAAAB" + except Exception: + return False diff --git a/main.py b/source_code/main.py similarity index 100% rename from main.py rename to source_code/main.py diff --git a/static/bg.jpg b/source_code/static/bg.jpg similarity index 100% rename from static/bg.jpg rename to source_code/static/bg.jpg diff --git a/static/品牌文档格式_模板.xlsx b/source_code/static/品牌文档格式_模板.xlsx similarity index 100% rename from static/品牌文档格式_模板.xlsx rename to source_code/static/品牌文档格式_模板.xlsx diff --git a/static/模板2-以文件夹方式上传.zip b/source_code/static/模板2-以文件夹方式上传.zip similarity index 100% rename from static/模板2-以文件夹方式上传.zip rename to source_code/static/模板2-以文件夹方式上传.zip diff --git a/tool/.device_id b/source_code/tool/.device_id similarity index 100% rename from tool/.device_id rename to source_code/tool/.device_id diff --git a/tool/__pycache__/devices.cpython-311.pyc b/source_code/tool/__pycache__/devices.cpython-311.pyc similarity index 100% rename from tool/__pycache__/devices.cpython-311.pyc rename to source_code/tool/__pycache__/devices.cpython-311.pyc diff --git a/tool/__pycache__/devices.cpython-39.pyc b/source_code/tool/__pycache__/devices.cpython-39.pyc similarity index 100% rename from tool/__pycache__/devices.cpython-39.pyc rename to source_code/tool/__pycache__/devices.cpython-39.pyc diff --git a/tool/devices.py b/source_code/tool/devices.py similarity index 96% rename from tool/devices.py rename to source_code/tool/devices.py index efdf582..acb0369 100644 --- a/tool/devices.py +++ b/source_code/tool/devices.py @@ -1,249 +1,249 @@ -import hashlib -import platform -import subprocess -import uuid -import os -from typing import Optional - - -class DeviceIDGenerator: - """ - Windows设备唯一ID生成器 - 通过收集多个硬件特征来生成稳定的设备唯一标识符 - """ - - def __init__(self, use_cache: bool = False, cache_file: str = ".device_id"): - """ - 初始化设备ID生成器 - - Args: - use_cache: 是否使用本地缓存 - cache_file: 缓存文件名 - """ - self.use_cache = use_cache - self.cache_file = cache_file - - def _run_wmic_command(self, command: str) -> Optional[str]: - """ - 执行WMIC命令并返回结果 - - Args: - command: WMIC命令 - - Returns: - 命令执行结果,失败则返回None - """ - try: - result = subprocess.run( - command, - shell=True, - capture_output=True, - text=True, - timeout=10 - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - except (subprocess.TimeoutExpired, Exception): - pass - return None - - def _get_motherboard_serial(self) -> Optional[str]: - """获取主板序列号""" - return self._run_wmic_command("wmic baseboard get serialnumber /value") - - def _get_cpu_id(self) -> Optional[str]: - """获取CPU ID""" - return self._run_wmic_command("wmic cpu get processorid /value") - - def _get_bios_serial(self) -> Optional[str]: - """获取BIOS序列号""" - return self._run_wmic_command("wmic bios get serialnumber /value") - - def _get_disk_serial(self) -> Optional[str]: - """获取系统盘序列号""" - return self._run_wmic_command("wmic diskdrive get serialnumber /value") - - def _get_machine_guid(self) -> Optional[str]: - """获取Windows机器GUID""" - try: - result = subprocess.run( - 'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid', - shell=True, - capture_output=True, - text=True, - timeout=10 - ) - if result.returncode == 0: - for line in result.stdout.split('\n'): - if 'MachineGuid' in line: - return line.split()[-1] - except Exception: - pass - return None - - def _extract_value(self, wmic_output: str) -> str: - """从WMIC输出中提取实际值""" - if not wmic_output: - return "" - - lines = wmic_output.split('\n') - for line in lines: - if '=' in line and not line.strip().endswith('='): - return line.split('=', 1)[1].strip() - return "" - - def _collect_hardware_info(self) -> dict: - """ - 收集硬件信息 - - Returns: - 包含各种硬件信息的字典 - """ - hardware_info = {} - - # 主板序列号 - motherboard = self._get_motherboard_serial() - hardware_info['motherboard'] = self._extract_value(motherboard) if motherboard else "" - - # CPU ID - cpu_id = self._get_cpu_id() - hardware_info['cpu'] = self._extract_value(cpu_id) if cpu_id else "" - - # BIOS序列号 - bios = self._get_bios_serial() - hardware_info['bios'] = self._extract_value(bios) if bios else "" - - # 硬盘序列号 - disk = self._get_disk_serial() - hardware_info['disk'] = self._extract_value(disk) if disk else "" - - # Windows机器GUID - machine_guid = self._get_machine_guid() - hardware_info['machine_guid'] = machine_guid if machine_guid else "" - - # 计算机名称 - hardware_info['computer_name'] = platform.node() - - # MAC地址(作为备用) - hardware_info['mac_address'] = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff) - for elements in range(0, 2*6, 2)][::-1]) - - return hardware_info - - def _generate_device_id(self, hardware_info: dict) -> str: - """ - 基于硬件信息生成设备ID - - Args: - hardware_info: 硬件信息字典 - - Returns: - 32位十六进制设备ID - """ - # 过滤掉空值,并按键排序确保一致性 - filtered_info = {k: v for k, v in hardware_info.items() if v and v.strip()} - - # 如果没有任何硬件信息,使用MAC地址作为后备方案 - if not filtered_info: - filtered_info = {'mac_address': hardware_info.get('mac_address', str(uuid.getnode()))} - - # 将所有信息连接成字符串 - info_string = '|'.join(f"{k}:{v}" for k, v in sorted(filtered_info.items())) - - # 使用SHA256生成哈希值 - hash_object = hashlib.sha256(info_string.encode('utf-8')) - device_id = hash_object.hexdigest() - - return device_id - - def _load_cached_device_id(self) -> Optional[str]: - """从缓存文件加载设备ID""" - try: - if os.path.exists(self.cache_file): - with open(self.cache_file, 'r', encoding='utf-8') as f: - cached_id = f.read().strip() - if len(cached_id) == 64: # SHA256哈希长度 - return cached_id - except Exception: - pass - return None - - def _save_device_id_to_cache(self, device_id: str) -> None: - """将设备ID保存到缓存文件""" - try: - with open(self.cache_file, 'w', encoding='utf-8') as f: - f.write(device_id) - except Exception: - pass - - def get_device_id(self) -> str: - """ - 获取设备唯一ID - - Returns: - 64字符的十六进制设备ID - """ - # 如果启用缓存,先尝试从缓存加载 - if self.use_cache: - cached_id = self._load_cached_device_id() - if cached_id: - return cached_id - - # 收集硬件信息 - hardware_info = self._collect_hardware_info() - - # 生成设备ID - device_id = self._generate_device_id(hardware_info) - - # 保存到缓存 - if self.use_cache: - self._save_device_id_to_cache(device_id) - - return device_id - - def get_device_id_short(self, length: int = 16) -> str: - """ - 获取短版本的设备ID - - Args: - length: 返回ID的长度 - - Returns: - 指定长度的设备ID - """ - full_id = self.get_device_id() - return full_id[:length] - - def get_hardware_info(self) -> dict: - """ - 获取硬件信息(用于调试) - - Returns: - 硬件信息字典 - """ - return self._collect_hardware_info() - - -# 使用示例 -def main(): - """使用示例""" - # 创建设备ID生成器实例 - device_generator = DeviceIDGenerator() - - # 获取完整设备ID(64字符) - device_id = device_generator.get_device_id() - print(f"完整设备ID: {device_id}") - - # 获取短版本设备ID(16字符) - short_id = device_generator.get_device_id_short(16) - print(f"短设备ID: {short_id}") - - # 查看硬件信息(调试用) - hardware_info = device_generator.get_hardware_info() - print("\n硬件信息:") - for key, value in hardware_info.items(): - print(f" {key}: {value}") - - -if __name__ == "__main__": +import hashlib +import platform +import subprocess +import uuid +import os +from typing import Optional + + +class DeviceIDGenerator: + """ + Windows设备唯一ID生成器 + 通过收集多个硬件特征来生成稳定的设备唯一标识符 + """ + + def __init__(self, use_cache: bool = False, cache_file: str = ".device_id"): + """ + 初始化设备ID生成器 + + Args: + use_cache: 是否使用本地缓存 + cache_file: 缓存文件名 + """ + self.use_cache = use_cache + self.cache_file = cache_file + + def _run_wmic_command(self, command: str) -> Optional[str]: + """ + 执行WMIC命令并返回结果 + + Args: + command: WMIC命令 + + Returns: + 命令执行结果,失败则返回None + """ + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except (subprocess.TimeoutExpired, Exception): + pass + return None + + def _get_motherboard_serial(self) -> Optional[str]: + """获取主板序列号""" + return self._run_wmic_command("wmic baseboard get serialnumber /value") + + def _get_cpu_id(self) -> Optional[str]: + """获取CPU ID""" + return self._run_wmic_command("wmic cpu get processorid /value") + + def _get_bios_serial(self) -> Optional[str]: + """获取BIOS序列号""" + return self._run_wmic_command("wmic bios get serialnumber /value") + + def _get_disk_serial(self) -> Optional[str]: + """获取系统盘序列号""" + return self._run_wmic_command("wmic diskdrive get serialnumber /value") + + def _get_machine_guid(self) -> Optional[str]: + """获取Windows机器GUID""" + try: + result = subprocess.run( + 'reg query "HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography" /v MachineGuid', + shell=True, + capture_output=True, + text=True, + timeout=10 + ) + if result.returncode == 0: + for line in result.stdout.split('\n'): + if 'MachineGuid' in line: + return line.split()[-1] + except Exception: + pass + return None + + def _extract_value(self, wmic_output: str) -> str: + """从WMIC输出中提取实际值""" + if not wmic_output: + return "" + + lines = wmic_output.split('\n') + for line in lines: + if '=' in line and not line.strip().endswith('='): + return line.split('=', 1)[1].strip() + return "" + + def _collect_hardware_info(self) -> dict: + """ + 收集硬件信息 + + Returns: + 包含各种硬件信息的字典 + """ + hardware_info = {} + + # 主板序列号 + motherboard = self._get_motherboard_serial() + hardware_info['motherboard'] = self._extract_value(motherboard) if motherboard else "" + + # CPU ID + cpu_id = self._get_cpu_id() + hardware_info['cpu'] = self._extract_value(cpu_id) if cpu_id else "" + + # BIOS序列号 + bios = self._get_bios_serial() + hardware_info['bios'] = self._extract_value(bios) if bios else "" + + # 硬盘序列号 + disk = self._get_disk_serial() + hardware_info['disk'] = self._extract_value(disk) if disk else "" + + # Windows机器GUID + machine_guid = self._get_machine_guid() + hardware_info['machine_guid'] = machine_guid if machine_guid else "" + + # 计算机名称 + hardware_info['computer_name'] = platform.node() + + # MAC地址(作为备用) + hardware_info['mac_address'] = ':'.join(['{:02x}'.format((uuid.getnode() >> elements) & 0xff) + for elements in range(0, 2*6, 2)][::-1]) + + return hardware_info + + def _generate_device_id(self, hardware_info: dict) -> str: + """ + 基于硬件信息生成设备ID + + Args: + hardware_info: 硬件信息字典 + + Returns: + 32位十六进制设备ID + """ + # 过滤掉空值,并按键排序确保一致性 + filtered_info = {k: v for k, v in hardware_info.items() if v and v.strip()} + + # 如果没有任何硬件信息,使用MAC地址作为后备方案 + if not filtered_info: + filtered_info = {'mac_address': hardware_info.get('mac_address', str(uuid.getnode()))} + + # 将所有信息连接成字符串 + info_string = '|'.join(f"{k}:{v}" for k, v in sorted(filtered_info.items())) + + # 使用SHA256生成哈希值 + hash_object = hashlib.sha256(info_string.encode('utf-8')) + device_id = hash_object.hexdigest() + + return device_id + + def _load_cached_device_id(self) -> Optional[str]: + """从缓存文件加载设备ID""" + try: + if os.path.exists(self.cache_file): + with open(self.cache_file, 'r', encoding='utf-8') as f: + cached_id = f.read().strip() + if len(cached_id) == 64: # SHA256哈希长度 + return cached_id + except Exception: + pass + return None + + def _save_device_id_to_cache(self, device_id: str) -> None: + """将设备ID保存到缓存文件""" + try: + with open(self.cache_file, 'w', encoding='utf-8') as f: + f.write(device_id) + except Exception: + pass + + def get_device_id(self) -> str: + """ + 获取设备唯一ID + + Returns: + 64字符的十六进制设备ID + """ + # 如果启用缓存,先尝试从缓存加载 + if self.use_cache: + cached_id = self._load_cached_device_id() + if cached_id: + return cached_id + + # 收集硬件信息 + hardware_info = self._collect_hardware_info() + + # 生成设备ID + device_id = self._generate_device_id(hardware_info) + + # 保存到缓存 + if self.use_cache: + self._save_device_id_to_cache(device_id) + + return device_id + + def get_device_id_short(self, length: int = 16) -> str: + """ + 获取短版本的设备ID + + Args: + length: 返回ID的长度 + + Returns: + 指定长度的设备ID + """ + full_id = self.get_device_id() + return full_id[:length] + + def get_hardware_info(self) -> dict: + """ + 获取硬件信息(用于调试) + + Returns: + 硬件信息字典 + """ + return self._collect_hardware_info() + + +# 使用示例 +def main(): + """使用示例""" + # 创建设备ID生成器实例 + device_generator = DeviceIDGenerator() + + # 获取完整设备ID(64字符) + device_id = device_generator.get_device_id() + print(f"完整设备ID: {device_id}") + + # 获取短版本设备ID(16字符) + short_id = device_generator.get_device_id_short(16) + print(f"短设备ID: {short_id}") + + # 查看硬件信息(调试用) + hardware_info = device_generator.get_hardware_info() + print("\n硬件信息:") + for key, value in hardware_info.items(): + print(f" {key}: {value}") + + +if __name__ == "__main__": main() \ No newline at end of file diff --git a/source_code/web_source/brand_tools.html b/source_code/web_source/brand_tools.html new file mode 100644 index 0000000..bd15d2a --- /dev/null +++ b/source_code/web_source/brand_tools.html @@ -0,0 +1,14 @@ + + + + + + + 品牌工具 - 数富AI + + + + + From fb32f7bd355fe581ede8a957e0773fc579e31646 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Fri, 20 Mar 2026 19:31:02 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-java/pom.xml | 6 ----- .../controller/ConvertRunController.java | 24 +++++++++++++++++-- .../controller/ConvertTemplateController.java | 24 +++++++++++++++++-- .../controller/DedupeRunController.java | 24 +++++++++++++++++-- .../file/controller/FileUploadController.java | 14 ++++++++++- .../split/controller/SplitRunController.java | 24 +++++++++++++++++-- .../src/main/resources/application.yml | 2 -- 7 files changed, 101 insertions(+), 17 deletions(-) diff --git a/backend-java/pom.xml b/backend-java/pom.xml index 7f2ff65..bf45bcb 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -17,7 +17,6 @@ 21 - 2.6.0 4.5.0 3.5.7 1.5.5.Final @@ -63,11 +62,6 @@ aliyun-sdk-oss ${aliyun.oss.version} - - org.springdoc - springdoc-openapi-starter-webmvc-ui - ${springdoc.version} - com.github.xiaoymin knife4j-openapi3-jakarta-spring-boot-starter diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java index 49ed3c7..babd8f6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java @@ -6,6 +6,10 @@ import com.nanri.aiimage.modules.convert.model.vo.ConvertHistoryVo; import com.nanri.aiimage.modules.convert.model.vo.ConvertRunVo; import com.nanri.aiimage.modules.convert.service.ConvertRunService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -52,12 +56,20 @@ public class ConvertRunController { 输出文件名遵循模板配置的 outputFilename,若临时目录中重名则自动追加序号。 """) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功,返回转换结果统计", content = @Content(schema = @Schema(implementation = ConvertRunVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数不合法或模板不存在"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "执行格式转换失败") + }) public ApiResponse run(@Valid @RequestBody ConvertRunRequest request) { return ApiResponse.success(convertRunService.run(request)); } @GetMapping("/history") @Operation(summary = "查询格式转换历史", description = "查询最近的格式转换成功记录,用于页面右侧历史下载列表展示。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ConvertHistoryVo.class))) + }) public ApiResponse history() { ConvertHistoryVo vo = new ConvertHistoryVo(); vo.setItems(convertRunService.listHistory()); @@ -66,14 +78,22 @@ public class ConvertRunController { @DeleteMapping("/history/{resultId}") @Operation(summary = "删除格式转换历史", description = "删除一条格式转换历史记录。") - public ApiResponse deleteHistory(@PathVariable Long resultId) { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在") + }) + public ApiResponse deleteHistory(@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId) { convertRunService.deleteHistory(resultId); return ApiResponse.success(null); } @GetMapping("/results/{resultId}/download") @Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载已生成的转换结果文件。桌面端前端可继续通过 save_file_from_url 选择保存位置。") - public ResponseEntity download(@PathVariable Long resultId) { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在") + }) + public ResponseEntity download(@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId) { File resultFile = convertRunService.getResultFile(resultId); String encodedFilename = URLEncoder.encode(resultFile.getName(), StandardCharsets.UTF_8).replaceAll("\\+", "%20"); return ResponseEntity.ok() diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java index 370dc97..5132dae 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java @@ -5,6 +5,10 @@ import com.nanri.aiimage.modules.convert.model.dto.ConvertTemplateImportRequest; import com.nanri.aiimage.modules.convert.model.vo.ConvertTemplateVo; import com.nanri.aiimage.modules.convert.service.ConvertTemplateService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; @@ -27,26 +31,42 @@ public class ConvertTemplateController { @GetMapping @Operation(summary = "查询启用模板", description = "返回当前启用的格式转换模板列表,供前端‘格式转换’模块展示。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功,返回模板列表") + }) public ApiResponse> listTemplates() { return ApiResponse.success(convertTemplateService.listEnabledTemplates()); } @PostMapping("/import") @Operation(summary = "导入模板", description = "导入自定义 txt 模板,成功后返回新模板并可立即刷新模板列表。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "导入成功,返回新模板信息", content = @Content(schema = @Schema(implementation = ConvertTemplateVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "模板内容不合法或必填字段缺失") + }) public ApiResponse importTemplate(@RequestBody ConvertTemplateImportRequest request) { return ApiResponse.success(convertTemplateService.importTemplate(request)); } @PostMapping("/{templateCode}/default") @Operation(summary = "设为默认模板", description = "将指定模板设为默认模板。") - public ApiResponse setDefault(@PathVariable String templateCode) { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "设置成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "模板不存在") + }) + public ApiResponse setDefault(@Parameter(description = "模板编码", required = true) @PathVariable String templateCode) { convertTemplateService.setDefaultTemplate(templateCode); return ApiResponse.success(null); } @DeleteMapping("/{templateCode}") @Operation(summary = "删除模板", description = "删除指定模板。内置模板不允许删除。") - public ApiResponse delete(@PathVariable String templateCode) { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "内置模板不允许删除"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "模板不存在") + }) + public ApiResponse delete(@Parameter(description = "模板编码", required = true) @PathVariable String templateCode) { convertTemplateService.deleteTemplate(templateCode); return ApiResponse.success(null); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java index 6cf2987..4e9c247 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java @@ -6,6 +6,10 @@ import com.nanri.aiimage.modules.dedupe.model.vo.DedupeHistoryVo; import com.nanri.aiimage.modules.dedupe.model.vo.DedupeRunVo; import com.nanri.aiimage.modules.dedupe.service.DedupeRunService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -43,12 +47,20 @@ public class DedupeRunController { - keepUnderscoreIds=true 时保留类似 1_1 的 ID; - 输出文件命名遵循 原文件名_cleaned.xlsx,若重名自动追加序号。 """) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功,返回处理统计与结果列表", content = @Content(schema = @Schema(implementation = DedupeRunVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数不合法或缺少必要字段"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "执行数据去重失败") + }) public ApiResponse run(@Valid @RequestBody DedupeRunRequest request) { return ApiResponse.success(dedupeRunService.run(request)); } @GetMapping("/history") @Operation(summary = "查询去重历史", description = "查询最近的去重成功记录,用于页面右侧历史下载列表展示。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = DedupeHistoryVo.class))) + }) public ApiResponse history() { DedupeHistoryVo vo = new DedupeHistoryVo(); vo.setItems(dedupeRunService.listHistory()); @@ -57,14 +69,22 @@ public class DedupeRunController { @DeleteMapping("/history/{resultId}") @Operation(summary = "删除去重历史", description = "删除一条去重历史记录。") - public ApiResponse deleteHistory(@PathVariable Long resultId) { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在") + }) + public ApiResponse deleteHistory(@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId) { dedupeRunService.deleteHistory(resultId); return ApiResponse.success(null); } @GetMapping("/results/{resultId}/download") @Operation(summary = "下载数据去重结果", description = "根据结果记录 ID 下载清洗后的 Excel 文件。") - public ResponseEntity download(@PathVariable Long resultId) { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在") + }) + public ResponseEntity download(@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId) { Resource resource = dedupeRunService.getResultFile(resultId); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java index 19d14a0..0f61e6c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java @@ -4,6 +4,11 @@ import com.nanri.aiimage.common.api.ApiResponse; import com.nanri.aiimage.modules.file.model.vo.UploadFileVo; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; @@ -31,7 +36,14 @@ public class FileUploadController { - 然后把 fileKey 传给 dedupe/convert/split 执行接口; - 处理完成后,桌面端再调用下载接口把结果文件保存回用户本地目录。 """) - public ApiResponse upload(MultipartFile file) throws Exception { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "上传成功,返回临时文件信息", content = @Content(schema = @Schema(implementation = UploadFileVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "未传文件或文件不合法"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "服务器保存临时文件失败") + }) + public ApiResponse upload( + @Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY) + MultipartFile file) throws Exception { return ApiResponse.success(localFileStorageService.saveTempFile(file)); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java index 2057e1a..d46a0c2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java @@ -6,6 +6,10 @@ import com.nanri.aiimage.modules.split.model.vo.SplitHistoryVo; import com.nanri.aiimage.modules.split.model.vo.SplitRunVo; import com.nanri.aiimage.modules.split.service.SplitRunService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -44,12 +48,20 @@ public class SplitRunController { - 输出文件命名遵循 原文件名_split_序号.xlsx; - 返回结果中 rowCount 表示该结果文件包含的数据行数。 """) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功,返回拆分结果统计", content = @Content(schema = @Schema(implementation = SplitRunVo.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数不合法或拆分模式错误"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "执行数据拆分失败") + }) public ApiResponse run(@Valid @RequestBody SplitRunRequest request) { return ApiResponse.success(splitRunService.run(request)); } @GetMapping("/history") @Operation(summary = "查询数据拆分历史", description = "查询最近的数据拆分成功记录,用于页面右侧历史下载列表展示。") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = SplitHistoryVo.class))) + }) public ApiResponse history() { SplitHistoryVo vo = new SplitHistoryVo(); vo.setItems(splitRunService.listHistory()); @@ -58,14 +70,22 @@ public class SplitRunController { @DeleteMapping("/history/{resultId}") @Operation(summary = "删除数据拆分历史", description = "删除一条数据拆分历史记录。") - public ApiResponse deleteHistory(@PathVariable Long resultId) { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在") + }) + public ApiResponse deleteHistory(@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId) { splitRunService.deleteHistory(resultId); return ApiResponse.success(null); } @GetMapping("/results/{resultId}/download") @Operation(summary = "下载数据拆分结果", description = "根据结果记录 ID 下载拆分后的 Excel 文件。") - public ResponseEntity download(@PathVariable Long resultId) { + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在") + }) + public ResponseEntity download(@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId) { Resource resource = splitRunService.getResultFile(resultId); return ResponseEntity.ok() .contentType(MediaType.APPLICATION_OCTET_STREAM) diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 362a417..2c6a7d3 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -30,8 +30,6 @@ mybatis-plus: springdoc: api-docs: enabled: true - swagger-ui: - path: /swagger-ui.html knife4j: enable: true