birc-generator 0.9.1 → 1.0.0
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/PROJECT.md +23 -4
- package/README.md +13 -11
- package/bin/birc.js +10 -4
- package/bin/cli-util.js +30 -2
- package/lib/create.js +150 -0
- package/lib/features.js +558 -0
- package/lib/make.js +658 -0
- package/lib/project.js +234 -0
- package/package.json +5 -3
- package/plopfile.js +60 -1340
- package/project-docs/PROJECT.md.hbs +13 -0
- package/templates/aop/OperationLogAspect.hbs +10 -3
- package/templates/auth/AuthController.hbs +73 -0
- package/templates/auth/AuthFlowTest.hbs +112 -0
- package/templates/auth/AuthSecurityCustomizer.hbs +38 -0
- package/templates/auth/AuthUser.hbs +54 -0
- package/templates/auth/AuthUserDAO.hbs +9 -0
- package/templates/auth/JpaUserDetailsService.hbs +47 -0
- package/templates/auth/JwtAuthenticationFilter.hbs +50 -0
- package/templates/auth/JwtProperties.hbs +35 -0
- package/templates/auth/JwtSecretEnvTest.hbs +68 -0
- package/templates/auth/JwtSecretEnvironmentPostProcessor.hbs +102 -0
- package/templates/auth/JwtService.hbs +60 -0
- package/templates/auth/LoginFailedException.hbs +27 -0
- package/templates/auth/LoginRequest.hbs +16 -0
- package/templates/auth/V1__create_auth_users_table.sql.hbs +13 -0
- package/templates/auth/application-yml-block.hbs +7 -0
- package/templates/auth/build-gradle-dep.hbs +4 -0
- package/templates/auth/spring.factories.hbs +1 -0
- package/templates/base/CorsTest.java.hbs +93 -0
- package/templates/base/README.md.hbs +6 -0
- package/templates/base/application-test.yml.hbs +3 -0
- package/templates/base/application.yml.hbs +12 -0
- package/templates/clockin/ClockInApiException.hbs +37 -0
- package/templates/clockin/ClockInApiResponse.hbs +14 -0
- package/templates/clockin/ClockInClient.hbs +251 -0
- package/templates/clockin/ClockInClientConfig.hbs +41 -0
- package/templates/clockin/ClockInController.hbs +77 -0
- package/templates/clockin/ClockInPage.hbs +11 -0
- package/templates/clockin/ClockInProperties.hbs +34 -0
- package/templates/clockin/ClockInRecord.hbs +20 -0
- package/templates/clockin/MemberImage.hbs +7 -0
- package/templates/clockin/OnDutyWeek.hbs +26 -0
- package/templates/clockin/UnclockedMember.hbs +7 -0
- package/templates/clockin/UserPermission.hbs +11 -0
- package/templates/clockin/application-yml-block.hbs +10 -0
- package/templates/docker/docker-compose.prod.yml.hbs +5 -0
- package/templates/docker/docker-compose.yml.hbs +7 -1
- package/templates/docker/env.example.hbs +15 -0
- package/templates/file-upload/FileExtensionUtils.hbs +45 -0
- package/templates/file-upload/FileExtensionUtilsTest.hbs +48 -0
- package/templates/file-upload/FileStorageServiceImpl.hbs +6 -0
- package/templates/multi-module/config/SecurityConfig.java.hbs +123 -8
- package/templates/multi-module/config/SecurityCustomizer.java.hbs +24 -0
- package/templates/openapi/ApiDocsAccessTest.hbs +60 -0
- package/templates/openapi/application-yml-block.hbs +8 -0
- package/test.md +6 -3
- package/versions.js +1 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package {{basePackage}}.security;
|
|
2
|
+
|
|
3
|
+
import io.jsonwebtoken.Claims;
|
|
4
|
+
import io.jsonwebtoken.JwtException;
|
|
5
|
+
import io.jsonwebtoken.Jwts;
|
|
6
|
+
import java.nio.charset.StandardCharsets;
|
|
7
|
+
import java.time.Instant;
|
|
8
|
+
import java.util.Date;
|
|
9
|
+
import java.util.List;
|
|
10
|
+
import javax.crypto.SecretKey;
|
|
11
|
+
import io.jsonwebtoken.security.Keys;
|
|
12
|
+
import lombok.RequiredArgsConstructor;
|
|
13
|
+
import org.springframework.stereotype.Service;
|
|
14
|
+
import {{basePackage}}.config.JwtProperties;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 發與驗 JWT。權限代碼放在 authorities claim,跟 {@code @RequirePermission} 比對的是
|
|
18
|
+
* 同一組字串,不加 ROLE_ 前綴。
|
|
19
|
+
*/
|
|
20
|
+
@Service
|
|
21
|
+
@RequiredArgsConstructor
|
|
22
|
+
public class JwtService {
|
|
23
|
+
|
|
24
|
+
private static final String AUTHORITIES_CLAIM = "authorities";
|
|
25
|
+
|
|
26
|
+
private final JwtProperties properties;
|
|
27
|
+
|
|
28
|
+
private SecretKey key() {
|
|
29
|
+
return Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public String issue(String account, List<String> authorities) {
|
|
33
|
+
Instant now = Instant.now();
|
|
34
|
+
return Jwts.builder()
|
|
35
|
+
.subject(account)
|
|
36
|
+
.claim(AUTHORITIES_CLAIM, authorities)
|
|
37
|
+
.issuedAt(Date.from(now))
|
|
38
|
+
.expiration(Date.from(now.plusSeconds(properties.getExpirationSeconds())))
|
|
39
|
+
.signWith(key())
|
|
40
|
+
.compact();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 驗章並取出內容。簽章不對、過期、格式壞掉都回 null,由呼叫端當成沒登入處理——
|
|
45
|
+
* 不要把解析失敗的原因回給客戶端,那會變成攻擊者的除錯工具。
|
|
46
|
+
*/
|
|
47
|
+
public AuthenticatedUser parse(String token) {
|
|
48
|
+
try {
|
|
49
|
+
Claims claims = Jwts.parser().verifyWith(key()).build().parseSignedClaims(token).getPayload();
|
|
50
|
+
List<?> raw = claims.get(AUTHORITIES_CLAIM, List.class);
|
|
51
|
+
List<String> authorities =
|
|
52
|
+
raw == null ? List.of() : raw.stream().map(String::valueOf).toList();
|
|
53
|
+
return new AuthenticatedUser(claims.getSubject(), authorities);
|
|
54
|
+
} catch (JwtException | IllegalArgumentException e) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public record AuthenticatedUser(String account, List<String> authorities) {}
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
package {{basePackage}}.exception;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 帳號不存在與密碼錯誤共用這一種錯誤。分開回會讓人可以拿登入端點列舉帳號。
|
|
7
|
+
*/
|
|
8
|
+
public class LoginFailedException extends ProjectException {
|
|
9
|
+
|
|
10
|
+
public LoginFailedException() {
|
|
11
|
+
super("帳號或密碼錯誤");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public LoginFailedException(String message) {
|
|
15
|
+
super(message);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Override
|
|
19
|
+
public String getErrorCode() {
|
|
20
|
+
return "LoginFailed";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Override
|
|
24
|
+
public HttpStatus getHttpStatus() {
|
|
25
|
+
return HttpStatus.UNAUTHORIZED;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package {{basePackage}}.dto;
|
|
2
|
+
|
|
3
|
+
import jakarta.validation.constraints.NotBlank;
|
|
4
|
+
import lombok.Getter;
|
|
5
|
+
import lombok.Setter;
|
|
6
|
+
|
|
7
|
+
@Getter
|
|
8
|
+
@Setter
|
|
9
|
+
public class LoginRequest {
|
|
10
|
+
|
|
11
|
+
@NotBlank(message = "帳號不可為空")
|
|
12
|
+
private String account;
|
|
13
|
+
|
|
14
|
+
@NotBlank(message = "密碼不可為空")
|
|
15
|
+
private String password;
|
|
16
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- 登入用的帳號表(由 birc-generator add auth 產生)
|
|
2
|
+
-- 專案已經有 V1 的話,把這個檔改成還沒用過的版本號,Flyway 不允許重複版本。
|
|
3
|
+
|
|
4
|
+
CREATE TABLE auth_users (
|
|
5
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
6
|
+
account VARCHAR(50) NOT NULL UNIQUE,
|
|
7
|
+
password VARCHAR(100) NOT NULL,
|
|
8
|
+
display_name VARCHAR(50) NULL,
|
|
9
|
+
authorities VARCHAR(500) NULL,
|
|
10
|
+
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
|
11
|
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
12
|
+
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
13
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
org.springframework.boot.EnvironmentPostProcessor={{basePackage}}.config.JwtSecretEnvironmentPostProcessor
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
package {{basePackage}};
|
|
2
|
+
|
|
3
|
+
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
4
|
+
import static org.junit.jupiter.api.Assertions.assertNull;
|
|
5
|
+
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
6
|
+
|
|
7
|
+
import java.net.URI;
|
|
8
|
+
import java.net.http.HttpClient;
|
|
9
|
+
import java.net.http.HttpRequest;
|
|
10
|
+
import java.net.http.HttpResponse;
|
|
11
|
+
import java.util.List;
|
|
12
|
+
import java.util.stream.Stream;
|
|
13
|
+
import org.junit.jupiter.api.Test;
|
|
14
|
+
import org.springframework.beans.factory.ObjectProvider;
|
|
15
|
+
import org.springframework.boot.test.context.SpringBootTest;
|
|
16
|
+
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
17
|
+
import {{basePackage}}.config.SecurityConfig;
|
|
18
|
+
import {{basePackage}}.config.SecurityCustomizer;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* CORS 是瀏覽器端唯一會靜默失敗的設定:漏掉就只有前端看得到錯誤,後端日誌乾乾淨淨。
|
|
22
|
+
* 這份測試打真的 preflight,確認放行與拒絕都如預期,並擋住「設定看起來合法、實際上全開」的寫法。
|
|
23
|
+
*/
|
|
24
|
+
@SpringBootTest(
|
|
25
|
+
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
|
26
|
+
properties = "app.cors.allowed-origin-patterns=http://localhost:5173")
|
|
27
|
+
class CorsTest {
|
|
28
|
+
|
|
29
|
+
@LocalServerPort
|
|
30
|
+
private int port;
|
|
31
|
+
|
|
32
|
+
private HttpResponse<String> preflight(String origin) throws Exception {
|
|
33
|
+
HttpRequest request = HttpRequest.newBuilder()
|
|
34
|
+
.uri(URI.create("http://localhost:" + port + "/api/preflight-probe"))
|
|
35
|
+
.method("OPTIONS", HttpRequest.BodyPublishers.noBody())
|
|
36
|
+
.header("Origin", origin)
|
|
37
|
+
.header("Access-Control-Request-Method", "GET")
|
|
38
|
+
.header("Access-Control-Request-Headers", "authorization")
|
|
39
|
+
.build();
|
|
40
|
+
return HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Test
|
|
44
|
+
void allowsConfiguredOrigin() throws Exception {
|
|
45
|
+
// 防住:CORS 沒掛在 SecurityFilterChain 上時,preflight 會被擋成 401。
|
|
46
|
+
HttpResponse<String> response = preflight("http://localhost:5173");
|
|
47
|
+
|
|
48
|
+
assertEquals(200, response.statusCode());
|
|
49
|
+
assertEquals(
|
|
50
|
+
"http://localhost:5173",
|
|
51
|
+
response.headers().firstValue("Access-Control-Allow-Origin").orElse(null));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Test
|
|
55
|
+
void rejectsUnknownOrigin() throws Exception {
|
|
56
|
+
HttpResponse<String> response = preflight("https://evil.example.com");
|
|
57
|
+
|
|
58
|
+
assertNull(response.headers().firstValue("Access-Control-Allow-Origin").orElse(null));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 這幾條只驗 CORS 設定本身,不需要任何 SecurityCustomizer。 */
|
|
62
|
+
private static ObjectProvider<SecurityCustomizer> noCustomizers() {
|
|
63
|
+
return new ObjectProvider<>() {
|
|
64
|
+
@Override
|
|
65
|
+
public SecurityCustomizer getObject() {
|
|
66
|
+
throw new UnsupportedOperationException();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Override
|
|
70
|
+
public Stream<SecurityCustomizer> orderedStream() {
|
|
71
|
+
return Stream.empty();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
void rejectsOriginPatternsThatOpenTheApiToEveryone() {
|
|
78
|
+
// 防住:守衛只比對 "*" 這類字面字串時,https://*.* 會繞過去變成全開。
|
|
79
|
+
List<String> unsafe = List.of("*", "http://*", "https://*", "https://*.*", "http*://*", "https://*.com");
|
|
80
|
+
|
|
81
|
+
for (String pattern : unsafe) {
|
|
82
|
+
assertThrows(
|
|
83
|
+
IllegalStateException.class,
|
|
84
|
+
() -> new SecurityConfig(List.of(pattern), false, true, noCustomizers()).corsConfigurationSource(),
|
|
85
|
+
pattern + " 應該要被拒絕");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Test
|
|
90
|
+
void allowsSingleLevelSubdomainWildcard() {
|
|
91
|
+
new SecurityConfig(List.of("https://*.ntub.edu.tw"), false, true, noCustomizers()).corsConfigurationSource();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -47,6 +47,12 @@ CORS_ALLOWED_ORIGIN_PATTERNS=http://localhost:5173
|
|
|
47
47
|
|
|
48
48
|
逗號分隔、不要尾斜線、不要 `*`。只改這變數;不要在 `WebMvcConfigurer` 加 `addCorsMappings`,Spring Security 不會吃。
|
|
49
49
|
|
|
50
|
+
## API 文件(Swagger)
|
|
51
|
+
|
|
52
|
+
預設開著,開發時直接開 `/swagger-ui.html`。上正式環境設 `API_DOCS_ENABLED=false`,
|
|
53
|
+
springdoc 端點與匿名放行會一起關掉——文件開著等於把所有端點與 schema 攤給任何人看。
|
|
54
|
+
`docker-compose.prod.yml` 已經預設關閉。
|
|
55
|
+
|
|
50
56
|
## 生骨架
|
|
51
57
|
|
|
52
58
|
在有 `.bircrc.json` 的目錄跑即可;子目錄也可以,`birc` 會往上找。
|
|
@@ -13,6 +13,18 @@ spring:
|
|
|
13
13
|
server:
|
|
14
14
|
port: ${SPRING_SERVER_PORT:8080}
|
|
15
15
|
|
|
16
|
+
app:
|
|
17
|
+
cors:
|
|
18
|
+
# 允許跨來源呼叫的前端 Origin,用逗號分隔,例如 http://localhost:5173。
|
|
19
|
+
# 留空 = 不開放跨來源。不接受 *,那等於讓任何網站帶著使用者憑證呼叫這支 API。
|
|
20
|
+
allowed-origin-patterns: ${CORS_ALLOWED_ORIGIN_PATTERNS:}
|
|
21
|
+
# 前端要帶 cookie 才需要打開。Bearer Token 的 Authorization 標頭不需要。
|
|
22
|
+
allow-credentials: ${CORS_ALLOW_CREDENTIALS:false}
|
|
23
|
+
api-docs:
|
|
24
|
+
# Swagger UI / OpenAPI JSON。開著等於公開所有端點與 schema,
|
|
25
|
+
# 正式環境設 false(同時會關掉 springdoc 端點與匿名放行)。
|
|
26
|
+
enabled: ${API_DOCS_ENABLED:true}
|
|
27
|
+
|
|
16
28
|
# 下面這行是 plop add 的插入點,不要刪、不要移動位置。
|
|
17
29
|
# 新的 feature 設定會被插到這行「之後」。
|
|
18
30
|
# birc-generator:config-anchor
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
package {{basePackage}}.exception.clockin;
|
|
2
|
+
|
|
3
|
+
import org.springframework.http.HttpStatus;
|
|
4
|
+
import {{basePackage}}.exception.ProjectException;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 簽到系統回 result=false,或連不上時丟這個。
|
|
8
|
+
* upstreamErrorCode 是對方的 errorCode,原樣往外帶,方便對照它的訊息。
|
|
9
|
+
*/
|
|
10
|
+
public class ClockInApiException extends ProjectException {
|
|
11
|
+
|
|
12
|
+
private final String upstreamErrorCode;
|
|
13
|
+
|
|
14
|
+
public ClockInApiException(String upstreamErrorCode, String message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.upstreamErrorCode = upstreamErrorCode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public ClockInApiException(String message, Throwable cause) {
|
|
20
|
+
super(message, cause);
|
|
21
|
+
this.upstreamErrorCode = "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public String getUpstreamErrorCode() {
|
|
25
|
+
return upstreamErrorCode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Override
|
|
29
|
+
public String getErrorCode() {
|
|
30
|
+
return "ClockInApiFailed";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Override
|
|
34
|
+
public HttpStatus getHttpStatus() {
|
|
35
|
+
return HttpStatus.BAD_GATEWAY;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
package {{basePackage}}.dto.clockin;
|
|
2
|
+
|
|
3
|
+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
4
|
+
import tools.jackson.databind.JsonNode;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 簽到系統的統一外殼。它一律回 HTTP 200,成敗看 result;
|
|
8
|
+
* 失敗時 errorCode 例如 NotFound、User - AccessDenied。
|
|
9
|
+
*
|
|
10
|
+
* data 故意留 JsonNode 不直接定型:失敗時它是空物件 {},先定成 ClockInPage 或 List 會在反序列化就炸掉,
|
|
11
|
+
* 蓋掉真正的錯誤訊息。所以先確認 result,成功才把 data 轉成要的型別。
|
|
12
|
+
*/
|
|
13
|
+
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
14
|
+
public record ClockInApiResponse(boolean result, String errorCode, String message, JsonNode data) {}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
package {{basePackage}}.client;
|
|
2
|
+
|
|
3
|
+
import java.util.List;
|
|
4
|
+
import java.util.Map;
|
|
5
|
+
import java.util.function.Function;
|
|
6
|
+
import lombok.RequiredArgsConstructor;
|
|
7
|
+
import lombok.extern.slf4j.Slf4j;
|
|
8
|
+
import org.springframework.http.HttpHeaders;
|
|
9
|
+
import org.springframework.http.MediaType;
|
|
10
|
+
import org.springframework.http.ResponseEntity;
|
|
11
|
+
import org.springframework.stereotype.Component;
|
|
12
|
+
import org.springframework.util.LinkedMultiValueMap;
|
|
13
|
+
import org.springframework.util.MultiValueMap;
|
|
14
|
+
import org.springframework.web.client.RestClient;
|
|
15
|
+
import org.springframework.web.client.RestClientException;
|
|
16
|
+
import org.springframework.web.util.UriBuilder;
|
|
17
|
+
import tools.jackson.databind.ObjectMapper;
|
|
18
|
+
import {{basePackage}}.config.ClockInProperties;
|
|
19
|
+
import {{basePackage}}.dto.clockin.ClockInApiResponse;
|
|
20
|
+
import {{basePackage}}.dto.clockin.ClockInPage;
|
|
21
|
+
import {{basePackage}}.dto.clockin.ClockInRecord;
|
|
22
|
+
import {{basePackage}}.dto.clockin.OnDutyWeek;
|
|
23
|
+
import {{basePackage}}.dto.clockin.UnclockedMember;
|
|
24
|
+
import {{basePackage}}.dto.clockin.UserPermission;
|
|
25
|
+
import {{basePackage}}.exception.clockin.ClockInApiException;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 中心簽到系統(BIRC CMS)的 API 封裝。
|
|
29
|
+
*
|
|
30
|
+
* 三件事跟一般 REST 不一樣,所以邏輯集中在這裡,不要散到 service:
|
|
31
|
+
* 1. 登入是 form-urlencoded 打 /login,JWT 從回應的 X-Auth-Token 標頭拿,之後用 Authorization: Bearer 帶。
|
|
32
|
+
* 2. 它一律回 HTTP 200,成敗看 body 的 result;權限過期是 errorCode「User - AccessDenied」,不是 401。
|
|
33
|
+
* 所以 token 失效要看 errorCode 判斷,重新登入後重試一次。
|
|
34
|
+
* 3. 回應的 Content-Type 是 text/plain,converter 設定在 ClockInClientConfig。
|
|
35
|
+
*/
|
|
36
|
+
@Slf4j
|
|
37
|
+
@Component
|
|
38
|
+
@RequiredArgsConstructor
|
|
39
|
+
public class ClockInClient {
|
|
40
|
+
|
|
41
|
+
private static final String AUTH_TOKEN_HEADER = "X-Auth-Token";
|
|
42
|
+
|
|
43
|
+
private final RestClient clockInRestClient;
|
|
44
|
+
|
|
45
|
+
private final ClockInProperties properties;
|
|
46
|
+
|
|
47
|
+
private final ObjectMapper objectMapper;
|
|
48
|
+
|
|
49
|
+
private volatile String token;
|
|
50
|
+
|
|
51
|
+
/** 今天還沒簽到的成員。這支不需要權限,可以拿來確認連線通不通。 */
|
|
52
|
+
public List<UnclockedMember> findTodayUnclocked() {
|
|
53
|
+
UnclockedMember[] members = read(
|
|
54
|
+
jwt -> clockInRestClient.get()
|
|
55
|
+
.uri("/clockin/today")
|
|
56
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
57
|
+
.retrieve()
|
|
58
|
+
.body(ClockInApiResponse.class),
|
|
59
|
+
UnclockedMember[].class);
|
|
60
|
+
return members == null ? List.of() : List.of(members);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 目前登入者是誰、權限是什麼。要不要顯示全中心的紀錄看這支的 authority。 */
|
|
64
|
+
public UserPermission findMyPermission() {
|
|
65
|
+
return read(
|
|
66
|
+
jwt -> clockInRestClient.get()
|
|
67
|
+
.uri("/user/permissions")
|
|
68
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
69
|
+
.retrieve()
|
|
70
|
+
.body(ClockInApiResponse.class),
|
|
71
|
+
UserPermission.class);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 值班表,一週一筆。簽到頁面用它顯示這週誰值班。 */
|
|
75
|
+
public List<OnDutyWeek> findAllOnDuty() {
|
|
76
|
+
OnDutyWeek[] weeks = read(
|
|
77
|
+
jwt -> clockInRestClient.get()
|
|
78
|
+
.uri("/onduty")
|
|
79
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
80
|
+
.retrieve()
|
|
81
|
+
.body(ClockInApiResponse.class),
|
|
82
|
+
OnDutyWeek[].class);
|
|
83
|
+
return weeks == null ? List.of() : List.of(weeks);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 某個帳號的打卡紀錄,分頁。nowPage 從 1 開始;year / month 給 null 就是不限月份。 */
|
|
87
|
+
public ClockInPage findMemberRecords(String userAccount, Integer year, Integer month, int nowPage) {
|
|
88
|
+
return read(
|
|
89
|
+
jwt -> clockInRestClient.get()
|
|
90
|
+
.uri(builder -> period(builder.path("/clockin/userAccount/{userAccount}"), year, month)
|
|
91
|
+
.queryParam("nowPage", nowPage)
|
|
92
|
+
.build(userAccount))
|
|
93
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
94
|
+
.retrieve()
|
|
95
|
+
.body(ClockInApiResponse.class),
|
|
96
|
+
ClockInPage.class);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 全部學生的打卡紀錄。這支沒有分頁,但資料仍然包在 list 裡面,所以借用同一個型別再取 list。 */
|
|
100
|
+
public List<ClockInRecord> findStudentRecords(Integer year, Integer month) {
|
|
101
|
+
ClockInPage page = read(
|
|
102
|
+
jwt -> clockInRestClient.get()
|
|
103
|
+
.uri(builder -> period(builder.path("/clockin/students"), year, month)
|
|
104
|
+
.build())
|
|
105
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
106
|
+
.retrieve()
|
|
107
|
+
.body(ClockInApiResponse.class),
|
|
108
|
+
ClockInPage.class);
|
|
109
|
+
return page == null || page.list() == null ? List.of() : page.list();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 全中心的打卡紀錄,分頁。要管理者權限,學生帳號會拿到 AccessDenied。 */
|
|
113
|
+
public ClockInPage findAllRecords(Integer year, Integer month, int nowPage) {
|
|
114
|
+
return read(
|
|
115
|
+
jwt -> clockInRestClient.get()
|
|
116
|
+
.uri(builder -> period(builder.path("/clockin"), year, month)
|
|
117
|
+
.queryParam("nowPage", nowPage)
|
|
118
|
+
.build())
|
|
119
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
120
|
+
.retrieve()
|
|
121
|
+
.body(ClockInApiResponse.class),
|
|
122
|
+
ClockInPage.class);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 用學號簽到。同一天重複簽到由簽到系統擋,這裡不先查。 */
|
|
126
|
+
public String clockIn(String stuNumber) {
|
|
127
|
+
return send(jwt -> clockInRestClient.post()
|
|
128
|
+
.uri("/clockin/{stuNumber}", stuNumber)
|
|
129
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
130
|
+
.retrieve()
|
|
131
|
+
.body(ClockInApiResponse.class))
|
|
132
|
+
.message();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 簽退,時數由簽到系統算。 */
|
|
136
|
+
public String clockOut(String userAccount) {
|
|
137
|
+
return send(jwt -> clockInRestClient.patch()
|
|
138
|
+
.uri("/clockin/clockout/{userAccount}", userAccount)
|
|
139
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
140
|
+
.retrieve()
|
|
141
|
+
.body(ClockInApiResponse.class))
|
|
142
|
+
.message();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 改某一筆紀錄的工作內容,id 是 ClockInRecord.id。 */
|
|
146
|
+
public String editContent(int id, String content) {
|
|
147
|
+
return send(jwt -> clockInRestClient.patch()
|
|
148
|
+
.uri("/clockin/content/{id}", id)
|
|
149
|
+
.header(HttpHeaders.AUTHORIZATION, bearer(jwt))
|
|
150
|
+
.contentType(MediaType.APPLICATION_JSON)
|
|
151
|
+
.body(Map.of("content", content))
|
|
152
|
+
.retrieve()
|
|
153
|
+
.body(ClockInApiResponse.class))
|
|
154
|
+
.message();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private <T> T read(Function<String, ClockInApiResponse> request, Class<T> type) {
|
|
158
|
+
ClockInApiResponse response = send(request);
|
|
159
|
+
return response.data() == null ? null : objectMapper.treeToValue(response.data(), type);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 帶 token 呼叫一次;遇到權限錯誤就丟掉 token 重新登入再試一次,
|
|
164
|
+
* 仍然失敗或業務失敗就轉成 ClockInApiException。
|
|
165
|
+
*/
|
|
166
|
+
private ClockInApiResponse send(Function<String, ClockInApiResponse> request) {
|
|
167
|
+
ClockInApiResponse response = execute(request);
|
|
168
|
+
if (response != null && !response.result() && isAuthFailure(response.errorCode())) {
|
|
169
|
+
log.info("簽到系統回 {},重新登入後重試", response.errorCode());
|
|
170
|
+
token = null;
|
|
171
|
+
response = execute(request);
|
|
172
|
+
}
|
|
173
|
+
if (response == null) {
|
|
174
|
+
throw new ClockInApiException("EmptyResponse", "簽到系統沒有回應內容");
|
|
175
|
+
}
|
|
176
|
+
if (!response.result()) {
|
|
177
|
+
throw new ClockInApiException(response.errorCode(), response.message());
|
|
178
|
+
}
|
|
179
|
+
return response;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private ClockInApiResponse execute(Function<String, ClockInApiResponse> request) {
|
|
183
|
+
try {
|
|
184
|
+
return request.apply(token());
|
|
185
|
+
} catch (RestClientException e) {
|
|
186
|
+
throw new ClockInApiException("呼叫簽到系統失敗: " + properties.getBaseUrl(), e);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private String token() {
|
|
191
|
+
String current = token;
|
|
192
|
+
if (current != null) {
|
|
193
|
+
return current;
|
|
194
|
+
}
|
|
195
|
+
synchronized (this) {
|
|
196
|
+
if (token == null) {
|
|
197
|
+
token = login();
|
|
198
|
+
}
|
|
199
|
+
return token;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private String login() {
|
|
204
|
+
if (properties.getAccount().isBlank() || properties.getPassword().isBlank()) {
|
|
205
|
+
throw new ClockInApiException(
|
|
206
|
+
"MissingCredential", "沒有設定 BIRC_CLOCKIN_ACCOUNT / BIRC_CLOCKIN_PASSWORD");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
|
|
210
|
+
form.add("account", properties.getAccount());
|
|
211
|
+
form.add("password", properties.getPassword());
|
|
212
|
+
|
|
213
|
+
ResponseEntity<ClockInApiResponse> response;
|
|
214
|
+
try {
|
|
215
|
+
response = clockInRestClient.post()
|
|
216
|
+
.uri("/login")
|
|
217
|
+
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
|
218
|
+
.body(form)
|
|
219
|
+
.retrieve()
|
|
220
|
+
.toEntity(ClockInApiResponse.class);
|
|
221
|
+
} catch (RestClientException e) {
|
|
222
|
+
throw new ClockInApiException("登入簽到系統失敗,請確認帳號密碼: " + properties.getAccount(), e);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
String issued = response.getHeaders().getFirst(AUTH_TOKEN_HEADER);
|
|
226
|
+
if (issued == null || issued.isBlank()) {
|
|
227
|
+
throw new ClockInApiException("LoginFail", "登入沒有拿到 " + AUTH_TOKEN_HEADER + " 標頭");
|
|
228
|
+
}
|
|
229
|
+
log.info("已登入簽到系統: {}", properties.getAccount());
|
|
230
|
+
return issued;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private static String bearer(String jwt) {
|
|
234
|
+
return "Bearer " + jwt;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** year / month 是可選的,兩個都不帶就查全部月份,所以 null 要整個略過、不能帶空值。 */
|
|
238
|
+
private static UriBuilder period(UriBuilder builder, Integer year, Integer month) {
|
|
239
|
+
if (year != null) {
|
|
240
|
+
builder.queryParam("year", year);
|
|
241
|
+
}
|
|
242
|
+
if (month != null) {
|
|
243
|
+
builder.queryParam("month", month);
|
|
244
|
+
}
|
|
245
|
+
return builder;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private static boolean isAuthFailure(String errorCode) {
|
|
249
|
+
return errorCode != null && errorCode.contains("AccessDenied");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
package {{basePackage}}.config;
|
|
2
|
+
|
|
3
|
+
import java.util.List;
|
|
4
|
+
import lombok.RequiredArgsConstructor;
|
|
5
|
+
import org.springframework.context.annotation.Bean;
|
|
6
|
+
import org.springframework.context.annotation.Configuration;
|
|
7
|
+
import org.springframework.http.MediaType;
|
|
8
|
+
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
|
9
|
+
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter;
|
|
10
|
+
import org.springframework.web.client.RestClient;
|
|
11
|
+
|
|
12
|
+
@Configuration
|
|
13
|
+
@RequiredArgsConstructor
|
|
14
|
+
public class ClockInClientConfig {
|
|
15
|
+
|
|
16
|
+
private final ClockInProperties properties;
|
|
17
|
+
|
|
18
|
+
@Bean
|
|
19
|
+
public RestClient clockInRestClient() {
|
|
20
|
+
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
|
|
21
|
+
requestFactory.setConnectTimeout(properties.getConnectTimeoutMs());
|
|
22
|
+
requestFactory.setReadTimeout(properties.getReadTimeoutMs());
|
|
23
|
+
|
|
24
|
+
return RestClient.builder()
|
|
25
|
+
.baseUrl(properties.getBaseUrl())
|
|
26
|
+
.requestFactory(requestFactory)
|
|
27
|
+
.configureMessageConverters(
|
|
28
|
+
converters -> converters.registerDefaults().withJsonConverter(jsonConverter()))
|
|
29
|
+
.build();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 簽到系統的 JSON 回應標成 text/plain,預設的 JSON converter 不會接手,
|
|
34
|
+
* 所以把 text/plain 也算進可讀的型別。form-urlencoded 的登入不受影響,還是走 FormHttpMessageConverter。
|
|
35
|
+
*/
|
|
36
|
+
private static JacksonJsonHttpMessageConverter jsonConverter() {
|
|
37
|
+
JacksonJsonHttpMessageConverter converter = new JacksonJsonHttpMessageConverter();
|
|
38
|
+
converter.setSupportedMediaTypes(List.of(MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN));
|
|
39
|
+
return converter;
|
|
40
|
+
}
|
|
41
|
+
}
|