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.
Files changed (128) hide show
  1. package/.bircrc.example.json +14 -0
  2. package/AGENTS.md +20 -0
  3. package/PROJECT.md +83 -0
  4. package/README.md +300 -0
  5. package/bin/birc.js +645 -0
  6. package/bin/cli-util.js +428 -0
  7. package/bin/home-install.js +346 -0
  8. package/bin/update-check.js +153 -0
  9. package/implement.md +76 -0
  10. package/install.ps1 +156 -0
  11. package/install.sh +201 -0
  12. package/package.json +68 -0
  13. package/plopfile.js +1358 -0
  14. package/project-docs/AGENTS.md.hbs +12 -0
  15. package/project-docs/PROJECT.md.hbs +128 -0
  16. package/project-docs/implement.hbs +54 -0
  17. package/project-docs/test.hbs +31 -0
  18. package/scripts/install-bin.sh +72 -0
  19. package/scripts/update.sh +11 -0
  20. package/templates/aop/OperationLog.hbs +18 -0
  21. package/templates/aop/OperationLogAspect.hbs +95 -0
  22. package/templates/aop/build-gradle-dep.hbs +1 -0
  23. package/templates/base/Application.hbs +11 -0
  24. package/templates/base/ApplicationTests.java.hbs +11 -0
  25. package/templates/base/BaseService.hbs +15 -0
  26. package/templates/base/BaseServiceImpl.hbs +53 -0
  27. package/templates/base/EntityMapper.hbs +12 -0
  28. package/templates/base/README.md.hbs +56 -0
  29. package/templates/base/Result.hbs +48 -0
  30. package/templates/base/application-test.yml.hbs +27 -0
  31. package/templates/base/application.yml.hbs +18 -0
  32. package/templates/base/bircrc.hbs +14 -0
  33. package/templates/base/build.gradle.hbs +33 -0
  34. package/templates/base/gitignore.hbs +15 -0
  35. package/templates/client/ExternalApiClient.hbs +28 -0
  36. package/templates/client/ExternalApiClientConfig.hbs +26 -0
  37. package/templates/client/ExternalApiProperties.hbs +30 -0
  38. package/templates/client/application-yml-block.hbs +7 -0
  39. package/templates/controller.hbs +55 -0
  40. package/templates/dao.hbs +11 -0
  41. package/templates/docker/Dockerfile.hbs +29 -0
  42. package/templates/docker/docker-compose.prod.yml.hbs +65 -0
  43. package/templates/docker/docker-compose.yml.hbs +52 -0
  44. package/templates/docker/dockerignore.hbs +6 -0
  45. package/templates/docker/env.example.hbs +24 -0
  46. package/templates/dto-request.hbs +19 -0
  47. package/templates/dto-response.hbs +13 -0
  48. package/templates/email/EmailAsyncConfig.hbs +28 -0
  49. package/templates/email/EmailService.hbs +26 -0
  50. package/templates/email/EmailServiceImpl.hbs +49 -0
  51. package/templates/email/application-yml-block.hbs +19 -0
  52. package/templates/email/build-gradle-dep.hbs +2 -0
  53. package/templates/email/sample-template.hbs +16 -0
  54. package/templates/entity.hbs +46 -0
  55. package/templates/exception/ExceptionHandleController.hbs +16 -0
  56. package/templates/exception/NotFoundException.hbs +28 -0
  57. package/templates/exception/ProjectException.hbs +20 -0
  58. package/templates/exception.hbs +30 -0
  59. package/templates/file-upload/EmptyFileException.hbs +25 -0
  60. package/templates/file-upload/FileExtensionIllegalException.hbs +21 -0
  61. package/templates/file-upload/FileExtensionUtils.hbs +20 -0
  62. package/templates/file-upload/FileStorageProperties.hbs +37 -0
  63. package/templates/file-upload/FileStorageService.hbs +15 -0
  64. package/templates/file-upload/FileStorageServiceImpl.hbs +78 -0
  65. package/templates/file-upload/FileTooLargeException.hbs +21 -0
  66. package/templates/file-upload/FileUploadController.hbs +57 -0
  67. package/templates/file-upload/FileUtils.hbs +39 -0
  68. package/templates/file-upload/InvalidStoredFileException.hbs +21 -0
  69. package/templates/file-upload/application-yml-block.hbs +12 -0
  70. package/templates/gitlab-ci/gitlab-ci.hbs +158 -0
  71. package/templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.jar +0 -0
  72. package/templates/gradle-wrapper/gradle/wrapper/gradle-wrapper.properties +8 -0
  73. package/templates/gradle-wrapper/gradlew +248 -0
  74. package/templates/gradle-wrapper/gradlew.bat +93 -0
  75. package/templates/log4j2/build-gradle-dep.hbs +6 -0
  76. package/templates/log4j2/log4j2-spring.xml.hbs +34 -0
  77. package/templates/mapper-gradle-dep.hbs +3 -0
  78. package/templates/mapper.hbs +11 -0
  79. package/templates/migration/add-column.hbs +4 -0
  80. package/templates/migration/create-table.hbs +9 -0
  81. package/templates/migration/custom.hbs +2 -0
  82. package/templates/migration/remove-column.hbs +4 -0
  83. package/templates/migration/seed.hbs +7 -0
  84. package/templates/multi-module/build.gradle.hbs +86 -0
  85. package/templates/multi-module/config/ApplicationConfig.java.hbs +33 -0
  86. package/templates/multi-module/config/Config.java.hbs +19 -0
  87. package/templates/multi-module/config/SecurityConfig.java.hbs +48 -0
  88. package/templates/multi-module/config/build.gradle.hbs +10 -0
  89. package/templates/multi-module/database-config/BaseDAO.java.hbs +31 -0
  90. package/templates/multi-module/database-config/BaseViewDAO.java.hbs +48 -0
  91. package/templates/multi-module/database-config/Config.java.hbs +14 -0
  92. package/templates/multi-module/database-config/JpaConfig.java.hbs +9 -0
  93. package/templates/multi-module/database-config/build.gradle.hbs +17 -0
  94. package/templates/multi-module/settings.gradle.hbs +9 -0
  95. package/templates/oauth/OAuth2LoginSuccessHandler.hbs +31 -0
  96. package/templates/oauth/application-yml-block.hbs +11 -0
  97. package/templates/oauth/build-gradle-dep.hbs +1 -0
  98. package/templates/openapi/OpenApiConfig.hbs +23 -0
  99. package/templates/openapi/build-gradle-dep.hbs +1 -0
  100. package/templates/pagination/GenericSpecification.hbs +76 -0
  101. package/templates/pagination/PageInfo.hbs +20 -0
  102. package/templates/pagination/PageRequest.hbs +27 -0
  103. package/templates/pagination/PageResponse.hbs +24 -0
  104. package/templates/pagination/Pager.hbs +37 -0
  105. package/templates/pagination/SearchCriteria.hbs +26 -0
  106. package/templates/pagination/SpecificationSupport.hbs +34 -0
  107. package/templates/permission/PermissionAspect.hbs +41 -0
  108. package/templates/permission/RequirePermission.hbs +18 -0
  109. package/templates/permission/build-gradle-dep.hbs +2 -0
  110. package/templates/scheduling/SampleSchedule.hbs +22 -0
  111. package/templates/scheduling/SchedulingConfig.hbs +13 -0
  112. package/templates/scheduling/application-yml-block.hbs +5 -0
  113. package/templates/sentry/SentryConfig.hbs +74 -0
  114. package/templates/sentry/application-yml-block.hbs +15 -0
  115. package/templates/sentry/build-gradle-dep.hbs +1 -0
  116. package/templates/service.hbs +9 -0
  117. package/templates/serviceImpl.hbs +23 -0
  118. package/templates/spotless/build-gradle-allprojects.hbs +16 -0
  119. package/templates/spotless/build-gradle-plugin.hbs +1 -0
  120. package/templates/spotless/spotless_formatter.xml +83 -0
  121. package/templates/sso/SsoAutoConfiguration.hbs +17 -0
  122. package/templates/sso/SsoProperties.hbs +27 -0
  123. package/templates/sso/application-yml-block.hbs +7 -0
  124. package/templates/valid-group/ValidGroup.hbs +20 -0
  125. package/test.md +56 -0
  126. package/uninstall.ps1 +60 -0
  127. package/uninstall.sh +76 -0
  128. 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
@@ -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