diff --git a/docs/superpowers/plans/2026-08-03-issuance-request-implementation.md b/docs/superpowers/plans/2026-08-03-issuance-request-implementation.md
new file mode 100644
index 0000000..fde97ef
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-03-issuance-request-implementation.md
@@ -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
+
+ org.bouncycastle
+ bcprov-jdk18on
+ 1.84
+
+```
+
+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`.
+
+- [ ] **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 1–4 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.
diff --git a/docs/superpowers/plans/2026-08-04-current-user-context.md b/docs/superpowers/plans/2026-08-04-current-user-context.md
new file mode 100644
index 0000000..09fd0b7
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-current-user-context.md
@@ -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 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.
diff --git a/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md b/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md
new file mode 100644
index 0000000..1979886
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md
@@ -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)`.
+- 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"
+```
diff --git a/docs/superpowers/specs/2026-08-03-issuance-request-design.md b/docs/superpowers/specs/2026-08-03-issuance-request-design.md
new file mode 100644
index 0000000..0fa509e
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-03-issuance-request-design.md
@@ -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 测试库验证。
+- 不在日志、接口响应或数据库中保存私钥明文。
+- 不实现真实央行网络调用、准备金扣减或数字货币生成。
diff --git a/docs/superpowers/specs/2026-08-04-current-user-context-design.md b/docs/superpowers/specs/2026-08-04-current-user-context-design.md
new file mode 100644
index 0000000..6c34ccb
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-04-current-user-context-design.md
@@ -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 中复制完整用户资料。
diff --git a/docs/superpowers/specs/2026-08-04-local-token-sso-design.md b/docs/superpowers/specs/2026-08-04-local-token-sso-design.md
new file mode 100644
index 0000000..1fe8f53
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-04-local-token-sso-design.md
@@ -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 或缺失必需字段不能创建/更新本地用户。
+- 本地账号密码登录、当前用户查询和注销的既有行为保持可用。
diff --git a/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java
new file mode 100644
index 0000000..f47ba21
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java
@@ -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);
+ }
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java
deleted file mode 100644
index 6a619fd..0000000
--- a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java
+++ /dev/null
@@ -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;
- }
-}
diff --git a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java b/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java
deleted file mode 100644
index 0268c84..0000000
--- a/src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java
index abd8913..c320678 100644
--- a/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java
+++ b/src/main/java/com/yau/digitalrmb/identity/infrastructure/persistence/entity/PlatformUserSnapshotEntity.java
@@ -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")
diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java
deleted file mode 100644
index 81bd2c1..0000000
--- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java
+++ /dev/null
@@ -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 findByPlatformUserId(long platformUserId);
-
- Optional findBySchoolAccount(String schoolAccount);
-
- List findChangedSince(Instant watermark);
-}
diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java
index efbc87c..9c37225 100644
--- a/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java
+++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java
@@ -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"); }
}
diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java
index b4aa863..2ec3fc8 100644
--- a/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java
+++ b/src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java
@@ -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; }
}
diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java b/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java
index 7868a99..a40298f 100644
--- a/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java
+++ b/src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java
@@ -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);
- }
-
}
diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java
deleted file mode 100644
index ab3c9d2..0000000
--- a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java
+++ /dev/null
@@ -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; }
-}
diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java b/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java
deleted file mode 100644
index 63bf6f8..0000000
--- a/src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java
+++ /dev/null
@@ -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;
- }
-}
diff --git a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java
index 3879d14..196ed9d 100644
--- a/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java
+++ b/src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java
@@ -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 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")
diff --git a/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java b/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java
new file mode 100644
index 0000000..2bd0e07
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/security/application/CurrentUser.java
@@ -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;
+}
diff --git a/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java b/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java
new file mode 100644
index 0000000..09f451f
--- /dev/null
+++ b/src/main/java/com/yau/digitalrmb/security/application/CurrentUserService.java
@@ -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);
+ }
+}
diff --git a/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java b/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java
deleted file mode 100644
index 4da3dbe..0000000
--- a/src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java
+++ /dev/null
@@ -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 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, "登录兑换码无效或已过期");
- }
-}
diff --git a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java
index 8f0ab3d..5ca6871 100644
--- a/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java
+++ b/src/main/java/com/yau/digitalrmb/security/application/RefreshTokenService.java
@@ -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);
+ }
}
}
diff --git a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java
index 3df3699..99640ce 100644
--- a/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java
+++ b/src/main/java/com/yau/digitalrmb/security/config/SecurityConfig.java
@@ -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())
diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java
index 968b9ae..183cc62 100644
--- a/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java
+++ b/src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java
@@ -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 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 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 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 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 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");
}
}
}
diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java
index eb74b4f..24c7e0f 100644
--- a/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java
+++ b/src/main/java/com/yau/digitalrmb/security/interfaces/CurrentUserResponse.java
@@ -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 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;
+}
diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java b/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java
deleted file mode 100644
index 84addff..0000000
--- a/src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java
+++ /dev/null
@@ -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; } }
diff --git a/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java b/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java
deleted file mode 100644
index 9748d9c..0000000
--- a/src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java
+++ /dev/null
@@ -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; }
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 6b2c878..8a14bb0 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -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
diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml
index 1bbcd72..2316a2e 100644
--- a/src/main/resources/application-test.yml
+++ b/src/main/resources/application-test.yml
@@ -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
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index e6342f7..85c016c 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -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}
diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql
index 4d5165d..3e9453e 100644
--- a/src/main/resources/schema.sql
+++ b/src/main/resources/schema.sql
@@ -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 (
diff --git a/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java b/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java
index fb10e0a..34ec9bc 100644
--- a/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java
+++ b/src/test/java/com/yau/digitalrmb/ApplicationContextTest.java
@@ -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);
}
}
diff --git a/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java
new file mode 100644
index 0000000..cdac355
--- /dev/null
+++ b/src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java
@@ -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 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");
+ }
+}
diff --git a/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java b/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java
deleted file mode 100644
index 9ce2f5a..0000000
--- a/src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java
+++ /dev/null
@@ -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();
- }
-}
diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java
deleted file mode 100644
index b5d010b..0000000
--- a/src/test/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidatorTest.java
+++ /dev/null
@@ -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 = ""
- + "t001"
- + "";
- String xxe = "]>"
- + "&xxe;";
-
- 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);
- }
-}
diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java
index d237664..467d2e3 100644
--- a/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java
+++ b/src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java
@@ -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 findByPlatformUserId(long platformUserId) {
- return platformUserId == 101L ? Optional.of(teacher) : Optional.empty();
- }
-
- @Override
- public Optional findBySchoolAccount(String schoolAccount) {
- return Optional.empty();
- }
-
- @Override
- public List 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 incomplete = new HashMap();
+ 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 headerClaims = new HashMap();
- headerClaims.put("alg", "HS256");
- headerClaims.put("typ", "JWT");
- Map payloadClaims = new HashMap();
- 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());
}
-
- 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 profile) throws Exception {
+ Map claims = new HashMap();
+ 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 completeProfile() {
+ Map profile = new HashMap();
+ 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 claims, Instant expiresAt) throws Exception {
+ claims.put("exp", expiresAt.getEpochSecond());
+ ObjectMapper mapper = new ObjectMapper();
+ Map header = new HashMap(); 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); }
}
diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java
deleted file mode 100644
index b6ef7c4..0000000
--- a/src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java
+++ /dev/null
@@ -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 {
- }
-}
diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java
deleted file mode 100644
index 353f32e..0000000
--- a/src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java
+++ /dev/null
@@ -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);
- }
-}
diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java
deleted file mode 100644
index 4c5b0bc..0000000
--- a/src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java
+++ /dev/null
@@ -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);
- }
- }
-}
diff --git a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java
index 4d7facd..635c24d 100644
--- a/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java
+++ b/src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java
@@ -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);
}
}
diff --git a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java
index c6dcb08..ab26f91 100644
--- a/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java
+++ b/src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java
@@ -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
diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java
index e5f7fd6..52eed24 100644
--- a/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java
+++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserAndLogoutTest.java
@@ -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());
diff --git a/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java b/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java
new file mode 100644
index 0000000..d9acd2a
--- /dev/null
+++ b/src/test/java/com/yau/digitalrmb/security/CurrentUserServiceTest.java
@@ -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");
+ }
+}
diff --git a/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java b/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java
deleted file mode 100644
index d3c687b..0000000
--- a/src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java
+++ /dev/null
@@ -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);
- }
-}