birc-generator 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bircrc.example.json +14 -0
- package/AGENTS.md +20 -0
- package/PROJECT.md +83 -0
- package/README.md +300 -0
- package/bin/birc.js +645 -0
- package/bin/cli-util.js +428 -0
- package/bin/home-install.js +346 -0
- package/bin/update-check.js +153 -0
- package/implement.md +76 -0
- package/install.ps1 +156 -0
- package/install.sh +201 -0
- package/package.json +68 -0
- package/plopfile.js +1358 -0
- package/project-docs/AGENTS.md.hbs +12 -0
- package/project-docs/PROJECT.md.hbs +128 -0
- package/project-docs/implement.hbs +54 -0
- package/project-docs/test.hbs +31 -0
- package/scripts/install-bin.sh +72 -0
- package/scripts/update.sh +11 -0
- package/templates/aop/OperationLog.hbs +18 -0
- package/templates/aop/OperationLogAspect.hbs +95 -0
- package/templates/aop/build-gradle-dep.hbs +1 -0
- package/templates/base/Application.hbs +11 -0
- package/templates/base/ApplicationTests.java.hbs +11 -0
- package/templates/base/BaseService.hbs +15 -0
- package/templates/base/BaseServiceImpl.hbs +53 -0
- package/templates/base/EntityMapper.hbs +12 -0
- package/templates/base/README.md.hbs +56 -0
- package/templates/base/Result.hbs +48 -0
- package/templates/base/application-test.yml.hbs +27 -0
- package/templates/base/application.yml.hbs +18 -0
- package/templates/base/bircrc.hbs +14 -0
- package/templates/base/build.gradle.hbs +33 -0
- package/templates/base/gitignore.hbs +15 -0
- package/templates/client/ExternalApiClient.hbs +28 -0
- package/templates/client/ExternalApiClientConfig.hbs +26 -0
- package/templates/client/ExternalApiProperties.hbs +30 -0
- package/templates/client/application-yml-block.hbs +7 -0
- package/templates/controller.hbs +55 -0
- package/templates/dao.hbs +11 -0
- package/templates/docker/Dockerfile.hbs +29 -0
- package/templates/docker/docker-compose.prod.yml.hbs +65 -0
- package/templates/docker/docker-compose.yml.hbs +52 -0
- package/templates/docker/dockerignore.hbs +6 -0
- package/templates/docker/env.example.hbs +24 -0
- package/templates/dto-request.hbs +19 -0
- package/templates/dto-response.hbs +13 -0
- package/templates/email/EmailAsyncConfig.hbs +28 -0
- package/templates/email/EmailService.hbs +26 -0
- package/templates/email/EmailServiceImpl.hbs +49 -0
- package/templates/email/application-yml-block.hbs +19 -0
- package/templates/email/build-gradle-dep.hbs +2 -0
- package/templates/email/sample-template.hbs +16 -0
- package/templates/entity.hbs +46 -0
- package/templates/exception/ExceptionHandleController.hbs +16 -0
- package/templates/exception/NotFoundException.hbs +28 -0
- package/templates/exception/ProjectException.hbs +20 -0
- package/templates/exception.hbs +30 -0
- package/templates/file-upload/EmptyFileException.hbs +25 -0
- package/templates/file-upload/FileExtensionIllegalException.hbs +21 -0
- package/templates/file-upload/FileExtensionUtils.hbs +20 -0
- package/templates/file-upload/FileStorageProperties.hbs +37 -0
- package/templates/file-upload/FileStorageService.hbs +15 -0
- package/templates/file-upload/FileStorageServiceImpl.hbs +78 -0
- package/templates/file-upload/FileTooLargeException.hbs +21 -0
- package/templates/file-upload/FileUploadController.hbs +57 -0
- package/templates/file-upload/FileUtils.hbs +39 -0
- package/templates/file-upload/InvalidStoredFileException.hbs +21 -0
- package/templates/file-upload/application-yml-block.hbs +12 -0
- package/templates/gitlab-ci/gitlab-ci.hbs +158 -0
- package/templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.properties +8 -0
- package/templates/gradle-wrapper/gradlew +248 -0
- package/templates/gradle-wrapper/gradlew.bat +93 -0
- package/templates/log4j2/build-gradle-dep.hbs +6 -0
- package/templates/log4j2/log4j2-spring.xml.hbs +34 -0
- package/templates/mapper-gradle-dep.hbs +3 -0
- package/templates/mapper.hbs +11 -0
- package/templates/migration/add-column.hbs +4 -0
- package/templates/migration/create-table.hbs +9 -0
- package/templates/migration/custom.hbs +2 -0
- package/templates/migration/remove-column.hbs +4 -0
- package/templates/migration/seed.hbs +7 -0
- package/templates/multi-module/build.gradle.hbs +86 -0
- package/templates/multi-module/config/ApplicationConfig.java.hbs +33 -0
- package/templates/multi-module/config/Config.java.hbs +19 -0
- package/templates/multi-module/config/SecurityConfig.java.hbs +48 -0
- package/templates/multi-module/config/build.gradle.hbs +10 -0
- package/templates/multi-module/database-config/BaseDAO.java.hbs +31 -0
- package/templates/multi-module/database-config/BaseViewDAO.java.hbs +48 -0
- package/templates/multi-module/database-config/Config.java.hbs +14 -0
- package/templates/multi-module/database-config/JpaConfig.java.hbs +9 -0
- package/templates/multi-module/database-config/build.gradle.hbs +17 -0
- package/templates/multi-module/settings.gradle.hbs +9 -0
- package/templates/oauth/OAuth2LoginSuccessHandler.hbs +31 -0
- package/templates/oauth/application-yml-block.hbs +11 -0
- package/templates/oauth/build-gradle-dep.hbs +1 -0
- package/templates/openapi/OpenApiConfig.hbs +23 -0
- package/templates/openapi/build-gradle-dep.hbs +1 -0
- package/templates/pagination/GenericSpecification.hbs +76 -0
- package/templates/pagination/PageInfo.hbs +20 -0
- package/templates/pagination/PageRequest.hbs +27 -0
- package/templates/pagination/PageResponse.hbs +24 -0
- package/templates/pagination/Pager.hbs +37 -0
- package/templates/pagination/SearchCriteria.hbs +26 -0
- package/templates/pagination/SpecificationSupport.hbs +34 -0
- package/templates/permission/PermissionAspect.hbs +41 -0
- package/templates/permission/RequirePermission.hbs +18 -0
- package/templates/permission/build-gradle-dep.hbs +2 -0
- package/templates/scheduling/SampleSchedule.hbs +22 -0
- package/templates/scheduling/SchedulingConfig.hbs +13 -0
- package/templates/scheduling/application-yml-block.hbs +5 -0
- package/templates/sentry/SentryConfig.hbs +74 -0
- package/templates/sentry/application-yml-block.hbs +15 -0
- package/templates/sentry/build-gradle-dep.hbs +1 -0
- package/templates/service.hbs +9 -0
- package/templates/serviceImpl.hbs +23 -0
- package/templates/spotless/build-gradle-allprojects.hbs +16 -0
- package/templates/spotless/build-gradle-plugin.hbs +1 -0
- package/templates/spotless/spotless_formatter.xml +83 -0
- package/templates/sso/SsoAutoConfiguration.hbs +17 -0
- package/templates/sso/SsoProperties.hbs +27 -0
- package/templates/sso/application-yml-block.hbs +7 -0
- package/templates/valid-group/ValidGroup.hbs +20 -0
- package/test.md +56 -0
- package/uninstall.ps1 +60 -0
- package/uninstall.sh +76 -0
- package/versions.js +10 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package {{basePackage}}.exception;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
|
|
5
|
+
public class NotFoundException extends ProjectException {
|
|
6
|
+
|
|
7
|
+
public NotFoundException() {
|
|
8
|
+
super("查無資料");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public NotFoundException(String message) {
|
|
12
|
+
super(message);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public NotFoundException(String message, Throwable cause) {
|
|
16
|
+
super(message, cause);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Override
|
|
20
|
+
public String getErrorCode() {
|
|
21
|
+
return "NotFound";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Override
|
|
25
|
+
public HttpStatus getHttpStatus() {
|
|
26
|
+
return HttpStatus.NOT_FOUND;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package {{basePackage}}.exception;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
|
|
5
|
+
public abstract class ProjectException extends RuntimeException {
|
|
6
|
+
|
|
7
|
+
public ProjectException(String message) {
|
|
8
|
+
super(message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public ProjectException(String message, Throwable cause) {
|
|
12
|
+
super(message, cause);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public abstract String getErrorCode();
|
|
16
|
+
|
|
17
|
+
public HttpStatus getHttpStatus() {
|
|
18
|
+
return HttpStatus.INTERNAL_SERVER_ERROR;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package {{basePackage}}.exception;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
|
|
5
|
+
public class {{exceptionClass}} extends ProjectException {
|
|
6
|
+
|
|
7
|
+
public {{exceptionClass}}() {
|
|
8
|
+
super("{{defaultMessage}}");
|
|
9
|
+
}
|
|
10
|
+
{{#if withExample}}
|
|
11
|
+
|
|
12
|
+
public {{exceptionClass}}(String message) {
|
|
13
|
+
super(message);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public {{exceptionClass}}(String message, Throwable cause) {
|
|
17
|
+
super(message, cause);
|
|
18
|
+
}
|
|
19
|
+
{{/if}}
|
|
20
|
+
|
|
21
|
+
@Override
|
|
22
|
+
public String getErrorCode() {
|
|
23
|
+
return "{{errorCode}}";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Override
|
|
27
|
+
public HttpStatus getHttpStatus() {
|
|
28
|
+
return HttpStatus.{{httpStatus}};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package {{basePackage}}.exception.file;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
import {{basePackage}}.exception.ProjectException;
|
|
5
|
+
|
|
6
|
+
public class EmptyFileException extends ProjectException {
|
|
7
|
+
|
|
8
|
+
public EmptyFileException() {
|
|
9
|
+
super("未選擇檔案");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
public EmptyFileException(String fileName) {
|
|
13
|
+
super("未選擇檔案: " + fileName);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Override
|
|
17
|
+
public String getErrorCode() {
|
|
18
|
+
return "EmptyFile";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Override
|
|
22
|
+
public HttpStatus getHttpStatus() {
|
|
23
|
+
return HttpStatus.BAD_REQUEST;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package {{basePackage}}.exception.file;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
import {{basePackage}}.exception.ProjectException;
|
|
5
|
+
|
|
6
|
+
public class FileExtensionIllegalException extends ProjectException {
|
|
7
|
+
|
|
8
|
+
public FileExtensionIllegalException(String message) {
|
|
9
|
+
super(message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@Override
|
|
13
|
+
public String getErrorCode() {
|
|
14
|
+
return "FileExtensionIllegal";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Override
|
|
18
|
+
public HttpStatus getHttpStatus() {
|
|
19
|
+
return HttpStatus.BAD_REQUEST;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
package {{basePackage}}.util.file;
|
|
2
|
+
|
|
3
|
+
import java.util.Locale;
|
|
4
|
+
import java.util.Set;
|
|
5
|
+
import lombok.experimental.UtilityClass;
|
|
6
|
+
import {{basePackage}}.exception.file.FileExtensionIllegalException;
|
|
7
|
+
|
|
8
|
+
@UtilityClass
|
|
9
|
+
public class FileExtensionUtils {
|
|
10
|
+
|
|
11
|
+
public static final Set<String> IMAGE = Set.of("png", "jpg", "jpeg", "webp");
|
|
12
|
+
public static final Set<String> DOCUMENT = Set.of("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx");
|
|
13
|
+
|
|
14
|
+
public void assertAllowed(String fileName, Set<String> allowed) {
|
|
15
|
+
String extension = FileUtils.getFileExtension(fileName).toLowerCase(Locale.ROOT);
|
|
16
|
+
if (extension.isBlank() || !allowed.contains(extension)) {
|
|
17
|
+
throw new FileExtensionIllegalException("不允許的副檔名:" + extension);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package {{basePackage}}.config;
|
|
2
|
+
|
|
3
|
+
import jakarta.validation.constraints.Min;
|
|
4
|
+
import jakarta.validation.constraints.NotBlank;
|
|
5
|
+
import java.util.Arrays;
|
|
6
|
+
import java.util.Set;
|
|
7
|
+
import java.util.stream.Collectors;
|
|
8
|
+
import lombok.Getter;
|
|
9
|
+
import lombok.Setter;
|
|
10
|
+
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
11
|
+
import org.springframework.stereotype.Component;
|
|
12
|
+
import org.springframework.validation.annotation.Validated;
|
|
13
|
+
|
|
14
|
+
@Getter
|
|
15
|
+
@Setter
|
|
16
|
+
@Validated
|
|
17
|
+
@Component
|
|
18
|
+
@ConfigurationProperties(prefix = "file-storage")
|
|
19
|
+
public class FileStorageProperties {
|
|
20
|
+
|
|
21
|
+
@NotBlank
|
|
22
|
+
private String basePath = "./uploads";
|
|
23
|
+
|
|
24
|
+
@Min(1)
|
|
25
|
+
private long maxFileSizeBytes = 10 * 1024 * 1024L;
|
|
26
|
+
|
|
27
|
+
@NotBlank
|
|
28
|
+
private String allowedExtensions = "png,jpg,jpeg,webp,pdf,doc,docx,xls,xlsx,ppt,pptx";
|
|
29
|
+
|
|
30
|
+
public Set<String> allowedExtensionSet() {
|
|
31
|
+
return Arrays.stream(allowedExtensions.split(","))
|
|
32
|
+
.map(String::trim)
|
|
33
|
+
.map(String::toLowerCase)
|
|
34
|
+
.filter(s -> !s.isBlank())
|
|
35
|
+
.collect(Collectors.toSet());
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
package {{basePackage}}.service;
|
|
2
|
+
|
|
3
|
+
import java.nio.file.Path;
|
|
4
|
+
import org.springframework.web.multipart.MultipartFile;
|
|
5
|
+
|
|
6
|
+
public interface FileStorageService {
|
|
7
|
+
|
|
8
|
+
String store(MultipartFile file);
|
|
9
|
+
|
|
10
|
+
String store(MultipartFile file, String... subDirectory);
|
|
11
|
+
|
|
12
|
+
Path resolve(String storedFileName);
|
|
13
|
+
|
|
14
|
+
void delete(String storedFileName);
|
|
15
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package {{basePackage}}.service.impl;
|
|
2
|
+
|
|
3
|
+
import java.io.IOException;
|
|
4
|
+
import java.io.UncheckedIOException;
|
|
5
|
+
import java.nio.file.Files;
|
|
6
|
+
import java.nio.file.Path;
|
|
7
|
+
import java.util.Set;
|
|
8
|
+
import lombok.RequiredArgsConstructor;
|
|
9
|
+
import org.springframework.stereotype.Service;
|
|
10
|
+
import org.springframework.web.multipart.MultipartFile;
|
|
11
|
+
import {{basePackage}}.config.FileStorageProperties;
|
|
12
|
+
import {{basePackage}}.exception.file.EmptyFileException;
|
|
13
|
+
import {{basePackage}}.exception.file.FileTooLargeException;
|
|
14
|
+
import {{basePackage}}.exception.file.InvalidStoredFileException;
|
|
15
|
+
import {{basePackage}}.service.FileStorageService;
|
|
16
|
+
import {{basePackage}}.util.file.FileExtensionUtils;
|
|
17
|
+
import {{basePackage}}.util.file.FileUtils;
|
|
18
|
+
|
|
19
|
+
@Service
|
|
20
|
+
@RequiredArgsConstructor
|
|
21
|
+
public class FileStorageServiceImpl implements FileStorageService {
|
|
22
|
+
|
|
23
|
+
private final FileStorageProperties properties;
|
|
24
|
+
|
|
25
|
+
@Override
|
|
26
|
+
public String store(MultipartFile file) {
|
|
27
|
+
return store(file, new String[0]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Override
|
|
31
|
+
public String store(MultipartFile file, String... subDirectory) {
|
|
32
|
+
if (file == null || file.isEmpty()) {
|
|
33
|
+
throw new EmptyFileException(file == null ? "file" : file.getOriginalFilename());
|
|
34
|
+
}
|
|
35
|
+
if (file.getSize() > properties.getMaxFileSizeBytes()) {
|
|
36
|
+
throw new FileTooLargeException();
|
|
37
|
+
}
|
|
38
|
+
String originalName = file.getOriginalFilename() == null ? "" : file.getOriginalFilename();
|
|
39
|
+
Set<String> allowed = properties.allowedExtensionSet();
|
|
40
|
+
FileExtensionUtils.assertAllowed(originalName, allowed);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
Path baseDir = Path.of(properties.getBasePath()).toAbsolutePath().normalize();
|
|
44
|
+
Path targetDir = baseDir;
|
|
45
|
+
if (subDirectory != null) {
|
|
46
|
+
for (String name : subDirectory) {
|
|
47
|
+
if (name == null || name.isBlank() || name.contains("..") || name.contains("/")
|
|
48
|
+
|| name.contains("\\")) {
|
|
49
|
+
throw new InvalidStoredFileException(String.valueOf(name));
|
|
50
|
+
}
|
|
51
|
+
targetDir = targetDir.resolve(name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
Files.createDirectories(targetDir);
|
|
55
|
+
String storedFileName = FileUtils.randomNameKeepExtension(originalName);
|
|
56
|
+
Path storePath = FileUtils.requireUnderBase(baseDir, targetDir.resolve(storedFileName));
|
|
57
|
+
file.transferTo(storePath);
|
|
58
|
+
return baseDir.relativize(storePath).toString().replace('\\', '/');
|
|
59
|
+
} catch (IOException e) {
|
|
60
|
+
throw new UncheckedIOException("儲存檔案失敗", e);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Override
|
|
65
|
+
public Path resolve(String storedFileName) {
|
|
66
|
+
Path baseDir = Path.of(properties.getBasePath()).toAbsolutePath().normalize();
|
|
67
|
+
return FileUtils.requireUnderBase(baseDir, baseDir.resolve(storedFileName));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Override
|
|
71
|
+
public void delete(String storedFileName) {
|
|
72
|
+
try {
|
|
73
|
+
Files.deleteIfExists(resolve(storedFileName));
|
|
74
|
+
} catch (IOException e) {
|
|
75
|
+
throw new UncheckedIOException("刪除檔案失敗", e);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package {{basePackage}}.exception.file;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
import {{basePackage}}.exception.ProjectException;
|
|
5
|
+
|
|
6
|
+
public class FileTooLargeException extends ProjectException {
|
|
7
|
+
|
|
8
|
+
public FileTooLargeException() {
|
|
9
|
+
super("檔案超過大小上限");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@Override
|
|
13
|
+
public String getErrorCode() {
|
|
14
|
+
return "FileTooLarge";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Override
|
|
18
|
+
public HttpStatus getHttpStatus() {
|
|
19
|
+
return HttpStatus.BAD_REQUEST;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package {{basePackage}}.controller;
|
|
2
|
+
|
|
3
|
+
import java.net.URLEncoder;
|
|
4
|
+
import java.nio.charset.StandardCharsets;
|
|
5
|
+
import java.util.LinkedHashMap;
|
|
6
|
+
import java.util.Map;
|
|
7
|
+
import lombok.RequiredArgsConstructor;
|
|
8
|
+
import org.springframework.core.io.FileSystemResource;
|
|
9
|
+
import org.springframework.core.io.Resource;
|
|
10
|
+
import org.springframework.http.HttpHeaders;
|
|
11
|
+
import org.springframework.http.MediaType;
|
|
12
|
+
import org.springframework.http.ResponseEntity;
|
|
13
|
+
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
14
|
+
import org.springframework.web.bind.annotation.GetMapping;
|
|
15
|
+
import org.springframework.web.bind.annotation.PathVariable;
|
|
16
|
+
import org.springframework.web.bind.annotation.PostMapping;
|
|
17
|
+
import org.springframework.web.bind.annotation.RequestMapping;
|
|
18
|
+
import org.springframework.web.bind.annotation.RequestParam;
|
|
19
|
+
import org.springframework.web.bind.annotation.RestController;
|
|
20
|
+
import org.springframework.web.multipart.MultipartFile;
|
|
21
|
+
import {{basePackage}}.service.FileStorageService;
|
|
22
|
+
|
|
23
|
+
@RestController
|
|
24
|
+
@RequestMapping("/api/files")
|
|
25
|
+
@RequiredArgsConstructor
|
|
26
|
+
public class FileUploadController {
|
|
27
|
+
|
|
28
|
+
private final FileStorageService fileStorageService;
|
|
29
|
+
|
|
30
|
+
@PostMapping
|
|
31
|
+
public ResponseEntity<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
|
|
32
|
+
Map<String, Object> body = new LinkedHashMap<>();
|
|
33
|
+
body.put("success", true);
|
|
34
|
+
body.put("data", fileStorageService.store(file));
|
|
35
|
+
return ResponseEntity.ok(body);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@GetMapping("/{*storedFileName}")
|
|
39
|
+
public ResponseEntity<Resource> download(@PathVariable String storedFileName) {
|
|
40
|
+
Resource resource = new FileSystemResource(fileStorageService.resolve(storedFileName));
|
|
41
|
+
String fileName = storedFileName.substring(storedFileName.lastIndexOf('/') + 1);
|
|
42
|
+
return ResponseEntity.ok()
|
|
43
|
+
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
|
44
|
+
.header(
|
|
45
|
+
HttpHeaders.CONTENT_DISPOSITION,
|
|
46
|
+
"attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8))
|
|
47
|
+
.body(resource);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@DeleteMapping("/{*storedFileName}")
|
|
51
|
+
public ResponseEntity<Map<String, Object>> delete(@PathVariable String storedFileName) {
|
|
52
|
+
fileStorageService.delete(storedFileName);
|
|
53
|
+
Map<String, Object> body = new LinkedHashMap<>();
|
|
54
|
+
body.put("success", true);
|
|
55
|
+
return ResponseEntity.ok(body);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package {{basePackage}}.util.file;
|
|
2
|
+
|
|
3
|
+
import java.nio.file.Path;
|
|
4
|
+
import java.util.UUID;
|
|
5
|
+
import lombok.experimental.UtilityClass;
|
|
6
|
+
import {{basePackage}}.exception.file.InvalidStoredFileException;
|
|
7
|
+
|
|
8
|
+
@UtilityClass
|
|
9
|
+
public class FileUtils {
|
|
10
|
+
|
|
11
|
+
public String getRandomFileName() {
|
|
12
|
+
return UUID.randomUUID().toString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public String getFileExtension(String fullFileName) {
|
|
16
|
+
if (fullFileName == null) {
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
int lastDot = fullFileName.lastIndexOf('.');
|
|
20
|
+
if (lastDot < 0 || lastDot == fullFileName.length() - 1) {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
return fullFileName.substring(lastDot + 1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public String randomNameKeepExtension(String originalFileName) {
|
|
27
|
+
String extension = getFileExtension(originalFileName);
|
|
28
|
+
return extension.isBlank() ? getRandomFileName() : getRandomFileName() + "." + extension;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public Path requireUnderBase(Path baseDir, Path candidate) {
|
|
32
|
+
Path normalizedBase = baseDir.toAbsolutePath().normalize();
|
|
33
|
+
Path normalized = candidate.toAbsolutePath().normalize();
|
|
34
|
+
if (!normalized.startsWith(normalizedBase)) {
|
|
35
|
+
throw new InvalidStoredFileException(candidate.toString());
|
|
36
|
+
}
|
|
37
|
+
return normalized;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package {{basePackage}}.exception.file;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
import {{basePackage}}.exception.ProjectException;
|
|
5
|
+
|
|
6
|
+
public class InvalidStoredFileException extends ProjectException {
|
|
7
|
+
|
|
8
|
+
public InvalidStoredFileException(String path) {
|
|
9
|
+
super("不合法的檔案路徑: " + path);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@Override
|
|
13
|
+
public String getErrorCode() {
|
|
14
|
+
return "InvalidStoredFile";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Override
|
|
18
|
+
public HttpStatus getHttpStatus() {
|
|
19
|
+
return HttpStatus.BAD_REQUEST;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
# --- file-upload(由 birc-generator add 產生) ---
|
|
3
|
+
file-storage:
|
|
4
|
+
base-path: ${FILE_STORAGE_PATH:./uploads}
|
|
5
|
+
max-file-size-bytes: 10485760
|
|
6
|
+
allowed-extensions: png,jpg,jpeg,webp,pdf,doc,docx,xls,xlsx,ppt,pptx
|
|
7
|
+
|
|
8
|
+
spring:
|
|
9
|
+
servlet:
|
|
10
|
+
multipart:
|
|
11
|
+
max-file-size: 10MB
|
|
12
|
+
max-request-size: 10MB
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# 單一 CI 定義:development / main 共用此檔,以 only.refs 分流環境。
|
|
2
|
+
# 環境差異請用 GitLab CI/CD Variables(勿讓 main 與 development 分叉養兩份 CI)。
|
|
3
|
+
#
|
|
4
|
+
# 共用變數:HARBOR_URL, HARBOR_USER, HARBOR_PASSWORD
|
|
5
|
+
# (不使用 build-arg 傳憑證:build args 會留在 image layer,docker history 看得到)
|
|
6
|
+
# Beta(development):BETA_USER, BETA_HOST, BETA_SSH_KEY, BETA_SSH_HOST_KEY
|
|
7
|
+
# Online(main):ONLINE_USER, ONLINE_HOST, ONLINE_SSH_KEY, ONLINE_SSH_HOST_KEY
|
|
8
|
+
# SSH_HOST_KEY 用 ssh-keyscan -t ed25519 <host> 的輸出;部署前會寫進
|
|
9
|
+
# known_hosts 並以 StrictHostKeyChecking=yes 驗證,缺變數時 pipeline 直接失敗。
|
|
10
|
+
# HARBOR_PASSWORD 不放進遠端指令字串,走 ssh stdin 餵給 docker login。
|
|
11
|
+
# 伺服器 ${DEPLOY_DIR}/.env 需含 APP_TAG 等應用設定 (DEPLOY_DIR=/opt/{{projectNameKebab}}/backend)
|
|
12
|
+
|
|
13
|
+
stages:
|
|
14
|
+
- build
|
|
15
|
+
- deploy
|
|
16
|
+
|
|
17
|
+
variables:
|
|
18
|
+
IMAGE_NAME: $HARBOR_URL/{{projectNameKebab}}/{{snakeCase projectNameKebab}}_app
|
|
19
|
+
DEPLOY_DIR: /opt/{{projectNameKebab}}/backend
|
|
20
|
+
APP_HEALTH_URL: http://localhost:${SERVER_PORT}/actuator/health
|
|
21
|
+
|
|
22
|
+
# ===== Build Beta =====
|
|
23
|
+
build-beta:
|
|
24
|
+
stage: build
|
|
25
|
+
image: docker:latest
|
|
26
|
+
services:
|
|
27
|
+
- docker:dind
|
|
28
|
+
variables:
|
|
29
|
+
DOCKER_TLS_CERTDIR: ""
|
|
30
|
+
IMAGE_TAG: beta-$CI_COMMIT_SHORT_SHA
|
|
31
|
+
before_script:
|
|
32
|
+
- echo "$HARBOR_PASSWORD" | docker login -u "$HARBOR_USER" --password-stdin "$HARBOR_URL"
|
|
33
|
+
script:
|
|
34
|
+
- docker pull $IMAGE_NAME:beta-latest || true
|
|
35
|
+
- docker build --cache-from $IMAGE_NAME:beta-latest
|
|
36
|
+
-t $IMAGE_NAME:$IMAGE_TAG
|
|
37
|
+
-t $IMAGE_NAME:beta-latest .
|
|
38
|
+
- docker push $IMAGE_NAME:$IMAGE_TAG
|
|
39
|
+
- docker push $IMAGE_NAME:beta-latest
|
|
40
|
+
only:
|
|
41
|
+
refs:
|
|
42
|
+
- development
|
|
43
|
+
- merge_requests
|
|
44
|
+
{{#if gitlabProjectPath}}
|
|
45
|
+
variables:
|
|
46
|
+
- $CI_PROJECT_PATH == "{{gitlabProjectPath}}"
|
|
47
|
+
{{/if}}
|
|
48
|
+
|
|
49
|
+
# ===== Build Online =====
|
|
50
|
+
build-online:
|
|
51
|
+
stage: build
|
|
52
|
+
image: docker:latest
|
|
53
|
+
services:
|
|
54
|
+
- docker:dind
|
|
55
|
+
variables:
|
|
56
|
+
DOCKER_TLS_CERTDIR: ""
|
|
57
|
+
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
|
|
58
|
+
before_script:
|
|
59
|
+
- echo "$HARBOR_PASSWORD" | docker login -u "$HARBOR_USER" --password-stdin "$HARBOR_URL"
|
|
60
|
+
script:
|
|
61
|
+
- docker pull $IMAGE_NAME:latest || true
|
|
62
|
+
- docker build --cache-from $IMAGE_NAME:latest
|
|
63
|
+
-t $IMAGE_NAME:$IMAGE_TAG
|
|
64
|
+
-t $IMAGE_NAME:latest .
|
|
65
|
+
- docker push $IMAGE_NAME:$IMAGE_TAG
|
|
66
|
+
- docker push $IMAGE_NAME:latest
|
|
67
|
+
only:
|
|
68
|
+
refs:
|
|
69
|
+
- main
|
|
70
|
+
{{#if gitlabProjectPath}}
|
|
71
|
+
variables:
|
|
72
|
+
- $CI_PROJECT_PATH == "{{gitlabProjectPath}}"
|
|
73
|
+
{{/if}}
|
|
74
|
+
|
|
75
|
+
# ===== Deploy Beta =====
|
|
76
|
+
deploy-beta:
|
|
77
|
+
stage: deploy
|
|
78
|
+
image: alpine:latest
|
|
79
|
+
variables:
|
|
80
|
+
IMAGE_TAG: beta-$CI_COMMIT_SHORT_SHA
|
|
81
|
+
before_script:
|
|
82
|
+
- apk add --no-cache openssh-client curl
|
|
83
|
+
- eval $(ssh-agent -s)
|
|
84
|
+
- chmod 600 "$BETA_SSH_KEY"
|
|
85
|
+
- ssh-add "$BETA_SSH_KEY"
|
|
86
|
+
- mkdir -p ~/.ssh
|
|
87
|
+
- test -n "$BETA_SSH_HOST_KEY" || (echo "請在 GitLab CI/CD Variables 設定 BETA_SSH_HOST_KEY(ssh-keyscan -t ed25519 $BETA_HOST 的輸出)" && exit 1)
|
|
88
|
+
- echo "$BETA_SSH_HOST_KEY" > ~/.ssh/known_hosts
|
|
89
|
+
script:
|
|
90
|
+
- |
|
|
91
|
+
SSH_OPTS="-o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
|
92
|
+
SSH="ssh $SSH_OPTS ${BETA_USER}@${BETA_HOST}"
|
|
93
|
+
SCP="scp $SSH_OPTS"
|
|
94
|
+
HARBOR_USER_Q=$(printf '%q' "$HARBOR_USER")
|
|
95
|
+
HARBOR_URL_Q=$(printf '%q' "$HARBOR_URL")
|
|
96
|
+
${SSH} "mkdir -p '${DEPLOY_DIR}'"
|
|
97
|
+
${SCP} docker-compose.prod.yml "${BETA_USER}@${BETA_HOST}:${DEPLOY_DIR}/"
|
|
98
|
+
${SCP} .env.example "${BETA_USER}@${BETA_HOST}:${DEPLOY_DIR}/.env.example"
|
|
99
|
+
${SSH} "cd '${DEPLOY_DIR}' && test -f .env || cp .env.example .env"
|
|
100
|
+
printf '%s\n' "$HARBOR_PASSWORD" | ${SSH} "docker login -u ${HARBOR_USER_Q} --password-stdin ${HARBOR_URL_Q}"
|
|
101
|
+
${SSH} "cd '${DEPLOY_DIR}' && touch .env && (grep -q '^APP_TAG=' .env || echo 'APP_TAG=${IMAGE_TAG}' >> .env) && sed -i 's/^APP_TAG=.*/APP_TAG=${IMAGE_TAG}/' .env"
|
|
102
|
+
${SSH} "cd '${DEPLOY_DIR}' && docker compose -f docker-compose.prod.yml ps db | grep -q running || (docker compose -f docker-compose.prod.yml up -d db && until docker compose -f docker-compose.prod.yml ps db | grep -q healthy; do sleep 2; done)"
|
|
103
|
+
${SSH} "cd '${DEPLOY_DIR}' && docker compose -f docker-compose.prod.yml pull app"
|
|
104
|
+
${SSH} "cd '${DEPLOY_DIR}' && docker compose -f docker-compose.prod.yml up -d --no-deps app"
|
|
105
|
+
sleep 10
|
|
106
|
+
${SSH} "curl -sf ${APP_HEALTH_URL} || echo 'Warning: health check failed'"
|
|
107
|
+
${SSH} "docker image prune -f"
|
|
108
|
+
echo "Beta deploy complete: ${IMAGE_TAG}"
|
|
109
|
+
only:
|
|
110
|
+
refs:
|
|
111
|
+
- development
|
|
112
|
+
{{#if gitlabProjectPath}}
|
|
113
|
+
variables:
|
|
114
|
+
- $CI_PROJECT_PATH == "{{gitlabProjectPath}}"
|
|
115
|
+
{{/if}}
|
|
116
|
+
|
|
117
|
+
# ===== Deploy Online =====
|
|
118
|
+
deploy-online:
|
|
119
|
+
stage: deploy
|
|
120
|
+
image: alpine:latest
|
|
121
|
+
variables:
|
|
122
|
+
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
|
|
123
|
+
before_script:
|
|
124
|
+
- apk add --no-cache openssh-client curl
|
|
125
|
+
- eval $(ssh-agent -s)
|
|
126
|
+
- chmod 600 "$ONLINE_SSH_KEY"
|
|
127
|
+
- ssh-add "$ONLINE_SSH_KEY"
|
|
128
|
+
- mkdir -p ~/.ssh
|
|
129
|
+
- test -n "$ONLINE_SSH_HOST_KEY" || (echo "請在 GitLab CI/CD Variables 設定 ONLINE_SSH_HOST_KEY(ssh-keyscan -t ed25519 $ONLINE_HOST 的輸出)" && exit 1)
|
|
130
|
+
- echo "$ONLINE_SSH_HOST_KEY" > ~/.ssh/known_hosts
|
|
131
|
+
script:
|
|
132
|
+
- |
|
|
133
|
+
SSH_OPTS="-o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
|
|
134
|
+
SSH="ssh $SSH_OPTS ${ONLINE_USER}@${ONLINE_HOST}"
|
|
135
|
+
SCP="scp $SSH_OPTS"
|
|
136
|
+
HARBOR_USER_Q=$(printf '%q' "$HARBOR_USER")
|
|
137
|
+
HARBOR_URL_Q=$(printf '%q' "$HARBOR_URL")
|
|
138
|
+
${SSH} "mkdir -p '${DEPLOY_DIR}'"
|
|
139
|
+
${SCP} docker-compose.prod.yml "${ONLINE_USER}@${ONLINE_HOST}:${DEPLOY_DIR}/"
|
|
140
|
+
${SCP} .env.example "${ONLINE_USER}@${ONLINE_HOST}:${DEPLOY_DIR}/.env.example"
|
|
141
|
+
${SSH} "cd '${DEPLOY_DIR}' && test -f .env || cp .env.example .env"
|
|
142
|
+
printf '%s\n' "$HARBOR_PASSWORD" | ${SSH} "docker login -u ${HARBOR_USER_Q} --password-stdin ${HARBOR_URL_Q}"
|
|
143
|
+
${SSH} "cd '${DEPLOY_DIR}' && touch .env && (grep -q '^APP_TAG=' .env || echo 'APP_TAG=${IMAGE_TAG}' >> .env) && sed -i 's/^APP_TAG=.*/APP_TAG=${IMAGE_TAG}/' .env"
|
|
144
|
+
${SSH} "cd '${DEPLOY_DIR}' && docker compose -f docker-compose.prod.yml ps db | grep -q running || (docker compose -f docker-compose.prod.yml up -d db && until docker compose -f docker-compose.prod.yml ps db | grep -q healthy; do sleep 2; done)"
|
|
145
|
+
${SSH} "cd '${DEPLOY_DIR}' && docker compose -f docker-compose.prod.yml pull app"
|
|
146
|
+
${SSH} "cd '${DEPLOY_DIR}' && docker compose -f docker-compose.prod.yml up -d --no-deps app"
|
|
147
|
+
sleep 10
|
|
148
|
+
${SSH} "curl -sf ${APP_HEALTH_URL} || echo 'Warning: health check failed'"
|
|
149
|
+
${SSH} "docker image prune -f"
|
|
150
|
+
echo "Online deploy complete: ${IMAGE_TAG}"
|
|
151
|
+
only:
|
|
152
|
+
refs:
|
|
153
|
+
- main
|
|
154
|
+
{{#if gitlabProjectPath}}
|
|
155
|
+
variables:
|
|
156
|
+
- $CI_PROJECT_PATH == "{{gitlabProjectPath}}"
|
|
157
|
+
{{/if}}
|
|
158
|
+
when: manual
|
|
Binary file
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
distributionBase=GRADLE_USER_HOME
|
|
2
|
+
distributionPath=wrapper/dists
|
|
3
|
+
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
|
4
|
+
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
|
|
5
|
+
networkTimeout=10000
|
|
6
|
+
validateDistributionUrl=true
|
|
7
|
+
zipStoreBase=GRADLE_USER_HOME
|
|
8
|
+
zipStorePath=wrapper/dists
|