暂存提交
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) { }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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='文件下载审计日志表';
|
||||
Reference in New Issue
Block a user