Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java
#	src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java
#	src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java
#	src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java
#	src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java
#	src/main/resources/application-test.yml
#	src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java
master
jiazheng.zhao 4 weeks ago
commit 7c078e7d1b

@ -0,0 +1,500 @@
# 数字货币发行请求模块 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the first-step digital-currency issuance workflow with separate commercial-bank and central-bank APIs, real SM3/SM2 teaching operations, and Chinese Swagger documentation.
**Architecture:** Extend the existing `issuance` bounded context around one `IssuanceRequest` aggregate. Commercial-bank APIs own request creation and state transitions; central-bank APIs are a read-only projection of the same request, with a separate receive-status field. MyBatis-Plus persists the aggregate and denomination children; `schema.sql` remains the only idempotent database bootstrap mechanism.
**Tech Stack:** Java 8, Spring Boot 2.7.18, Spring Security 5.7, MyBatis-Plus 3.5.17, Springdoc 1.8.0, MySQL 8, Bouncy Castle `bcprov-jdk18on:1.84`.
## Global Constraints
- Use Java 8 only; do not introduce records, `List.of`, text blocks, `jakarta.*`, or Java 9+ APIs.
- Keep all issuance code below `com.yau.digitalrmb.issuance`; do not place issuance business code in `shared`.
- All commercial-bank and central-bank APIs require a valid JWT, with no teacher/student role restriction.
- Do not restore Flyway. Schema and demo data changes belong in the idempotent `src/main/resources/schema.sql` and must work on MySQL 8 and H2 MySQL mode.
- Do not accept, log, persist, or return a private key. The only exposed signing-key identifier is `sm2-key-02`.
- Swagger labels, summaries, descriptions, field documentation, and root title must be Chinese UTF-8 without garbled characters.
- All work starts from a clean isolated worktree and ends with JDK 8 `mvn test -B`, `mvn package -DskipTests -B`, dev-profile startup, `/actuator/health`, `/v3/api-docs`, and Swagger UI verification.
---
## Target File Structure
```text
src/main/java/com/yau/digitalrmb/issuance/
domain/model/
DenominationItem.java
IssuanceRequest.java
IssuanceRequestStatus.java
CentralBankReceiveStatus.java
domain/repository/
IssuanceRequestRepository.java
domain/service/
IssuanceMessageComposer.java
IssuanceSignatureService.java
SignedIssuancePayload.java
application/command/
CreateIssuanceRequestCommand.java
UpdateIssuanceRequestCommand.java
application/query/
CommercialBankInventoryView.java
IssuanceRequestView.java
application/service/
CommercialBankIssuanceApplicationService.java
CentralBankIssuanceQueryService.java
infrastructure/crypto/
BouncyCastleIssuanceSignatureService.java
InMemorySm2SigningKeyProvider.java
infrastructure/persistence/entity/
IssuanceBankInventoryEntity.java
IssuanceRequestEntity.java
IssuanceRequestDenominationEntity.java
infrastructure/persistence/mapper/
IssuanceBankInventoryMapper.java
IssuanceRequestMapper.java
IssuanceRequestDenominationMapper.java
infrastructure/persistence/repository/
MybatisIssuanceRequestRepository.java
interfaces/dto/
CreateIssuanceRequestRequest.java
UpdateIssuanceRequestRequest.java
DenominationItemRequest.java
IssuanceRequestResponse.java
CommercialBankInventoryResponse.java
CentralBankIssuanceRequestResponse.java
interfaces/rest/
CommercialBankIssuanceController.java
CentralBankIssuanceController.java
```
## Task 1: Model the Issuance Request Aggregate
**Files:**
- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/model/DenominationItem.java`
- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequest.java`
- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestStatus.java`
- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/model/CentralBankReceiveStatus.java`
- Create: `src/test/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestTest.java`
**Interfaces:**
- Consumes: existing `IssuanceApplicationId`.
- Produces: aggregate methods `updateDraft(...)`, `prepareMessage(...)`, `recordDigest(...)`, `recordSignature(...)`, `packagePayload(...)`, and `sendToCentralBank(Instant)`.
- [ ] **Step 1: Write failing aggregate tests**
```java
@Test
void rejectsDenominationsWhoseTotalDoesNotMatchRequestAmount() {
assertThatThrownBy(() -> IssuanceRequest.create(id, "BKCHCNBJ00001", "ORG_3A4B5C6D7E8F",
new BigDecimal("50000.00"), "DC", Arrays.asList(new DenominationItem(100, 400))))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void sendsOnlyPackagedRequestAndMarksCentralBankAsReceived() {
IssuanceRequest request = preparedSignedAndPackagedRequest();
request.sendToCentralBank(Instant.parse("2026-08-03T12:00:00Z"));
assertThat(request.getStatus()).isEqualTo(IssuanceRequestStatus.SENT);
assertThat(request.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED);
}
```
- [ ] **Step 2: Run the aggregate test to verify it fails**
Run: `mvn -Dtest=IssuanceRequestTest test -B`
Expected: compilation fails because `IssuanceRequest`, status enums, and `DenominationItem` do not yet exist.
- [ ] **Step 3: Implement immutable value data and aggregate transitions**
```java
public enum IssuanceRequestStatus { DRAFT, MESSAGE_PREPARED, DIGESTED, SIGNED, PACKAGED, SENT }
public enum CentralBankReceiveStatus { NOT_RECEIVED, RECEIVED }
public void sendToCentralBank(Instant receivedAt) {
requireStatus(IssuanceRequestStatus.PACKAGED);
this.status = IssuanceRequestStatus.SENT;
this.centralBankReceiveStatus = CentralBankReceiveStatus.RECEIVED;
this.centralBankReceivedAt = receivedAt;
}
```
`DenominationItem` validates positive denomination and quantity. `IssuanceRequest.create(...)` validates exact `Σ(denomination × quantity) == totalAmount`, initializes `DRAFT` and `NOT_RECEIVED`, and normalizes monetary values to scale 2.
- [ ] **Step 4: Run aggregate tests to verify they pass**
Run: `mvn -Dtest=IssuanceRequestTest test -B`
Expected: PASS, including invalid amount, invalid state, normal state progression, and idempotent repeated send tests.
- [ ] **Step 5: Commit the aggregate model**
```bash
git add src/main/java/com/yau/digitalrmb/issuance/domain/model src/test/java/com/yau/digitalrmb/issuance/domain/model/IssuanceRequestTest.java
git commit -m "feat: add issuance request aggregate"
```
## Task 2: Implement SM3 and SM2 Teaching Services
**Files:**
- Modify: `pom.xml`
- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceMessageComposer.java`
- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceSignatureService.java`
- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/service/SignedIssuancePayload.java`
- Create: `src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/InMemorySm2SigningKeyProvider.java`
- Create: `src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureService.java`
- Create: `src/test/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureServiceTest.java`
**Interfaces:**
- Consumes: `IssuanceRequest` field values and fixed key reference `sm2-key-02`.
- Produces: `compose(...)` plain text and `sign(String keyRef, String plainText)` returning a SM3 hex digest plus Base64 SM2 signature.
- [ ] **Step 1: Write failing cryptography tests**
```java
@Test
void composesPrototypeCompatiblePlainTextInDescendingDenominationOrder() {
assertThat(composer.compose("BKCHCNBJ00001", "ORG_3A4B5C6D7E8F", new BigDecimal("50000.00"),
Arrays.asList(new DenominationItem(50, 100), new DenominationItem(100, 400)), "DC", "20260801103218"))
.isEqualTo("ISSUE|BKCHCNBJ00001|ORG_3A4B5C6D7E8F|50000.00|100:400,50:100|DC|20260801103218");
}
@Test
void signsWithSm3AndSm2UsingTheTeachingKey() {
SignedIssuancePayload payload = service.sign("sm2-key-02", "ISSUE|...");
assertThat(payload.getDigest()).matches("[0-9A-F]{64}");
assertThat(service.verify("sm2-key-02", "ISSUE|...", payload)).isTrue();
}
```
- [ ] **Step 2: Run the cryptography tests to verify they fail**
Run: `mvn -Dtest=BouncyCastleIssuanceSignatureServiceTest test -B`
Expected: compilation fails because the composer, payload, signing service, and Bouncy Castle provider are absent.
- [ ] **Step 3: Add the provider and minimal implementations**
Add the explicit dependency:
```xml
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.84</version>
</dependency>
```
Register `BouncyCastleProvider` once. Generate an in-memory `sm2p256v1` key pair for `sm2-key-02`; use `MessageDigest.getInstance("SM3", "BC")` for the upper-case hexadecimal digest and `Signature.getInstance("SM3withSM2", "BC")` for signing and verification. The `IssuanceMessageComposer` sorts denominations descending and uses `toPlainString()` for the scale-2 total amount.
- [ ] **Step 4: Run the cryptography tests to verify they pass**
Run: `mvn -Dtest=BouncyCastleIssuanceSignatureServiceTest test -B`
Expected: PASS; the digest has 64 uppercase hexadecimal characters and the generated signature verifies with the in-memory public key.
- [ ] **Step 5: Commit the cryptography teaching service**
```bash
git add pom.xml src/main/java/com/yau/digitalrmb/issuance/domain/service src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto src/test/java/com/yau/digitalrmb/issuance/infrastructure/crypto
git commit -m "feat: add issuance sm3 sm2 teaching service"
```
## Task 3: Add Idempotent Schema and MyBatis Persistence
**Files:**
- Modify: `src/main/resources/schema.sql`
- Create: `src/main/java/com/yau/digitalrmb/issuance/domain/repository/IssuanceRequestRepository.java`
- Create: the six entity, mapper, and repository files listed in Target File Structure under `infrastructure/persistence`
- Create: `src/test/java/com/yau/digitalrmb/issuance/infrastructure/persistence/MybatisIssuanceRequestRepositoryTest.java`
**Interfaces:**
- Consumes: `IssuanceRequestRepository.save(IssuanceRequest)` and `findById(IssuanceApplicationId)`.
- Produces: persistent requests with all denomination entries and persistent commercial-bank inventory lookups.
- [ ] **Step 1: Write failing persistence tests**
```java
@Test
void savesAndRestoresTheRequestWithItsDenominationsAndCentralBankStatus() {
repository.save(sentRequest);
IssuanceRequest restored = repository.findById(sentRequest.getId()).get();
assertThat(restored.getDenominations()).containsExactly(new DenominationItem(100, 400), new DenominationItem(50, 100));
assertThat(restored.getCentralBankReceiveStatus()).isEqualTo(CentralBankReceiveStatus.RECEIVED);
}
@Test
void readsSuggestedAmountAsThresholdMinusCurrentBalance() {
assertThat(inventoryQuery.findByBankCode("BKCHCNBJ00001").getSuggestedSupplementAmount())
.isEqualByComparingTo("50000.00");
}
```
- [ ] **Step 2: Run persistence tests to verify they fail**
Run: `mvn -Dtest=MybatisIssuanceRequestRepositoryTest test -B`
Expected: compilation fails because the repository and mappers do not exist.
- [ ] **Step 3: Add schema and persistence mapping**
Append idempotent MySQL/H2-compatible DDL to `schema.sql`:
```sql
CREATE TABLE IF NOT EXISTS issuance_bank_inventory (
bank_code VARCHAR(32) PRIMARY KEY,
current_balance DECIMAL(20, 2) NOT NULL,
warning_threshold DECIMAL(20, 2) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS issuance_request (
id CHAR(36) PRIMARY KEY,
request_no VARCHAR(64) NOT NULL UNIQUE,
bank_code VARCHAR(32) NOT NULL,
organization_id VARCHAR(64) NOT NULL,
total_amount DECIMAL(20, 2) NOT NULL,
currency VARCHAR(16) NOT NULL,
request_timestamp VARCHAR(32),
message_text TEXT,
digest CHAR(64),
signature TEXT,
signing_key_ref VARCHAR(64),
payload_json TEXT,
status VARCHAR(32) NOT NULL,
central_receive_status VARCHAR(32) NOT NULL,
central_received_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
created_by VARCHAR(64) NOT NULL,
updated_by VARCHAR(64) NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS issuance_request_denomination (
request_id CHAR(36) NOT NULL,
denomination INT NOT NULL,
quantity INT NOT NULL,
PRIMARY KEY (request_id, denomination)
);
```
Seed `BKCHCNBJ00001` with `49950000.00` balance and `50000000.00` threshold using an idempotent upsert. Map the root row and denomination rows in the repository; replacing denomination rows occurs only inside a repository save transaction.
- [ ] **Step 4: Run persistence tests to verify they pass**
Run: `mvn -Dtest=MybatisIssuanceRequestRepositoryTest test -B`
Expected: PASS against the existing H2 MySQL-mode test profile.
- [ ] **Step 5: Commit schema and persistence**
```bash
git add src/main/resources/schema.sql src/main/java/com/yau/digitalrmb/issuance/domain/repository src/main/java/com/yau/digitalrmb/issuance/infrastructure/persistence src/test/java/com/yau/digitalrmb/issuance/infrastructure/persistence
git commit -m "feat: persist issuance requests and bank inventory"
```
## Task 4: Add Commercial-Bank and Central-Bank Application Services
**Files:**
- Create: all command, query, and application-service files listed in Target File Structure
- Create: `src/test/java/com/yau/digitalrmb/issuance/application/service/CommercialBankIssuanceApplicationServiceTest.java`
- Create: `src/test/java/com/yau/digitalrmb/issuance/application/service/CentralBankIssuanceQueryServiceTest.java`
**Interfaces:**
- Consumes: domain aggregate, repository, message composer, signing service, and authenticated account name.
- Produces: `create`, `update`, `prepareMessage`, `digest`, `sign`, `packagePayload`, `send`, `getCommercialBankView`, `getInventory`, and `getCentralBankView` application methods.
- [ ] **Step 1: Write failing application-service tests**
```java
@Test
void processesACommercialBankRequestThroughSendAndMakesItVisibleToCentralBank() {
UUID id = service.create(createCommand, "tzs001").getId();
service.prepareMessage(id, "tzs001");
service.digest(id, "tzs001");
service.sign(id, "tzs001");
service.packagePayload(id, "tzs001");
service.send(id, "tzs001");
assertThat(centralBankService.get(id).getReceiveStatus()).isEqualTo("RECEIVED");
}
@Test
void rejectsUpdateAfterMessagePreparation() {
UUID id = createAndPrepareRequest();
assertThatThrownBy(() -> service.update(id, changedCommand, "tzs001"))
.isInstanceOf(BusinessException.class);
}
```
- [ ] **Step 2: Run application-service tests to verify they fail**
Run: `mvn -Dtest=CommercialBankIssuanceApplicationServiceTest,CentralBankIssuanceQueryServiceTest test -B`
Expected: compilation fails because the command, query, and application services do not exist.
- [ ] **Step 3: Implement application orchestration and error mapping**
Generate request numbers using `ISSUE_REQ_` plus a UUID-derived upper-case suffix. Wrap writes in `@Transactional`; read inventory and central-bank data with dedicated query methods. Convert missing aggregate lookups into `BusinessException(ErrorCode.RESOURCE_NOT_FOUND, ...)`; convert domain `IllegalStateException` and `IllegalArgumentException` into `BusinessException(ErrorCode.VALIDATION_ERROR, ...)`. Make `send` return the existing `SENT` result when called again.
- [ ] **Step 4: Run application-service tests to verify they pass**
Run: `mvn -Dtest=CommercialBankIssuanceApplicationServiceTest,CentralBankIssuanceQueryServiceTest test -B`
Expected: PASS; a commercial-bank send produces a central-bank `RECEIVED` view and draft editing is rejected after preparation.
- [ ] **Step 5: Commit application services**
```bash
git add src/main/java/com/yau/digitalrmb/issuance/application src/test/java/com/yau/digitalrmb/issuance/application
git commit -m "feat: add issuance application workflow"
```
## Task 5: Expose Separate Chinese-Swagger REST Ends
**Files:**
- Create: all DTO and controller files listed in Target File Structure
- Modify: `src/main/java/com/yau/digitalrmb/shared/config/OpenApiConfig.java`
- Create: `src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/CommercialBankIssuanceControllerTest.java`
- Create: `src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/CentralBankIssuanceControllerTest.java`
**Interfaces:**
- Consumes: application service methods and current authenticated JWT subject.
- Produces: the ten REST paths specified in the approved design, wrapped in `ApiResponse<T>`.
- [ ] **Step 1: Write failing MVC and OpenAPI documentation tests**
```java
@Test
void commercialBankCreateRequiresJwtAndReturnsDraftRequest() throws Exception {
mockMvc.perform(post("/api/v1/commercial-banks/issuance/requests")
.with(jwt().jwt(jwt -> jwt.subject("tzs001")))
.contentType(MediaType.APPLICATION_JSON)
.content(validCreateBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("DRAFT"));
}
@Test
void apiDocsContainChineseCommercialAndCentralBankTags() throws Exception {
mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("数字货币发行模块 - 商业银行端")))
.andExpect(content().string(containsString("数字货币发行模块 - 中央银行端")));
}
```
- [ ] **Step 2: Run controller tests to verify they fail**
Run: `mvn -Dtest=CommercialBankIssuanceControllerTest,CentralBankIssuanceControllerTest test -B`
Expected: compilation fails because the controllers and DTOs do not exist.
- [ ] **Step 3: Implement controllers, DTO validation, and Chinese Swagger metadata**
Use the two class-level tags and paths exactly:
```java
@Tag(name = "数字货币发行模块 - 商业银行端", description = "商业银行发起和处理数字货币发行申请")
@RequestMapping("/api/v1/commercial-banks/issuance")
@Tag(name = "数字货币发行模块 - 中央银行端", description = "中央银行查看数字货币发行请求接收结果")
@RequestMapping("/api/v1/central-banks/issuance")
```
Annotate each endpoint with Chinese `@Operation(summary = ..., description = ...)` and each DTO property with Chinese `@Schema(description = ..., example = ...)`. Set the root OpenAPI title to `数字人民币教学仿真后端` using UTF-8 source text. Commercial-bank controllers expose inventory, CRUD-read/update, process actions, and send; the central-bank controller exposes only `GET /requests/{id}`.
- [ ] **Step 4: Run controller tests to verify they pass**
Run: `mvn -Dtest=CommercialBankIssuanceControllerTest,CentralBankIssuanceControllerTest test -B`
Expected: PASS; all commercial-bank write operations require JWT, central-bank query is isolated to its prefix, and `/v3/api-docs` has both readable Chinese tags.
- [ ] **Step 5: Commit REST and Swagger work**
```bash
git add src/main/java/com/yau/digitalrmb/issuance/interfaces src/main/java/com/yau/digitalrmb/shared/config/OpenApiConfig.java src/test/java/com/yau/digitalrmb/issuance/interfaces
git commit -m "feat: expose commercial and central issuance APIs"
```
## Task 6: Verify the Full Workflow on the Dev Database
**Files:**
- Modify: `README.md`
- Test: existing and new `src/test/java/com/yau/digitalrmb/issuance/**` tests
**Interfaces:**
- Consumes: completed application JAR and `dev` profile.
- Produces: documented startup and verified commercial-bank-to-central-bank workflow.
- [ ] **Step 1: Write a failing integration workflow test**
```java
@Test
void completeTeachingWorkflowReturnsReceivedPayloadToCentralBankEnd() throws Exception {
UUID requestId = createPrepareDigestSignPackageAndSendThroughCommercialBankApi();
mockMvc.perform(get("/api/v1/central-banks/issuance/requests/{id}", requestId)
.with(jwt().jwt(jwt -> jwt.subject("tzs001"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.receiveStatus").value("RECEIVED"))
.andExpect(jsonPath("$.data.payload.requestId").exists());
}
```
- [ ] **Step 2: Run the full workflow test to verify it fails before the missing integration behavior is completed**
Run: `mvn -Dtest=IssuanceWorkflowIntegrationTest test -B`
Expected: FAIL until all commercial-bank action endpoints and the central-bank projection are connected.
- [ ] **Step 3: Add only the missing integration wiring and document the dev workflow**
Add a README section with the `dev` profile startup command, the Chinese Swagger URL, the commercial-bank inventory endpoint, and the central-bank request query endpoint. Do not document or expose any database password or private key.
- [ ] **Step 4: Run all automated verification**
Run:
```powershell
$env:JAVA_HOME='C:\Users\Acer\.jdks\temurin-8\jdk8u502-b07'
$env:Path="$env:JAVA_HOME\bin;$env:Path"
mvn test -B
mvn package -DskipTests -B
```
Expected: Maven exits 0 with all tests passing and the JAR built for Java 8.
- [ ] **Step 5: Run the dev-profile smoke test against the 118 test database**
Run:
```powershell
java -jar target\digital-rmb-backend-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev --server.port=8081
Invoke-RestMethod http://localhost:8081/actuator/health
Invoke-WebRequest http://localhost:8081/swagger-ui/index.html -UseBasicParsing
Invoke-WebRequest http://localhost:8081/v3/api-docs -UseBasicParsing
```
Expected: health status is `UP`, Swagger returns HTTP 200, and OpenAPI JSON contains both readable Chinese tag names without replacement characters.
- [ ] **Step 6: Commit verification and documentation**
```bash
git add README.md src/test/java/com/yau/digitalrmb/issuance/interfaces/rest/IssuanceWorkflowIntegrationTest.java
git commit -m "test: verify issuance teaching workflow"
```
## Plan Self-Review
- Spec coverage: Tasks 14 implement the aggregate, two-end state separation, inventory, persistence, message construction, SM3/SM2, and send simulation. Task 5 implements all approved API paths and Chinese Swagger grouping. Task 6 covers full workflow, JDK 8 build, dev database startup, and non-garbled OpenAPI verification.
- Placeholder scan: no tasks defer behavior; each has concrete paths, interfaces, test commands, expected results, and implementation details.
- Type consistency: all request IDs are UUID-backed `IssuanceApplicationId`; persistence and HTTP boundaries expose UUID values, while application services accept UUID and adapt to the value object internally. The status names are consistently `DRAFT`, `MESSAGE_PREPARED`, `DIGESTED`, `SIGNED`, `PACKAGED`, `SENT`, `NOT_RECEIVED`, and `RECEIVED`.
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-08-03-issuance-request-implementation.md`.
1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task and review between tasks.
2. **Inline Execution** — execute tasks in this session in batches with review checkpoints.

@ -0,0 +1,185 @@
# Current User Context Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
**Goal:** Add no-argument current-user access for backend code and return the same complete profile from GET /api/v1/auth/me.
**Architecture:** PlatformTokenVerifier parses verified profile claims into VerifiedPlatformToken. LocalSsoAccountService saves the profile in platform_user_snapshot. CurrentUserService reads that snapshot from the Jwt in SecurityContext, and AuthController delegates to it.
**Tech Stack:** Java 8, Spring Boot 2.7, Spring Security, MyBatis-Plus, H2, JUnit 5, MockMvc.
## Global Constraints
- Keep Java 8 and ApiResponse.
- userId and raw roleid are long; school, college, major, class, and student identifiers are String.
- Do not put profile data in application JWTs or expose passwords or Tokens.
- Business requests only read the local snapshot.
---
### Task 1: Parse complete profile claims
**Files:**
- Modify: src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java
- Modify: src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java
- Modify: src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java
**Interfaces:** VerifiedPlatformToken gains schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleId, name, classId, className, studentId.
- [ ] **Step 1: Write the failing test**
~~~java
@Test
void parsesCompleteUserProfileFromVerifiedToken() throws Exception {
VerifiedPlatformToken token = verifier.verify(token(487L, "tzs001", "new-password", 2L,
now.plus(Duration.ofMinutes(5)), completeProfile()));
assertThat(token.getSchoolId()).isEqualTo("610000");
assertThat(token.getCollegeName()).isEqualTo("Computer College");
assertThat(token.getMajorName()).isEqualTo("Software Engineering");
assertThat(token.getRoleId()).isEqualTo(2L);
assertThat(token.getName()).isEqualTo("Test Student");
assertThat(token.getClassId()).isEqualTo("202401");
assertThat(token.getStudentId()).isEqualTo("20240001");
}
~~~
- [ ] **Step 2: Verify red**
Run: mvn -Dtest=PlatformTokenVerifierTest test -DforkCount=0 -B
Expected: FAIL because the new getters do not exist.
- [ ] **Step 3: Minimal implementation**
Add immutable fields and a full constructor to VerifiedPlatformToken while retaining the old constructor for existing callers. In PlatformTokenVerifier, preserve parsed roleid as roleId; parse name with username fallback and all remaining profile fields via:
~~~java
private String optionalText(JsonNode payload, String field) {
JsonNode node = payload.path(field);
return node.isMissingNode() || node.isNull() ? null : node.asText();
}
~~~
- [ ] **Step 4: Verify green**
Run: mvn -Dtest=PlatformTokenVerifierTest test -DforkCount=0 -B
Expected: PASS.
### Task 2: Persist the profile snapshot
**Files:**
- Modify: src/main/resources/schema.sql
- Modify: src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java
- Modify: src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java
- Modify: src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java
**Interfaces:** synchronize(VerifiedPlatformToken) upserts every complete profile field in platform_user_snapshot.
- [ ] **Step 1: Write the failing test**
~~~java
@Test
void synchronizesCompleteProfileIntoLocalSnapshot() {
service.synchronize(completeToken(603L, "sso603", 2L));
Map<String, Object> row = new JdbcTemplate(dataSource).queryForMap(
"SELECT school_id, college_name, major_name, role_id, class_name, student_id "
+ "FROM platform_user_snapshot WHERE platform_user_id = 603");
assertThat(row).containsEntry("school_id", "610000")
.containsEntry("college_name", "Computer College")
.containsEntry("major_name", "Software Engineering")
.containsEntry("role_id", 2L)
.containsEntry("class_name", "Class 1")
.containsEntry("student_id", "20240001");
}
~~~
- [ ] **Step 2: Verify red**
Run: mvn -Dtest=LocalSsoAccountServiceTest test -DforkCount=0 -B
Expected: FAIL because the columns do not exist.
- [ ] **Step 3: Minimal implementation**
Add nullable school_id, school_name, college_id, college_name, major_id, major_name, role_id, class_id, class_name, student_id columns to the table creation and idempotent ADD COLUMN IF NOT EXISTS statements afterward. Map them with TableField in the snapshot Entity and copy token fields in upsertSnapshot.
- [ ] **Step 4: Verify green**
Run: mvn -Dtest=LocalSsoAccountServiceTest test -DforkCount=0 -B
Expected: PASS.
### Task 3: Implement the common current-user service
**Files:**
- Create: src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java
- Create: src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java
- Create: src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java
**Interfaces:** CurrentUserService.getCurrentUser() returns schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleid, userId, username, name, classId, className, studentid.
- [ ] **Step 1: Write the failing test**
Synchronize a complete profile, put JwtAuthenticationToken for that user into SecurityContextHolder, call currentUserService.getCurrentUser(), and assert all fields. Also test no snapshot and assert BusinessException ErrorCode.UNAUTHORIZED. Clear SecurityContextHolder in AfterEach.
- [ ] **Step 2: Verify red**
Run: mvn -Dtest=CurrentUserServiceTest test -DforkCount=0 -B
Expected: FAIL because the service does not exist.
- [ ] **Step 3: Minimal implementation**
~~~java
public CurrentUser getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份无效");
}
long userId = parseUserId((Jwt) authentication.getPrincipal());
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(userId);
if (snapshot == null) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在");
}
return map(snapshot);
}
~~~
map converts the complete snapshot, and parseUserId maps malformed subjects to UNAUTHORIZED.
- [ ] **Step 4: Verify green**
Run: mvn -Dtest=CurrentUserServiceTest test -DforkCount=0 -B
Expected: PASS.
### Task 4: Extend /auth/me
**Files:**
- Modify: src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java
- Modify: src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java
- Modify: src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java
- Modify: src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java
**Interfaces:** GET /api/v1/auth/me returns exactly the 13 CurrentUser fields in data.
- [ ] **Step 1: Write the failing endpoint test**
Synchronize complete Token data and assert data.userId, username, name, schoolId, schoolName, collegeId, collegeName, majorId, majorName, roleid, classId, className, studentid. Update local login assertion to username.
- [ ] **Step 2: Verify red**
Run: mvn -Dtest=CurrentUserAndLogoutTest,AuthControllerTest test -DforkCount=0 -B
Expected: FAIL because the response DTO lacks the fields.
- [ ] **Step 3: Minimal implementation**
Replace CurrentUserResponse with the 13 CurrentUser fields. Inject CurrentUserService and have /me map currentUserService.getCurrentUser() into the response; do not query the Mapper in that method.
- [ ] **Step 4: Verify green and build**
Run: mvn -Dtest=CurrentUserAndLogoutTest,AuthControllerTest test -DforkCount=0 -B
Expected: PASS.
Run: mvn test -DforkCount=0 -B
Expected: PASS.
Run: mvn package -DskipTests -B
Expected: BUILD SUCCESS.

@ -0,0 +1,262 @@
# Local Token SSO Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace database-coupled platform SSO with shared-secret JWT verification, local user/password synchronization, and direct system-JWT redirect.
**Architecture:** The SSO boundary verifies a three-segment HS256 parent JWT with a configured shared secret and produces validated claims. A transactional local account service finds or creates `sys_user` by `userId`, synchronizes the profile, role, and BCrypt password hash, then the controller issues the existing system JWT and redirects it to the frontend. No runtime component connects to or queries a platform database.
**Tech Stack:** Java 8, Spring Boot 2.7.18, Spring Security OAuth2 JOSE/Nimbus, MyBatis-Plus, H2 test profile, BCrypt.
## Global Constraints
- Never connect to, query, or synchronize from the main platform database.
- Verify the parent JWT with `platform-integration.token.link-secret-key`; never log or commit the shared secret, parent token, or password claim.
- Require parent claims `userId`, `username`, `password`, and `roleid`.
- Use `sys_user.id = userId`; map `roleid == 3` to `TEACHER`, all other values to `STUDENT`.
- Every valid SSO login overwrites the local password hash with BCrypt of the parent `password` claim.
- The application has one local datasource and `spring.datasource.hikari.minimum-idle: 2`.
---
## Task 1: Verify the parent Token without a platform repository
**Files:**
- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java`
- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java`
- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java`
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java`
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java`
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java`
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java`
- Test: `src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java`
- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java`
- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java`
**Interfaces:**
- Produces `VerifiedPlatformToken verify(String rawToken)` with `userId()`, `username()`, `displayName()`, `rawPassword()`, `roleKey()`, and `optionalClaims()`.
- [ ] **Step 1: Write failing verifier tests.**
```java
@Test
void acceptsSignedParentTokenWithoutDatabaseLookup() {
VerifiedPlatformToken token = verifier.verify(parentToken(claims(
"userId", 487L, "username", "tzs001", "password", "new-password", "roleid", 2L)));
assertThat(token.userId()).isEqualTo(487L);
assertThat(token.username()).isEqualTo("tzs001");
assertThat(token.rawPassword()).isEqualTo("new-password");
assertThat(token.roleKey()).isEqualTo("STUDENT");
}
@Test
void rejectsTamperedExpiredOrIncompleteToken() {
assertThatThrownBy(() -> verifier.verify(tamperedToken)).isInstanceOf(PlatformTokenException.class);
assertThatThrownBy(() -> verifier.verify(expiredToken)).isInstanceOf(PlatformTokenException.class);
assertThatThrownBy(() -> verifier.verify(parentToken(claims("userId", 487L))))
.isInstanceOf(PlatformTokenException.class);
}
```
- [ ] **Step 2: Run the verifier test to prove the old four-segment, repository-backed verifier fails.**
Run: `mvn -B -Dtest=PlatformTokenVerifierTest test`
Expected: FAIL because the old verifier requires `PlatformIdentityRepository`.
- [ ] **Step 3: Implement the minimal shared-secret verifier.**
Use the already available Spring Security JOSE support with a `SecretKeySpec(linkSecretKey.getBytes(UTF_8), "HmacSHA256")` to decode an HS256 JWT and validate its standard expiration. Reject blank or missing `userId`, `username`, `password`, or non-numeric `roleid`. Set `roleKey` to `TEACHER` only for `roleid == 3`; retain raw password only in-process and exclude it from logs, exceptions, and responses. Replace the old `Token` properties with:
```java
public static class Token {
@NotBlank
private String linkSecretKey;
}
```
- [ ] **Step 4: Run the verifier tests and commit.**
Run: `mvn -B -Dtest=PlatformTokenVerifierTest test`
Expected: PASS with no repository mock or platform datasource fixture.
```bash
git add src/main/java/com/yau/digitalrmb/platformintegration src/test/java/com/yau/digitalrmb/platformintegration
git commit -m "feat: verify platform jwt locally"
```
## Task 2: Synchronize only the local user, role, snapshot, and password
**Files:**
- Create: `src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java`
- Delete: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java`
- Delete: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java`
- Test: `src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java`
- Delete: `src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java`
**Interfaces:**
- Consumes `VerifiedPlatformToken`, `UserMapper`, `PlatformUserSnapshotMapper`, `PasswordEncoder`, and primary-datasource JDBC access.
- Produces `LocalSsoAccount synchronize(VerifiedPlatformToken token)` with `userId()`, `username()`, `displayName()`, and `roleKey()`.
- [ ] **Step 1: Write failing account synchronization tests.**
```java
@Test
void createsUserRoleSnapshotAndBcryptPassword() {
LocalSsoAccount account = service.synchronize(studentToken(487L, "tzs001", "first-password"));
assertThat(account.userId()).isEqualTo(487L);
assertThat(passwordEncoder.matches("first-password", userMapper.selectById(487L).getPasswordHash())).isTrue();
assertThat(roleIdsFor(487L)).containsExactly(1002L);
}
@Test
void refreshesPasswordAndRoleForExistingUser() {
service.synchronize(studentToken(487L, "tzs001", "old-password"));
service.synchronize(teacherToken(487L, "tzs002", "new-password"));
assertThat(passwordEncoder.matches("new-password", userMapper.selectById(487L).getPasswordHash())).isTrue();
assertThat(passwordEncoder.matches("old-password", userMapper.selectById(487L).getPasswordHash())).isFalse();
assertThat(roleIdsFor(487L)).containsExactly(1001L);
}
```
- [ ] **Step 2: Run the new test to prove it fails.**
Run: `mvn -B -Dtest=LocalSsoAccountServiceTest test`
Expected: FAIL because `LocalSsoAccountService` does not exist.
- [ ] **Step 3: Implement the transactional local synchronizer.**
Within one `@Transactional` method, load `sys_user` by `token.userId()`, create it with that ID when absent, and otherwise update it. In both branches set username, enabled status, and `passwordHash = passwordEncoder.encode(token.rawPassword())`. Upsert `platform_user_snapshot` from non-sensitive claims. Delete that user's `sys_user_role` rows and insert role ID `1001` for teachers or `1002` for students. Do not inject a named or secondary datasource.
- [ ] **Step 4: Run the test and commit.**
Run: `mvn -B -Dtest=LocalSsoAccountServiceTest test`
Expected: PASS; repeated SSO has one user, one role row, and the current BCrypt password.
```bash
git add src/main/java/com/yau/digitalrmb/identity src/test/java/com/yau/digitalrmb/identity
git commit -m "feat: sync local account from platform token"
```
## Task 3: Redirect the system JWT and remove exchange-code login
**Files:**
- Modify: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java`
- Modify: `src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java`
- Delete: `src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java`
- Delete: `src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java`
- Delete: `src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java`
- Test: `src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java`
- Modify: `src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java`
- Delete: `src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java`
**Interfaces:**
- Consumes `PlatformTokenVerifier.verify`, `LocalSsoAccountService.synchronize`, and `JwtTokenService.issueFor(long, String, Set<String>)`.
- Produces `GET /api/v1/auth/sso?token=...` with 302 `Location: {frontendCallbackUrl}?token={urlEncodedSystemJwt}`.
- [ ] **Step 1: Write failing MVC tests.**
```java
mockMvc.perform(get("/api/v1/auth/sso").param("token", validParentToken))
.andExpect(status().isFound())
.andExpect(header().string("Location", startsWith("https://rmb.example.edu/sso-callback?token=")))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("Referrer-Policy", "no-referrer"));
mockMvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"tzs001\",\"password\":\"new-password\"}"))
.andExpect(status().isOk());
```
- [ ] **Step 2: Run controller tests to prove the old exchange-code redirect fails.**
Run: `mvn -B -Dtest=PlatformSsoControllerTest,AuthControllerTest test`
Expected: FAIL because the old `Location` has `code=`.
- [ ] **Step 3: Implement direct SSO redirect.**
Verify the parent Token, synchronize the local account, issue the existing system JWT for the local ID and one role, URL-encode that system JWT, and redirect to `frontend.callback-url` with query key `token`. Preserve `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. Remove `/session/exchange`; preserve `/login`, `/me`, and `/logout`.
- [ ] **Step 4: Run controller tests and commit.**
Run: `mvn -B -Dtest=PlatformSsoControllerTest,AuthControllerTest test`
Expected: PASS; neither the incoming Token nor its password appears in the redirect.
```bash
git add src/main/java/com/yau/digitalrmb/platformintegration/interfaces src/main/java/com/yau/digitalrmb/security src/test/java/com/yau/digitalrmb/platformintegration/interfaces src/test/java/com/yau/digitalrmb/security
git commit -m "feat: redirect local jwt after sso"
```
## Task 4: Remove the second datasource and verify Java 8 startup
**Files:**
- Modify: `src/main/resources/application.yml`
- Modify: `src/main/resources/application-dev.yml`
- Modify: `src/main/resources/application-local.yml`
- Modify: `src/main/resources/application-test.yml`
- Modify: `src/main/resources/schema.sql`
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java`
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java`
- Delete: `src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java`
- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java`
- Delete: `src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java`
- Modify: `src/test/java/com/yau/digitalrmb/ApplicationContextTest.java`
- Modify: `README.md`
**Interfaces:**
- Produces an application context with primary `dataSource` only; no `platformReadOnlyDataSource` or `platformNamedParameterJdbcTemplate` bean.
- [ ] **Step 1: Write the failing one-datasource context test.**
```java
@Autowired ApplicationContext context;
@Test
void startsWithOnlyLocalDatasource() {
assertThat(context.containsBean("dataSource")).isTrue();
assertThat(context.containsBean("platformReadOnlyDataSource")).isFalse();
assertThat(context.containsBean("platformNamedParameterJdbcTemplate")).isFalse();
}
```
- [ ] **Step 2: Run it and verify it fails while the platform datasource exists.**
Run: `mvn -B -Dtest=ApplicationContextTest test`
Expected: FAIL because `platformReadOnlyDataSource` exists.
- [ ] **Step 3: Remove platform database, CAS, and sync configuration.**
Delete secondary datasource/CAS/sync properties from all profiles and remove their classes/tests. Retain `platform_user_snapshot` and `auth_login_exchange_code` tables to avoid destructive database migration, but remove executable references to exchange codes. Configure only `platform-integration.token.link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY}` and `platform-integration.frontend.callback-url`; set the primary Hikari `minimum-idle` to `2`.
- [ ] **Step 4: Run context/full Java 8 tests and build.**
Run: `mvn -B -Dtest=ApplicationContextTest test`
Expected: PASS; only `dataSource` is built.
Run: `mvn -B test`
Expected: PASS under Java 8.
Run: `mvn -B -DskipTests package`
Expected: BUILD SUCCESS with Java 8.
- [ ] **Step 5: Update documentation, verify removed references, and commit.**
Document `DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY`, `DIGITAL_RMB_FRONTEND_CALLBACK_URL`, required claims, direct redirect, and password refresh. Never include a real secret or password.
Run: `rg -n "DIGITAL_RMB_PLATFORM_DB|platformReadOnlyDataSource|platformNamedParameterJdbcTemplate|LoginExchangeCodeService|/session/exchange" src README.md`
Expected: no executable source reference.
```bash
git add src/main/java src/main/resources src/test/java README.md
git commit -m "refactor: remove platform database dependency"
```

@ -0,0 +1,126 @@
# 数字货币发行请求模块设计
## 目标
实现“①发送数字货币发行请求”教学页面的后端。学生携带有效的本系统 JWT 后,可以在商业银行端查询库存、创建并逐步处理发行申请,并在中央银行端查看接收结果。接口不区分教师与学生角色。
## 范围
本期仅覆盖页面的第 1 步:商业银行生成并发送发行请求,以及中央银行显示已接收的请求。两个端是独立的接口入口和查询视图,但属于同一教学系统并共享发行申请数据。第 2 至第 7 步(验签、业务核查、准备金扣减、数字货币生成、确权)不在本期实现范围内。
## 限界上下文与分层
扩展既有 `issuance` 限界上下文,不新增顶层模块。`IssuanceRequest` 是聚合根;库存查询是该上下文的只读查询模型。
```text
issuance/
domain/
model/ IssuanceRequest、IssuanceApplicationId、DenominationItem、IssuanceRequestStatus
repository/ IssuanceRequestRepository
service/ IssuanceMessageComposer、IssuanceSignatureService
application/
command/ CreateIssuanceRequestCommand、UpdateDenominationsCommand
query/ IssuanceInventoryQueryService、IssuanceRequestQueryService
service/ IssuanceRequestApplicationService
infrastructure/
persistence/ MyBatis-Plus Entity、Mapper、Repository 实现
crypto/ Bouncy Castle 的 SM3/SM2 实验实现
interfaces/
rest/ CommercialBankIssuanceController、CentralBankIssuanceController
dto/ 请求与响应 DTO
```
依赖方向保持 `interfaces → application → domain``infrastructure` 只实现领域端口。Controller 不直接调用 Mapper。
## 聚合与状态机
`IssuanceRequest` 保存机构代码、机构标识、发行金额、面额明细、币种、时间戳、待签名原文、摘要、签名、签名密钥标识、请求报文和当前状态。
商业银行申请状态只能按下列顺序变化:
```text
DRAFT → MESSAGE_PREPARED → DIGESTED → SIGNED → PACKAGED → SENT
```
中央银行接收状态独立保存:
```text
NOT_RECEIVED → RECEIVED
```
- 只有 `DRAFT` 可修改总金额、面额明细和币种。
- 面额数量必须为正整数,`Σ(面额 × 数量)` 必须等于发行总金额。
- `prepare-message` 只允许从 `DRAFT` 执行,并冻结时间戳和待签名原文。
- `digest`、`sign`、`package` 只允许在前一状态执行。
- `send``PACKAGED` 进入 `SENT` 后,在同一事务中将中央银行接收状态设为 `RECEIVED` 并记录接收时间,模拟中央银行已接收;重复发送返回同一请求,不生成第二笔记录。
- 已到达目标状态的重复按钮请求返回当前数据,不重新计算摘要或签名。
待签名原文采用固定字段顺序:
```text
ISSUE|{bankCode}|{organizationId}|{totalAmount}|{denominations}|{currency}|{timestamp}
```
`denominations` 按面额从大到小序列化为 `面额:数量`,例如 `100:400,50:100,20:200,10:50,5:80,1:100`
## 密码学教学实现
服务端使用 Bouncy Castle 提供的 SM3 和 SM2 算法:摘要为 SM3 十六进制文本,签名算法为 `SM3withSM2`。私钥不通过接口输入或返回。
本期通过 `SigningKeyProvider` 端口提供 `sm2-key-02` 实验密钥;基础设施实现仅在服务端生成并缓存密钥对。响应只暴露 `signingKeyRef`、摘要和签名。后续第 2 步验签可替换为配置化或密钥库实现,不改变聚合和接口契约。
## 数据模型
基础初始化脚本 `src/main/resources/schema.sql` 增加以下幂等表和演示数据:
| 表 | 关键字段 | 用途 |
| --- | --- | --- |
| `issuance_bank_inventory` | `bank_code`、`current_balance`、`warning_threshold` | 查询库存、预警阈值和建议补充金额。预置 `BKCHCNBJ00001`。 |
| `issuance_request` | `id`、`request_no`、`bank_code`、`organization_id`、`total_amount`、`currency`、`request_timestamp`、`message_text`、`digest`、`signature`、`signing_key_ref`、`payload_json`、`status`、`central_receive_status`、`central_received_at`、审计字段 | 发行申请聚合持久化。 |
| `issuance_request_denomination` | `request_id`、`denomination`、`quantity` | 发行请求的面额明细,`request_id + denomination` 唯一。 |
建议补充金额始终计算为 `max(warning_threshold - current_balance, 0)`,不单独持久化。
## REST 接口
所有接口要求有效 JWT不添加角色限定。商业银行端与中央银行端使用不同的 URL 前缀和 Controller中央银行端不提供创建、修改、签名或发送操作。
| 方法与路径 | 作用 |
| --- | --- |
| `GET /api/v1/commercial-banks/issuance/inventory?bankCode=BKCHCNBJ00001` | 商业银行端返回库存余额、预警阈值、建议补充金额。 |
| `POST /api/v1/commercial-banks/issuance/requests` | 商业银行端创建 `DRAFT` 发行申请。 |
| `PUT /api/v1/commercial-banks/issuance/requests/{id}` | 商业银行端仅在 `DRAFT` 更新金额、面额明细和币种。 |
| `GET /api/v1/commercial-banks/issuance/requests/{id}` | 商业银行端返回完整申请与处理产物。 |
| `POST /api/v1/commercial-banks/issuance/requests/{id}/prepare-message` | 商业银行端冻结时间戳并生成待签名原文。 |
| `POST /api/v1/commercial-banks/issuance/requests/{id}/digest` | 商业银行端对待签名原文生成 SM3 摘要。 |
| `POST /api/v1/commercial-banks/issuance/requests/{id}/sign` | 商业银行端使用 `sm2-key-02` 生成 SM2 签名。 |
| `POST /api/v1/commercial-banks/issuance/requests/{id}/package` | 商业银行端生成请求 JSON 报文。 |
| `POST /api/v1/commercial-banks/issuance/requests/{id}/send` | 商业银行端模拟发送;申请变为 `SENT`,中央银行接收状态变为 `RECEIVED`。 |
| `GET /api/v1/central-banks/issuance/requests/{id}` | 中央银行端返回接收状态、接收时间和 JSON 报文。 |
不存在的申请返回 `RESOURCE_NOT_FOUND`;状态不合法或金额校验不通过返回 `VALIDATION_ERROR`;未认证请求沿用既有 Spring Security 的 `UNAUTHORIZED` 响应。
## Swagger 文档
Swagger 页面使用中文,所有新增 Java 源文件和 OpenAPI 元数据以 UTF-8 保存,页面不允许出现乱码。发行域接口按以下中文标签分组:
- `数字货币发行模块 - 商业银行端`:库存查询、创建申请、更新申请、生成原文、摘要、签名、封装和发送。
- `数字货币发行模块 - 中央银行端`:查询发行请求接收状态、接收时间和请求报文。
每个接口通过中文 `summary``description` 明确说明所属模块、所属端、调用条件、状态变化及返回内容;请求字段和响应字段使用中文 `@Schema` 描述。OpenAPI 根标题固定为“数字人民币教学仿真后端”,接口分组和接口说明不得使用英文替代中文。
## 测试标准
- 领域单元测试覆盖金额守恒、非法状态流转和重复发送幂等性。
- 应用/持久化测试覆盖库存建议金额、请求与面额明细的保存和读取。
- `MockMvc` 测试覆盖学生有效 JWT 下的所有首期接口,以及未认证被拒绝。
- 密码学测试校验 SM3 输出长度和 SM2 签名可由同一实验公钥验证。
- 全量 Maven 测试在 JDK 8 下通过;以 `dev` profile 启动后Swagger 页面可访问。
- 访问 `/v3/api-docs` 和 Swagger UI确认“数字货币发行模块 - 商业银行端”“数字货币发行模块 - 中央银行端”标签及中文说明可正常显示,不含乱码字符。
## 明确约束
- 使用 JDK 8、Spring Boot 2.7.18、MyBatis-Plus 3.5.17、Spring Security 5.7。
- 不恢复 Flyway所有基础表变化同步更新幂等 `schema.sql` 并在 118 测试库验证。
- 不在日志、接口响应或数据库中保存私钥明文。
- 不实现真实央行网络调用、准备金扣减或数字货币生成。

@ -0,0 +1,72 @@
# 当前用户上下文设计
## 目标
为后端业务代码提供统一的当前用户读取能力,并将 `GET /api/v1/auth/me` 扩展为返回主平台 Token 中经验证的完整用户资料。
## 范围
- SSO 成功后,从已验证的主平台 Token payload 提取并本地持久化用户资料。
- 提供可注入的当前用户读取服务,供任意已认证业务接口使用。
- 扩展 `/api/v1/auth/me` 返回的 DTO。
- 不改变本系统 JWT 的主体语义、角色授权方式或本地账号密码登录流程。
## 用户资料契约
当前用户对象和 `/api/v1/auth/me``data` 使用下列 camelCase 字段。`roleid` 与 `studentid` 保持主平台字段拼写,以避免调用方转换。
| 字段 | 来源 | 说明 |
| --- | --- | --- |
| `schoolId` / `schoolName` | Token `schoolId` / `schoolName` | 学校标识与名称 |
| `collegeId` / `collegeName` | Token `collegeId` / `collegeName` | 院系标识与名称 |
| `majorId` / `majorName` | Token `majorId` / `majorName` | 专业标识与名称 |
| `roleid` | Token `roleid` | 主平台原始角色 ID不替换为本系统角色 ID |
| `userId` | Token `userId` | 主平台用户 ID也是本地用户 ID 与 JWT subject |
| `username` | Token `username` | 用户登录账号 |
| `name` | Token `name` | 用户姓名 |
| `classId` / `className` | Token `classId` / `className` | 班级标识与名称 |
| `studentid` | Token `studentid` | 学号/学生标识 |
标识类字段以字符串持久化和返回,以兼容主平台可能出现的非纯数字编码;`userId` 是唯一的数值主键,解析为 `long`
## 架构与数据流
```text
主平台 Token
-> PlatformTokenVerifier验签、校验必填资料
-> PlatformActor完整的已验证身份
-> PlatformIdentityProjectionService事务内更新 sys_user、快照、授权角色
-> platform_user_snapshot
本系统 JWT subject(userId)
-> CurrentUserService
-> platform_user_snapshot
-> 业务代码 / GET /api/v1/auth/me
```
`PlatformTokenVerifier` 只能在签名、登录时间和现有身份声明均通过后,才构造含完整资料的 `PlatformActor`。它校验 `userId`、`username`、`name`、`roleid` 必填,并将 `roleid` 映射为既有 `TEACHER``STUDENT` 以保留 Spring Security 授权行为。学校、院系、专业、班级和 `studentid` 允许为空,以兼容教师等不具备学生组织信息的身份。
`PlatformIdentityProjectionService` 在现有事务中更新扩展后的 `platform_user_snapshot`。快照是当前用户资料的唯一读取源;业务请求不会重新解析主平台 Token也不会查询主平台数据库。
`CurrentUserService` 位于 `security.application`,从 Spring Security 的 `Jwt` 读取 subject查询快照并返回不可变当前用户对象。未认证、subject 不是正整数或快照不存在时抛出既有 `UNAUTHORIZED` 业务异常。Controller 只调用该服务并将结果转换/直接作为响应 DTO 返回,不直接访问 Mapper。
## 持久化
扩展 `platform_user_snapshot`,增加学校、院系、专业、班级、主平台原始角色 ID 和学生标识字段。`schema.sql` 需同时覆盖新环境建表和已有数据库的幂等升级;不引入 Flyway。快照中保留 `role_key` 作为本系统授权映射,新增的 `roleid` 独立保存主平台原始值。
## API 与错误处理
`GET /api/v1/auth/me` 仍要求本系统 Bearer JWT并返回既有 `ApiResponse` 包装。成功响应只包含上述用户资料字段,不包含密码、主平台 Token、刷新令牌或快照同步时间。未认证或快照缺失时维持现有未授权错误语义。
## 测试
- Token 验证测试:完整资料被解析进已验证身份;缺少必填字段的 Token 被拒绝。
- 身份投影测试:扩展资料写入并在重复 SSO 时更新,不产生重复角色关联。
- 当前用户服务/接口测试:持有本系统 JWT 时能读取全部字段;无效 subject 与不存在快照返回未授权。
- 运行项目规定的 Maven 测试与打包命令。
## 非目标
- 不向业务 Controller 暴露用户、角色或快照 CRUD。
- 不在每次业务请求时调用主平台或解析主平台 Token。
- 不在本系统 JWT 中复制完整用户资料。

@ -0,0 +1,57 @@
# 本地 Token SSO 设计
## 目标
数字人民币后端不连接、不读取主平台数据库。主平台通过跳转链接携带 JWT后端使用双方约定的共享密钥验证该 JWT并仅使用 Token 中的用户数据创建或更新本地用户,然后直接签发本系统 JWT 并重定向给前端。
本地账号密码登录必须保留。主平台 Token 中携带的密码是本地密码的同步来源:每次 SSO 登录成功后,后端都将该密码 BCrypt 编码后写入本地用户表。因此,用户在主平台修改密码并再次通过 SSO 跳转后,可立即使用新密码独立登录本系统。
## 边界与依赖
保留的外部依赖只有主平台 Token 的共享密钥;不保留任何主平台数据源、数据库表查询、同步任务或远程身份仓储。
主平台 Token 采用 PEVC 示例相同的标准三段 JWT 与 HMAC 共享密钥验签。运行时通过环境变量配置共享密钥,禁止将其提交到代码库。
必需 claim
- `userId`:主平台用户 ID也是本地 `sys_user.id`
- `username`:本地登录账号。
- `password`:主平台当前明文密码,仅用于立即 BCrypt 编码后保存,不记录到日志、快照或响应。
- `roleid``3` 映射为 `TEACHER`,其他值映射为 `STUDENT`
可选 claim`name`、`schoolId`、`schoolName`、`classId`、`className`、`collegeId`、`collegeName`、`studentNo`、`realName`。这些字段仅在本地快照或本系统 JWT 扩展字段中使用。
## SSO 流程
1. 前端或主平台访问 `GET /api/v1/auth/sso?token={parentToken}`
2. 后端使用共享密钥验证 JWT 的签名与有效期,并提取必需 claim。
3. 后端以 `userId` 查询本地 `sys_user`
4. 若不存在,使用 Token 数据创建本地用户、用户快照和 `sys_user_role`;若存在,更新账号、启用状态、快照、角色关联及 BCrypt 密码散列。
5. 后端以本地用户信息签发数字人民币系统 JWT并以 302 重定向至 `{frontend-callback-url}/sso-callback?token={digitalRmbToken}`
本系统 JWT 的 subject 为本地 `userId`,并包含 `preferred_username` 与本地角色列表。SSO 入口不再生成或消费一次性交换码。
## 数据一致性与错误处理
用户、快照、角色关联和密码散列更新在同一个本地数据库事务内完成。已验证 Token 的用户同步必须幂等:同一用户多次跳转不会产生重复用户或角色行。
签名无效、过期、缺失必需 claim、非法 `userId` 或空账号/密码时,拒绝登录并重定向至前端登录页的 SSO 失败状态。不得在响应、日志或异常中输出原始 Token 或密码。
## 删除项
删除以下主平台数据库依赖:
- `platformReadOnlyDataSource``platformNamedParameterJdbcTemplate`
- `PlatformIdentityRepository` 的远程实现及相关主平台查询 SQL。
- `PlatformIdentitySyncJob``platform-integration.sync` 配置。
- 依赖远程身份数据验签的旧四段 Token 校验逻辑。
删除一次性交换码的 SSO 路径及其服务调用。为避免破坏现有数据库数据,既有 `auth_login_exchange_code` 表可暂时保留但不再被应用访问。
## 验证标准
- 应用启动时只创建一个本地业务数据源。
- 有效主平台 JWT 可创建本地用户并直接重定向携带本系统 JWT。
- 已存在用户每次 SSO 后密码散列都会更新;使用 Token 中的新密码可通过本地 `/api/v1/auth/login` 登录。
- 无效签名、过期 Token 或缺失必需字段不能创建/更新本地用户。
- 本地账号密码登录、当前用户查询和注销的既有行为保持可用。

@ -0,0 +1,80 @@
package com.yau.digitalrmb.identity.application;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity;
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper;
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper;
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
public class LocalSsoAccountService {
private final UserMapper userMapper;
private final PlatformUserSnapshotMapper snapshotMapper;
private final JdbcTemplate jdbcTemplate;
private final PasswordEncoder passwordEncoder;
public LocalSsoAccountService(UserMapper userMapper, PlatformUserSnapshotMapper snapshotMapper,
JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) {
this.userMapper = userMapper;
this.snapshotMapper = snapshotMapper;
this.jdbcTemplate = jdbcTemplate;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public void synchronize(VerifiedPlatformToken token) {
UserEntity user = userMapper.selectById(token.getUserId());
boolean newUser = user == null;
if (user == null) {
user = new UserEntity();
user.setId(token.getUserId());
}
user.setUsername(token.getUsername());
user.setPasswordHash(passwordEncoder.encode(token.getRawPassword()));
user.setEnabled(true);
if (newUser) {
userMapper.insert(user);
} else {
userMapper.updateById(user);
}
upsertSnapshot(token);
jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", token.getUserId());
jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)",
token.getUserId(), "TEACHER".equals(token.getRoleKey()) ? 1001L : 1002L);
}
private void upsertSnapshot(VerifiedPlatformToken token) {
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(token.getUserId());
boolean newSnapshot = snapshot == null;
if (snapshot == null) {
snapshot = new PlatformUserSnapshotEntity();
snapshot.setPlatformUserId(token.getUserId());
}
snapshot.setAccount(token.getUsername());
snapshot.setDisplayName(token.getDisplayName());
snapshot.setRoleKey(token.getRoleKey());
snapshot.setSchoolId(token.getSchoolId());
snapshot.setSchoolName(token.getSchoolName());
snapshot.setCollegeId(token.getCollegeId());
snapshot.setCollegeName(token.getCollegeName());
snapshot.setMajorId(token.getMajorId());
snapshot.setMajorName(token.getMajorName());
snapshot.setRoleId(token.getRoleId());
snapshot.setClassId(token.getClassId());
snapshot.setClassName(token.getClassName());
snapshot.setStudentId(token.getStudentId());
snapshot.setSourceUpdatedAt(LocalDateTime.now());
snapshot.setSyncedAt(LocalDateTime.now());
if (newSnapshot) {
snapshotMapper.insert(snapshot);
} else {
snapshotMapper.updateById(snapshot);
}
}
}

@ -1,85 +0,0 @@
package com.yau.digitalrmb.identity.application;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.UserEntity;
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper;
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.UserMapper;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
@Service
public class PlatformIdentityProjectionService {
private final UserMapper userMapper;
private final PlatformUserSnapshotMapper snapshotMapper;
private final JdbcTemplate jdbcTemplate;
private final PasswordEncoder passwordEncoder;
public PlatformIdentityProjectionService(UserMapper userMapper,
PlatformUserSnapshotMapper snapshotMapper,
JdbcTemplate jdbcTemplate,
PasswordEncoder passwordEncoder) {
this.userMapper = userMapper;
this.snapshotMapper = snapshotMapper;
this.jdbcTemplate = jdbcTemplate;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public void project(PlatformActor actor) {
projectUser(actor);
projectSnapshot(actor);
projectRole(actor);
}
private void projectUser(PlatformActor actor) {
UserEntity user = userMapper.selectById(actor.platformUserId());
if (user == null) {
user = new UserEntity();
user.setId(actor.platformUserId());
user.setUsername(actor.account());
user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString()));
user.setEnabled(true);
userMapper.insert(user);
return;
}
user.setUsername(actor.account());
user.setEnabled(true);
userMapper.updateById(user);
}
private void projectSnapshot(PlatformActor actor) {
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(actor.platformUserId());
boolean newSnapshot = snapshot == null;
if (newSnapshot) {
snapshot = new PlatformUserSnapshotEntity();
snapshot.setPlatformUserId(actor.platformUserId());
}
snapshot.setAccount(actor.account());
snapshot.setDisplayName(actor.displayName());
snapshot.setRoleKey(actor.role().name());
snapshot.setSourceUpdatedAt(LocalDateTime.ofInstant(actor.tokenSigningTime(), ZoneOffset.UTC));
snapshot.setSyncedAt(LocalDateTime.now(ZoneOffset.UTC));
if (newSnapshot) {
snapshotMapper.insert(snapshot);
} else {
snapshotMapper.updateById(snapshot);
}
}
private void projectRole(PlatformActor actor) {
jdbcTemplate.update("DELETE FROM sys_user_role WHERE user_id = ?", actor.platformUserId());
jdbcTemplate.update("INSERT INTO sys_user_role (user_id, role_id) VALUES (?, ?)",
actor.platformUserId(), roleId(actor));
}
private long roleId(PlatformActor actor) {
return actor.role().name().equals("TEACHER") ? 1001L : 1002L;
}
}

@ -1,33 +0,0 @@
package com.yau.digitalrmb.identity.application;
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Instant;
@Component
@ConditionalOnProperty(prefix = "platform-integration.sync", name = "enabled", havingValue = "true")
public class PlatformIdentitySyncJob {
private final PlatformIdentityRepository identityRepository;
private final PlatformIdentityProjectionService projectionService;
private Instant watermark = Instant.EPOCH;
public PlatformIdentitySyncJob(PlatformIdentityRepository identityRepository,
PlatformIdentityProjectionService projectionService) {
this.identityRepository = identityRepository;
this.projectionService = projectionService;
}
@Scheduled(fixedDelayString = "${platform-integration.sync.fixed-delay:PT15M}")
public synchronized void sync() {
Instant nextWatermark = Instant.now();
syncChangedSince(watermark);
watermark = nextWatermark;
}
public void syncChangedSince(Instant since) {
identityRepository.findChangedSince(since).forEach(projectionService::project);
}
}

@ -22,6 +22,36 @@ public class PlatformUserSnapshotEntity {
private String roleKey;
@TableField("school_id")
private String schoolId;
@TableField("school_name")
private String schoolName;
@TableField("college_id")
private String collegeId;
@TableField("college_name")
private String collegeName;
@TableField("major_id")
private String majorId;
@TableField("major_name")
private String majorName;
@TableField("role_id")
private Long roleId;
@TableField("class_id")
private String classId;
@TableField("class_name")
private String className;
@TableField("student_id")
private String studentId;
private LocalDateTime sourceUpdatedAt;
@TableField("synced_at")

@ -1,15 +0,0 @@
package com.yau.digitalrmb.platformintegration.application;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import java.util.Optional;
import java.time.Instant;
import java.util.List;
public interface PlatformIdentityRepository {
Optional<PlatformActor> findByPlatformUserId(long platformUserId);
Optional<PlatformActor> findBySchoolAccount(String schoolAccount);
List<PlatformActor> findChangedSince(Instant watermark);
}

@ -3,7 +3,6 @@ package com.yau.digitalrmb.platformintegration.application;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
@ -13,36 +12,22 @@ import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
@Component
@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true")
public class PlatformTokenVerifier {
private static final Duration CLOCK_SKEW = Duration.ofSeconds(30);
private final PlatformIdentityRepository identityRepository;
private final PlatformIntegrationProperties.Token properties;
private final ObjectMapper objectMapper;
private final Clock clock;
@Autowired
public PlatformTokenVerifier(PlatformIdentityRepository identityRepository,
PlatformIntegrationProperties properties) {
this(identityRepository, properties.getToken(), new ObjectMapper(), Clock.systemUTC());
}
PlatformTokenVerifier(PlatformIdentityRepository identityRepository,
PlatformIntegrationProperties.Token properties) {
this(identityRepository, properties, new ObjectMapper(), Clock.systemUTC());
public PlatformTokenVerifier(PlatformIntegrationProperties properties) {
this(properties.getToken(), new ObjectMapper(), Clock.systemUTC());
}
PlatformTokenVerifier(PlatformIdentityRepository identityRepository,
PlatformIntegrationProperties.Token properties,
ObjectMapper objectMapper,
Clock clock) {
this.identityRepository = identityRepository;
PlatformTokenVerifier(PlatformIntegrationProperties.Token properties, ObjectMapper objectMapper, Clock clock) {
this.properties = properties;
this.objectMapper = objectMapper;
this.clock = clock;
@ -55,23 +40,32 @@ public class PlatformTokenVerifier {
VerifiedPlatformToken verify(String rawToken, Instant now) {
try {
String[] parts = rawToken.split("\\.", -1);
if (parts.length != 4) {
if (parts.length != 3) {
throw invalid();
}
verifyLoginTime(parts[3], now);
JsonNode header = readJson(parts[0]);
if (!"HS256".equals(header.path("alg").asText())) {
throw invalid();
}
verifySignature(parts);
JsonNode payload = readJson(parts[1]);
long platformUserId = parseAudience(payload.path("aud"));
PlatformActor actor = identityRepository.findByPlatformUserId(platformUserId)
.orElseThrow(this::invalid);
verifyIdentityClaim(payload, actor);
verifySignature(parts, actor);
return new VerifiedPlatformToken(actor, fingerprint(rawToken));
if (!payload.path("exp").canConvertToLong() || Instant.ofEpochSecond(payload.path("exp").asLong()).compareTo(now) <= 0) {
throw invalid();
}
long userId = requiredPositiveLong(payload, "userId");
String username = requiredText(payload, "username");
String password = requiredText(payload, "password");
long roleId = requiredLong(payload, "roleid");
String displayName = optionalText(payload, "name");
if (displayName == null || displayName.trim().isEmpty()) {
displayName = username;
}
return new VerifiedPlatformToken(userId, username, displayName, password, roleId,
roleId == 3L ? "TEACHER" : "STUDENT", optionalText(payload, "schoolId"),
optionalText(payload, "schoolName"), optionalText(payload, "collegeId"),
optionalText(payload, "collegeName"), optionalText(payload, "majorId"),
optionalText(payload, "majorName"), optionalText(payload, "classId"),
optionalText(payload, "className"), optionalText(payload, "studentid"));
} catch (PlatformTokenException exception) {
throw exception;
} catch (Exception exception) {
@ -79,75 +73,19 @@ public class PlatformTokenVerifier {
}
}
private void verifyLoginTime(String value, Instant now) {
if (!value.matches("\\d{13}")) {
throw invalid();
}
Instant loginTime;
try {
loginTime = Instant.ofEpochMilli(Long.parseLong(value));
} catch (NumberFormatException exception) {
throw invalid();
}
if (loginTime.isAfter(now.plus(CLOCK_SKEW))
|| loginTime.isBefore(now.minus(properties.getMaxAge()))) {
throw invalid();
}
}
private JsonNode readJson(String encoded) throws Exception {
return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded));
}
private long parseAudience(JsonNode audience) {
JsonNode value = audience;
if (audience.isArray() && audience.size() == 1) {
value = audience.get(0);
}
if (!value.isTextual()) {
throw invalid();
}
try {
return Long.parseLong(value.textValue());
} catch (NumberFormatException exception) {
throw invalid();
}
}
private void verifyIdentityClaim(JsonNode payload, PlatformActor actor) {
String expected = actor.role().name().equals("TEACHER")
? properties.getTeacherClaimValue()
: properties.getStudentClaimValue();
if (!expected.equals(payload.path(String.valueOf(actor.profileId())).asText())) {
throw invalid();
}
}
private void verifySignature(String[] parts, PlatformActor actor) throws Exception {
private void verifySignature(String[] parts) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
byte[] key = String.valueOf(actor.tokenSigningTime().toEpochMilli()).getBytes(StandardCharsets.UTF_8);
mac.init(new SecretKeySpec(key, "HmacSHA256"));
mac.init(new SecretKeySpec(properties.getLinkSecretKey().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] expected = mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII));
byte[] actual = Base64.getUrlDecoder().decode(parts[2]);
if (!MessageDigest.isEqual(expected, actual)) {
if (!MessageDigest.isEqual(expected, Base64.getUrlDecoder().decode(parts[2]))) {
throw invalid();
}
}
private String fingerprint(String rawToken) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8));
StringBuilder fingerprint = new StringBuilder(digest.length * 2);
for (byte value : digest) {
String hex = Integer.toHexString(value & 0xff);
if (hex.length() == 1) {
fingerprint.append('0');
}
fingerprint.append(hex);
}
return fingerprint.toString();
}
private PlatformTokenException invalid() {
return new PlatformTokenException("Invalid platform token");
}
private JsonNode readJson(String encoded) throws Exception { return objectMapper.readTree(Base64.getUrlDecoder().decode(encoded)); }
private long requiredPositiveLong(JsonNode payload, String name) { long value = requiredLong(payload, name); if (value <= 0) throw invalid(); return value; }
private long requiredLong(JsonNode payload, String name) { JsonNode node = payload.path(name); if (!node.canConvertToLong()) throw invalid(); return node.asLong(); }
private String requiredText(JsonNode payload, String name) { String value = payload.path(name).asText(); if (value == null || value.trim().isEmpty()) throw invalid(); return value; }
private String optionalText(JsonNode payload, String name) { JsonNode node = payload.path(name); return node.isMissingNode() || node.isNull() ? null : node.asText(); }
private PlatformTokenException invalid() { return new PlatformTokenException("Invalid platform token"); }
}

@ -1,13 +1,50 @@
package com.yau.digitalrmb.platformintegration.application;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@Getter
@EqualsAndHashCode
public class VerifiedPlatformToken {
private final PlatformActor actor; private final String fingerprint;
public VerifiedPlatformToken(PlatformActor actor, String fingerprint) { this.actor = actor; this.fingerprint = fingerprint; }
public PlatformActor actor() { return actor; } public String fingerprint() { return fingerprint; }
private final long userId;
private final String username;
private final String displayName;
private final String rawPassword;
private final long roleId;
private final String roleKey;
private final String schoolId;
private final String schoolName;
private final String collegeId;
private final String collegeName;
private final String majorId;
private final String majorName;
private final String classId;
private final String className;
private final String studentId;
public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, String roleKey) {
this(userId, username, displayName, rawPassword, "TEACHER".equals(roleKey) ? 3L : 2L, roleKey,
null, null, null, null, null, null, null, null, null);
}
public VerifiedPlatformToken(long userId, String username, String displayName, String rawPassword, long roleId,
String roleKey, String schoolId, String schoolName, String collegeId,
String collegeName, String majorId, String majorName, String classId,
String className, String studentId) {
this.userId = userId;
this.username = username;
this.displayName = displayName;
this.rawPassword = rawPassword;
this.roleId = roleId;
this.roleKey = roleKey;
this.schoolId = schoolId;
this.schoolName = schoolName;
this.collegeId = collegeId;
this.collegeName = collegeName;
this.majorId = majorId;
this.majorName = majorName;
this.classId = classId;
this.className = className;
this.studentId = studentId;
}
public String getName() { return displayName; }
}

@ -1,68 +1,28 @@
package com.yau.digitalrmb.platformintegration.config;
import javax.validation.Valid;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
@Getter
@Setter
@Validated
@ConfigurationProperties("platform-integration")
public class PlatformIntegrationProperties {
@Valid
private Datasource datasource = new Datasource();
@Valid
private Token token = new Token();
@Valid
private Cas cas = new Cas();
@Valid
private Frontend frontend = new Frontend();
@Valid
private Sync sync = new Sync();
@Getter
@Setter
public static class Datasource {
@NotBlank
private String url;
@NotBlank
private String username;
@NotBlank
private String password;
}
@Getter
@Setter
public static class Token {
@NotNull
private Duration maxAge;
@NotBlank
private String teacherClaimValue;
@NotBlank
private String studentClaimValue;
@AssertTrue(message = "token.max-age must be positive")
public boolean isMaxAgePositive() {
return maxAge != null && !maxAge.isNegative() && !maxAge.isZero();
}
}
@Getter
@Setter
public static class Cas {
@NotBlank
private String loginUrl;
@NotBlank
private String validateUrl;
@NotBlank
private String callbackUrl;
private String linkSecretKey;
}
@Getter
@ -71,13 +31,4 @@ public class PlatformIntegrationProperties {
@NotBlank
private String callbackUrl;
}
@Getter
@Setter
public static class Sync {
private boolean enabled = false;
@NotNull
private Duration fixedDelay = Duration.ofMinutes(15);
}
}

@ -1,13 +0,0 @@
package com.yau.digitalrmb.platformintegration.domain;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import java.time.Instant;
@Getter
@EqualsAndHashCode
public class PlatformActor {
private final long platformUserId; private final long profileId; private final String account; private final String displayName; private final PlatformRole role; private final Instant tokenSigningTime;
public PlatformActor(long platformUserId, long profileId, String account, String displayName, PlatformRole role, Instant tokenSigningTime) { this.platformUserId = platformUserId; this.profileId = profileId; this.account = account; this.displayName = displayName; this.role = role; this.tokenSigningTime = tokenSigningTime; }
public long platformUserId() { return platformUserId; } public long profileId() { return profileId; } public String account() { return account; } public String displayName() { return displayName; } public PlatformRole role() { return role; } public Instant tokenSigningTime() { return tokenSigningTime; }
}

@ -1,25 +0,0 @@
package com.yau.digitalrmb.platformintegration.domain;
public enum PlatformRole {
TEACHER("JT_S_02"),
STUDENT("JT_S_03");
private final String jobType;
PlatformRole(String jobType) {
this.jobType = jobType;
}
public static PlatformRole fromJobType(String jobType) {
for (PlatformRole role : values()) {
if (role.jobType.equals(jobType)) {
return role;
}
}
throw new IllegalArgumentException("Unsupported platform job type: " + jobType);
}
public String jobType() {
return jobType;
}
}

@ -1,11 +1,16 @@
package com.yau.digitalrmb.platformintegration.interfaces;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.identity.application.LocalSsoAccountService;
import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier;
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
<<<<<<< HEAD
import com.yau.digitalrmb.security.application.LoginExchangeCodeService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
=======
import com.yau.digitalrmb.security.application.JwtTokenService;
import io.swagger.v3.oas.annotations.Operation;
>>>>>>> origin/master
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
@ -14,32 +19,34 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriComponentsBuilder;
import java.util.Collections;
@RestController
@ConditionalOnProperty(prefix = "platform-integration", name = "enabled", havingValue = "true")
@RequestMapping("/api/v1/auth")
public class PlatformSsoController {
private final PlatformTokenVerifier tokenVerifier;
private final PlatformIdentityProjectionService projectionService;
private final LoginExchangeCodeService exchangeCodeService;
private final LocalSsoAccountService localSsoAccountService;
private final JwtTokenService jwtTokenService;
private final PlatformIntegrationProperties.Frontend frontend;
public PlatformSsoController(PlatformTokenVerifier tokenVerifier,
PlatformIdentityProjectionService projectionService,
LoginExchangeCodeService exchangeCodeService,
PlatformIntegrationProperties properties) {
public PlatformSsoController(PlatformTokenVerifier tokenVerifier, LocalSsoAccountService localSsoAccountService,
JwtTokenService jwtTokenService, PlatformIntegrationProperties properties) {
this.tokenVerifier = tokenVerifier;
this.projectionService = projectionService;
this.exchangeCodeService = exchangeCodeService;
this.localSsoAccountService = localSsoAccountService;
this.jwtTokenService = jwtTokenService;
this.frontend = properties.getFrontend();
}
@GetMapping("/sso")
@Operation(description = "单点登录鉴权")
public ResponseEntity<Void> loginFromPlatform(@RequestParam("token") String token) {
VerifiedPlatformToken verified = tokenVerifier.verify(token);
projectionService.project(verified.actor());
String exchangeCode = exchangeCodeService.issue(verified.actor().platformUserId());
localSsoAccountService.synchronize(verified);
JwtTokenService.Token localToken = jwtTokenService.issueFor(verified.getUserId(), verified.getUsername(),
Collections.singleton(verified.getRoleKey()));
String location = UriComponentsBuilder.fromUriString(frontend.getCallbackUrl())
.queryParam("code", exchangeCode).build().encode().toUriString();
.queryParam("token", localToken.accessToken()).build().encode().toUriString();
return ResponseEntity.status(302)
.header(HttpHeaders.LOCATION, location)
.header(HttpHeaders.CACHE_CONTROL, "no-store")

@ -0,0 +1,22 @@
package com.yau.digitalrmb.security.application;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class CurrentUser {
private final String schoolId;
private final String schoolName;
private final String collegeId;
private final String collegeName;
private final String majorId;
private final String majorName;
private final Long roleid;
private final long userId;
private final String username;
private final String name;
private final String classId;
private final String className;
private final String studentid;
}

@ -0,0 +1,46 @@
package com.yau.digitalrmb.security.application;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity;
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.stereotype.Service;
@Service
public class CurrentUserService {
private final PlatformUserSnapshotMapper snapshotMapper;
public CurrentUserService(PlatformUserSnapshotMapper snapshotMapper) {
this.snapshotMapper = snapshotMapper;
}
public CurrentUser getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) {
throw unauthorized("用户身份无效");
}
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(parseUserId((Jwt) authentication.getPrincipal()));
if (snapshot == null) {
throw unauthorized("用户身份不存在");
}
return new CurrentUser(snapshot.getSchoolId(), snapshot.getSchoolName(), snapshot.getCollegeId(),
snapshot.getCollegeName(), snapshot.getMajorId(), snapshot.getMajorName(), snapshot.getRoleId(),
snapshot.getPlatformUserId(), snapshot.getAccount(), snapshot.getDisplayName(), snapshot.getClassId(),
snapshot.getClassName(), snapshot.getStudentId());
}
private long parseUserId(Jwt jwt) {
try {
return Long.parseLong(jwt.getSubject());
} catch (NumberFormatException exception) {
throw unauthorized("用户身份无效");
}
}
private BusinessException unauthorized(String message) {
return new BusinessException(ErrorCode.UNAUTHORIZED, message);
}
}

@ -1,79 +0,0 @@
package com.yau.digitalrmb.security.application;
import com.yau.digitalrmb.security.config.SecurityProperties;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
@Service
public class LoginExchangeCodeService {
private static final SecureRandom RANDOM = new SecureRandom();
private final JdbcTemplate jdbcTemplate;
private final SecurityProperties properties;
public LoginExchangeCodeService(JdbcTemplate jdbcTemplate, SecurityProperties properties) {
this.jdbcTemplate = jdbcTemplate;
this.properties = properties;
}
public String issue(long platformUserId) {
String code = randomValue();
jdbcTemplate.update("INSERT INTO auth_login_exchange_code (code_hash, platform_user_id, expires_at, consumed_at) VALUES (?, ?, ?, NULL)",
hash(code), platformUserId, Timestamp.from(Instant.now().plus(properties.getSession().getExchangeCodeTtl())));
return code;
}
public long exchange(String code) {
String hash = hash(code);
List<Long> platformUserIds = jdbcTemplate.query(
"SELECT platform_user_id FROM auth_login_exchange_code WHERE code_hash = ?",
(resultSet, rowNum) -> resultSet.getLong(1), hash);
if (platformUserIds.isEmpty()) {
throw invalidCode();
}
int consumed = jdbcTemplate.update(
"UPDATE auth_login_exchange_code SET consumed_at = CURRENT_TIMESTAMP "
+ "WHERE code_hash = ? AND consumed_at IS NULL AND expires_at > CURRENT_TIMESTAMP", hash);
if (consumed != 1) {
throw invalidCode();
}
return platformUserIds.get(0);
}
static String hash(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder hash = new StringBuilder(digest.length * 2);
for (byte item : digest) {
String hex = Integer.toHexString(item & 0xff);
if (hex.length() == 1) {
hash.append('0');
}
hash.append(hex);
}
return hash.toString();
} catch (Exception exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private static String randomValue() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private BusinessException invalidCode() {
return new BusinessException(ErrorCode.UNAUTHORIZED, "登录兑换码无效或已过期");
}
}

@ -4,6 +4,7 @@ import com.yau.digitalrmb.security.config.SecurityProperties;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.sql.Timestamp;
import java.time.Instant;
@ -26,7 +27,7 @@ public class RefreshTokenService {
RANDOM.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
jdbcTemplate.update("INSERT INTO auth_refresh_token (token_hash, platform_user_id, expires_at, revoked_at) VALUES (?, ?, ?, NULL)",
LoginExchangeCodeService.hash(token), platformUserId,
hash(token), platformUserId,
Timestamp.from(Instant.now().plus(properties.getSession().getRefreshTokenTtl())));
return token;
}
@ -34,6 +35,20 @@ public class RefreshTokenService {
public void revokeForUser(String token, long platformUserId) {
jdbcTemplate.update("UPDATE auth_refresh_token SET revoked_at = CURRENT_TIMESTAMP "
+ "WHERE token_hash = ? AND platform_user_id = ? AND revoked_at IS NULL",
LoginExchangeCodeService.hash(token), platformUserId);
hash(token), platformUserId);
}
private static String hash(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder(digest.length * 2);
for (byte item : digest) {
result.append(String.format("%02x", item));
}
return result.toString();
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}

@ -51,7 +51,6 @@ public class SecurityConfig {
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeRequests(authorize -> authorize
.antMatchers("/actuator/health", "/api/v1/auth/login", "/api/v1/auth/sso",
"/api/v1/auth/cas/**", "/api/v1/auth/session/exchange",
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.permitAll()
.anyRequest().authenticated())

@ -1,16 +1,16 @@
package com.yau.digitalrmb.security.interfaces;
import com.yau.digitalrmb.identity.infrastructure.persistence.entity.PlatformUserSnapshotEntity;
import com.yau.digitalrmb.identity.infrastructure.persistence.mapper.PlatformUserSnapshotMapper;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.security.application.JwtTokenService;
import com.yau.digitalrmb.security.application.LoginExchangeCodeService;
import com.yau.digitalrmb.security.application.LocalAccountAuthenticationService;
import com.yau.digitalrmb.security.application.RefreshTokenService;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import javax.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.MDC;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
@ -20,72 +20,53 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Set;
import java.util.Collections;
import javax.validation.Valid;
@RestController
@RequestMapping("/api/v1/auth")
@Tag(name = "用户登录及权限认证")
public class AuthController {
private final JwtTokenService tokenService;
private final LoginExchangeCodeService exchangeCodeService;
private final RefreshTokenService refreshTokenService;
private final PlatformUserSnapshotMapper snapshotMapper;
private final CurrentUserService currentUserService;
private final LocalAccountAuthenticationService localAccountAuthenticationService;
public AuthController(JwtTokenService tokenService, LoginExchangeCodeService exchangeCodeService,
RefreshTokenService refreshTokenService, PlatformUserSnapshotMapper snapshotMapper,
public AuthController(RefreshTokenService refreshTokenService, CurrentUserService currentUserService,
LocalAccountAuthenticationService localAccountAuthenticationService) {
this.tokenService = tokenService;
this.exchangeCodeService = exchangeCodeService;
this.refreshTokenService = refreshTokenService;
this.snapshotMapper = snapshotMapper;
this.currentUserService = currentUserService;
this.localAccountAuthenticationService = localAccountAuthenticationService;
}
@PostMapping("/login")
@Operation(description = "用户登录")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
JwtTokenService.Token token = localAccountAuthenticationService.login(request.username(), request.password());
return ApiResponse.success(new LoginResponse(token.accessToken(), "Bearer", token.expiresIn()),
MDC.get(TraceIdFilter.MDC_KEY));
}
@PostMapping("/session/exchange")
public ApiResponse<SessionResponse> exchange(@Valid @RequestBody ExchangeCodeRequest request) {
long platformUserId = exchangeCodeService.exchange(request.code());
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(platformUserId);
if (snapshot == null) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在");
}
JwtTokenService.Token accessToken = tokenService.issueFor(platformUserId, snapshot.getAccount(),
Collections.singleton(snapshot.getRoleKey()));
String refreshToken = refreshTokenService.issue(platformUserId);
return ApiResponse.success(new SessionResponse(accessToken.accessToken(), refreshToken, "Bearer", accessToken.expiresIn()),
MDC.get(TraceIdFilter.MDC_KEY));
}
@GetMapping("/me")
public ApiResponse<CurrentUserResponse> currentUser(@AuthenticationPrincipal Jwt jwt) {
long platformUserId = platformUserId(jwt);
PlatformUserSnapshotEntity snapshot = snapshotMapper.selectById(platformUserId);
if (snapshot == null) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在");
}
return ApiResponse.success(new CurrentUserResponse(platformUserId, snapshot.getAccount(), snapshot.getDisplayName(),
Collections.singletonList(snapshot.getRoleKey())), MDC.get(TraceIdFilter.MDC_KEY));
@Operation(description = "获取当前登录用户信息")
public ApiResponse<CurrentUserResponse> currentUser() {
CurrentUser user = currentUserService.getCurrentUser();
return ApiResponse.success(new CurrentUserResponse(user.getSchoolId(), user.getSchoolName(),
user.getCollegeId(), user.getCollegeName(), user.getMajorId(), user.getMajorName(), user.getRoleid(),
user.getUserId(), user.getUsername(), user.getName(), user.getClassId(), user.getClassName(),
user.getStudentid()), MDC.get(TraceIdFilter.MDC_KEY));
}
@PostMapping("/logout")
@Operation(description = "登出")
public ApiResponse<Void> logout(@AuthenticationPrincipal Jwt jwt, @Valid @RequestBody LogoutRequest request) {
refreshTokenService.revokeForUser(request.refreshToken(), platformUserId(jwt));
refreshTokenService.revokeForUser(request.refreshToken(), userId(jwt));
return ApiResponse.success(null, MDC.get(TraceIdFilter.MDC_KEY));
}
private long platformUserId(Jwt jwt) {
private long userId(Jwt jwt) {
try {
return Long.parseLong(jwt.getSubject());
} catch (NumberFormatException exception) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份无效");
throw new BusinessException(ErrorCode.UNAUTHORIZED, "User identity is invalid");
}
}
}

@ -1,4 +1,22 @@
package com.yau.digitalrmb.security.interfaces;
import lombok.AllArgsConstructor; import lombok.Getter; import java.util.List;
@Getter @AllArgsConstructor
public class CurrentUserResponse { private final long platformUserId; private final String account; private final String displayName; private final List<String> roles; }
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class CurrentUserResponse {
private final String schoolId;
private final String schoolName;
private final String collegeId;
private final String collegeName;
private final String majorId;
private final String majorName;
private final Long roleid;
private final long userId;
private final String username;
private final String name;
private final String classId;
private final String className;
private final String studentid;
}

@ -1,4 +0,0 @@
package com.yau.digitalrmb.security.interfaces;
import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank;
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class ExchangeCodeRequest { @NotBlank private String code; public String code() { return code; } }

@ -1,4 +0,0 @@
package com.yau.digitalrmb.security.interfaces;
import lombok.AllArgsConstructor; import lombok.Getter;
@Getter @AllArgsConstructor
public class SessionResponse { private final String accessToken; private final String refreshToken; private final String tokenType; private final long expiresIn; }

@ -8,10 +8,8 @@ springdoc:
swagger-ui:
enabled: true
platform-integration:
datasource:
url: ${spring.datasource.url}
username: ${spring.datasource.username}
password: ${spring.datasource.password}
token:
link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY:local-token-sso-test-secret-key-123456}
security:
jwt:
secret: 0123456789012345678901234567890123456789012345678901234567890123

@ -9,18 +9,15 @@ security:
secret: 0123456789012345678901234567890123456789012345678901234567890123
access-token-ttl: PT30M
platform-integration:
<<<<<<< HEAD
enabled: true
datasource:
url: jdbc:h2:mem:platform;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE
username: sa
password: test-password
=======
>>>>>>> origin/master
token:
max-age: PT2M
teacher-claim-value: teacher
student-claim-value: student
cas:
login-url: https://sso.example.edu/login
validate-url: https://sso.example.edu/p3/serviceValidate
callback-url: https://rmb.example.edu/api/v1/auth/cas/callback
link-secret-key: local-token-sso-test-secret-key-123456
frontend:
callback-url: https://rmb.example.edu/sso-callback

@ -7,6 +7,8 @@ spring:
url: ${DIGITAL_RMB_DB_URL}
username: ${DIGITAL_RMB_DB_USERNAME}
password: ${DIGITAL_RMB_DB_PASSWORD}
hikari:
minimum-idle: 2
driver-class-name: com.mysql.cj.jdbc.Driver
sql:
init:
@ -24,17 +26,7 @@ springdoc:
swagger-ui:
enabled: false
platform-integration:
datasource:
url: ${DIGITAL_RMB_PLATFORM_DB_URL}
username: ${DIGITAL_RMB_PLATFORM_DB_USERNAME}
password: ${DIGITAL_RMB_PLATFORM_DB_PASSWORD}
token:
max-age: PT2M
teacher-claim-value: teacher
student-claim-value: student
cas:
login-url: ${DIGITAL_RMB_CAS_LOGIN_URL}
validate-url: ${DIGITAL_RMB_CAS_VALIDATE_URL}
callback-url: ${DIGITAL_RMB_CAS_CALLBACK_URL}
link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY}
frontend:
callback-url: ${DIGITAL_RMB_FRONTEND_CALLBACK_URL}

@ -34,7 +34,17 @@ CREATE TABLE IF NOT EXISTS platform_user_snapshot (
display_name VARCHAR(64) NOT NULL,
role_key VARCHAR(16) NOT NULL,
source_updated_at TIMESTAMP NOT NULL,
synced_at TIMESTAMP NOT NULL
synced_at TIMESTAMP NOT NULL,
school_id VARCHAR(64) NULL,
school_name VARCHAR(128) NULL,
college_id VARCHAR(64) NULL,
college_name VARCHAR(128) NULL,
major_id VARCHAR(64) NULL,
major_name VARCHAR(128) NULL,
role_id BIGINT NULL,
class_id VARCHAR(64) NULL,
class_name VARCHAR(128) NULL,
student_id VARCHAR(64) NULL
);
CREATE TABLE IF NOT EXISTS auth_login_exchange_code (

@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
@ -18,13 +19,11 @@ class ApplicationContextTest {
private HikariDataSource applicationDataSource;
@Autowired
@Qualifier("platformReadOnlyDataSource")
private HikariDataSource platformReadOnlyDataSource;
private ApplicationContext applicationContext;
@Test
void applicationDatasourceIsSeparateAndWritable() {
assertThat(applicationDataSource).isNotSameAs(platformReadOnlyDataSource);
void usesOnlyTheWritableLocalDatasource() {
assertThat(applicationDataSource.isReadOnly()).isFalse();
assertThat(platformReadOnlyDataSource.isReadOnly()).isTrue();
assertThat(applicationContext.getBeansOfType(HikariDataSource.class)).hasSize(1);
}
}

@ -0,0 +1,68 @@
package com.yau.digitalrmb.identity;
import com.yau.digitalrmb.identity.application.LocalSsoAccountService;
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import javax.sql.DataSource;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
class LocalSsoAccountServiceTest {
@Autowired private LocalSsoAccountService service;
@Autowired private DataSource dataSource;
@Autowired private PasswordEncoder passwordEncoder;
@Test
void createsLocalUserWithTokenPasswordAndStudentRole() {
service.synchronize(new VerifiedPlatformToken(601L, "sso601", "张三", "first-password", "STUDENT"));
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 601", String.class);
Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 601", Long.class);
assertThat(passwordEncoder.matches("first-password", passwordHash)).isTrue();
assertThat(roleId).isEqualTo(1002L);
}
@Test
void refreshesLocalPasswordWhenPlatformPasswordChanges() {
service.synchronize(new VerifiedPlatformToken(602L, "sso602", "李四", "old-password", "STUDENT"));
service.synchronize(new VerifiedPlatformToken(602L, "sso602-new", "李四", "new-password", "TEACHER"));
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
String passwordHash = jdbc.queryForObject("SELECT password_hash FROM sys_user WHERE id = 602", String.class);
Long roleId = jdbc.queryForObject("SELECT role_id FROM sys_user_role WHERE user_id = 602", Long.class);
assertThat(passwordEncoder.matches("new-password", passwordHash)).isTrue();
assertThat(passwordEncoder.matches("old-password", passwordHash)).isFalse();
assertThat(roleId).isEqualTo(1001L);
}
@Test
void synchronizesCompleteProfileIntoLocalSnapshot() {
service.synchronize(completeToken(603L, "sso603", 2L));
Map<String, Object> row = new JdbcTemplate(dataSource).queryForMap(
"SELECT school_id, college_name, major_name, role_id, class_name, student_id "
+ "FROM platform_user_snapshot WHERE platform_user_id = 603");
assertThat(row).containsEntry("school_id", "610000")
.containsEntry("college_name", "Computer College")
.containsEntry("major_name", "Software Engineering")
.containsEntry("role_id", 2L)
.containsEntry("class_name", "Class 1")
.containsEntry("student_id", "20240001");
}
private VerifiedPlatformToken completeToken(long userId, String username, long roleId) {
return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "STUDENT",
"610000", "Yan'an University", "100", "Computer College", "101", "Software Engineering",
"202401", "Class 1", "20240001");
}
}

@ -1,60 +0,0 @@
package com.yau.digitalrmb.identity;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.security.crypto.password.PasswordEncoder;
import javax.sql.DataSource;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("test")
class PlatformIdentityProjectionServiceTest {
@Autowired
private PlatformIdentityProjectionService projectionService;
@Autowired
private DataSource dataSource;
@Autowired
private PasswordEncoder passwordEncoder;
@Test
void projectionIsIdempotentAndOwnsExactlyOneRole() {
PlatformActor teacher = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z"));
projectionService.project(teacher);
projectionService.project(teacher);
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
Integer associationCount = jdbc.queryForObject("SELECT COUNT(*) FROM sys_user_role WHERE user_id = 101", Integer.class);
String role = jdbc.queryForObject("SELECT role_key FROM platform_user_snapshot WHERE platform_user_id = 101", String.class);
String userName = jdbc.queryForObject("SELECT username FROM sys_user WHERE id = 101", String.class);
assertThat(associationCount).isEqualTo(1);
assertThat(role).isEqualTo("TEACHER");
assertThat(userName).isEqualTo("t001");
}
@Test
void ssoProjectionCreatesUserWithRandomBcryptPassword() {
PlatformActor actor = new PlatformActor(302L, 3L, "sso-user", "教师", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z"));
projectionService.project(actor);
String passwordHash = new JdbcTemplate(dataSource)
.queryForObject("SELECT password_hash FROM sys_user WHERE id = 302", String.class);
assertThat(passwordHash).startsWith("$2");
assertThat(passwordEncoder.matches("EXTERNAL_SSO_ONLY", passwordHash)).isFalse();
}
}

@ -1,47 +0,0 @@
package com.yau.digitalrmb.platformintegration.application;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import java.lang.reflect.Field;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CasTicketValidatorTest {
@Test
void configuresFiveSecondTimeoutsForCasRequests() throws Exception {
PlatformIntegrationProperties properties = new PlatformIntegrationProperties();
CasTicketValidator validator = new CasTicketValidator(properties);
Field restTemplateField = ReflectionUtils.findField(CasTicketValidator.class, "restTemplate");
ReflectionUtils.makeAccessible(restTemplateField);
RestTemplate restTemplate = (RestTemplate) ReflectionUtils.getField(restTemplateField, validator);
assertThat(restTemplate.getRequestFactory()).isInstanceOf(SimpleClientHttpRequestFactory.class);
SimpleClientHttpRequestFactory requestFactory = (SimpleClientHttpRequestFactory) restTemplate.getRequestFactory();
assertThat(readIntField(requestFactory, "connectTimeout")).isEqualTo(5000);
assertThat(readIntField(requestFactory, "readTimeout")).isEqualTo(5000);
}
@Test
void parsesSuccessfulCasAccountAndRejectsExternalEntityPayloads() {
String success = "<cas:serviceResponse xmlns:cas=\"http://www.yale.edu/tp/cas\">"
+ "<cas:authenticationSuccess><cas:user>t001</cas:user></cas:authenticationSuccess>"
+ "</cas:serviceResponse>";
String xxe = "<!DOCTYPE serviceResponse [<!ENTITY xxe SYSTEM \"file:///etc/passwd\">]>"
+ "<serviceResponse><authenticationSuccess><user>&xxe;</user></authenticationSuccess></serviceResponse>";
assertThat(CasTicketValidator.parseAccount(success)).isEqualTo("t001");
assertThatThrownBy(() -> CasTicketValidator.parseAccount(xxe)).isInstanceOf(BusinessException.class);
}
private int readIntField(Object target, String fieldName) throws Exception {
Field field = ReflectionUtils.findField(target.getClass(), fieldName);
ReflectionUtils.makeAccessible(field);
return (Integer) ReflectionUtils.getField(field, target);
}
}

@ -2,99 +2,101 @@ package com.yau.digitalrmb.platformintegration.application;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PlatformTokenVerifierTest {
private static final String SECRET = "local-token-sso-test-secret-key-123456";
private final Instant now = Instant.parse("2026-08-03T04:00:00Z");
private final PlatformActor teacher = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z"));
private PlatformTokenVerifier verifier;
@BeforeEach
void setUp() {
PlatformIntegrationProperties.Token properties = new PlatformIntegrationProperties.Token();
properties.setMaxAge(Duration.ofMinutes(2));
properties.setTeacherClaimValue("teacher");
properties.setStudentClaimValue("student");
PlatformIdentityRepository repository = new PlatformIdentityRepository() {
@Override
public Optional<PlatformActor> findByPlatformUserId(long platformUserId) {
return platformUserId == 101L ? Optional.of(teacher) : Optional.empty();
}
@Override
public Optional<PlatformActor> findBySchoolAccount(String schoolAccount) {
return Optional.empty();
}
@Override
public List<PlatformActor> findChangedSince(Instant watermark) {
return Collections.emptyList();
}
};
verifier = new PlatformTokenVerifier(repository, properties);
properties.setLinkSecretKey(SECRET);
verifier = new PlatformTokenVerifier(properties, new ObjectMapper(), Clock.fixed(now, ZoneOffset.UTC));
}
@Test
void acceptsFreshTeacherTokenSignedWithProfileAddTime() throws Exception {
String token = issueToken(teacher, now);
VerifiedPlatformToken verified = verifier.verify(token, now);
void acceptsStandardThreeSegmentTokenWithoutPlatformDatabaseIdentity() throws Exception {
VerifiedPlatformToken verified = verifier.verify(token(487L, "tzs001", "new-password", 2L, now.plus(Duration.ofMinutes(5))));
assertThat(verified.actor()).isEqualTo(teacher);
assertThat(verified.fingerprint()).hasSize(64);
assertThat(verified.getUserId()).isEqualTo(487L);
assertThat(verified.getUsername()).isEqualTo("tzs001");
assertThat(verified.getRawPassword()).isEqualTo("new-password");
assertThat(verified.getRoleKey()).isEqualTo("STUDENT");
}
@Test
void rejectsTamperedStaleAndRoleMismatchedTokens() throws Exception {
String valid = issueToken(teacher, now);
String stale = issueToken(teacher, now.minus(Duration.ofMinutes(3)));
String wrongRole = issueToken(teacher, now, "student");
void parsesCompleteUserProfileFromVerifiedToken() throws Exception {
VerifiedPlatformToken verified = verifier.verify(token(487L, "tzs001", "new-password", 2L,
now.plus(Duration.ofMinutes(5)), completeProfile()));
assertThatThrownBy(() -> verifier.verify(valid + "x", now)).isInstanceOf(PlatformTokenException.class);
assertThatThrownBy(() -> verifier.verify(stale, now)).isInstanceOf(PlatformTokenException.class);
assertThatThrownBy(() -> verifier.verify(wrongRole, now)).isInstanceOf(PlatformTokenException.class);
assertThat(verified.getSchoolId()).isEqualTo("610000");
assertThat(verified.getSchoolName()).isEqualTo("Yan'an University");
assertThat(verified.getCollegeName()).isEqualTo("Computer College");
assertThat(verified.getMajorName()).isEqualTo("Software Engineering");
assertThat(verified.getRoleId()).isEqualTo(2L);
assertThat(verified.getName()).isEqualTo("Test Student");
assertThat(verified.getClassId()).isEqualTo("202401");
assertThat(verified.getStudentId()).isEqualTo("20240001");
}
private String issueToken(PlatformActor actor, Instant loginTime) throws Exception {
return issueToken(actor, loginTime, "teacher");
@Test
void rejectsTamperedExpiredOrIncompleteToken() throws Exception {
String valid = token(487L, "tzs001", "new-password", 3L, now.plus(Duration.ofMinutes(5)));
assertThatThrownBy(() -> verifier.verify(valid + "x")).isInstanceOf(PlatformTokenException.class);
assertThatThrownBy(() -> verifier.verify(token(487L, "tzs001", "new-password", 2L, now.minusSeconds(1)))).isInstanceOf(PlatformTokenException.class);
Map<String, Object> incomplete = new HashMap<String, Object>();
incomplete.put("userId", 487L);
assertThatThrownBy(() -> verifier.verify(unsignedToken(incomplete, now.plus(Duration.ofMinutes(5))))).isInstanceOf(PlatformTokenException.class);
}
private String issueToken(PlatformActor actor, Instant loginTime, String identityClaimValue) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> headerClaims = new HashMap<String, Object>();
headerClaims.put("alg", "HS256");
headerClaims.put("typ", "JWT");
Map<String, Object> payloadClaims = new HashMap<String, Object>();
payloadClaims.put("aud", new String[]{String.valueOf(actor.platformUserId())});
payloadClaims.put(String.valueOf(actor.profileId()), identityClaimValue);
String header = encode(mapper.writeValueAsBytes(headerClaims));
String payload = encode(mapper.writeValueAsBytes(payloadClaims));
String unsigned = header + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(String.valueOf(actor.tokenSigningTime().toEpochMilli()).getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return unsigned + "." + encode(mac.doFinal(unsigned.getBytes(StandardCharsets.US_ASCII))) + "." + loginTime.toEpochMilli();
private String token(long userId, String username, String password, long roleId, Instant expiresAt) throws Exception {
return token(userId, username, password, roleId, expiresAt, new HashMap<String, Object>());
}
private String encode(byte[] value) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(value);
private String token(long userId, String username, String password, long roleId, Instant expiresAt,
Map<String, Object> profile) throws Exception {
Map<String, Object> claims = new HashMap<String, Object>();
claims.put("userId", userId); claims.put("username", username); claims.put("password", password); claims.put("roleid", roleId);
claims.putAll(profile);
return unsignedToken(claims, expiresAt);
}
private Map<String, Object> completeProfile() {
Map<String, Object> profile = new HashMap<String, Object>();
profile.put("name", "Test Student");
profile.put("schoolId", "610000");
profile.put("schoolName", "Yan'an University");
profile.put("collegeId", "100");
profile.put("collegeName", "Computer College");
profile.put("majorId", "101");
profile.put("majorName", "Software Engineering");
profile.put("classId", "202401");
profile.put("className", "Class 1");
profile.put("studentid", "20240001");
return profile;
}
private String unsignedToken(Map<String, Object> claims, Instant expiresAt) throws Exception {
claims.put("exp", expiresAt.getEpochSecond());
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> header = new HashMap<String, Object>(); header.put("alg", "HS256");
String unsigned = encode(mapper.writeValueAsBytes(header)) + "." + encode(mapper.writeValueAsBytes(claims));
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return unsigned + "." + encode(mac.doFinal(unsigned.getBytes(StandardCharsets.US_ASCII)));
}
private String encode(byte[] value) { return Base64.getUrlEncoder().withoutPadding().encodeToString(value); }
}

@ -1,43 +0,0 @@
package com.yau.digitalrmb.platformintegration.config;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformIntegrationPropertiesTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(PropertiesConfiguration.class))
.withPropertyValues(
"platform-integration.datasource.url=jdbc:mysql://localhost:3306/tianze",
"platform-integration.datasource.username=readonly",
"platform-integration.datasource.password=secret",
"platform-integration.token.max-age=PT2M",
"platform-integration.token.teacher-claim-value=teacher",
"platform-integration.token.student-claim-value=student",
"platform-integration.cas.login-url=https://sso.example.edu/login",
"platform-integration.cas.validate-url=https://sso.example.edu/p3/serviceValidate",
"platform-integration.cas.callback-url=https://rmb.example.edu/api/v1/auth/cas/callback",
"platform-integration.frontend.callback-url=https://rmb.example.edu/sso-callback");
@Test
void bindsReadOnlyDatasourceAndAuthenticationEndpoints() {
contextRunner.run(context -> {
PlatformIntegrationProperties properties = context.getBean(PlatformIntegrationProperties.class);
assertThat(properties.getDatasource().getUsername()).isEqualTo("readonly");
assertThat(properties.getToken().getMaxAge()).isEqualTo(Duration.ofMinutes(2));
assertThat(properties.getCas().getCallbackUrl()).isEqualTo("https://rmb.example.edu/api/v1/auth/cas/callback");
});
}
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(PlatformIntegrationProperties.class)
static class PropertiesConfiguration {
}
}

@ -1,17 +0,0 @@
package com.yau.digitalrmb.platformintegration.domain;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PlatformRoleTest {
@Test
void mapsOnlyTeacherAndStudentJobTypes() {
assertThat(PlatformRole.fromJobType("JT_S_02")).isEqualTo(PlatformRole.TEACHER);
assertThat(PlatformRole.fromJobType("JT_S_03")).isEqualTo(PlatformRole.STUDENT);
assertThatThrownBy(() -> PlatformRole.fromJobType("JT_S_01"))
.isInstanceOf(IllegalArgumentException.class);
}
}

@ -1,82 +0,0 @@
package com.yau.digitalrmb.platformintegration.infrastructure;
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JdbcPlatformIdentityRepositoryTest {
private DataSource dataSource;
private JdbcPlatformIdentityRepository repository;
@BeforeEach
void setUp() throws Exception {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:platform_identity;MODE=MySQL;DB_CLOSE_DELAY=-1");
source.setUser("sa");
dataSource = source;
execute("DROP ALL OBJECTS");
createSchema();
repository = new JdbcPlatformIdentityRepository(new NamedParameterJdbcTemplate(dataSource));
}
@Test
void resolvesEnabledTeacherFromCoreUserAndTeacherProfile() throws Exception {
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (101, 't001', '教师甲', 'S1', 'JT_S_02', 0)");
execute("INSERT INTO teacher(teacher_id, user_id, teacher_status, add_time) VALUES (1, 101, 1, '2026-01-01 00:00:00')");
PlatformActor actor = repository.findByPlatformUserId(101L)
.orElseThrow(() -> new IllegalStateException("teacher not found"));
assertThat(actor.account()).isEqualTo("t001");
assertThat(actor.role()).isEqualTo(PlatformRole.TEACHER);
assertThat(actor.profileId()).isEqualTo(1L);
assertThat(actor.tokenSigningTime()).isEqualTo(Instant.parse("2025-12-31T16:00:00Z"));
}
@Test
void excludesDisabledOrUnsupportedUsers() throws Exception {
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (201, 's001', '学生甲', 'S1', 'JT_S_03', 0)");
execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (1, 201, 2, '2026-01-01 00:00:00')");
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (202, 'admin', '管理员', 'S1', 'JT_S_01', 0)");
assertThat(repository.findByPlatformUserId(201L)).isEmpty();
assertThat(repository.findByPlatformUserId(202L)).isEmpty();
}
@Test
void rejectsDuplicateSchoolAccounts() throws Exception {
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (301, 'duplicate', 'student one', 'S1', 'JT_S_03', 0)");
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (302, 'duplicate', 'student two', 'S1', 'JT_S_03', 0)");
execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (11, 301, 1, '2026-01-01 00:00:00')");
execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (12, 302, 1, '2026-01-01 00:00:00')");
assertThatThrownBy(() -> repository.findBySchoolAccount("duplicate"))
.isInstanceOf(IncorrectResultSizeDataAccessException.class);
}
@Test
private void createSchema() throws Exception {
execute("CREATE TABLE core_user(ID BIGINT PRIMARY KEY, CODE VARCHAR(64), NAME VARCHAR(64), STATE VARCHAR(16), JOB_TYPE1 VARCHAR(16), DEL_FLAG INT)");
execute("CREATE TABLE teacher(teacher_id BIGINT PRIMARY KEY, user_id BIGINT, teacher_status INT, add_time TIMESTAMP)");
execute("CREATE TABLE student(student_id BIGINT PRIMARY KEY, user_id BIGINT, student_status INT, add_time TIMESTAMP)");
}
private void execute(String sql) throws Exception {
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.execute(sql);
}
}
}

@ -1,45 +1,44 @@
package com.yau.digitalrmb.platformintegration.interfaces;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.identity.application.LocalSsoAccountService;
import com.yau.digitalrmb.platformintegration.application.PlatformTokenVerifier;
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import com.yau.digitalrmb.security.application.LoginExchangeCodeService;
import com.yau.digitalrmb.security.application.JwtTokenService;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import java.util.Collections;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PlatformSsoControllerTest {
@Test
void ssoRedirectDoesNotLeakIncomingToken() throws Exception {
void ssoRedirectIssuesOnlyLocalToken() throws Exception {
PlatformTokenVerifier verifier = mock(PlatformTokenVerifier.class);
PlatformIdentityProjectionService projection = mock(PlatformIdentityProjectionService.class);
LoginExchangeCodeService exchangeCodes = mock(LoginExchangeCodeService.class);
LocalSsoAccountService localAccounts = mock(LocalSsoAccountService.class);
JwtTokenService jwtTokenService = mock(JwtTokenService.class);
PlatformIntegrationProperties properties = new PlatformIntegrationProperties();
properties.getFrontend().setCallbackUrl("https://rmb.example.edu/sso-callback");
PlatformActor actor = new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z"));
when(verifier.verify(anyString())).thenReturn(new VerifiedPlatformToken(actor, "fingerprint"));
when(exchangeCodes.issue(101L)).thenReturn("one-time-code");
MockMvc mvc = MockMvcBuilders.standaloneSetup(new PlatformSsoController(verifier, projection, exchangeCodes, properties)).build();
VerifiedPlatformToken verified = new VerifiedPlatformToken(101L, "t001", "Teacher", "password", "TEACHER");
when(verifier.verify(anyString())).thenReturn(verified);
when(jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER")))
.thenReturn(new JwtTokenService.Token("local-system-jwt", 1800L));
MockMvc mvc = MockMvcBuilders.standaloneSetup(
new PlatformSsoController(verifier, localAccounts, jwtTokenService, properties)).build();
mvc.perform(get("/api/v1/auth/sso").param("token", "incoming-platform-token"))
.andExpect(status().isFound())
.andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?code=one-time-code"))
.andExpect(header().string("Location", "https://rmb.example.edu/sso-callback?token=local-system-jwt"))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("Referrer-Policy", "no-referrer"));
verify(projection).project(actor);
verify(localAccounts).synchronize(verified);
}
}

@ -35,8 +35,8 @@ class AuthControllerTest {
mvc.perform(get("/api/v1/auth/me")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.account").value("tzs001"))
.andExpect(jsonPath("$.data.roles[0]").value("STUDENT"));
.andExpect(jsonPath("$.data.userId").value(487))
.andExpect(jsonPath("$.data.username").value("tzs001"));
}
@Test

@ -1,22 +1,19 @@
package com.yau.digitalrmb.security;
import com.yau.digitalrmb.identity.application.PlatformIdentityProjectionService;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import com.yau.digitalrmb.identity.application.LocalSsoAccountService;
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
import com.yau.digitalrmb.security.application.JwtTokenService;
import com.yau.digitalrmb.security.application.RefreshTokenService;
import java.util.Collections;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.Collections;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@ -26,22 +23,19 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@AutoConfigureMockMvc
@ActiveProfiles("test")
class CurrentUserAndLogoutTest {
@Autowired
private MockMvc mvc;
@Autowired
private PlatformIdentityProjectionService projectionService;
@Autowired
private JwtTokenService jwtTokenService;
@Autowired
private RefreshTokenService refreshTokenService;
@Autowired private MockMvc mvc;
@Autowired private LocalSsoAccountService localSsoAccountService;
@Autowired private JwtTokenService jwtTokenService;
@Autowired private RefreshTokenService refreshTokenService;
private String teacherJwt;
private String refreshToken;
@BeforeEach
void setUp() {
projectionService.project(new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z")));
localSsoAccountService.synchronize(new VerifiedPlatformToken(101L, "t001", "Teacher User", "password", 3L,
"TEACHER", "610000", "Yan'an University", "100", "Computer College", "101",
"Software Engineering", "202401", "Class 1", "20240001"));
teacherJwt = jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER")).accessToken();
refreshToken = refreshTokenService.issue(101L);
}
@ -50,8 +44,19 @@ class CurrentUserAndLogoutTest {
void currentUserIsReadonlyTeacherAndLogoutRevokesOwnRefreshToken() throws Exception {
mvc.perform(get("/api/v1/auth/me").header("Authorization", "Bearer " + teacherJwt))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.account").value("t001"))
.andExpect(jsonPath("$.data.roles[0]").value("TEACHER"));
.andExpect(jsonPath("$.data.userId").value(101))
.andExpect(jsonPath("$.data.username").value("t001"))
.andExpect(jsonPath("$.data.name").value("Teacher User"))
.andExpect(jsonPath("$.data.schoolId").value("610000"))
.andExpect(jsonPath("$.data.schoolName").value("Yan'an University"))
.andExpect(jsonPath("$.data.collegeId").value("100"))
.andExpect(jsonPath("$.data.collegeName").value("Computer College"))
.andExpect(jsonPath("$.data.majorId").value("101"))
.andExpect(jsonPath("$.data.majorName").value("Software Engineering"))
.andExpect(jsonPath("$.data.roleid").value(3))
.andExpect(jsonPath("$.data.classId").value("202401"))
.andExpect(jsonPath("$.data.className").value("Class 1"))
.andExpect(jsonPath("$.data.studentid").value("20240001"));
mvc.perform(post("/api/v1/auth/logout").header("Authorization", "Bearer " + teacherJwt)
.contentType(MediaType.APPLICATION_JSON).content("{\"refreshToken\":\"" + refreshToken + "\"}"))
.andExpect(status().isOk());

@ -0,0 +1,77 @@
package com.yau.digitalrmb.security;
import com.yau.digitalrmb.identity.application.LocalSsoAccountService;
import com.yau.digitalrmb.platformintegration.application.VerifiedPlatformToken;
import com.yau.digitalrmb.security.application.CurrentUser;
import com.yau.digitalrmb.security.application.CurrentUserService;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import java.time.Instant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@ActiveProfiles("test")
class CurrentUserServiceTest {
@Autowired private CurrentUserService currentUserService;
@Autowired private LocalSsoAccountService localSsoAccountService;
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void readsCompleteProfileForCurrentSecurityContextUser() {
localSsoAccountService.synchronize(completeToken(701L, "sso701", 2L));
authenticateAs(701L);
CurrentUser user = currentUserService.getCurrentUser();
assertThat(user.getUserId()).isEqualTo(701L);
assertThat(user.getUsername()).isEqualTo("sso701");
assertThat(user.getName()).isEqualTo("Test Student");
assertThat(user.getSchoolId()).isEqualTo("610000");
assertThat(user.getCollegeName()).isEqualTo("Computer College");
assertThat(user.getMajorName()).isEqualTo("Software Engineering");
assertThat(user.getRoleid()).isEqualTo(2L);
assertThat(user.getClassId()).isEqualTo("202401");
assertThat(user.getStudentid()).isEqualTo("20240001");
}
@Test
void rejectsCurrentSecurityContextUserWithoutSnapshot() {
authenticateAs(702L);
assertThatThrownBy(() -> currentUserService.getCurrentUser())
.isInstanceOfSatisfying(BusinessException.class,
exception -> assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.UNAUTHORIZED));
}
private void authenticateAs(long userId) {
Jwt jwt = Jwt.withTokenValue("test-token")
.header("alg", "none")
.subject(String.valueOf(userId))
.issuedAt(Instant.parse("2026-08-04T00:00:00Z"))
.expiresAt(Instant.parse("2026-08-04T01:00:00Z"))
.build();
AbstractAuthenticationToken authentication = new JwtAuthenticationToken(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private VerifiedPlatformToken completeToken(long userId, String username, long roleId) {
return new VerifiedPlatformToken(userId, username, "Test Student", "password", roleId, "STUDENT",
"610000", "Yan'an University", "100", "Computer College", "101", "Software Engineering",
"202401", "Class 1", "20240001");
}
}

@ -1,26 +0,0 @@
package com.yau.digitalrmb.security;
import com.yau.digitalrmb.security.application.LoginExchangeCodeService;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
@ActiveProfiles("test")
class LoginExchangeCodeServiceTest {
@Autowired
private LoginExchangeCodeService service;
@Test
void exchangeCodeCanOnlyBeUsedOnce() {
String code = service.issue(101L);
assertThat(service.exchange(code)).isEqualTo(101L);
assertThatThrownBy(() -> service.exchange(code)).isInstanceOf(BusinessException.class);
}
}
Loading…
Cancel
Save