暂存提交

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) { }
}
+39
View File
@@ -0,0 +1,39 @@
server:
port: ${FILE_PORT:9206}
spring:
application:
name: file-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config.namespace: ${NACOS_NAMESPACE:public}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FILE_DB_USERNAME:root}
password: ${FILE_DB_PASSWORD:123456}
servlet:
multipart:
max-file-size: 5MB
max-request-size: 6MB
baiyue:
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
clients:
iam:
url: ${IAM_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads}
management.endpoints.web.exposure.include: health,info,prometheus
+61
View File
@@ -0,0 +1,61 @@
-- 文件中心独占数据库,其他服务只保存文件逻辑ID。
CREATE DATABASE IF NOT EXISTS baiyue_file
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_file;
CREATE TABLE IF NOT EXISTS file_object (
id BIGINT UNSIGNED NOT NULL COMMENT '文件ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
object_key VARCHAR(512) NOT NULL COMMENT '对象存储键',
storage_provider VARCHAR(32) NOT NULL COMMENT '存储提供方',
bucket_name VARCHAR(128) NOT NULL COMMENT '存储桶名称',
content_type VARCHAR(128) NOT NULL COMMENT '文件内容类型',
size_bytes BIGINT UNSIGNED NOT NULL COMMENT '文件字节数',
sha256 CHAR(64) NULL COMMENT '文件SHA-256摘要',
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '文件状态',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_file_tenant_object (tenant_id, object_key),
KEY idx_file_tenant_business (tenant_id, business_type, business_id),
KEY idx_file_tenant_created (tenant_id, created_at)
) ENGINE=InnoDB COMMENT='文件元数据表';
CREATE TABLE IF NOT EXISTS file_download_token (
id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID',
token_hash CHAR(64) NOT NULL COMMENT '下载令牌SHA-256摘要',
expires_at DATETIME(3) NOT NULL COMMENT '过期时间',
max_downloads INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '最大下载次数',
download_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已下载次数',
revoked_at DATETIME(3) NULL COMMENT '撤销时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_download_token_hash (token_hash),
KEY idx_download_file (tenant_id, file_id),
KEY idx_download_expiry (expires_at)
) ENGINE=InnoDB COMMENT='临时下载授权令牌表';
CREATE TABLE IF NOT EXISTS file_download_log (
id BIGINT UNSIGNED NOT NULL COMMENT '下载日志ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件逻辑ID',
user_id BIGINT UNSIGNED NULL COMMENT '下载用户ID',
request_id VARCHAR(64) NULL COMMENT '请求ID',
client_ip VARCHAR(64) NULL COMMENT '客户端IP',
result VARCHAR(16) NOT NULL COMMENT '下载结果',
failure_reason VARCHAR(500) NULL COMMENT '失败原因',
downloaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下载时间',
PRIMARY KEY (id),
KEY idx_download_log_file (tenant_id, file_id, downloaded_at),
KEY idx_download_log_user (tenant_id, user_id, downloaded_at)
) ENGINE=InnoDB COMMENT='文件下载审计日志表';