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..bf45bcb --- /dev/null +++ b/backend-java/pom.xml @@ -0,0 +1,125 @@ + + 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 + 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} + + + 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..babd8f6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertRunController.java @@ -0,0 +1,104 @@ +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.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; +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,若临时目录中重名则自动追加序号。 + """) + @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()); + return ApiResponse.success(vo); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除格式转换历史", description = "删除一条格式转换历史记录。") + @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 选择保存位置。") + @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() + .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..5132dae --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/convert/controller/ConvertTemplateController.java @@ -0,0 +1,73 @@ +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.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; +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 = "返回当前启用的格式转换模板列表,供前端‘格式转换’模块展示。") + @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 = "将指定模板设为默认模板。") + @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 = "删除指定模板。内置模板不允许删除。") + @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/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..4e9c247 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeRunController.java @@ -0,0 +1,94 @@ +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.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; +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,若重名自动追加序号。 + """) + @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()); + return ApiResponse.success(vo); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除去重历史", description = "删除一条去重历史记录。") + @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 文件。") + @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) + .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..0f61e6c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/controller/FileUploadController.java @@ -0,0 +1,49 @@ +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.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; +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 执行接口; + - 处理完成后,桌面端再调用下载接口把结果文件保存回用户本地目录。 + """) + @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/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..d46a0c2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/split/controller/SplitRunController.java @@ -0,0 +1,95 @@ +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.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; +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 表示该结果文件包含的数据行数。 + """) + @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()); + return ApiResponse.success(vo); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除数据拆分历史", description = "删除一条数据拆分历史记录。") + @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 文件。") + @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) + .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..2c6a7d3 --- /dev/null +++ b/backend-java/src/main/resources/application.yml @@ -0,0 +1,47 @@ +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 + +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 ba952b6..6ec347a 100644 Binary files a/desktop/__pycache__/app.cpython-312.pyc and b/desktop/__pycache__/app.cpython-312.pyc differ 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/convert.html b/frontend-vue/convert.html new file mode 100644 index 0000000..2639248 --- /dev/null +++ b/frontend-vue/convert.html @@ -0,0 +1,12 @@ + + + + + + 格式转换 - 数富AI + + +
+ + + diff --git a/frontend-vue/dedupe.html b/frontend-vue/dedupe.html new file mode 100644 index 0000000..12ed0d5 --- /dev/null +++ b/frontend-vue/dedupe.html @@ -0,0 +1,12 @@ + + + + + + 数据去重 - 数富AI + + +
+ + + 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/split.html b/frontend-vue/split.html new file mode 100644 index 0000000..ac5f76a --- /dev/null +++ b/frontend-vue/split.html @@ -0,0 +1,12 @@ + + + + + + 数据拆分 - 数富AI + + +
+ + + diff --git a/frontend-vue/src/App.vue b/frontend-vue/src/App.vue deleted file mode 100644 index 98240ae..0000000 --- a/frontend-vue/src/App.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/frontend-vue/src/convert-main.ts b/frontend-vue/src/convert-main.ts new file mode 100644 index 0000000..68ec465 --- /dev/null +++ b/frontend-vue/src/convert-main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import '@/styles/main.css' +import BrandConvertTab from '@/pages/brand/components/BrandConvertTab.vue' + +createApp(BrandConvertTab).use(ElementPlus).mount('#app') diff --git a/frontend-vue/src/dedupe-main.ts b/frontend-vue/src/dedupe-main.ts new file mode 100644 index 0000000..75f0a21 --- /dev/null +++ b/frontend-vue/src/dedupe-main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import '@/styles/main.css' +import BrandDedupeTab from '@/pages/brand/components/BrandDedupeTab.vue' + +createApp(BrandDedupeTab).use(ElementPlus).mount('#app') diff --git a/frontend-vue/src/pages/admin/index.vue b/frontend-vue/src/pages/admin/index.vue deleted file mode 100644 index ba566d8..0000000 --- a/frontend-vue/src/pages/admin/index.vue +++ /dev/null @@ -1,584 +0,0 @@ - - - - - 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 deleted file mode 100644 index aa23520..0000000 --- a/frontend-vue/src/pages/brand/index.vue +++ /dev/null @@ -1,1101 +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 edba641..0000000 --- a/frontend-vue/src/pages/home/index.vue +++ /dev/null @@ -1,171 +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 acf5087..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 f9ee27c..0000000 --- a/frontend-vue/src/pages/login/index.vue +++ /dev/null @@ -1,148 +0,0 @@ - - - - - diff --git a/frontend-vue/src/router.ts b/frontend-vue/src/router.ts deleted file mode 100644 index fee1061..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/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 9266028..5aa0109 100644 --- a/frontend-vue/src/shared/bridges/pywebview.ts +++ b/frontend-vue/src/shared/bridges/pywebview.ts @@ -4,10 +4,11 @@ export interface SplitExcelPayload { split_mode: 'rows_per_file' | 'parts' rows_per_file?: number parts?: number - output_dir: string + output_dir?: string } export interface SplitExcelResultItem { + result_id?: number source_path: string output_path?: string success: boolean @@ -36,7 +37,16 @@ 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 output_path?: string success: boolean @@ -45,7 +55,7 @@ export interface CleanExcelResultItem { export interface ConvertTxtPayload { paths: string[] - output_dir: string + output_dir?: string template_id: string } @@ -58,6 +68,7 @@ export interface ConvertTxtTemplateItem { } export interface ConvertTxtResultItem { + result_id?: number source_path: string output_path?: string success: boolean @@ -92,6 +103,24 @@ export interface CleanExcelResponse { error?: string } +export interface ConvertTxtHistoryResponse { + success: boolean + items?: ConvertTxtResultItem[] + error?: string +} + +export interface SplitExcelHistoryResponse { + success: boolean + items?: SplitExcelResultItem[] + error?: string +} + +export interface CleanExcelHistoryResponse { + success: boolean + items?: CleanExcelResultItem[] + error?: string +} + export interface PywebviewApi { select_brand_xlsx_files?: () => Promise select_clean_xlsx_files?: () => Promise @@ -99,12 +128,17 @@ export interface PywebviewApi { select_clean_folder?: () => Promise select_folder?: () => Promise get_excel_headers?: (filePath: string) => Promise<{ success: boolean; headers?: string[]; error?: string }> + get_dedupe_history?: () => Promise + get_convert_history?: () => Promise + get_split_history?: () => Promise + delete_history_item?: (moduleType: string, resultId: number) => Promise<{ success: boolean; error?: string }> get_excel_info?: (filePath: string) => Promise clean_excel_files?: (payload: CleanExcelPayload) => Promise split_excel_files?: (payload: SplitExcelPayload) => Promise list_txt_templates?: () => Promise import_txt_template?: (name?: string) => Promise<{ success: boolean; template?: ConvertTxtTemplateItem; error?: string }> set_default_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }> + delete_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }> convert_excel_to_uk_txt?: (payload: ConvertTxtPayload) => Promise open_path?: (path: string) => Promise<{ success: boolean; error?: string }> save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: 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 + + + + + diff --git a/web_source/templates_backup/admin.html b/web_source/templates_backup/admin.html index eaba62a..ac8e238 100644 --- a/web_source/templates_backup/admin.html +++ b/web_source/templates_backup/admin.html @@ -3,7 +3,7 @@ - 管理后台 - 南日AI + 管理后台 - 数富AI