暂存提交

This commit is contained in:
2026-07-30 18:01:36 +08:00
commit 2f4e159356
368 changed files with 20370 additions and 0 deletions
@@ -0,0 +1,16 @@
package com.baiyue.file;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 文件中心服务启动入口。
*/
@SpringBootApplication(scanBasePackages = "com.baiyue")
@EnableFeignClients(basePackages = "com.baiyue.file.client")
public class FileServiceApplication {
public static void main(String[] args) {
SpringApplication.run(FileServiceApplication.class, args);
}
}
@@ -0,0 +1,20 @@
package com.baiyue.file.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "iam-service", contextId = "iamQualificationClient",
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface IamQualificationClient {
@PostMapping("/qualifications")
ApiResponse<QualificationResponse> submit(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody QualificationRequest request);
record QualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl) { }
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
}
@@ -0,0 +1,28 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 文件中心状态检查接口。
*/
@RestController
@RequestMapping("/api/platform/v1/files")
@Tag(name = "文件中心状态", description = "文件中心服务可用性接口")
public class FileHealthController {
@GetMapping("/status")
@Operation(summary = "查询服务状态", description = "返回文件中心服务的逻辑运行状态")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功")
})
public ApiResponse<Map<String, String>> status() {
return ApiResponse.success(Map.of("service", "file-service", "status", "UP"));
}
}
@@ -0,0 +1,36 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.service.LocalQualificationStorage;
import jakarta.annotation.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/platform/v1/files/qualifications")
public class PlatformQualificationFileController {
@Resource private LocalQualificationStorage storage;
@GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAuthority('platform:customer:review')")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable String fileName) {
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required");
}
var resource = storage.load(tenantId, fileName);
String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE;
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(mediaType)).body(resource);
}
}
@@ -0,0 +1,74 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.client.IamQualificationClient;
import com.baiyue.file.service.LocalQualificationStorage;
import feign.FeignException;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/merchant/v1/files/qualifications")
public class QualificationFileController {
@Resource private LocalQualificationStorage storage;
@Resource private IamQualificationClient iamClient;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
public ApiResponse<UploadResponse> upload(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file,
@RequestParam(value = "certificateNo", required = false) String certificateNo) {
long tenantId = merchantTenant(user);
var stored = storage.store(tenantId, file);
try {
var response = iamClient.submit(internalToken,
new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE",
certificateNo, stored.documentUrl()));
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Qualification workflow failed");
}
var data = response.data();
return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(),
data.tenantStatus(), stored.documentUrl()));
} catch (RuntimeException exception) {
storage.delete(stored);
if (exception instanceof FeignException feign && feign.status() == 409) {
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review");
}
if (exception instanceof BizException business) throw business;
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable");
}
}
@GetMapping("/{fileName}")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) {
var resource = storage.load(merchantTenant(user), fileName);
String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE;
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(mediaType)).body(resource);
}
private long merchantTenant(AuthenticatedUser user) {
if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
|| user.tenantId() == null || user.tenantId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required");
}
return user.tenantId();
}
public record UploadResponse(Long qualificationId, Long reviewId,
String tenantStatus, String documentUrl) { }
}
@@ -0,0 +1,81 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.io.InputStream;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@Service
public class LocalQualificationStorage {
private static final long MAX_SIZE = 5L * 1024 * 1024;
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png");
private final Path root;
public LocalQualificationStorage(@Value("${baiyue.file.storage-root}") String root) {
this.root = Path.of(root).toAbsolutePath().normalize();
}
public StoredFile store(long tenantId, MultipartFile file) {
if (file == null || file.isEmpty()) throw new BizException("One business-license image is required");
if (file.getSize() > MAX_SIZE) throw new BizException("Business-license image must not exceed 5 MB");
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!TYPES.contains(contentType)) throw new BizException("Only JPG and PNG images are supported");
if (!matchesImageSignature(file, contentType)) {
throw new BizException("The uploaded file content is not a valid JPG or PNG image");
}
String extension = "image/png".equals(contentType) ? ".png" : ".jpg";
String fileName = UUID.randomUUID() + extension;
Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize();
Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory)) throw new BizException("Invalid upload path");
try {
Files.createDirectories(directory);
file.transferTo(target);
return new StoredFile(target, fileName,
"/api/merchant/v1/files/qualifications/" + fileName);
} catch (IOException exception) {
throw new BizException("Unable to store enterprise qualification");
}
}
private boolean matchesImageSignature(MultipartFile file, String contentType) {
byte[] header = new byte[8];
try (InputStream input = file.getInputStream()) {
int read = input.read(header);
if ("image/jpeg".equals(contentType)) {
return read >= 3 && (header[0] & 0xff) == 0xff && (header[1] & 0xff) == 0xd8
&& (header[2] & 0xff) == 0xff;
}
return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50
&& header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d
&& header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a;
} catch (IOException exception) {
throw new BizException("Unable to read enterprise qualification image");
}
}
public Resource load(long tenantId, String fileName) {
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name");
Path directory = root.resolve("qualifications").resolve(Long.toString(tenantId)).normalize();
Path target = directory.resolve(fileName).normalize();
if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found");
try { return new UrlResource(target.toUri()); }
catch (Exception exception) { throw new BizException("File not found"); }
}
public void delete(StoredFile file) {
try { Files.deleteIfExists(file.path()); } catch (IOException ignored) { }
}
public record StoredFile(Path path, String fileName, String documentUrl) { }
}
@@ -0,0 +1,58 @@
package com.baiyue.file.service;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@Service
public class QualificationFileStorage {
private static final Set<String> CONTENT_TYPES = Set.of("image/jpeg", "image/png");
private final Path root;
public QualificationFileStorage(@Value("${baiyue.file.storage-root}") String root) {
this.root = Path.of(root).toAbsolutePath().normalize().resolve("qualifications");
}
public StoredFile store(long tenantId, MultipartFile file) {
if (file == null || file.isEmpty()) throw new BizException("One qualification image is required");
if (file.getSize() > 5L * 1024 * 1024) throw new BizException("Qualification image must not exceed 5MB");
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!CONTENT_TYPES.contains(contentType)) throw new BizException("Only JPG and PNG qualification images are supported");
String filename = UUID.randomUUID() + ("image/png".equals(contentType) ? ".png" : ".jpg");
Path tenantRoot = root.resolve(Long.toString(tenantId)).normalize();
Path target = tenantRoot.resolve(filename).normalize();
if (!target.startsWith(root)) throw new BizException("Invalid qualification file path");
try {
Files.createDirectories(tenantRoot);
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
return new StoredFile(filename, target);
} catch (IOException exception) {
throw new BizException("Unable to store qualification image");
}
}
public Resource load(long tenantId, String filename) {
if (!filename.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name");
Path path = root.resolve(Long.toString(tenantId)).resolve(filename).normalize();
if (!path.startsWith(root) || !Files.isRegularFile(path)) throw new BizException("Qualification image not found");
try { return new UrlResource(path.toUri()); }
catch (IOException exception) { throw new BizException("Unable to read qualification image"); }
}
public void delete(StoredFile stored) {
try { Files.deleteIfExists(stored.path()); } catch (IOException ignored) { }
}
public record StoredFile(String filename, Path path) { }
}