暂存提交
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
+36
@@ -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) { }
|
||||
}
|
||||
Reference in New Issue
Block a user