WIP: feat(education): complete Flyway migration and atomic submit #21
115
.claude/skills/flyway-postgresql/SKILL.md
Normal file
115
.claude/skills/flyway-postgresql/SKILL.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: flyway-postgresql
|
||||
description: Flyway PostgreSQL migrations for this project. Use when adding or changing database schema, indexes, constraints, required seed data, migration baselines, or Flyway configuration.
|
||||
---
|
||||
|
||||
# Flyway PostgreSQL migrations
|
||||
|
||||
Use a **forward-only** migration process. Treat `flyway_schema_history` as immutable release history.
|
||||
|
||||
## 1. Inspect the migration state
|
||||
|
||||
Before editing:
|
||||
|
||||
1. Read `CLAUDE.md` PostgreSQL and Flyway rules.
|
||||
2. Inspect `yudao-server/src/main/resources/application-{local,dev}.yaml` and `yudao-server/pom.xml` when configuration is involved.
|
||||
3. List every `db/migration` directory and versioned migration across active modules.
|
||||
4. Inspect the target table DO, Mapper, service use, and relevant PostgreSQL DDL.
|
||||
5. Check the working tree so existing uncommitted work is preserved.
|
||||
|
||||
**Complete when:** the active Flyway locations, baseline, highest migration version, affected database objects, and pending user changes are known.
|
||||
|
||||
## 2. Choose the migration branch
|
||||
|
||||
### New schema change
|
||||
|
||||
Create a new versioned SQL migration under:
|
||||
|
||||
```text
|
||||
<module>/src/main/resources/db/migration/<module>/
|
||||
```
|
||||
|
||||
Use the next unused project-wide version after `V4010`. Leave gaps of 10 for normal changes when practical:
|
||||
|
||||
```text
|
||||
V4020__add_student_progress.sql
|
||||
V4030__add_practice_report_index.sql
|
||||
```
|
||||
|
||||
### Fix an executed migration
|
||||
|
||||
Create a higher version that repairs or reverses the prior change. Preserve the executed file byte-for-byte.
|
||||
|
||||
### Existing database adoption
|
||||
|
||||
The current baseline is `4009`; `V4010__initialize_education_flyway.sql` is the first managed migration. Keep `baseline-on-migrate` only while existing environments are being adopted. After every existing environment has a baseline record, change it to `false` in a separate reviewed change.
|
||||
|
||||
### Configuration change
|
||||
|
||||
Flyway must target the dynamic datasource `master`, never `slave`. Keep these safeguards enabled:
|
||||
|
||||
```yaml
|
||||
validate-on-migrate: true
|
||||
clean-disabled: true
|
||||
out-of-order: false
|
||||
```
|
||||
|
||||
Allow `FLYWAY_URL`, `FLYWAY_USER`, and `FLYWAY_PASSWORD` to override master credentials.
|
||||
|
||||
**Complete when:** exactly one branch is selected and its version/configuration does not conflict with the current repository state.
|
||||
|
||||
## 3. Write PostgreSQL-native SQL
|
||||
|
||||
Follow these project conventions:
|
||||
|
||||
- Identity primary key: `BIGINT GENERATED BY DEFAULT AS IDENTITY`.
|
||||
- Time: `TIMESTAMP`; use `CURRENT_TIMESTAMP` for defaults.
|
||||
- Boolean: `BOOLEAN NOT NULL DEFAULT false` where appropriate.
|
||||
- Idempotency/upsert: `ON CONFLICT ... DO NOTHING` or `DO UPDATE SET ... EXCLUDED.column`.
|
||||
- Null fallback: `COALESCE`.
|
||||
- Date formatting: `TO_CHAR`; date parts: `EXTRACT`.
|
||||
- Bounded delete: delete by IDs selected in an ordered, limited subquery or CTE.
|
||||
- Add comments for business tables and non-obvious columns.
|
||||
- Add indexes from observed query and conflict targets, not speculation.
|
||||
- Required seed data must be deterministic and idempotent.
|
||||
- Put `CREATE INDEX CONCURRENTLY` in its own non-transactional migration; otherwise prefer transactional PostgreSQL DDL.
|
||||
|
||||
Keep verification queries in comments when useful. Put rollback notes in the change description or a separate operational document; production recovery is another forward migration.
|
||||
|
||||
**Complete when:** every affected object, data backfill, constraint, index, and application assumption is represented in PostgreSQL-native SQL.
|
||||
|
||||
## 4. Align application code
|
||||
|
||||
Update all affected DOs, Mappers, services, tests, and fixtures. Search Java annotations and MyBatis XML for stale column names and incompatible SQL. For a new identity/sequence-backed DO, follow the surrounding project’s `@TableId` and `@KeySequence` pattern.
|
||||
|
||||
**Complete when:** every code reference agrees with the post-migration schema and no active runtime SQL depends on the previous shape.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
Run, in order:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
mvn -pl yudao-server -am -DskipTests clean compile
|
||||
```
|
||||
|
||||
Confirm each migration is present under the owning module’s `target/classes/db/migration/...` after compilation. If an authorized disposable PostgreSQL database is available, run Flyway against it and inspect:
|
||||
|
||||
```sql
|
||||
SELECT installed_rank, version, description, script, checksum, success
|
||||
FROM flyway_schema_history
|
||||
ORDER BY installed_rank;
|
||||
```
|
||||
|
||||
Run focused tests for the affected module. Report any skipped database execution separately from compilation success.
|
||||
|
||||
**Complete when:** formatting and compilation pass, migration packaging is confirmed, focused tests pass or their exact blocker is reported, and any real-database migration status is stated truthfully.
|
||||
|
||||
## Release rules
|
||||
|
||||
- Version numbers are project-wide across every Flyway location.
|
||||
- One committed migration version has one immutable meaning.
|
||||
- Production migrations move forward; recovery is a higher version.
|
||||
- `clean` remains disabled.
|
||||
- Demo/test seed data lives outside production migrations.
|
||||
- Do not copy the legacy `sql/postgresql/ruoyi-vue-pro.sql` dump into a versioned runtime migration; it contains destructive bootstrap statements and embedded transactions. Use it only to initialize a disposable empty database or to establish the pre-Flyway baseline.
|
||||
88
CLAUDE.md
Normal file
88
CLAUDE.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# CLAUDE.md — 恭学教育
|
||||
|
||||
## 项目定位
|
||||
|
||||
恭学教育是基于 RuoYi-Vue-Pro 的教育 SaaS 平台,后端技术栈为 Spring Boot 4、MyBatis-Plus、PostgreSQL、Redis。
|
||||
|
||||
主要开发入口:
|
||||
|
||||
- `yudao-module-education/`:教育业务模块
|
||||
- `yudao-server/`:应用启动模块,默认端口 `48080`
|
||||
- `sql/postgresql/`:PostgreSQL 初始化与历史 SQL
|
||||
- `tools/education-student-harness/`:学生端 Playwright E2E 测试
|
||||
|
||||
分支、工作区状态、运行中的容器和临时任务属于动态信息;执行任务时从 Git、配置文件和运行环境读取,不在本文档固化。
|
||||
|
||||
## 工作方式
|
||||
|
||||
### Serena 优先
|
||||
|
||||
编码任务开始前调用 Serena `initial_instructions` 并激活项目。优先使用 Serena 完成符号检索、引用分析和结构化编辑;Serena 不可用或不适合时再使用通用文件与命令工具。互不依赖的查询或编辑应批量调用。
|
||||
|
||||
### 遵循现有代码
|
||||
|
||||
- 修改前检查工作区,保留用户已有的未提交修改。
|
||||
- 代码风格、命名、注释密度和分层方式与相邻代码保持一致。
|
||||
- 优先复用框架现有能力,避免为单一场景建立平行抽象。
|
||||
- 只修改当前任务需要的文件;发现相邻问题时先判断是否影响本次交付。
|
||||
|
||||
### Gitea 与仓库操作
|
||||
|
||||
项目托管在自建 Gitea。远程仓库、Issue 和 Pull Request 操作统一使用 `tea` CLI,不使用 GitHub CLI(`gh`)。执行创建或查看 Pull Request 等操作前,从当前 Git remote 与 `tea` 登录配置读取仓库和实例信息。
|
||||
|
||||
### Skills
|
||||
|
||||
项目级 Skills 位于 `.claude/skills/`,已有规范索引见 `.claude/skills/index.yaml`。
|
||||
|
||||
数据库结构、索引、约束、数据回填、必要种子数据、基线或 Flyway 配置发生变化时,使用项目 Skill:
|
||||
|
||||
```text
|
||||
/flyway-postgresql
|
||||
```
|
||||
|
||||
Flyway 的版本分配、接管策略、验证步骤以 `.claude/skills/flyway-postgresql/SKILL.md` 为唯一事实来源。
|
||||
|
||||
## 架构约束
|
||||
|
||||
### Education 模块
|
||||
|
||||
- `yudao.education.catalog-mode` 控制目录数据源:
|
||||
- `SCALAR_READ`:通过 `ScalarCatalogProvider` 访问 HTTP 数据源。
|
||||
- `JAVA_READ`:通过 `JavaCatalogProvider` 直连 PostgreSQL。
|
||||
- `QuestionCatalogServiceImpl` 返回前端前必须剥离答案与解析等敏感字段。
|
||||
- 题目不可见或数据源不可用时采用 fail-closed,不进行静默降级。
|
||||
- 多租户业务 DO 继承 `TenantBaseDO`,由 MyBatis-Plus 注入 `tenant_id`。
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
项目运行数据库为 PostgreSQL。Java 注解 SQL、MyBatis XML、测试 SQL 和运行配置均使用 PostgreSQL 方言。
|
||||
|
||||
- 主键:`BIGINT GENERATED BY DEFAULT AS IDENTITY`。
|
||||
- 时间:`TIMESTAMP`,默认当前时间使用 `CURRENT_TIMESTAMP`。
|
||||
- 布尔:`BOOLEAN`,按需使用 `NOT NULL DEFAULT false`。
|
||||
- 幂等插入:`ON CONFLICT ... DO NOTHING`。
|
||||
- Upsert:`ON CONFLICT (...) DO UPDATE SET ... EXCLUDED.column`。
|
||||
- 空值兜底:`COALESCE`;时间格式化:`TO_CHAR`;日期字段提取:`EXTRACT`。
|
||||
- 有界删除使用有序、限量的主键子查询或 CTE。
|
||||
- DO 主键遵循项目既有的 `@TableId`、`@KeySequence("{table}_seq")` 模式。
|
||||
|
||||
### Flyway
|
||||
|
||||
运行时 migration 放在所属模块:
|
||||
|
||||
```text
|
||||
<module>/src/main/resources/db/migration/<module>/
|
||||
```
|
||||
|
||||
已在共享环境执行的 migration 是不可变发布历史。数据库修复通过更高版本的向前 migration 完成,生产环境保持 `clean-disabled: true`。
|
||||
|
||||
## 验证
|
||||
|
||||
按改动范围执行最小充分验证。后端主链路至少运行:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
mvn -pl yudao-server -am -DskipTests clean compile
|
||||
```
|
||||
|
||||
涉及行为变化时运行对应模块的聚焦测试;涉及数据库 migration 时还要确认脚本被打包到模块的 `target/classes/db/migration/`。只有实际执行过 PostgreSQL migration,才能报告数据库迁移成功;否则明确说明仅完成静态检查或编译验证。
|
||||
11
CONTEXT-MAP.md
Normal file
11
CONTEXT-MAP.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Context Map
|
||||
|
||||
## Contexts
|
||||
|
||||
- [Education](./yudao-module-education/CONTEXT.md) — owns education content, practice, assessment, and learning-state language.
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Education → Member**: Education references the authenticated Member user as the student identity; it does not own credentials or generic user accounts.
|
||||
- **Education → System**: Education consumes tenant and authorization capabilities; it does not own generic tenants or RBAC.
|
||||
- **Education → Infra**: Education composes file, job, messaging, and audit capabilities for education workflows.
|
||||
38
docs/education/migration/00-current-state.md
Normal file
38
docs/education/migration/00-current-state.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Current State
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
## Executive summary
|
||||
|
||||
Phase 0 remains a read-only architecture assessment, not an implementation claim. Verified evidence shows the target is on feature/education-core-loop with a heavily dirty worktree, while the source checkout has no local or remote feature/education-core-loop ref and is main at 033701a. The target contains a meaningful committed core-loop slice (ce02f8a) plus substantial dirty provider/catalog/session work, so all roadmap status must distinguish committed, dirty, absent, and runtime-unverified behavior. EDU-003 has now decided public tenant resolution: Origin/Referer are forgeable browser-context claims rather than trusted identity; headless lookup uses the constrained System-name Public Tenant Handle; successful lookup accepts tenant-existence disclosure while unknown/disabled/expired failures are identical; exact wire errors, canonical host-only websites, a secure-default local flag, and abuse controls are assigned to EDU-004. This is distinct from the already verified authenticated tenant mismatch rejection in TenantSecurityWebFilter; /education/context still requires EDU-004 Member/UserType enforcement. The most reliable core-loop slice remains provider-neutral fail-closed question content handling across the active default Scalar path, conditional Java path, safe catalog projection, and persisted session restoration. Core-loop schema is not proven to be in active Flyway: V4010 is a placeholder, V4020 is catalog-only, and practice/report/idempotency DDL is untracked manual SQL. Native reads are an intentional explicit-scope bypass requiring mapper audit, while schema foreign keys do not enforce tenant-consistent graphs. Phase 0 coverage must also add first-class Auth/Profile/extended Learning, granular tenant-admin, and granular platform-admin groups. Paid access, provider authority, option schema, public graph semantics, and source baseline remain product/architecture decisions.
|
||||
|
||||
## Verified program decisions
|
||||
|
||||
- Verified source provenance is limited: /Users/tiku1/code/tiku-backend has only main and origin/main at 033701a785c7012139e7f86995eea6041225592e; no local or remote feature/education-core-loop ref exists. Use main/033701a provisionally only, or obtain explicit approval for that baseline.
|
||||
- Verified target branch is feature/education-core-loop and its worktree is dirty. Current read-only inventory reports 65 modified tracked files and 97 untracked entries; preserve all, and do not rely on an older 21-untracked count.
|
||||
- Classify target behavior as committed-and-tested, committed-but-not-runtime-verified, dirty/uncommitted, or absent before scheduling work. ce02f8a is committed core-loop evidence; native provider/catalog and much of the schema are dirty.
|
||||
- Verified V4010 is SELECT 1 and V4020 is native catalog only. Practice/report/idempotency/wrong/favorite DDL in sql/postgresql/education is untracked/manual and not proven active Flyway. Convert required DDL to immutable module-owned PostgreSQL Flyway migrations before claiming schema delivery; never modify published migrations.
|
||||
- Keep PostgreSQL/Flyway as the only new schema delivery mechanism. Historical MySQL files and root SQL are not active delivery unless explicitly labeled archival/manual and removed from operational runbooks.
|
||||
- Treat public tenant resolution origin-binding absence as a P0 correction, not merely a richer-legacy gap. Separately acknowledge that TenantSecurityWebFilter already rejects authenticated tenant/header mismatch; the remaining principal issue is missing Member/UserType enforcement in /education/context.
|
||||
- Treat hostname port handling as a verified internal contradiction requiring alignment across implementation, properties, API documentation, System lookup normalization, and tests.
|
||||
- Treat native catalog isolation as an intentional TenantUtils.executeIgnore/manual-scope boundary, not evidence of a current leak. Make mapper audit and tenant/scope-consistent graph constraints concrete blockers before authoring.
|
||||
- Make the first slice provider-neutral or cover both providers because SCALAR_READ is the verified default and Java provider is conditional. The slice must include fresh browsing and persisted session restoration, with a common option-schema contract and fail-closed behavior.
|
||||
- Do not treat submit idempotency as complete: check-then-insert is not an atomic claim. Reserve keys atomically and define crash recovery before the submit slice.
|
||||
- Add first-class Auth/Profile/extended Learning, tenant appearance/integrations/secrets/codes, and granular platform-admin capability groups so every required legacy cluster has a disposition.
|
||||
- Do not expose paid/private practice until entitlement semantics and public target contracts are decided.
|
||||
- No tests, builds, PostgreSQL connections, Flyway execution, or runtime verification were performed; all conclusions are static repository evidence unless explicitly marked otherwise.
|
||||
|
||||
## Unknowns
|
||||
|
||||
- EDU-003 decided the public resolver threat model and contract. Browser headers are forgeable context claims; a constrained Public Tenant Handle supports headless clients; success discloses tenant existence; unknown/disabled/expired failures are identical; exact errors, canonical websites, local activation, Member-only context, and abuse controls are assigned to EDU-004.
|
||||
- Whether a future System-owned immutable Tenant Code or authenticated/signed locator is required beyond the accepted public-handle contract.
|
||||
- The valid option schema for each question type, including whether absent options are legal; whether malformed published content is omitted or produces a controlled source failure.
|
||||
- Whether PUBLIC tenant_id=0 rows may reference only PUBLIC parents, whether tenant-owned rows may reference global rows, and the precise composite constraint/trigger strategy.
|
||||
- Whether untracked /Users/tiku1/code/ruoyi-vue-pro/sql/postgresql/education files are intended for promotion into Flyway or are design/manual artifacts.
|
||||
- Whether V4010/V4020 or any manual core-loop DDL has ever run successfully in PostgreSQL; no runtime migration evidence exists.
|
||||
- Whether target test H2 MODE=MYSQL is test-only and compatible with PostgreSQL-only delivery.
|
||||
- Which Auth/Profile/extended Learning semantics are replaced by Member/System/Infra versus Education-owned, including vocabulary, leaderboard, stats, trend, feedback, exam dates, notifications, points, and badges.
|
||||
- Whether tenant appearance, domains, payment accounts, auth providers, secrets, activation codes, coupons, integrations, marketing, public-bank grants, and sync are in scope or explicitly retired.
|
||||
- Whether legacy assets are migrated, re-uploaded, re-scanned, or retired, and who owns ClamAV/scanner integration.
|
||||
- Which legacy RLS, triggers, functions, grants, seeds, queue leases, retry behavior, and operational semantics are contractual and need Java/constraint/event/job reproduction.
|
||||
- Whether the ten required Phase 0 artifacts must be committed files or may remain in reviewed scratch form during discovery.
|
||||
17
docs/education/migration/01-capability-matrix.md
Normal file
17
docs/education/migration/01-capability-matrix.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Capability Migration Matrix
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
Statuses are restricted to the Goal vocabulary. Evidence marked as verified is static repository evidence.
|
||||
|
||||
| Legacy capability | Legacy code location | Legacy database objects | Business value | Target module | Existing capability to reuse | Education gap | Other-module change | Priority | Risk | Verification | Status | Evidence | Open decision |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Tenant resolution, identity, and student context | /Users/tiku1/code/tiku-backend/apps/api/src/nest/auth.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/features/tenant/locator.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/features/tenant/resolver.ts | System tenant and website/domain records are authoritative.<br>Legacy tenant, domain, branding, identity, membership, and RLS objects are reference-only and must not be copied mechanically. | Resolves the pre-login tenant and authenticated student context needed to route users into the correct education tenant without trusting client identity. | System + Member + framework tenant support with an Education context adapter | System TenantCommonApi/TenantApiImpl, TenantContextHolder, TenantSecurityWebFilter, Member/System authentication and UserTypeEnum, framework tenant injection and tenant-ignore only for explicitly authorized platform operations. | Verified: EducationTenantController currently accepts caller-supplied hostname or tenantName under @PermitAll. EDU-003 establishes that Origin/Referer are forgeable browser-context claims rather than trusted identity, success discloses available-tenant existence, and legacy tenantName is unsupported. Verified: TenantSecurityWebFilter already rejects authenticated LoginUser/request-tenant mismatches and requires a tenant on non-ignored URLs. Verified: EducationContextController checks login presence and tenant validity but not LoginUser.userType. Verified: hostname documentation says no port while implementation preserves legal ports and tests expect port preservation. Decision: EDU-004 must implement the exact Public Tenant Handle, canonical website, secure local flag, wire-error, Member-principal, redaction, and abuse-control contract. | System/framework tenant and security boundaries remain authoritative; Education adapts them. EDU-003 retained generic System lookup APIs and selected Member-only context enforcement. | P0 | High: wrong pre-login tenant selection, tenant discovery, or treating an admin ID as a Member ID can cross security boundaries. | Add exact Origin/Referer claim, forged-header threat-model, requested-host conflict, explicit handle, legacy tenantName rejection, lifecycle-indistinguishability, canonical website, local-flag, normalization, anonymous, missing-tenant, mismatch, Member/admin-principal, redaction, and abuse-control tests. Static audit is not runtime proof. | partially migrated | Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantController.java:51-63,109-132,159-177.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java:66-105.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/EducationContextController.java:43-57.<br>Verified source baseline /Users/tiku1/code/tiku-backend/apps/api/src/features/tenant/locator.ts:90-151 requires production origin/request-host checks.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantResolveIntegrationTest.java:80-89. | EDU-003 accepted: Origin/Referer are forgeable browser-context claims; headless lookup uses the constrained System-name Public Tenant Handle; successful lookup discloses existence while unknown/disabled/expired are indistinguishable; host identity and canonical stored websites are host-only; local fallback has a secure-default flag; context is Member-only. Future signed locator or immutable System Tenant Code remains optional later scope. Source feature ref remains unavailable. |
|
||||
| Student core learning loop | /Users/tiku1/code/tiku-backend/apps/api/src/nest/learning.module.ts:23-113<br>/Users/tiku1/code/tiku-backend/apps/api/src/features/learning/use-cases.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/features/learning/access.ts | Education catalog and practice/report/idempotency/wrong-question/favorite tables.<br>Verified: native catalog V4020 is a dirty module resource; practice/report/idempotency DDL is currently in untracked sql/postgresql/education files rather than proven active Flyway history. | Provides the student loop from published catalog browsing through practice creation, answer saving, restore, submission, report, wrong questions, and favorites. | Education | Education provider/adapter boundary, Member/System identity, database uniqueness and transactions, framework locks/idempotency only as supplements—not replacements—for atomic database claims. | Verified: commit ce02f8a contains committed access/core controllers, safe projections, and focused tests, while native provider/catalog and additional core-loop work are dirty; these statuses must be separated. Verified: native and Scalar providers disagree on malformed/absent options; QuestionCatalogService and SessionResponseAssembler can emit apparently valid empty options. Verified: submit idempotency performs check-then-insert rather than atomic initial reservation. Inference: core-loop completion and concurrency guarantees are not established. | Education owns education-domain state and orchestration; Member/System context is reused. Paid/private access remains blocked on an entitlement decision. | P0 | High: corrupt assessment content, mode-dependent behavior, duplicate state transitions, answer leakage, or unauthorized access. | Provider-neutral tests across Scalar and Java, browsing/collection/practice-create/restore safe projections, malformed/unavailable/unpublished fail-closed cases, cross-tenant cases, and PostgreSQL concurrent same-key/different-key submit tests. | partially migrated | Verified ce02f8a, target repository, for committed EducationAccessService, core controllers/services, projections, and HTTP/service tests.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java:34-36 defaults to SCALAR_READ.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/integration/scalar/config/ScalarAutoConfiguration.java:30-35 selects Scalar for SCALAR_READ/missing mode.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/provider/JavaCatalogProvider.java:397-417, ScalarCatalogProvider.java:736-751, QuestionCatalogServiceImpl.java:182-202, SessionResponseAssembler.java:69-89.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java:294-307,397-409.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/sql/postgresql/education/009-education-idempotency-unified.sql:81, but no execution evidence. | Select the pilot-authoritative provider or require a provider-neutral contract; define valid option structure by question type and absent-option semantics; define atomic submit claim/crash recovery; decide entitlement contract before paid/private practice. |
|
||||
| Education catalog and question content | /Users/tiku1/code/tiku-backend/apps/api/src/nest/catalog.module.ts:74-138<br>/Users/tiku1/code/tiku-backend/apps/nest/tenant-content.module.ts | V4020 native catalog tables for regions, schools, majors, subjects, categories, banks, questions, versions, content, collections, blueprints, and bindings.<br>Legacy catalog/question/content/asset tables, constraints, functions, triggers, grants, and RLS are reference objects requiring semantic mapping. | Supplies reusable published catalog, question, classification, and content reads for student and future admin workflows. | Education | Education Provider boundary, explicit CatalogScopeQuery, framework tenant context, Infra File public API for future assets. | Verified: current native reads intentionally run inside TenantUtils.executeIgnore and apply explicit scope predicates; this is a controlled manual-isolation boundary, not proof of a current leak. Verified: V4020 uses ordinary single-column foreign keys, so tenant-owned/public graph consistency is not enforced. Inference: every mapper needs audit and content admission needs composite constraints or equivalent enforcement. | Education owns domain reads; Infra File may later provide asset transport. No provider expansion should occur before provider authority and graph-integrity rules are decided. | P0 | High if manual scope is bypassed or invalid cross-tenant/public relationships are admitted. | Inventory every mapper, provider contract tests, invalid graph insert tests, malformed/unpublished tests, PostgreSQL Flyway syntax/resource-packaging checks, and runtime migration evidence only when executed. | partially migrated | Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/provider/JavaCatalogProvider.java:82-89.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/catalog/CatalogScopeQuery.java:12-20.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/resources/db/migration/education/V4020__create_native_catalog.sql:38-123,133-152,160-265,324-386.<br>Verified /Users/tiku1/code/tiku-backend/supabase/migrations/202606210008_content_navigation_practice.sql:3-178. | Choose Scalar-only, native PostgreSQL, or explicit coexistence; define tenant_id=0 PUBLIC graph semantics and composite-key strategy; decide whether source RLS/functions/triggers are contractual. |
|
||||
| Auth, student profile, and extended learning | /Users/tiku1/code/tiku-backend/apps/api/src/nest/auth.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/profile.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/learning.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/features/profile/ | Legacy auth/session/verification/OAuth/phone-binding objects.<br>Legacy profile/check-in/points/tasks/exchange/notifications/badges/feedback/exam-countdown objects.<br>Legacy leaderboard/history/report/stats/trend/vocabulary progress/review/favorites/stats objects. | Covers broader student learning and profile experiences beyond the core loop, preserving discoverable legacy behavior and its disposition. | Member + System + Education, with Infra composition | Member/System auth and profile primitives, System/Infra notifications, Member points/levels where semantics match, Education-specific projections and authorization. | Verified source inventory shows these are distinct required Phase 0 domains, not merely generic context or secondary engagement. Target ownership and compatibility are not established. Inference: the definition-of-done is unsupported until each endpoint/state family is classified. | Member/System own authentication and generic membership; Education owns education-specific profile/progress projections. System/Infra may own notifications, while product owners must decide points, badges, feedback, exams, and vocabulary ownership. | P1 | High for auth compatibility and medium for omitted student progress/profile behavior. | Endpoint/API mapping, principal and tenant tests, profile redaction, progress/report compatibility, vocabulary state transitions, and explicit retired/product-decision checks. | pending migration | Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/auth.module.ts:31-56.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/profile.module.ts:38-87.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/learning.module.ts:23-60,70-113.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/docs/education/migration/GOAL.md:126-141,393-405. | For every Auth/Profile/extended Learning family, assign Member/System/Education/Infra ownership, compatibility requirement, data disposition, and phase; decide vocabulary, analytics, feedback, exam dates, notifications, points, and badges. |
|
||||
| Tenant education operations, appearance, integrations, secrets, and codes | /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-classes.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-appearance.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-integrations.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-secrets.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-codes.module.ts | Legacy classes, student relationships, supervision, roles/configuration, branding/settings/themes, domains, payment accounts, auth-provider configuration, tenant secrets, activation codes, coupons/redemptions, integrations, and marketing objects. | Enables tenant administrators to operate education organizations while preserving separate security and ownership boundaries. | Education + System + Member + Mall/Pay + Infra | System Tenant/RBAC/DataPermission/AdminUserApi, Member relationships, Mall/Pay/Member APIs, Infra secret/file/message/audit facilities. | Verified: classes/supervision were only part of the source tenant-admin surface. Appearance/theme lifecycle, domains/payment/auth integrations, secret rotation, and codes/coupons are separate migration/security surfaces with no verified target equivalent. Inference: collapsing them into one row would hide authorization and secret-handling decisions. | System RBAC/DataPermission and tenant configuration are reused; Mall/Pay/Member own commercial primitives; Infra owns secrets/messaging/files where applicable; Education owns only domain relationships and configuration extensions. | P1 | High: admin scope, secret leakage, payment configuration, and code redemption errors. | Permission matrix, row-scope negatives, secret redaction/rotation, integration authorization, code/coupon idempotency, audit, and cross-tenant tests. | pending migration | Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-appearance.module.ts:17-41.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-integrations.module.ts:18-42.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-secrets.module.ts:13-21.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-codes.module.ts:12-31.<br>Verified /Users/tiku1/code/tiku-backend/supabase/migrations/202606290002_tenant_classes.sql.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-spring-boot-starter-biz-data-permission/src/main/java/cn/iocoder/yudao/framework/datapermission/core/annotation/DataPermission.java:12-32. | Define class/student/teacher scope semantics and separately decide appearance, domain, payment/auth integration, secret, activation-code, coupon, public-bank grant, and marketing ownership or retirement. |
|
||||
| Platform administration and governance | /Users/tiku1/code/tiku-backend/apps/api/src/nest/platform-admin-overview.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/platform-admin-permissions.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/platform-admin-*.module.ts | Legacy platform staff, tenant lifecycle/billing profiles, public question-bank grant/sync, SaaS plan/invoice/usage/overage/dunning, audit/export/alert/notification-channel, and permission objects. | Provides platform staff and governance over tenants, staff lifecycle, public-bank grants, SaaS plans, billing, usage, dunning, audits, alerts, and permissions. | System + Pay + Mall + Infra + CRM with Education extensions | System RBAC/DataPermission/AdminUserApi, authorized tenant-ignore mechanisms, Pay/Mall/Infra/CRM public APIs, audit/logging. | Verified source surface is broader than one aggregated platform-admin row. Target seams exist, but object-level ownership, data scopes, and cross-tenant operation policy remain incomplete. | System, Pay, Mall, Infra, CRM, and Education-specific extension permissions; Education must not duplicate platform ledgers or generic administration. | P1 | High access-control and financial-governance risk. | Permission matrix, platform-admin integration, cross-tenant negative, audit-redaction, billing/usage reconciliation, and alert/export tests. | pending migration | Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/platform-admin-overview.module.ts.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/platform-admin-permissions.module.ts.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/permission/PermissionApi.java:12-20.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-spring-boot-starter-security/src/main/java/cn/iocoder/yudao/framework/security/core/service/SecurityFrameworkService.java:7-57. | Define separate Student App, Tenant Admin, Platform Admin, public, and internal policies; map each platform surface to System/Pay/Mall/Infra/CRM/Education or explicit retirement. |
|
||||
| Commercialization and growth | /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-orders.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-payments.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-reconciliation.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/referral-growth.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/referral-crm.module.ts | Legacy product/order/payment/event/entitlement/coupon/refund/reconciliation/commission/referral/points/dunning objects.<br>Target Mall/Pay/Member tables remain authoritative; Education may add minimal binding records. | Supports paid products, fulfillment, entitlements, refunds, reconciliation, commissions, referrals, and CRM conversion without recreating platform ledgers. | Mall + Pay + Member + CRM with Education binding | Mall/Pay DTO APIs, Member identity/entitlement/points, CRM services, Infra Job/MQ/audit. | Verified target Pay/Mall APIs expose core seams, but scoped entitlement issuance/revocation, activation codes, reconciliation, commissions, dunning, and referral semantics are not proven. One prior evidence path was malformed; corrected source location is /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-reconciliation.module.ts. | Mall Trade/Product, Pay, Member entitlement/points, CRM, Infra jobs/events/audit; Education owns product-to-education bindings and fulfillment orchestration only. | P2 | High financial and authorization risk. | Callback/idempotency/amount/refund, entitlement lifecycle, reconciliation, and education fulfillment contract tests. | product decision required | Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/order/PayOrderApi.java:13-38.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/refund/PayRefundApi.java:12-30.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-mall/yudao-module-trade-api/src/main/java/cn/iocoder/yudao/module/trade/api/order/TradeOrderApi.java:12-38.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-orders.module.ts:15-70 and /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-reconciliation.module.ts.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/docs/education/migration/GOAL.md:36-40,226-228. | Choose entitlement/activation-code/coupon model and confirm issuance, revocation, callback, refund, reconciliation, commission, and referral contracts before paid practice. |
|
||||
| Background processing, assets, and operational platform | /Users/tiku1/code/tiku-backend/apps/worker/src/worker-jobs.ts<br>/Users/tiku1/code/tiku-backend/apps/worker/src/jobs/imports.ts<br>/Users/tiku1/code/tiku-backend/apps/worker/src/jobs/exports.ts<br>/Users/tiku1/code/tiku-backend/apps/asset-scanner/src/ | Legacy worker queues, leases, retries/dead letters, imports/exports, reconciliation, notification/audit, usage, and security scan state.<br>Target owns business state in domain modules and uses platform execution primitives; do not copy queue tables wholesale. | Preserves operational reliability for imports, exports, payments, CRM, scanning, notifications, retries, and audit while removing dependence on NestJS workers. | Infra platform plus owning domain modules | Infra Job, Redis MQ, File, locks, idempotency, logging, tracing, Excel utilities, tenant propagation. | Verified target primitives exist, but durable claim/lease/heartbeat/retry and malware-scanner equivalence are not proven. Education import/export business state is absent or not verified. | Infra Job/MQ/File/logging/observability plus owning Education/Pay/Mall/CRM handlers; scanner deployment or adapter ownership must be decided. | P1 | High operational and security risk. | Concurrent claim/lease/recovery, retries/dead letters, scan fail-closed, file access, tenant propagation, audit, and deployment smoke tests. | partially migrated | Verified /Users/tiku1/code/tiku-backend/apps/worker/src/worker-jobs.ts:24-220 and /Users/tiku1/code/tiku-backend/apps/worker/src/jobs/imports.ts:87-260.<br>Verified /Users/tiku1/code/tiku-backend/apps/asset-scanner/src/scanner.service.ts:23-50.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-spring-boot-starter-mq/src/main/java/cn/iocoder/yudao/framework/mq/redis/core/RedisMQTemplate.java.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/core/handler/JobHandler.java. | Confirm Infra claim/lease semantics and scanner ownership, file privacy/retention, legacy asset migration/re-scan, and duplicate-safe at-least-once processing. |
|
||||
| Secondary learning, media, AI, and engagement | /Users/tiku1/code/tiku-backend/apps/api/src/nest/scoreline.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/video.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/nest/ai.module.ts<br>/Users/tiku1/code/tiku-backend/apps/api/src/features/profile/ | Legacy scoreline, vocabulary, handbook, video entitlement/progress, recommendation, notification, badge, exam-date, and analytics objects. | Delivers selected recommendation, scoreline, vocabulary, handbook, video, AI, notification, badge, exam-date, and engagement experiences after ownership and priority are explicit. | Education plus AI/Infra/Member/System | AI services, Infra File/notifications, Member points/levels, Education authorization/projections. | Verified legacy capabilities exist, but target equivalence and priority are not established. These cannot remain an undifferentiated P3 bucket if Phase 0 must give every capability a disposition. | AI, Infra File/messaging, Member growth primitives, System notifications, and Education extensions. | P3 | Medium-to-high due to entitlement, media access, sensitive reporting, and unclear scope. | Per-capability contract, authorization, entitlement, export/redaction, and migration compatibility tests. | product decision required | Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/scoreline.module.ts:207-226.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/video.module.ts:431-464.<br>Verified /Users/tiku1/code/tiku-backend/apps/api/src/nest/ai.module.ts:120-139.<br>Verified /Users/tiku1/code/tiku-backend/supabase/migrations/202606210009_content_import_vocabulary_handbook.sql.<br>Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/service/chat/AiChatMessageService.java. | For each capability, assign Education, existing platform ownership, explicit retirement, or later product scope; decide entitlement and safe export/redaction requirements. |
|
||||
110
docs/education/migration/02-api-mapping.md
Normal file
110
docs/education/migration/02-api-mapping.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Legacy API Mapping
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
This Phase 0 artifact maps API families rather than all 342 operations. Endpoint-level method/path/request/response mapping remains required before implementing each family.
|
||||
|
||||
## Tenant resolution, identity, and student context
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/api/src/nest/auth.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/features/tenant/locator.ts, /Users/tiku1/code/tiku-backend/apps/api/src/features/tenant/resolver.ts
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** System + Member + framework tenant support with an Education context adapter
|
||||
- **Reuse:** System TenantCommonApi/TenantApiImpl, TenantContextHolder, TenantSecurityWebFilter, Member/System authentication and UserTypeEnum, framework tenant injection and tenant-ignore only for explicitly authorized platform operations.
|
||||
- **Migration conclusion:** decision complete; implementation pending EDU-004
|
||||
- **Selected contract:** Public inputs are **Tenant Locator Claims**, not authenticated identity. Production browser routing derives a host-only claim from valid HTTP(S) `Origin`, falling back to `Referer`; both are forgeable by non-browser callers and provide browser UX consistency only. A supplied hostname may confirm that claim, and disagreement is a conflict. Headless clients use `tenantHandle`, explicitly defined as the target's unique System tenant `name` under a case-sensitive constrained public contract because no distinct stable Tenant Code exists. Legacy public `tenantName` is rejected rather than silently aliased.
|
||||
- **Threat model:** Successful resolution returns tenant ID/display name and therefore permits available-tenant existence probing. Only unknown, disabled, and expired tenants are indistinguishable. Reuse public throttling/ingress controls and structured abuse metrics; a deployment requiring spoof resistance needs a future authenticated/signed locator, not trust in Origin/Referer or forwarding-header controls.
|
||||
- **Normalization and compatibility:** Host identity is lowercase, trimmed, trailing-dot-free, bracket-free for IPv6, and independent of all ports. DNS hosts, IPv4, and IPv6 are accepted when valid; credentials, paths, multi-value input, malformed authorities, and unsupported schemes are rejected. Canonical `system_tenant.websites` values for this resolver are host-only. Scheme/path/port-bearing stored values do not silently normalize or match; configuration correction is required, or a separate Flyway/data ticket if automated correction is later approved.
|
||||
- **Local activation:** Only `yudao.education.tenant-resolution.local-development-enabled=true` enables local/request-host fallback; default and absence are false, and profiles are not authoritative. With the flag true, code-less configured local-host resolution is allowed and an explicit handle takes precedence.
|
||||
- **Authenticated context:** `/education/context` accepts only a Student Principal: an authenticated `LoginUser` with `userType == UserTypeEnum.MEMBER`. User and tenant IDs remain security/tenant-context derived. `TenantSecurityWebFilter` already fills a missing tenant from the authenticated principal, rejects principal/request-tenant mismatch, requires a tenant for non-ignored URLs, and validates tenant availability; EDU-004 preserves rather than duplicates these checks.
|
||||
- **Ownership and seam:** Login-method metadata belongs to Member authentication, not System tenant metadata or Education. EDU-004 removes/deprecates Education `loginMethods` unless a minimal Member-owned interface is proven necessary. Retain generic System-owned `TenantCommonApi`; make lookup methods required and add focused `TenantApiImpl` contract tests. `EducationTenantController` remains the public claim-consistency/redaction adapter.
|
||||
- **Exact public wire contract:** Framework business responses remain HTTP 200. Invalid/malformed/missing/local-forbidden claim is code `1005001003`, message `租户识别请求无效`, null data. Domain/handle or requested-host conflict is code `1005001008`, message `租户识别信息冲突`, null data. Unknown/disabled/expired is code `1005001004`, message `当前租户不可用`, null data, with identical shape. Success is code 0 and data contains only `tenantId` and `displayName`; never expose or echo handle, websites, expiry, package, status, private config, or login methods.
|
||||
- **Required EDU-004 verification:** Education HTTP tests cover Origin resolution, Referer fallback, forged-header threat-model naming, malformed Origin, Origin/requested-host conflict, untrusted arbitrary hostname, explicit handle, legacy tenantName rejection, unavailable-state exact wire equivalence, host normalization, canonical/non-canonical website behavior, local flag false/true and precedence, domain/handle conflict, anonymous/ADMIN/MEMBER context, redaction, and abuse-control attachment/metrics where a reusable seam exists. System owns adapter contract tests; framework owns existing missing-tenant and authenticated mismatch tests.
|
||||
- **Decision evidence:** [`issues/EDU-003-tenant-resolution-decision.md`](issues/EDU-003-tenant-resolution-decision.md). Static only; no production or database change was made.
|
||||
|
||||
## Student core learning loop
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/api/src/nest/learning.module.ts:23-113, /Users/tiku1/code/tiku-backend/apps/api/src/features/learning/use-cases.ts, /Users/tiku1/code/tiku-backend/apps/api/src/features/learning/access.ts
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** Education
|
||||
- **Reuse:** Education provider/adapter boundary, Member/System identity, database uniqueness and transactions, framework locks/idempotency only as supplements—not replacements—for atomic database claims.
|
||||
- **Migration conclusion:** partially migrated
|
||||
- **Contract gap:** Verified: commit ce02f8a contains committed access/core controllers, safe projections, and focused tests, while native provider/catalog and additional core-loop work are dirty; these statuses must be separated. Verified: native and Scalar providers disagree on malformed/absent options; QuestionCatalogService and SessionResponseAssembler can emit apparently valid empty options. Verified: submit idempotency performs check-then-insert rather than atomic initial reservation. Inference: core-loop completion and concurrency guarantees are not established.
|
||||
- **Required verification:** Provider-neutral tests across Scalar and Java, browsing/collection/practice-create/restore safe projections, malformed/unavailable/unpublished fail-closed cases, cross-tenant cases, and PostgreSQL concurrent same-key/different-key submit tests.
|
||||
- **Open decision:** Select the pilot-authoritative provider or require a provider-neutral contract; define valid option structure by question type and absent-option semantics; define atomic submit claim/crash recovery; decide entitlement contract before paid/private practice.
|
||||
|
||||
## Education catalog and question content
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/api/src/nest/catalog.module.ts:74-138, /Users/tiku1/code/tiku-backend/apps/nest/tenant-content.module.ts
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** Education
|
||||
- **Reuse:** Education Provider boundary, explicit CatalogScopeQuery, framework tenant context, Infra File public API for future assets.
|
||||
- **Migration conclusion:** partially migrated
|
||||
- **Contract gap:** Verified: current native reads intentionally run inside TenantUtils.executeIgnore and apply explicit scope predicates; this is a controlled manual-isolation boundary, not proof of a current leak. Verified: V4020 uses ordinary single-column foreign keys, so tenant-owned/public graph consistency is not enforced. Inference: every mapper needs audit and content admission needs composite constraints or equivalent enforcement.
|
||||
- **Required verification:** Inventory every mapper, provider contract tests, invalid graph insert tests, malformed/unpublished tests, PostgreSQL Flyway syntax/resource-packaging checks, and runtime migration evidence only when executed.
|
||||
- **Open decision:** Choose Scalar-only, native PostgreSQL, or explicit coexistence; define tenant_id=0 PUBLIC graph semantics and composite-key strategy; decide whether source RLS/functions/triggers are contractual.
|
||||
|
||||
## Auth, student profile, and extended learning
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/api/src/nest/auth.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/profile.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/learning.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/features/profile/
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** Member + System + Education, with Infra composition
|
||||
- **Reuse:** Member/System auth and profile primitives, System/Infra notifications, Member points/levels where semantics match, Education-specific projections and authorization.
|
||||
- **Migration conclusion:** pending migration
|
||||
- **Contract gap:** Verified source inventory shows these are distinct required Phase 0 domains, not merely generic context or secondary engagement. Target ownership and compatibility are not established. Inference: the definition-of-done is unsupported until each endpoint/state family is classified.
|
||||
- **Required verification:** Endpoint/API mapping, principal and tenant tests, profile redaction, progress/report compatibility, vocabulary state transitions, and explicit retired/product-decision checks.
|
||||
- **Open decision:** For every Auth/Profile/extended Learning family, assign Member/System/Education/Infra ownership, compatibility requirement, data disposition, and phase; decide vocabulary, analytics, feedback, exam dates, notifications, points, and badges.
|
||||
|
||||
## Tenant education operations, appearance, integrations, secrets, and codes
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-classes.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-appearance.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-integrations.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-secrets.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/tenant-admin-codes.module.ts
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** Education + System + Member + Mall/Pay + Infra
|
||||
- **Reuse:** System Tenant/RBAC/DataPermission/AdminUserApi, Member relationships, Mall/Pay/Member APIs, Infra secret/file/message/audit facilities.
|
||||
- **Migration conclusion:** pending migration
|
||||
- **Contract gap:** Verified: classes/supervision were only part of the source tenant-admin surface. Appearance/theme lifecycle, domains/payment/auth integrations, secret rotation, and codes/coupons are separate migration/security surfaces with no verified target equivalent. Inference: collapsing them into one row would hide authorization and secret-handling decisions.
|
||||
- **Required verification:** Permission matrix, row-scope negatives, secret redaction/rotation, integration authorization, code/coupon idempotency, audit, and cross-tenant tests.
|
||||
- **Open decision:** Define class/student/teacher scope semantics and separately decide appearance, domain, payment/auth integration, secret, activation-code, coupon, public-bank grant, and marketing ownership or retirement.
|
||||
|
||||
## Platform administration and governance
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/api/src/nest/platform-admin-overview.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/platform-admin-permissions.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/platform-admin-*.module.ts
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** System + Pay + Mall + Infra + CRM with Education extensions
|
||||
- **Reuse:** System RBAC/DataPermission/AdminUserApi, authorized tenant-ignore mechanisms, Pay/Mall/Infra/CRM public APIs, audit/logging.
|
||||
- **Migration conclusion:** pending migration
|
||||
- **Contract gap:** Verified source surface is broader than one aggregated platform-admin row. Target seams exist, but object-level ownership, data scopes, and cross-tenant operation policy remain incomplete.
|
||||
- **Required verification:** Permission matrix, platform-admin integration, cross-tenant negative, audit-redaction, billing/usage reconciliation, and alert/export tests.
|
||||
- **Open decision:** Define separate Student App, Tenant Admin, Platform Admin, public, and internal policies; map each platform surface to System/Pay/Mall/Infra/CRM/Education or explicit retirement.
|
||||
|
||||
## Commercialization and growth
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-orders.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-payments.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-reconciliation.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/referral-growth.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/referral-crm.module.ts
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** Mall + Pay + Member + CRM with Education binding
|
||||
- **Reuse:** Mall/Pay DTO APIs, Member identity/entitlement/points, CRM services, Infra Job/MQ/audit.
|
||||
- **Migration conclusion:** product decision required
|
||||
- **Contract gap:** Verified target Pay/Mall APIs expose core seams, but scoped entitlement issuance/revocation, activation codes, reconciliation, commissions, dunning, and referral semantics are not proven. One prior evidence path was malformed; corrected source location is /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-reconciliation.module.ts.
|
||||
- **Required verification:** Callback/idempotency/amount/refund, entitlement lifecycle, reconciliation, and education fulfillment contract tests.
|
||||
- **Open decision:** Choose entitlement/activation-code/coupon model and confirm issuance, revocation, callback, refund, reconciliation, commission, and referral contracts before paid practice.
|
||||
|
||||
## Background processing, assets, and operational platform
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/worker/src/worker-jobs.ts, /Users/tiku1/code/tiku-backend/apps/worker/src/jobs/imports.ts, /Users/tiku1/code/tiku-backend/apps/worker/src/jobs/exports.ts, /Users/tiku1/code/tiku-backend/apps/asset-scanner/src/
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** Infra platform plus owning domain modules
|
||||
- **Reuse:** Infra Job, Redis MQ, File, locks, idempotency, logging, tracing, Excel utilities, tenant propagation.
|
||||
- **Migration conclusion:** partially migrated
|
||||
- **Contract gap:** Verified target primitives exist, but durable claim/lease/heartbeat/retry and malware-scanner equivalence are not proven. Education import/export business state is absent or not verified.
|
||||
- **Required verification:** Concurrent claim/lease/recovery, retries/dead letters, scan fail-closed, file access, tenant propagation, audit, and deployment smoke tests.
|
||||
- **Open decision:** Confirm Infra claim/lease semantics and scanner ownership, file privacy/retention, legacy asset migration/re-scan, and duplicate-safe at-least-once processing.
|
||||
|
||||
## Secondary learning, media, AI, and engagement
|
||||
|
||||
- **Legacy locations:** /Users/tiku1/code/tiku-backend/apps/api/src/nest/scoreline.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/video.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/nest/ai.module.ts, /Users/tiku1/code/tiku-backend/apps/api/src/features/profile/
|
||||
- **Legacy authorization semantics to preserve:** tenant boundary, principal type, visibility, idempotency, state transitions, and redaction as applicable.
|
||||
- **Target:** Education plus AI/Infra/Member/System
|
||||
- **Reuse:** AI services, Infra File/notifications, Member points/levels, Education authorization/projections.
|
||||
- **Migration conclusion:** product decision required
|
||||
- **Contract gap:** Verified legacy capabilities exist, but target equivalence and priority are not established. These cannot remain an undifferentiated P3 bucket if Phase 0 must give every capability a disposition.
|
||||
- **Required verification:** Per-capability contract, authorization, entitlement, export/redaction, and migration compatibility tests.
|
||||
- **Open decision:** For each capability, assign Education, existing platform ownership, explicit retirement, or later product scope; decide entitlement and safe export/redaction requirements.
|
||||
145
docs/education/migration/03-database-object-mapping.md
Normal file
145
docs/education/migration/03-database-object-mapping.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Database Object Mapping
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
## Global disposition rules
|
||||
|
||||
- Ordinary education tables and constraints become immutable module-owned PostgreSQL Flyway migrations.
|
||||
- Supabase RLS maps primarily to framework tenant isolation and application authorization, not copied RLS.
|
||||
- RPCs/functions map to Java services unless database execution is demonstrably the better boundary.
|
||||
- Triggers map to transactions, events, jobs, or audit facilities unless database enforcement is required.
|
||||
- Storage maps to Infra File; auth schema maps to Member/System.
|
||||
- Current manual SQL is not considered executed or active Flyway history without runtime evidence.
|
||||
|
||||
## Tenant resolution, identity, and student context
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- System tenant and website/domain records are authoritative.
|
||||
- Legacy tenant, domain, branding, identity, membership, and RLS objects are reference-only and must not be copied mechanically.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** System + Member + framework tenant support with an Education context adapter
|
||||
- **Current status:** partially migrated
|
||||
- **Required cross-module treatment:** System/framework tenant and security boundaries remain authoritative; Education adapts them. EDU-003 retains generic required `TenantCommonApi` lookup methods and assigns Member-only context enforcement to EDU-004.
|
||||
- **Risk:** High: the unauthenticated resolver permits available-tenant existence probing, while wrong authenticated principal/tenant handling can cross security boundaries.
|
||||
- **Accepted decision:** Browser headers are forgeable context claims; headless clients use the constrained System-name Public Tenant Handle; unknown/disabled/expired failures are identical. Canonical website values for Public Tenant Resolution are normalized host-only strings. Scheme/path/port-bearing stored values are not silently normalized and require configuration correction. If automated correction is later required, create a separate Flyway/data ticket and invoke `flyway-postgresql`; EDU-003/EDU-004 authorize no database change.
|
||||
|
||||
## Student core learning loop
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- Education catalog and practice/report/idempotency/wrong-question/favorite tables.
|
||||
- Verified: native catalog V4020 is a dirty module resource; practice/report/idempotency DDL is currently in untracked sql/postgresql/education files rather than proven active Flyway history.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** Education
|
||||
- **Current status:** partially migrated
|
||||
- **Required cross-module treatment:** Education owns education-domain state and orchestration; Member/System context is reused. Paid/private access remains blocked on an entitlement decision.
|
||||
- **Risk:** High: corrupt assessment content, mode-dependent behavior, duplicate state transitions, answer leakage, or unauthorized access.
|
||||
- **Decision still required:** Select the pilot-authoritative provider or require a provider-neutral contract; define valid option structure by question type and absent-option semantics; define atomic submit claim/crash recovery; decide entitlement contract before paid/private practice.
|
||||
|
||||
## Education catalog and question content
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- V4020 native catalog tables for regions, schools, majors, subjects, categories, banks, questions, versions, content, collections, blueprints, and bindings.
|
||||
- Legacy catalog/question/content/asset tables, constraints, functions, triggers, grants, and RLS are reference objects requiring semantic mapping.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** Education
|
||||
- **Current status:** partially migrated
|
||||
- **Required cross-module treatment:** Education owns domain reads; Infra File may later provide asset transport. No provider expansion should occur before provider authority and graph-integrity rules are decided.
|
||||
- **Risk:** High if manual scope is bypassed or invalid cross-tenant/public relationships are admitted.
|
||||
- **Decision still required:** Choose Scalar-only, native PostgreSQL, or explicit coexistence; define tenant_id=0 PUBLIC graph semantics and composite-key strategy; decide whether source RLS/functions/triggers are contractual.
|
||||
|
||||
## Auth, student profile, and extended learning
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- Legacy auth/session/verification/OAuth/phone-binding objects.
|
||||
- Legacy profile/check-in/points/tasks/exchange/notifications/badges/feedback/exam-countdown objects.
|
||||
- Legacy leaderboard/history/report/stats/trend/vocabulary progress/review/favorites/stats objects.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** Member + System + Education, with Infra composition
|
||||
- **Current status:** pending migration
|
||||
- **Required cross-module treatment:** Member/System own authentication and generic membership; Education owns education-specific profile/progress projections. System/Infra may own notifications, while product owners must decide points, badges, feedback, exams, and vocabulary ownership.
|
||||
- **Risk:** High for auth compatibility and medium for omitted student progress/profile behavior.
|
||||
- **Decision still required:** For every Auth/Profile/extended Learning family, assign Member/System/Education/Infra ownership, compatibility requirement, data disposition, and phase; decide vocabulary, analytics, feedback, exam dates, notifications, points, and badges.
|
||||
|
||||
## Tenant education operations, appearance, integrations, secrets, and codes
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- Legacy classes, student relationships, supervision, roles/configuration, branding/settings/themes, domains, payment accounts, auth-provider configuration, tenant secrets, activation codes, coupons/redemptions, integrations, and marketing objects.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** Education + System + Member + Mall/Pay + Infra
|
||||
- **Current status:** pending migration
|
||||
- **Required cross-module treatment:** System RBAC/DataPermission and tenant configuration are reused; Mall/Pay/Member own commercial primitives; Infra owns secrets/messaging/files where applicable; Education owns only domain relationships and configuration extensions.
|
||||
- **Risk:** High: admin scope, secret leakage, payment configuration, and code redemption errors.
|
||||
- **Decision still required:** Define class/student/teacher scope semantics and separately decide appearance, domain, payment/auth integration, secret, activation-code, coupon, public-bank grant, and marketing ownership or retirement.
|
||||
|
||||
## Platform administration and governance
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- Legacy platform staff, tenant lifecycle/billing profiles, public question-bank grant/sync, SaaS plan/invoice/usage/overage/dunning, audit/export/alert/notification-channel, and permission objects.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** System + Pay + Mall + Infra + CRM with Education extensions
|
||||
- **Current status:** pending migration
|
||||
- **Required cross-module treatment:** System, Pay, Mall, Infra, CRM, and Education-specific extension permissions; Education must not duplicate platform ledgers or generic administration.
|
||||
- **Risk:** High access-control and financial-governance risk.
|
||||
- **Decision still required:** Define separate Student App, Tenant Admin, Platform Admin, public, and internal policies; map each platform surface to System/Pay/Mall/Infra/CRM/Education or explicit retirement.
|
||||
|
||||
## Commercialization and growth
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- Legacy product/order/payment/event/entitlement/coupon/refund/reconciliation/commission/referral/points/dunning objects.
|
||||
- Target Mall/Pay/Member tables remain authoritative; Education may add minimal binding records.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** Mall + Pay + Member + CRM with Education binding
|
||||
- **Current status:** product decision required
|
||||
- **Required cross-module treatment:** Mall Trade/Product, Pay, Member entitlement/points, CRM, Infra jobs/events/audit; Education owns product-to-education bindings and fulfillment orchestration only.
|
||||
- **Risk:** High financial and authorization risk.
|
||||
- **Decision still required:** Choose entitlement/activation-code/coupon model and confirm issuance, revocation, callback, refund, reconciliation, commission, and referral contracts before paid practice.
|
||||
|
||||
## Background processing, assets, and operational platform
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- Legacy worker queues, leases, retries/dead letters, imports/exports, reconciliation, notification/audit, usage, and security scan state.
|
||||
- Target owns business state in domain modules and uses platform execution primitives; do not copy queue tables wholesale.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** Infra platform plus owning domain modules
|
||||
- **Current status:** partially migrated
|
||||
- **Required cross-module treatment:** Infra Job/MQ/File/logging/observability plus owning Education/Pay/Mall/CRM handlers; scanner deployment or adapter ownership must be decided.
|
||||
- **Risk:** High operational and security risk.
|
||||
- **Decision still required:** Confirm Infra claim/lease semantics and scanner ownership, file privacy/retention, legacy asset migration/re-scan, and duplicate-safe at-least-once processing.
|
||||
|
||||
## Secondary learning, media, AI, and engagement
|
||||
|
||||
### Source and target objects
|
||||
|
||||
- Legacy scoreline, vocabulary, handbook, video entitlement/progress, recommendation, notification, badge, exam-date, and analytics objects.
|
||||
|
||||
### Disposition
|
||||
|
||||
- **Target owner:** Education plus AI/Infra/Member/System
|
||||
- **Current status:** product decision required
|
||||
- **Required cross-module treatment:** AI, Infra File/messaging, Member growth primitives, System notifications, and Education extensions.
|
||||
- **Risk:** Medium-to-high due to entitlement, media access, sensitive reporting, and unclear scope.
|
||||
- **Decision still required:** For each capability, assign Education, existing platform ownership, explicit retirement, or later product scope; decide entitlement and safe export/redaction requirements.
|
||||
68
docs/education/migration/04-module-reuse-map.md
Normal file
68
docs/education/migration/04-module-reuse-map.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Module Reuse Map
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
Education must call public APIs, framework extension points, or events. It must not depend on another module's internal ServiceImpl, Mapper, or DO.
|
||||
|
||||
## Tenant resolution, identity, and student context
|
||||
|
||||
- **Target owner:** System + Member + framework tenant support with an Education context adapter
|
||||
- **Public/framework capability to reuse:** System TenantCommonApi/TenantApiImpl, TenantContextHolder, TenantSecurityWebFilter, Member/System authentication and UserTypeEnum, framework tenant injection and tenant-ignore only for explicitly authorized platform operations.
|
||||
- **Education-owned gap:** Verified: EducationTenantController accepts caller-supplied hostname or tenantName under @PermitAll and does not enforce the source resolver's production Origin/Referer/request-host binding. Verified: TenantSecurityWebFilter already rejects authenticated LoginUser/request-tenant mismatches and requires a tenant on non-ignored URLs. Verified: EducationContextController checks login presence and tenant validity but not LoginUser.userType. Verified: hostname documentation says no port while implementation preserves legal ports and tests expect port preservation. Inference: arbitrary tenant discovery, admin-principal acceptance, and port inconsistency are P0/P1 trust-boundary risks until policy is fixed.
|
||||
- **Allowed external-module change:** System/framework tenant and security boundaries remain authoritative; Education should only adapt them. A generic tenant lookup contract and possibly Member-only context policy require explicit decisions.
|
||||
|
||||
## Student core learning loop
|
||||
|
||||
- **Target owner:** Education
|
||||
- **Public/framework capability to reuse:** Education provider/adapter boundary, Member/System identity, database uniqueness and transactions, framework locks/idempotency only as supplements—not replacements—for atomic database claims.
|
||||
- **Education-owned gap:** Verified: commit ce02f8a contains committed access/core controllers, safe projections, and focused tests, while native provider/catalog and additional core-loop work are dirty; these statuses must be separated. Verified: native and Scalar providers disagree on malformed/absent options; QuestionCatalogService and SessionResponseAssembler can emit apparently valid empty options. Verified: submit idempotency performs check-then-insert rather than atomic initial reservation. Inference: core-loop completion and concurrency guarantees are not established.
|
||||
- **Allowed external-module change:** Education owns education-domain state and orchestration; Member/System context is reused. Paid/private access remains blocked on an entitlement decision.
|
||||
|
||||
## Education catalog and question content
|
||||
|
||||
- **Target owner:** Education
|
||||
- **Public/framework capability to reuse:** Education Provider boundary, explicit CatalogScopeQuery, framework tenant context, Infra File public API for future assets.
|
||||
- **Education-owned gap:** Verified: current native reads intentionally run inside TenantUtils.executeIgnore and apply explicit scope predicates; this is a controlled manual-isolation boundary, not proof of a current leak. Verified: V4020 uses ordinary single-column foreign keys, so tenant-owned/public graph consistency is not enforced. Inference: every mapper needs audit and content admission needs composite constraints or equivalent enforcement.
|
||||
- **Allowed external-module change:** Education owns domain reads; Infra File may later provide asset transport. No provider expansion should occur before provider authority and graph-integrity rules are decided.
|
||||
|
||||
## Auth, student profile, and extended learning
|
||||
|
||||
- **Target owner:** Member + System + Education, with Infra composition
|
||||
- **Public/framework capability to reuse:** Member/System auth and profile primitives, System/Infra notifications, Member points/levels where semantics match, Education-specific projections and authorization.
|
||||
- **Education-owned gap:** Verified source inventory shows these are distinct required Phase 0 domains, not merely generic context or secondary engagement. Target ownership and compatibility are not established. Inference: the definition-of-done is unsupported until each endpoint/state family is classified.
|
||||
- **Allowed external-module change:** Member/System own authentication and generic membership; Education owns education-specific profile/progress projections. System/Infra may own notifications, while product owners must decide points, badges, feedback, exams, and vocabulary ownership.
|
||||
|
||||
## Tenant education operations, appearance, integrations, secrets, and codes
|
||||
|
||||
- **Target owner:** Education + System + Member + Mall/Pay + Infra
|
||||
- **Public/framework capability to reuse:** System Tenant/RBAC/DataPermission/AdminUserApi, Member relationships, Mall/Pay/Member APIs, Infra secret/file/message/audit facilities.
|
||||
- **Education-owned gap:** Verified: classes/supervision were only part of the source tenant-admin surface. Appearance/theme lifecycle, domains/payment/auth integrations, secret rotation, and codes/coupons are separate migration/security surfaces with no verified target equivalent. Inference: collapsing them into one row would hide authorization and secret-handling decisions.
|
||||
- **Allowed external-module change:** System RBAC/DataPermission and tenant configuration are reused; Mall/Pay/Member own commercial primitives; Infra owns secrets/messaging/files where applicable; Education owns only domain relationships and configuration extensions.
|
||||
|
||||
## Platform administration and governance
|
||||
|
||||
- **Target owner:** System + Pay + Mall + Infra + CRM with Education extensions
|
||||
- **Public/framework capability to reuse:** System RBAC/DataPermission/AdminUserApi, authorized tenant-ignore mechanisms, Pay/Mall/Infra/CRM public APIs, audit/logging.
|
||||
- **Education-owned gap:** Verified source surface is broader than one aggregated platform-admin row. Target seams exist, but object-level ownership, data scopes, and cross-tenant operation policy remain incomplete.
|
||||
- **Allowed external-module change:** System, Pay, Mall, Infra, CRM, and Education-specific extension permissions; Education must not duplicate platform ledgers or generic administration.
|
||||
|
||||
## Commercialization and growth
|
||||
|
||||
- **Target owner:** Mall + Pay + Member + CRM with Education binding
|
||||
- **Public/framework capability to reuse:** Mall/Pay DTO APIs, Member identity/entitlement/points, CRM services, Infra Job/MQ/audit.
|
||||
- **Education-owned gap:** Verified target Pay/Mall APIs expose core seams, but scoped entitlement issuance/revocation, activation codes, reconciliation, commissions, dunning, and referral semantics are not proven. One prior evidence path was malformed; corrected source location is /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-reconciliation.module.ts.
|
||||
- **Allowed external-module change:** Mall Trade/Product, Pay, Member entitlement/points, CRM, Infra jobs/events/audit; Education owns product-to-education bindings and fulfillment orchestration only.
|
||||
|
||||
## Background processing, assets, and operational platform
|
||||
|
||||
- **Target owner:** Infra platform plus owning domain modules
|
||||
- **Public/framework capability to reuse:** Infra Job, Redis MQ, File, locks, idempotency, logging, tracing, Excel utilities, tenant propagation.
|
||||
- **Education-owned gap:** Verified target primitives exist, but durable claim/lease/heartbeat/retry and malware-scanner equivalence are not proven. Education import/export business state is absent or not verified.
|
||||
- **Allowed external-module change:** Infra Job/MQ/File/logging/observability plus owning Education/Pay/Mall/CRM handlers; scanner deployment or adapter ownership must be decided.
|
||||
|
||||
## Secondary learning, media, AI, and engagement
|
||||
|
||||
- **Target owner:** Education plus AI/Infra/Member/System
|
||||
- **Public/framework capability to reuse:** AI services, Infra File/notifications, Member points/levels, Education authorization/projections.
|
||||
- **Education-owned gap:** Verified legacy capabilities exist, but target equivalence and priority are not established. These cannot remain an undifferentiated P3 bucket if Phase 0 must give every capability a disposition.
|
||||
- **Allowed external-module change:** AI, Infra File/messaging, Member growth primitives, System notifications, and Education extensions.
|
||||
34
docs/education/migration/05-commit-review-11e9cc6.md
Normal file
34
docs/education/migration/05-commit-review-11e9cc6.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Commit Review: 11e9cc68547cf271b9d5de60bf41b3f71899d1db (11e9cc6), target repository
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
## Retain
|
||||
|
||||
- Education reactor registration, Member/server activation, Education server dependency, allocated error-code range, and XML correction unless later evidence disproves necessity.
|
||||
|
||||
## Adjust
|
||||
|
||||
- Treat historical MySQL schema/seed/rollback as archival or explicitly manual only; it is not a PostgreSQL/Flyway delivery path.
|
||||
- If global menu entries 6800/6801 remain required, replace them with an approved Education-owned PostgreSQL Flyway seed migration.
|
||||
- Remove operational documentation that invokes mysql or destructive rollback scripts; use forward correction and audited administrative procedures.
|
||||
|
||||
## Replace
|
||||
|
||||
- Required seed behavior with higher-version immutable PostgreSQL Flyway migration.
|
||||
|
||||
## Remove by forward correction
|
||||
|
||||
- README/runbook claims of MySQL active delivery and destructive rollback.
|
||||
- The MySQL menu seed as active delivery after approved Flyway replacement.
|
||||
|
||||
## Pending decisions
|
||||
|
||||
- Whether menu entries 6800/6801 remain product scope.
|
||||
- Whether historical sql/mysql/education artifacts remain labeled archival/manual material.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Verified /Users/tiku1/code/ruoyi-vue-pro/pom.xml:18-19.
|
||||
- Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-server/pom.xml:35-47.
|
||||
- Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/exception/enums/ServiceErrorCodeRange.java:31-46.
|
||||
- Verified /Users/tiku1/code/ruoyi-vue-pro/sql/mysql/education/000-education-schema.sql:1-5, 000-education-seed.sql:1-15, 000-education-rollback.sql:1-8.
|
||||
37
docs/education/migration/06-commit-review-0f846fd.md
Normal file
37
docs/education/migration/06-commit-review-0f846fd.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Commit Review: 0f846fdaf5377f00347b05b9950b53f92e4dc6df (0f846fd), target repository
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
## Retain
|
||||
|
||||
- TenantApiImpl public-service dependency, @PermitAll/@TenantIgnore pre-login route subject to security tests, and context derived from framework state rather than request tenant/user IDs.
|
||||
|
||||
## Adjust
|
||||
|
||||
- Retain TenantCommonApi/TenantApiImpl as a candidate generic lookup seam, but add contract tests and resolve DTO/null/serialization compatibility.
|
||||
- Replace unsupported default methods with abstract methods or a separate optional capability interface unless compatibility evidence requires them.
|
||||
- Make public resolution origin-binding, principal policy, port policy, and loginMethods ownership explicit.
|
||||
- Correct contradictory hostname documentation and test filter-chain/API behavior.
|
||||
|
||||
## Replace
|
||||
|
||||
- Unsupported default-method expansion with the selected interface design.
|
||||
|
||||
## Remove by forward correction
|
||||
|
||||
- Contradictory hostname/loginMethods documentation after policy decision.
|
||||
|
||||
## Pending decisions
|
||||
|
||||
- Public origin-binding versus intentionally public lookup.
|
||||
- Member-only context versus System/admin support.
|
||||
- Host-only versus authority-with-port identity.
|
||||
- Platform ownership of loginMethods and public error taxonomy.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/TenantCommonApi.java:28-56.
|
||||
- Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/biz/system/tenant/dto/TenantRespDTO.java:14-42.
|
||||
- Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/api/tenant/TenantApiImpl.java:19-49.
|
||||
- Verified /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantController.java:51-177 and /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/EducationContextController.java:43-57.
|
||||
- Verified mismatch rejection in /Users/tiku1/code/ruoyi-vue-pro/yudao-framework/yudao-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/yudao/framework/tenant/core/security/TenantSecurityWebFilter.java:66-105.
|
||||
67
docs/education/migration/07-decisions.md
Normal file
67
docs/education/migration/07-decisions.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Architecture and Product Decisions
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
## Decisions and constraints established by static evidence
|
||||
|
||||
### EDU-003 accepted decision — tenant resolution and student principal
|
||||
|
||||
The durable rationale, threat model, exact wire contract, compatibility policy, and EDU-004 test matrix are recorded in [`issues/EDU-003-tenant-resolution-decision.md`](issues/EDU-003-tenant-resolution-decision.md).
|
||||
|
||||
1. A **Tenant Locator Claim** is unauthenticated input, not identity. Browser `Origin` then `Referer` provides browser-context consistency evidence but is forgeable by non-browser callers.
|
||||
2. A supplied hostname may only confirm browser-context evidence; disagreement is a conflict. Preserving browser headers or rejecting forwarding-header forgery does not authenticate `Origin`/`Referer`.
|
||||
3. Headless clients use `tenantHandle`. The target currently has no distinct stable Tenant Code, so this is explicitly the unique System tenant `name` under a case-sensitive `^[A-Za-z0-9._-]{2,64}$`, operationally immutable public contract. Legacy public `tenantName` is rejected rather than aliased.
|
||||
4. Public resolution discloses tenant existence on success. The guarantee is only that unknown, disabled, and expired tenants share one response. Reuse public throttling/ingress controls and emit structured probing metrics; spoof-resistant deployments require a future signed/authenticated locator.
|
||||
5. Host identity is lowercase, trimmed, trailing-dot-free, bracket-free for IPv6, and port-independent. Canonical System website entries for this resolver are host-only; non-canonical scheme/path/port entries do not match and require configuration correction or a separately scoped Flyway/data ticket.
|
||||
6. Local fallback is enabled only by `yudao.education.tenant-resolution.local-development-enabled`, default `false`; profiles do not enable it. With the flag true, code-less configured local-host resolution is permitted, while an explicit handle takes precedence.
|
||||
7. `/education/context` is Member-only, obtains the full `LoginUser`, and continues deriving IDs only from security and tenant contexts. `TenantSecurityWebFilter` remains responsible for authenticated missing-tenant, mismatch, and availability checks.
|
||||
8. Login-method metadata belongs to Member authentication. EDU-004 removes/deprecates Education `loginMethods` unless a minimal Member-owned interface is first proven necessary.
|
||||
9. Exact public business failures use HTTP 200/CommonResult: invalid locator `1005001003` / `租户识别请求无效`; conflict `1005001008` / `租户识别信息冲突`; unknown/disabled/expired `1005001004` / `当前租户不可用`; all have null data and redacted detail. Success is code 0 and only `tenantId` plus `displayName`.
|
||||
10. Retain `TenantCommonApi` as the generic System-owned seam. EDU-004 makes lookup methods required and adds System-owned `TenantApiImpl` contract tests; no Education locator concept enters System.
|
||||
11. EDU-003 made no production, database, or Flyway change; verification is static only.
|
||||
|
||||
### EDU-005 accepted decision — PostgreSQL/Flyway takeover
|
||||
|
||||
The durable artifact classification, adoption matrix, version allocation, backfill policy, documentation corrections, and real-PostgreSQL verification gates are recorded in [`issues/EDU-005-flyway-takeover-decision.md`](issues/EDU-005-flyway-takeover-decision.md).
|
||||
|
||||
1. Education schema delivery is exclusively module-owned PostgreSQL Flyway under `yudao-module-education/src/main/resources/db/migration/education/`; root PostgreSQL scripts are manual bootstrap/design history and MySQL scripts are obsolete archives.
|
||||
2. V4010 (`SELECT 1`) and V4020 (native catalog) remain byte-for-byte frozen because execution outside the inspected environment is unverified. The next planned project-wide version is V4030, subject to a fresh version scan at implementation time.
|
||||
3. V4030 owns the final Practice core-loop schema, including sessions/questions, reports/details, wrong questions, favorites, and unified `education_idempotency`. Fresh schema does not create legacy answer/submit idempotency tables.
|
||||
4. Existing manually bootstrapped databases require explicit schema comparison and adoption. A verified V4020-equivalent catalog may use an environment-specific 4020 baseline; incompatible environments require a higher-version correction, never falsified history.
|
||||
5. Legacy idempotency data is backfilled into the unified table before any later forward cleanup. Legacy tables are preserved during initial adoption.
|
||||
6. The Education capability menu seed is a separate conditional V4040 owner only if the administrator endpoint remains approved; role assignment is not seeded.
|
||||
7. Docker/manual SQL initialization and MySQL rollback runbooks must be removed from active operations when EDU-006 lands. EDU-016's temporary test bridge becomes Flyway-driven after equivalence is proven.
|
||||
8. The inspected local disposable `postgresdb` had no Flyway history and no Education tables. EDU-005 ran no migration and makes no migration-success claim.
|
||||
|
||||
### Other established constraints
|
||||
|
||||
1. Verified source provenance is limited: /Users/tiku1/code/tiku-backend has only main and origin/main at 033701a785c7012139e7f86995eea6041225592e; no local or remote feature/education-core-loop ref exists. Use main/033701a provisionally only, or obtain explicit approval for that baseline.
|
||||
2. Verified target branch is feature/education-core-loop and its worktree is dirty. Preserve all existing changes and classify current state at execution time.
|
||||
3. Classify target behavior as committed-and-tested, committed-but-not-runtime-verified, dirty/uncommitted, or absent before scheduling work. ce02f8a is committed core-loop evidence; native provider/catalog and much of the schema are dirty.
|
||||
4. Verified V4010 is SELECT 1 and V4020 is native catalog only. Practice/report/idempotency/wrong/favorite DDL in sql/postgresql/education is untracked/manual and not proven active Flyway. Convert required DDL to immutable module-owned PostgreSQL Flyway migrations before claiming schema delivery; never modify published migrations.
|
||||
5. Keep PostgreSQL/Flyway as the only new schema delivery mechanism. Historical MySQL files and root SQL are not active delivery unless explicitly labeled archival/manual and removed from operational runbooks.
|
||||
6. Treat public tenant resolution as an unauthenticated disclosure surface requiring exact redaction and abuse controls. Separately preserve `TenantSecurityWebFilter` authenticated mismatch checks; the remaining principal issue is Member/UserType enforcement in `/education/context`.
|
||||
7. Treat hostname port and stored-website representation as verified contradictions requiring alignment across implementation, properties, API documentation, System lookup behavior, configuration, and tests.
|
||||
8. Treat native catalog isolation as an intentional TenantUtils.executeIgnore/manual-scope boundary, not evidence of a current leak. Make mapper audit and tenant/scope-consistent graph constraints concrete blockers before authoring.
|
||||
9. Make the first slice provider-neutral or cover both providers because SCALAR_READ is the verified default and Java provider is conditional. The slice must include fresh browsing and persisted session restoration, with a common option-schema contract and fail-closed behavior.
|
||||
10. Do not treat submit idempotency as complete: check-then-insert is not an atomic claim. Reserve keys atomically and define crash recovery before the submit slice.
|
||||
11. Add first-class Auth/Profile/extended Learning, tenant appearance/integrations/secrets/codes, and granular platform-admin capability groups so every required legacy cluster has a disposition.
|
||||
12. Do not expose paid/private practice until entitlement semantics and public target contracts are decided.
|
||||
13. No tests, builds, PostgreSQL connections, Flyway execution, or runtime verification were performed by the Phase 0 assessment unless a later ticket explicitly records otherwise.
|
||||
|
||||
## Unresolved decisions
|
||||
|
||||
1. The valid option schema for each question type, including whether absent options are legal; whether malformed published content is omitted or produces a controlled source failure.
|
||||
2. Whether PUBLIC tenant_id=0 rows may reference only PUBLIC parents, whether tenant-owned rows may reference global rows, and the precise composite constraint/trigger strategy.
|
||||
3. Whether untracked /Users/tiku1/code/ruoyi-vue-pro/sql/postgresql/education files are intended for promotion into Flyway or are design/manual artifacts.
|
||||
4. Whether V4010/V4020 or any manual core-loop DDL has ever run successfully in PostgreSQL; no runtime migration evidence exists.
|
||||
5. Which Auth/Profile/extended Learning semantics are replaced by Member/System/Infra versus Education-owned, including vocabulary, leaderboard, stats, trend, feedback, exam dates, notifications, points, and badges.
|
||||
6. Whether tenant appearance, domains, payment accounts, auth providers, secrets, activation codes, coupons, integrations, marketing, public-bank grants, and sync are in scope or explicitly retired.
|
||||
7. Whether legacy assets are migrated, re-uploaded, re-scanned, or retired, and who owns ClamAV/scanner integration.
|
||||
8. Which legacy RLS, triggers, functions, grants, seeds, queue leases, retry behavior, and operational semantics are contractual and need Java/constraint/event/job reproduction.
|
||||
9. Whether the ten required Phase 0 artifacts must be committed files or may remain in reviewed scratch form during discovery.
|
||||
10. Whether a future System-owned immutable Tenant Code or signed bootstrap locator is required beyond the accepted public-handle/existence-disclosure contract.
|
||||
|
||||
## Decision rule
|
||||
|
||||
Questions answerable from code, Git history, configuration, tests, or documentation must be investigated. Only genuine product choices should be escalated. Hard-to-reverse decisions should become ADRs before dependent implementation begins.
|
||||
165
docs/education/migration/08-slice-roadmap.md
Normal file
165
docs/education/migration/08-slice-roadmap.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Vertical Slice Roadmap
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
Tickets are vertical behaviors, not technical layers. Work blockers first and use a fresh implementation context per ticket.
|
||||
|
||||
## EDU-P0-S0 — Baseline, completeness, and architecture decision gate
|
||||
|
||||
- **Outcome:** Verified baseline, capability/API/database/module-reuse maps, commit reviews, decision register, corrected documentation plan, and bounded first-slice specification.
|
||||
- **Risk:** High provenance and operational risk; no implementation should begin from an unclassified dirty baseline.
|
||||
- **Blockers:**
|
||||
- Obtain or explicitly approve source baseline main/033701a because no source feature/education-core-loop ref exists.
|
||||
- Record current target status and preserve all 65 modified tracked and 97 untracked entries.
|
||||
- Complete the ten GOAL.md Phase 0 artifact dispositions and separate committed, dirty, absent, and runtime-unverified behavior.
|
||||
- Decide tenant origin-binding, principal policy, provider authority, option schema, and PUBLIC graph semantics.
|
||||
- **Verification:**
|
||||
- Read GOAL.md and target/source rules and module documentation.
|
||||
- Record both Git statuses, histories, and refs without destructive commands.
|
||||
- Review 11e9cc6, 0f846fd, and committed core-loop evidence ce02f8a.
|
||||
- Confirm no tests, builds, PostgreSQL connections, Flyway migrations, or runtime flows were executed.
|
||||
- Inventory Auth/Profile/extended Learning, granular tenant-admin, platform-admin, worker/scanner, and database object surfaces.
|
||||
|
||||
## EDU-P0-S1 — Provider-neutral safe question and session restoration
|
||||
|
||||
- **Outcome:** Both provider modes and restored sessions fail closed for malformed/unavailable published question content while preserving tenant scope and safe projections.
|
||||
- **Risk:** Medium implementation risk and high assessment-integrity/security risk if any alternate path remains fail-open.
|
||||
- **Blockers:**
|
||||
- EDU-P0-S0 provider and option-contract decisions.
|
||||
- Existing dirty provider/session files must be separated from unrelated work.
|
||||
- Safe response and question-type option semantics must be fixed.
|
||||
- **Verification:**
|
||||
- Provider contract tests for Scalar and Java.
|
||||
- Browsing, collection, practice-create/restore, malformed, unavailable, unpublished, cross-tenant, PUBLIC, and sensitive-field tests.
|
||||
- Run focused tests/compile/diff checks only after authorization and report exact results.
|
||||
|
||||
## EDU-P2-S2 — Create and restore practice
|
||||
|
||||
- **Outcome:** A student creates and restores a tenant-scoped practice session from valid published content without client-supplied identity or tenant IDs.
|
||||
- **Risk:** Medium; session ownership, graph scope, and schema packaging require negative tests.
|
||||
- **Blockers:**
|
||||
- EDU-P0-S1 safe-content contract.
|
||||
- Core-loop schema must be promoted into active module-owned Flyway history.
|
||||
- Provider authority and existing dirty session implementation classification.
|
||||
- **Verification:**
|
||||
- Tenant/user context and Member-principal tests.
|
||||
- Restore ownership, cross-tenant denial, PUBLIC-scope tests.
|
||||
- PostgreSQL uniqueness, transaction, packaging, and migration execution checks if schema changes are approved.
|
||||
|
||||
## EDU-P2-S3 — Idempotent answer save
|
||||
|
||||
- **Outcome:** A student saves one answer idempotently with explicit duplicate/conflicting-payload semantics and no sensitive-field exposure.
|
||||
- **Risk:** Medium-to-high due to concurrent writes, stale versions, and answer leakage.
|
||||
- **Blockers:**
|
||||
- EDU-P2-S2 session state.
|
||||
- Existing answer/idempotency schema and option snapshot contract.
|
||||
- Entitlement decision for non-public/private content.
|
||||
- **Verification:**
|
||||
- Same-payload duplicate and conflicting-payload tests.
|
||||
- Concurrent PostgreSQL uniqueness/transaction tests.
|
||||
- Tenant isolation, stale-version, safe-response, and malformed-snapshot tests.
|
||||
|
||||
## EDU-P2-S4 — Atomic submit, report, wrong questions, and favorites
|
||||
|
||||
- **Outcome:** A student atomically claims and submits a session, reads an immutable report, and receives consistent wrong-question/favorite projections.
|
||||
- **Risk:** High; current check-then-insert submit idempotency is not sufficient.
|
||||
- **Blockers:**
|
||||
- EDU-P2-S3 answer state.
|
||||
- Atomic submit-key reservation and crash recovery design.
|
||||
- Scoring/report immutability and entitlement decisions.
|
||||
- **Verification:**
|
||||
- ON CONFLICT/atomic claim concurrency tests.
|
||||
- Processing-row crash recovery and retry semantics.
|
||||
- Immutable report/scoring, duplicate submission, wrong-question/favorite idempotency, sensitive-field, tenant, and unauthorized tests.
|
||||
|
||||
## EDU-P3-S5 — Tenant content publication and graph integrity
|
||||
|
||||
- **Outcome:** Tenant administrators author, classify, publish, and safely retire question content with tenant-consistent graph integrity.
|
||||
- **Risk:** High due to publication, admin scope, public graph, and student-read consistency.
|
||||
- **Blockers:**
|
||||
- Core loop verified.
|
||||
- Provider and education content model decisions.
|
||||
- System RBAC/DataPermission policy.
|
||||
- **Verification:**
|
||||
- Admin permission/row-scope tests.
|
||||
- Composite tenant/scope relationship constraint or equivalent enforcement tests.
|
||||
- Publication visibility/provider consistency and safe-projection regression tests.
|
||||
|
||||
## EDU-P3-S6 — Content imports, exports, assets, and scanning
|
||||
|
||||
- **Outcome:** Tenant administrators import/export education content with durable business state, leases, retries, duplicate-safe processing, file security, and audit.
|
||||
- **Risk:** High operational and security risk.
|
||||
- **Blockers:**
|
||||
- Publication model.
|
||||
- Infra File contract and scanner ownership.
|
||||
- Infra Job/MQ durable claim/lease semantics.
|
||||
- **Verification:**
|
||||
- Preview/execute state machine.
|
||||
- Atomic claim/lease/heartbeat/expiry/retry/dead-letter tests.
|
||||
- MIME/size/object-key/scan fail-closed tests.
|
||||
- Tenant propagation, audit redaction, and partial-failure tests.
|
||||
|
||||
## EDU-P4-S7 — Classes and education relationships
|
||||
|
||||
- **Outcome:** Tenant administrators manage classes, education student relationships, invitations, supervision, and education operations with explicit scope.
|
||||
- **Risk:** High authorization risk.
|
||||
- **Blockers:**
|
||||
- Education relationship model.
|
||||
- System RBAC/DataPermission scope rules.
|
||||
- Member relationship contract and CRM supervision decision.
|
||||
- **Verification:**
|
||||
- Student/teacher/class permission matrix.
|
||||
- Cross-class/cross-tenant negative and duplicate invitation tests.
|
||||
- Audit redaction and operation-log tests.
|
||||
|
||||
## EDU-P4-S8 — Tenant configuration, integrations, and access operations
|
||||
|
||||
- **Outcome:** Selected tenant appearance, integrations, secrets, activation codes, coupons, and public-bank access capabilities have explicit owners and safe contracts.
|
||||
- **Risk:** High because secret, payment configuration, redemption, and public-bank synchronization boundaries differ.
|
||||
- **Blockers:**
|
||||
- Appearance/domain/integration/secrets/codes ownership decisions.
|
||||
- System tenant configuration and secret APIs.
|
||||
- Mall/Pay/Member entitlement and code contracts.
|
||||
- **Verification:**
|
||||
- Secret redaction/rotation and authorization tests.
|
||||
- Domain/auth-provider/payment-account configuration tests.
|
||||
- Code/coupon redemption idempotency and audit tests.
|
||||
- Public-bank grant/sync and cross-tenant tests.
|
||||
|
||||
## EDU-P5-S9 — Education commercialization binding
|
||||
|
||||
- **Outcome:** Education products bind to commerce purchases and Member entitlements without duplicated financial ledgers.
|
||||
- **Risk:** High financial and authorization risk.
|
||||
- **Blockers:**
|
||||
- Product binding model.
|
||||
- Mall/Pay public APIs.
|
||||
- Member entitlement decision and callback/refund semantics.
|
||||
- **Verification:**
|
||||
- Order/payment/refund callback contracts.
|
||||
- Entitlement issuance/revocation/expiry and idempotent fulfillment.
|
||||
- Reconciliation, commission/referral, authorization, and audit tests.
|
||||
|
||||
## EDU-P5-S10 — Extended student and secondary learning waves
|
||||
|
||||
- **Outcome:** Selected Auth/Profile/extended Learning/scoreline/vocabulary/video/AI/notification/badge/exam capabilities are migrated, replaced, retired, or deferred with traceable decisions.
|
||||
- **Risk:** Medium-to-high due to omitted student contracts, media entitlement, and unclear ownership.
|
||||
- **Blockers:**
|
||||
- Explicit scope for each secondary capability.
|
||||
- AI/File/Member/System/Infra contracts and entitlement model.
|
||||
- **Verification:**
|
||||
- Per-capability endpoint/data/authorization contract tests.
|
||||
- Progress/report/vocabulary state tests.
|
||||
- Media entitlement, safe export/redaction, tenant isolation, and retirement compatibility tests.
|
||||
|
||||
## EDU-P6-S11 — Operational independence and legacy exit
|
||||
|
||||
- **Outcome:** Background and platform operations run independently of NestJS with documented retries, scanning, audit, notifications, observability, and deployment evidence.
|
||||
- **Risk:** High deployment and reliability risk.
|
||||
- **Blockers:**
|
||||
- All owner and contract decisions.
|
||||
- Operational deployment, scanner, observability, and legacy exit plan.
|
||||
- **Verification:**
|
||||
- Worker/job deployment smoke tests.
|
||||
- At-least-once duplicate/dead-letter and scanner health/security tests.
|
||||
- PostgreSQL migration execution evidence.
|
||||
- Runbook and documentation consistency review.
|
||||
51
docs/education/migration/09-first-slice.md
Normal file
51
docs/education/migration/09-first-slice.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# First Recommended Slice: EDU-P0-S1
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
## Provider-neutral fail-closed safe question browsing and session restoration
|
||||
|
||||
### Rationale
|
||||
|
||||
The initial native-only slice was corrected because SCALAR_READ is the verified default and JavaCatalogProvider is conditional. A provider-neutral safe-content contract is the smallest observable correction that addresses both active and optional paths, avoids provider-authority assumptions, protects the dirty worktree, and covers the second fail-open path in restored sessions.
|
||||
|
||||
### Scope
|
||||
|
||||
- Define and document the common option validity contract, including whether absent options are legal for each question type; invalid published question payloads must be omitted or return a controlled failure, never an apparently valid empty-options question.
|
||||
- Apply and test the contract for both ScalarCatalogProvider and JavaCatalogProvider, despite SCALAR_READ being the current default, so provider mode cannot change safety behavior.
|
||||
- Apply and test safe projection for single-question browsing and collection/catalog browsing paths.
|
||||
- Make SessionResponseAssembler reject, mark unavailable, or otherwise fail closed on malformed persisted snapshots; do not silently convert parse failure to an empty list.
|
||||
- Verify publication/status/visibility and unavailable-provider fail-closed behavior at the service boundary.
|
||||
- Add cross-tenant and PUBLIC-scope negative tests, while recording that current native reads use an intentional TenantUtils.executeIgnore/manual predicate boundary.
|
||||
- Separate committed behavior from dirty behavior in the implementation report; do not claim runtime verification.
|
||||
- Critical files for implementation: /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/provider/JavaCatalogProvider.java; /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/ScalarCatalogProvider.java; /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/question/QuestionCatalogServiceImpl.java; /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/SessionResponseAssembler.java; /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/catalog/CatalogServiceImplTest.java.
|
||||
|
||||
### Reuse boundaries
|
||||
|
||||
- Reuse /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/catalog/CatalogProvider.java and existing provider selection; do not choose a new provider in this slice.
|
||||
- Reuse existing safe question DTO/assembler boundaries in QuestionCatalogServiceImpl; never return DOs, answers, explanations, correctness flags, or admin metadata.
|
||||
- Apply one shared, provider-neutral option validation contract at the provider-to-safe-question/session-snapshot boundary; do not duplicate divergent validation rules.
|
||||
- Reuse framework TenantContextHolder and the existing explicit CatalogScopeQuery predicate. Do not broaden TenantUtils.executeIgnore or add a custom tenant bypass.
|
||||
- Keep changes inside Education unless a proven public contract gap requires a minimal separately owned interface; do not alter System, Member, Scalar infrastructure, or database schema speculatively.
|
||||
- Preserve and classify the existing dirty tree; implementation must be serial and must not overwrite unrelated files.
|
||||
|
||||
### Database change
|
||||
|
||||
No database change for the bounded correctness slice. Do not modify V4010 or V4020. Do not promote untracked SQL during this slice. If later graph-integrity or core-loop schema work is approved, create new immutable module-owned PostgreSQL Flyway migrations under /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/resources/db/migration/education/ after schema review; never claim execution without a real PostgreSQL run.
|
||||
|
||||
### Test plan
|
||||
|
||||
- Focused unit/provider contract tests for null, absent, empty, malformed, structurally invalid, blank-label, duplicate/order-invalid options, and valid question-type-specific payloads.
|
||||
- Service tests proving malformed published content cannot produce a student-visible apparently valid safe question.
|
||||
- Session create/restore tests proving malformed persisted snapshots fail closed and valid snapshots retain safe fields only.
|
||||
- Tests for single browse, collection browse, unpublished/invisible content, unavailable provider, disabled feature, cross-tenant access, and tenant_id=0 PUBLIC scope.
|
||||
- If both providers cannot yet share a concrete contract, add contract tests parameterized over each implementation and record the remaining provider decision rather than silently selecting one.
|
||||
- No concurrency test is required for this read-only slice, but atomic submit-idempotency reservation and crash recovery must block the later submit slice.
|
||||
- After implementation authorization only: run focused Education tests, git diff --check, and mvn -pl yudao-server -am -DskipTests clean compile; if schema files remain unchanged, do not claim Flyway execution.
|
||||
|
||||
### Rollback
|
||||
|
||||
Application-level rollback is configuration/provider disablement or restoration of the prior provider behavior after review, without destructive Git or database rollback. No database rollback applies because this slice has no schema change. If malformed persisted snapshots are encountered, fail closed with a controlled unavailable/corrupt-content outcome rather than silently restoring an empty-options question.
|
||||
|
||||
### Authorization gate
|
||||
|
||||
This document selects and specifies the first slice; it does not authorize broad implementation. Before editing, re-read the dirty working tree, isolate existing user changes, state the exact files to touch, and execute the slice test-first.
|
||||
15
docs/education/migration/10-documentation-corrections.md
Normal file
15
docs/education/migration/10-documentation-corrections.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Documentation Corrections
|
||||
|
||||
> Phase 0 static assessment generated on 2026-07-29. No build, test, application startup, PostgreSQL connection, or Flyway migration was executed during this assessment.
|
||||
|
||||
These corrections are identified but not applied by the read-only discovery workflow.
|
||||
|
||||
- Correct /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/README.md:4-20,171-192,393-401 to separate committed versus dirty work, remove MySQL/rollback delivery claims, document PostgreSQL/Flyway forward-only operations, and state that V4010/V4020 and core-loop schema execution are unverified.
|
||||
- Correct /Users/tiku1/code/ruoyi-vue-pro/docs/education/student-core-learning-loop-prd.md:20-26,142-143,200-201 so it does not claim RuoYi/MySQL, ordered reversible SQL, or absent Flyway.
|
||||
- Correct /Users/tiku1/code/ruoyi-vue-pro/docs/education/pilot-acceptance-runbook.md:29-37,63-68,78-85 to require actual PostgreSQL/Flyway execution evidence and forward correction rather than MySQL rollback.
|
||||
- Align /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/tenant/EducationTenantController.java:55-59 documentation, /Users/tiku1/code/ruoyi-vue-pro/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/config/EducationProperties.java:53-59, implementation, System website normalization, and tests on port policy.
|
||||
- Document the provider-neutral malformed-option contract across JavaCatalogProvider, ScalarCatalogProvider, QuestionCatalogServiceImpl, and SessionResponseAssembler, including question-type-specific absent-option policy.
|
||||
- Document that native catalog scope is a deliberate TenantUtils.executeIgnore/manual predicate boundary and add mapper-audit plus graph-integrity design notes.
|
||||
- Label /Users/tiku1/code/ruoyi-vue-pro/sql/mysql/education/ and /Users/tiku1/code/ruoyi-vue-pro/sql/postgresql/education/ according to actual operational status; do not present either as active delivery until the latter is promoted into Flyway.
|
||||
- Correct the commercialization legacy path to /Users/tiku1/code/tiku-backend/apps/api/src/nest/commerce-reconciliation.module.ts.
|
||||
- Create or maintain the ten required Phase 0 artifacts named by /Users/tiku1/code/ruoyi-vue-pro/docs/education/migration/GOAL.md:147-160, without claiming they exist unless verified.
|
||||
174
docs/education/migration/11-question-content-safety-contract.md
Normal file
174
docs/education/migration/11-question-content-safety-contract.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Provider-neutral question content safety contract
|
||||
|
||||
> Status: proposed for EDU-P0-S1
|
||||
>
|
||||
> Evidence basis: current Education providers and services plus the legacy question import and learning rules. This document defines the implementation contract; it does not report tests as executed.
|
||||
|
||||
## Decision
|
||||
|
||||
Student-visible question content is validated by one provider-neutral contract before it is projected as a safe question or persisted/restored as a practice snapshot. Provider-specific parsing may reject malformed transport data earlier, but switching between Scalar and Java must not change whether the same logical question is considered safe.
|
||||
|
||||
Invalid or unsupported content fails closed. It must not be normalized into an apparently valid question with an empty option list.
|
||||
|
||||
## Question-type families
|
||||
|
||||
Type matching is case-insensitive after trimming. Persisted and returned canonical values remain an implementation concern; validation uses the following families.
|
||||
|
||||
### Option-backed
|
||||
|
||||
```text
|
||||
choice
|
||||
multi
|
||||
multi_choice
|
||||
judge
|
||||
image
|
||||
```
|
||||
|
||||
An option-backed question requires:
|
||||
|
||||
- at least two options;
|
||||
- every option object to be non-null;
|
||||
- a non-blank string `label`;
|
||||
- a non-blank string `content`;
|
||||
- labels unique after trimming;
|
||||
- each `order`, when present, to be a finite number;
|
||||
- no duplicate non-null order value;
|
||||
- no answer-bearing field in the student-safe projection or snapshot.
|
||||
|
||||
Options are emitted in deterministic order: numeric `order` first, preserving source order only when order is absent. This slice does not infer or repair missing labels, contents, or order values.
|
||||
|
||||
### Optionless
|
||||
|
||||
```text
|
||||
fill
|
||||
text
|
||||
terms
|
||||
short_answer
|
||||
composition
|
||||
discuss
|
||||
translation
|
||||
case_analysis
|
||||
brief_analysis
|
||||
calculation
|
||||
analysis_design
|
||||
combination
|
||||
solution
|
||||
```
|
||||
|
||||
An optionless question may have null or empty options. If options are supplied, the content is inconsistent with its type and fails closed rather than silently discarding them.
|
||||
|
||||
This slice only establishes safe display and snapshot behavior. It does not add text-answer submission or scoring semantics. Existing answer-saving behavior must not pretend an optionless question is option-backed.
|
||||
|
||||
### Composite
|
||||
|
||||
```text
|
||||
reading
|
||||
```
|
||||
|
||||
A composite question requires sub-questions in the legacy model. The current target `CatalogQuestionDTO`, safe response, and practice snapshot do not carry a supported sub-question contract. Therefore a top-level `reading` question is unsupported by EDU-P0-S1 and fails closed rather than appearing as an optionless standalone question.
|
||||
|
||||
Supporting composite questions requires a later explicit model and API contract.
|
||||
|
||||
### Unknown or missing type
|
||||
|
||||
A null, blank, or unknown type fails closed. The target must not default unknown content to `choice`, because doing so can turn malformed content into a different assessment.
|
||||
|
||||
## Common option shape
|
||||
|
||||
The provider-neutral safe option shape is:
|
||||
|
||||
```text
|
||||
label: non-blank string, unique after trim
|
||||
content: non-blank string
|
||||
order: optional finite number, unique when present
|
||||
```
|
||||
|
||||
Provider DTOs may temporarily contain `isCorrect` for internal scoring or migration needs, but that field and all answer-bearing fields are discarded before creation of a Safe Question or the student-visible Question Snapshot JSON. A separately stored Protected Answer Key may retain correctness and explanation data for stable server-side scoring, but it is never part of the safe snapshot projection or a pre-submit response.
|
||||
|
||||
The safety validator must not require `isCorrect`, because safe restored snapshots intentionally do not store it. Correct-answer completeness is a content-authoring/scoring concern and is outside this read-only display contract.
|
||||
|
||||
## Failure semantics
|
||||
|
||||
### Fresh provider content
|
||||
|
||||
Any of the following makes the provider result unsafe:
|
||||
|
||||
- malformed options transport or JSON;
|
||||
- null option element;
|
||||
- wrong field types;
|
||||
- fewer than two options for an option-backed question;
|
||||
- options on an optionless question;
|
||||
- blank or duplicate labels;
|
||||
- blank content;
|
||||
- non-finite or duplicate explicit order;
|
||||
- unsupported composite content;
|
||||
- null, blank, or unknown type.
|
||||
|
||||
A single-question request returns the existing controlled unsafe/malformed content error. Page and collection requests fail the response closed rather than silently changing totals or returning a partial assessment set.
|
||||
|
||||
### Persisted practice snapshot
|
||||
|
||||
A null/empty options value is valid only for an optionless type. Malformed JSON or a shape that violates the type family makes the snapshot unavailable. Session restoration must return a controlled error for the session/question; it must not convert parse failure into `[]`.
|
||||
|
||||
No automatic repair is performed during read or restore. Historical repair, quarantine, or backfill requires a separately reviewed migration or administrative process.
|
||||
|
||||
## Boundary placement
|
||||
|
||||
The contract is applied at two domain boundaries:
|
||||
|
||||
1. provider `CatalogQuestionDTO` → student-safe projection or practice-session creation;
|
||||
2. persisted `PracticeQuestionDO` snapshot → practice-session response.
|
||||
|
||||
Both boundaries use the same type classification and option-shape rules. Scalar and Java adapters remain responsible only for transport/database parsing and mapping; they must not define divergent business validity.
|
||||
|
||||
## Compatibility notes
|
||||
|
||||
- Legacy source evidence identifies `choice`, `multi`, `judge`, and `image` as objective/option-backed and requires at least two options.
|
||||
- Legacy source recognizes `reading` as a composite type with sub-questions.
|
||||
- Legacy source recognizes the optionless types listed above and requires answer text for authoring; answer text is intentionally not exposed by the safe read contract.
|
||||
- Current target tests sometimes construct `choice` questions with null, one, or empty options. Those fixtures describe previous permissive behavior and must be corrected where they cross a student-visible or snapshot boundary.
|
||||
- `multi_choice` is retained as a target compatibility alias because current submit tests use it, while the legacy canonical type is `multi`.
|
||||
|
||||
## Required tests
|
||||
|
||||
### Shared contract
|
||||
|
||||
- each recognized type is classified correctly;
|
||||
- type matching trims and ignores case;
|
||||
- null, blank, and unknown types fail;
|
||||
- option-backed types reject null, empty, and one-option lists;
|
||||
- optionless types accept null/empty and reject supplied options;
|
||||
- `reading` fails as unsupported composite content;
|
||||
- null elements, wrong field types, blank labels, duplicate labels, blank contents, non-finite orders, and duplicate explicit orders fail;
|
||||
- valid options preserve safe fields and never expose `isCorrect`.
|
||||
|
||||
### Provider paths
|
||||
|
||||
Run the same logical contract cases against Scalar and Java mappings. Transport-specific malformed data may fail earlier, but no provider may turn malformed input into an empty valid list.
|
||||
|
||||
### Service paths
|
||||
|
||||
- single-question browsing fails closed for unsafe content;
|
||||
- page browsing fails the whole response for unsafe content;
|
||||
- collection browsing fails the whole response for unsafe content;
|
||||
- unpublished, hidden, inactive, disabled-source, and unavailable-source behavior remains fail-closed;
|
||||
- successful output contains no answer-bearing fields.
|
||||
|
||||
### Practice paths
|
||||
|
||||
- session creation rejects unsafe source questions before persisting snapshots;
|
||||
- a valid option-backed snapshot restores its options;
|
||||
- a valid optionless snapshot restores an empty option list;
|
||||
- malformed or type-inconsistent snapshots fail closed;
|
||||
- snapshot JSON contains only `label`, `content`, and `order`;
|
||||
- cross-tenant and ownership protections remain unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- choosing Scalar or Java as the authoritative provider;
|
||||
- adding composite/sub-question APIs;
|
||||
- adding subjective answer submission or scoring;
|
||||
- validating that correct answers exist or are unique;
|
||||
- repairing historical snapshots;
|
||||
- changing database schema or Flyway migrations;
|
||||
- submit-idempotency redesign.
|
||||
432
docs/education/migration/GOAL.md
Normal file
432
docs/education/migration/GOAL.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# Education SaaS Migration Goal
|
||||
|
||||
> Status: active goal
|
||||
>
|
||||
> Source system: `/Users/tiku1/code/tiku-backend`
|
||||
>
|
||||
> Target system: `/Users/tiku1/code/ruoyi-vue-pro`
|
||||
>
|
||||
> Target branch at goal creation: `feature/education-core-loop`
|
||||
>
|
||||
> Created: 2026-07-29
|
||||
|
||||
## 1. Mission
|
||||
|
||||
Migrate the valuable business capabilities, data models, rules, state machines, authorization semantics, idempotency guarantees, and API contracts from `tiku-backend` into the RuoYi-Vue-Pro architecture.
|
||||
|
||||
This is a capability migration, not a file-by-file TypeScript-to-Java translation.
|
||||
|
||||
The resulting system must be a multi-tenant education SaaS backend that:
|
||||
|
||||
1. Places education-specific behavior in `yudao-module-education`.
|
||||
2. Reuses RuoYi-Vue-Pro platform modules before adding new infrastructure.
|
||||
3. Uses the framework's tenant, authentication, RBAC, logging, file, job, messaging, payment, and membership capabilities.
|
||||
4. Uses PostgreSQL and module-owned Flyway migrations for all forward database changes.
|
||||
5. Is independently buildable, testable, migratable, and progressively deployable by vertical slice.
|
||||
6. Does not require the old NestJS service after migration, except for explicitly documented temporary adapters with an exit plan.
|
||||
|
||||
## 2. Non-negotiable architecture rules
|
||||
|
||||
### 2.1 Reuse before building
|
||||
|
||||
| Legacy capability | Target capability to evaluate first |
|
||||
|---|---|
|
||||
| Login, token, refresh, logout, verification | Member/System authentication |
|
||||
| Student and administrator accounts | Member/System users; Education stores domain extensions only |
|
||||
| Tenant lookup, status, and isolation | System Tenant and framework tenant support |
|
||||
| Roles, menus, permissions, data permission | System RBAC |
|
||||
| Payment, refund, channel, callback | Pay |
|
||||
| Generic products and orders | Mall and Pay |
|
||||
| Membership, level, entitlement, points | Member first; Education only orchestrates domain rules |
|
||||
| Notifications, SMS, email | System/Infra messaging capabilities |
|
||||
| Uploads and object storage | Infra File |
|
||||
| Scheduled and background work | Infra Job or existing messaging facilities |
|
||||
| Audit and operation logs | System/Infra logging |
|
||||
| AI generation and recommendation | AI |
|
||||
| CRM leads and customer follow-up | CRM |
|
||||
| Questions, practice, exams, wrong questions, favorites, reports | Education |
|
||||
|
||||
Reuse means depending on public APIs, framework extension points, or events. Education must not depend on another module's internal `ServiceImpl`, Mapper, or DO and must not copy platform implementations.
|
||||
|
||||
A change outside Education is allowed only when the existing public capability cannot satisfy the need and the new interface is minimal, generic, backward-compatible, tested, and owned by the module that provides the capability.
|
||||
|
||||
### 2.2 Multi-tenancy and identity
|
||||
|
||||
- Tenant business DOs inherit `TenantBaseDO`.
|
||||
- MyBatis-Plus tenant injection remains the normal isolation mechanism.
|
||||
- Request bodies and query parameters are never trusted for `tenantId` or current `userId`.
|
||||
- The current tenant and user come from framework security context.
|
||||
- Student identity reuses Member; administrator identity reuses System.
|
||||
- Education stores education profiles and relationships, not passwords, tokens, or generic accounts.
|
||||
- Cross-tenant platform operations use existing tenant-ignore mechanisms with strict permissions; no custom bypass.
|
||||
- Unique constraints include `tenant_id` whenever uniqueness is tenant-scoped.
|
||||
|
||||
### 2.3 Security
|
||||
|
||||
- Controllers never return DOs directly.
|
||||
- Question responses strip answers, explanations, scoring rules, correctness flags, and administrative metadata before leaving the service boundary.
|
||||
- Invisible questions, unavailable tenants, disabled features, and unavailable catalog sources fail closed.
|
||||
- Logs do not contain tokens, passwords, verification codes, answers, or payment secrets.
|
||||
- Student App, Tenant Admin, Platform Admin, public, and internal APIs have explicit and separate authorization models.
|
||||
- Existing System RBAC and permission annotations are used for admin endpoints.
|
||||
|
||||
### 2.4 PostgreSQL and Flyway
|
||||
|
||||
All new or changed schema, indexes, constraints, required seed data, backfills, baselines, and Flyway configuration must use the project `flyway-postgresql` skill.
|
||||
|
||||
Required conventions include:
|
||||
|
||||
- `BIGINT GENERATED BY DEFAULT AS IDENTITY`
|
||||
- `TIMESTAMP` and `CURRENT_TIMESTAMP`
|
||||
- PostgreSQL `BOOLEAN`
|
||||
- `ON CONFLICT ... DO NOTHING`
|
||||
- `ON CONFLICT (...) DO UPDATE SET ... EXCLUDED.column`
|
||||
- `COALESCE`, `TO_CHAR`, and `EXTRACT` where applicable
|
||||
- module migration path: `<module>/src/main/resources/db/migration/<module>/`
|
||||
|
||||
Published migrations are immutable. Corrections use higher-version forward migrations. Historical `sql/mysql/education` files are not the delivery mechanism for new database changes. Application-layer tenant isolation must not be replaced by copied Supabase RLS.
|
||||
|
||||
Only an actual successful run against PostgreSQL may be reported as a successful database migration. Static SQL review, compilation, packaging, or resource copying must be described accurately as such.
|
||||
|
||||
## 3. Required Phase 0 investigation
|
||||
|
||||
Do not start broad feature implementation before completing this investigation.
|
||||
|
||||
### 3.1 Repository and rule inspection
|
||||
|
||||
Read and obey:
|
||||
|
||||
- target `CLAUDE.md`;
|
||||
- target `yudao-module-education/README.md`;
|
||||
- source `README.md`;
|
||||
- applicable `AGENTS.md`, module READMEs, database documentation, and `.claude/skills/index.yaml`;
|
||||
- actual runtime configuration and Git state.
|
||||
|
||||
Inspect both repositories' working trees and histories. Preserve all existing uncommitted work: no reset, destructive checkout, clean, or unrelated rewrite.
|
||||
|
||||
### 3.2 Historical commit review
|
||||
|
||||
Review these commits and determine whether their non-Education changes remain justified:
|
||||
|
||||
- `11e9cc6 feat(education): add module application shell`
|
||||
- `0f846fd feat(education): resolve student tenant context`
|
||||
|
||||
Review at least:
|
||||
|
||||
- root `pom.xml`;
|
||||
- `yudao-server/pom.xml`;
|
||||
- `ServiceErrorCodeRange`;
|
||||
- `TenantCommonApi`;
|
||||
- `TenantRespDTO`;
|
||||
- `TenantApiImpl`;
|
||||
- historical `sql/mysql/education` artifacts;
|
||||
- Education tenant-resolution logic.
|
||||
|
||||
Classify each design as retain, adjust, replace, remove by forward correction, or pending decision. Do not revert whole commits merely because one part is unsuitable.
|
||||
|
||||
### 3.3 Legacy capability inventory
|
||||
|
||||
Scan at least:
|
||||
|
||||
```text
|
||||
apps/api/src/features
|
||||
apps/api/src/nest
|
||||
apps/worker/src
|
||||
apps/asset-scanner/src
|
||||
packages
|
||||
supabase/migrations
|
||||
supabase/seed*
|
||||
docs
|
||||
```
|
||||
|
||||
Cluster capabilities rather than mechanically mapping every endpoint. Cover Auth, Tenant, Profile, Learning, Catalog, Scoreline, Video, AI, Tenant Content, Tenant Admin, Platform Admin, Referral, Commerce, Worker, Asset Scanner, tables, indexes, constraints, RLS, functions, triggers, and seeds.
|
||||
|
||||
### 3.4 Target capability inventory
|
||||
|
||||
Inspect Education's current implementation and reusable capabilities in System, Member, Pay, Mall, Infra, AI, CRM, framework starters, and Server integration. Account for both committed and uncommitted implementation; do not rebuild existing slices.
|
||||
|
||||
## 4. Required migration artifacts
|
||||
|
||||
Maintain these artifacts under `docs/education/migration/` or a reviewed scratch equivalent while discovery is incomplete:
|
||||
|
||||
1. `current-state.md` — verified implementation and working-tree state.
|
||||
2. `capability-matrix.md` — grouped legacy-to-target capability matrix.
|
||||
3. `api-mapping.md` — legacy method/path and authorization to target contract.
|
||||
4. `database-object-mapping.md` — table/RLS/function/trigger/storage disposition.
|
||||
5. `module-reuse-map.md` — reusable public APIs and identified gaps.
|
||||
6. `commit-review-11e9cc6.md`.
|
||||
7. `commit-review-0f846fd.md`.
|
||||
8. `decisions.md` — unresolved product or architecture decisions and ADR links.
|
||||
9. `slice-roadmap.md` — vertical slices with blocking edges.
|
||||
10. `first-slice.md` — first incomplete, bounded, low-risk delivery slice.
|
||||
|
||||
Each capability-matrix row must include:
|
||||
|
||||
```text
|
||||
legacy capability
|
||||
legacy code location
|
||||
legacy database objects
|
||||
business value
|
||||
target module
|
||||
existing capability to reuse
|
||||
Education gap
|
||||
whether another module must change
|
||||
priority
|
||||
risk
|
||||
verification method
|
||||
current status
|
||||
evidence
|
||||
open decision
|
||||
```
|
||||
|
||||
Allowed status values:
|
||||
|
||||
- replaced by RuoYi-Vue-Pro;
|
||||
- migrated;
|
||||
- partially migrated;
|
||||
- pending migration;
|
||||
- explicitly retired;
|
||||
- product decision required.
|
||||
|
||||
## 5. Delivery phases
|
||||
|
||||
### Phase 0 — inventory and architecture mapping
|
||||
|
||||
Complete the artifacts above, review the two historical commits, assess current uncommitted work, correct confirmed obsolete documentation, and select the first incomplete vertical slice.
|
||||
|
||||
### Phase 1 — tenant, identity, and permission baseline
|
||||
|
||||
Reuse System Tenant and Member/System Auth, unify Student/Tenant Admin/Platform Admin identity rules, verify cross-tenant protections, and decide whether the `TenantCommonApi` extension is a valid generic API.
|
||||
|
||||
### Phase 2 — student core learning loop
|
||||
|
||||
Verify and complete only genuine gaps in:
|
||||
|
||||
```text
|
||||
catalog browsing
|
||||
→ safe question browsing
|
||||
→ create practice
|
||||
→ save answer
|
||||
→ restore practice
|
||||
→ submit
|
||||
→ report
|
||||
→ wrong questions
|
||||
→ favorites
|
||||
```
|
||||
|
||||
The current branch may already implement much of this phase. Review before adding anything.
|
||||
|
||||
### Phase 3 — education content management
|
||||
|
||||
Question banks, questions, classifications, catalogs, publishing, imports/exports, resource associations, question videos, and content access control.
|
||||
|
||||
### Phase 4 — tenant education management
|
||||
|
||||
Classes, education student relationships, invitations, education roles, tenant education configuration, education points/badges, learning insight, and operations metrics. Generic users, roles, and tenants remain in Member/System.
|
||||
|
||||
### Phase 5 — commercialization
|
||||
|
||||
Products, orders, payments, refunds, subscriptions or entitlements, reconciliation, collection, commission, and referral relationships. Prefer composition of Mall, Pay, Member, and CRM. Education owns only education-domain bindings and orchestration.
|
||||
|
||||
### Phase 6 — asynchronous and operational capabilities
|
||||
|
||||
Imports/exports, content processing, billing work, notifications, resource scanning, audit, retries, and observability using Infra Job, messaging, File, and logging capabilities.
|
||||
|
||||
Each phase must be independently compilable, testable, deployable, and reversible at the application/configuration level.
|
||||
|
||||
## 6. Workflow operating model
|
||||
|
||||
This goal is executed as a decision-first, multi-session program:
|
||||
|
||||
```text
|
||||
Phase 0 read-only multi-agent discovery
|
||||
→ Wayfinder-style decision map
|
||||
→ domain modeling and module-boundary design
|
||||
→ migration specification
|
||||
→ blocker-aware vertical-slice tickets
|
||||
→ one fresh implementation context per ticket
|
||||
→ TDD and PostgreSQL/Flyway when applicable
|
||||
→ standards/spec review
|
||||
→ security review
|
||||
→ simplification
|
||||
→ focused and integration verification
|
||||
```
|
||||
|
||||
### 6.1 Multi-agent use
|
||||
|
||||
Use multi-agent workflows for broad read-only discovery, independent commit reviews, module capability mapping, adversarial verification, and completeness checks.
|
||||
|
||||
Do not allow multiple agents to edit the current dirty working tree concurrently. Implementation is serial by default. Isolated worktrees are permitted only for independent file sets with an explicit integration plan.
|
||||
|
||||
### 6.2 Ticket shape
|
||||
|
||||
Tickets are vertical behaviors, not technical layers. A ticket may include migration, DO, Mapper, Service, Controller, tests, and documentation needed to deliver one observable capability.
|
||||
|
||||
Good examples:
|
||||
|
||||
- a student can browse published catalog content in the current tenant;
|
||||
- a student can create and restore a practice session;
|
||||
- a student can idempotently save one answer;
|
||||
- a student can idempotently submit and read an immutable report;
|
||||
- a tenant administrator can publish a question.
|
||||
|
||||
Avoid tickets such as “create all DOs” or “create all Controllers.” Declare blocking edges explicitly and implement blockers first.
|
||||
|
||||
### 6.3 Skill selection
|
||||
|
||||
- `flyway-postgresql`: every database or Flyway change.
|
||||
- `mattpocock-skills:domain-modeling`: ambiguous or overloaded education language.
|
||||
- `mattpocock-skills:codebase-design`: module interfaces, provider/adapter seams, and public API boundaries.
|
||||
- `mattpocock-skills:research`: external primary-source research, not local repository inventory.
|
||||
- `mattpocock-skills:prototype`: throwaway executable exploration for a single unresolved design question.
|
||||
- `mattpocock-skills:tdd`: red-green implementation of a concrete behavior.
|
||||
- `mattpocock-skills:diagnosing-bugs`: hard defects after establishing a reliable failing command.
|
||||
- `mattpocock-skills:code-review`: standards and specification review from a fixed Git point.
|
||||
- `security-review`: tenant, identity, authorization, secret, answer, payment, and file boundaries.
|
||||
- `simplify`: reuse and structural cleanup after correctness review.
|
||||
- `run` and `webapp-testing`: real application and student-flow verification.
|
||||
|
||||
If a named planning skill is unavailable, preserve the same artifacts and gates using repository documents, issue files, and the workflow tool rather than skipping the phase.
|
||||
|
||||
## 7. Implementation rules
|
||||
|
||||
Follow the target layering:
|
||||
|
||||
```text
|
||||
controller
|
||||
service
|
||||
dal/dataobject
|
||||
dal/mysql
|
||||
convert
|
||||
enums
|
||||
api
|
||||
framework/integration
|
||||
```
|
||||
|
||||
- Controllers perform protocol adaptation and validation.
|
||||
- Services own transactions, state transitions, authorization-relevant domain checks, and idempotency semantics.
|
||||
- Mappers own data access only.
|
||||
- VO, DTO, and DO responsibilities remain distinct.
|
||||
- Use project `CommonResult`, paging, validation, conversion, exception, error-code, Redis, lock, transaction, and audit facilities.
|
||||
- External or legacy coexistence is hidden behind explicit Provider/Adapter boundaries.
|
||||
- Do not introduce NestJS runtime dependencies or reproduce NestJS Guard/Decorator architecture.
|
||||
|
||||
Idempotency and consistency requirements:
|
||||
|
||||
- database uniqueness is the final idempotency guard;
|
||||
- critical writes are transactional;
|
||||
- do not rely only on check-then-insert;
|
||||
- duplicate-request response semantics are explicit;
|
||||
- concurrency is tested;
|
||||
- external payment, notification, and file calls do not create long database transactions;
|
||||
- at-least-once consumers define duplicate handling.
|
||||
|
||||
## 8. Verification gates
|
||||
|
||||
Every implementation slice runs the minimum sufficient focused tests plus at least:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
mvn -pl yudao-server -am -DskipTests clean compile
|
||||
```
|
||||
|
||||
Behavior changes require focused tests in Education and every affected module. Database changes additionally require PostgreSQL syntax validation, module packaging, and confirmation that migration files appear under `target/classes/db/migration/`.
|
||||
|
||||
Test applicable negative and concurrent scenarios:
|
||||
|
||||
- cross-tenant access;
|
||||
- unauthenticated access;
|
||||
- unauthorized access;
|
||||
- duplicate request;
|
||||
- concurrent request;
|
||||
- sensitive-field leakage;
|
||||
- catalog source failure;
|
||||
- disabled feature;
|
||||
- historical-data compatibility.
|
||||
|
||||
## 9. Per-slice reporting contract
|
||||
|
||||
Before implementation, report:
|
||||
|
||||
1. legacy capability and evidence;
|
||||
2. legacy files and database objects;
|
||||
3. target module;
|
||||
4. RuoYi-Vue-Pro capabilities reused;
|
||||
5. why another module will or will not change;
|
||||
6. database changes;
|
||||
7. tests;
|
||||
8. risks and rollback method.
|
||||
|
||||
After implementation, report:
|
||||
|
||||
1. changed files;
|
||||
2. reused modules;
|
||||
3. new Education domain capability;
|
||||
4. reasons for every non-Education change;
|
||||
5. replaced legacy code;
|
||||
6. unmigrated capabilities;
|
||||
7. commands actually run and results;
|
||||
8. whether PostgreSQL migration was actually executed;
|
||||
9. known risks and recommended next slice.
|
||||
|
||||
Do not state “complete” without verifiable files and command results.
|
||||
|
||||
## 10. Prohibitions
|
||||
|
||||
Do not:
|
||||
|
||||
- embed the old NestJS project;
|
||||
- mechanically translate all TypeScript files or all 342 APIs;
|
||||
- duplicate authentication, tenant, RBAC, payment, membership, notification, file, job, or audit platforms in Education;
|
||||
- trust client `userId` or `tenantId`;
|
||||
- expose answers or explanations;
|
||||
- introduce MySQL dialect or new MySQL delivery scripts;
|
||||
- bypass Flyway or modify published migrations;
|
||||
- depend on internal implementations of other modules;
|
||||
- weaken security for backward compatibility;
|
||||
- overwrite unrelated uncommitted work;
|
||||
- use destructive Git commands;
|
||||
- claim tests or migrations succeeded without running them;
|
||||
- begin a broad implementation before Phase 0 identifies the actual gaps.
|
||||
|
||||
## 11. Definition of done
|
||||
|
||||
The migration is complete only when:
|
||||
|
||||
1. every legacy capability has a reuse, migration, retirement, or decision status;
|
||||
2. Education-specific behavior resides in Education;
|
||||
3. platform capabilities are reused through appropriate boundaries;
|
||||
4. every non-Education modification has a necessity statement and tests;
|
||||
5. tenant isolation and sensitive-question-field controls are verified;
|
||||
6. database changes use PostgreSQL Flyway;
|
||||
7. core vertical flows have automated tests;
|
||||
8. required compile and diff checks pass;
|
||||
9. documentation matches the current PostgreSQL/Flyway architecture;
|
||||
10. existing user changes have not been overwritten;
|
||||
11. the target can run without the old service, or every temporary dependency has an owner and exit plan.
|
||||
|
||||
## 12. Immediate execution directive
|
||||
|
||||
Phase 0 inventory and the first safe-question slice have been executed. Continue through the blocker-aware tickets under [`docs/education/migration/issues/`](issues/README.md).
|
||||
|
||||
Current execution order:
|
||||
|
||||
1. `EDU-002` — restore the full Practice regression baseline;
|
||||
2. `EDU-003` — decide tenant resolution and student-principal policy;
|
||||
3. `EDU-004` — enforce the selected tenant/identity policy;
|
||||
4. `EDU-005` — decide PostgreSQL/Flyway takeover;
|
||||
5. `EDU-006` — deliver the approved Practice schema through module-owned Flyway;
|
||||
6. `EDU-007` through `EDU-009` — verify and complete the student core loop;
|
||||
7. later phases proceed only when their ticket blockers are complete.
|
||||
|
||||
Before every ticket:
|
||||
|
||||
1. read this Goal, the ticket, relevant decisions, and current Git status;
|
||||
2. preserve all existing uncommitted work;
|
||||
3. state the legacy capability, reuse boundary, database impact, tests, risk, and rollback;
|
||||
4. use a fresh implementation context and work serially in the dirty tree;
|
||||
5. use `flyway-postgresql` for any database or Flyway change;
|
||||
6. finish with focused tests, `git diff --check`, and `mvn -pl yudao-server -am -DskipTests clean compile`;
|
||||
7. report exact results and never claim PostgreSQL migration success without a real successful run.
|
||||
|
||||
Questions that can be answered from code, Git history, configuration, tests, or documentation must be investigated rather than asked. Ask only for genuine product decisions whose outcomes materially change implementation.
|
||||
37
docs/education/migration/issues/EDU-000-phase-0-inventory.md
Normal file
37
docs/education/migration/issues/EDU-000-phase-0-inventory.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# EDU-000 — Phase 0 inventory and architecture map
|
||||
|
||||
- **Status:** done
|
||||
- **Type:** discovery
|
||||
- **Phase:** 0
|
||||
- **Blockers:** none
|
||||
|
||||
## Outcome
|
||||
|
||||
A verified static map of the source system, target capabilities, historical commits, database objects, reusable modules, unresolved decisions, and vertical-slice roadmap exists under `docs/education/migration/`.
|
||||
|
||||
## Delivered artifacts
|
||||
|
||||
- `00-current-state.md`
|
||||
- `01-capability-matrix.md`
|
||||
- `02-api-mapping.md`
|
||||
- `03-database-object-mapping.md`
|
||||
- `04-module-reuse-map.md`
|
||||
- `05-commit-review-11e9cc6.md`
|
||||
- `06-commit-review-0f846fd.md`
|
||||
- `07-decisions.md`
|
||||
- `08-slice-roadmap.md`
|
||||
- `09-first-slice.md`
|
||||
- `10-documentation-corrections.md`
|
||||
|
||||
## Evidence and caveats
|
||||
|
||||
- Investigation was read-only and multi-agent.
|
||||
- No PostgreSQL migration was executed during discovery.
|
||||
- The source baseline is provisionally `main` at `033701a`; no source `feature/education-core-loop` ref was found.
|
||||
- The target worktree is dirty and must remain protected.
|
||||
- Completing this ticket did not resolve the product and architecture decisions recorded in `07-decisions.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
- Artifacts generated and inspected.
|
||||
- `git diff --check -- docs/education/migration` passed at delivery time.
|
||||
@@ -0,0 +1,55 @@
|
||||
# EDU-001 — Provider-neutral safe question content
|
||||
|
||||
- **Status:** done with recorded follow-up coverage
|
||||
- **Type:** implementation
|
||||
- **Phase:** 0 / core-loop prerequisite
|
||||
- **Blockers:** EDU-000
|
||||
|
||||
## Student outcome
|
||||
|
||||
A student cannot receive or restore an apparently valid question when its type, visibility, or options are malformed. Student-visible question content and practice snapshot JSON do not expose answer-bearing fields.
|
||||
|
||||
## Scope delivered
|
||||
|
||||
- Shared question-type and option-shape contract.
|
||||
- Option-backed, optionless, unsupported composite, and unknown type handling.
|
||||
- Fail-closed single/page/collection safe projection.
|
||||
- Fail-closed practice creation for disabled/unavailable provider, invisible question, and unsafe options.
|
||||
- Strict practice snapshot restoration.
|
||||
- Answer-free option snapshot JSON.
|
||||
|
||||
## Relevant files
|
||||
|
||||
- `docs/education/migration/11-question-content-safety-contract.md`
|
||||
- `yudao-module-education/CONTEXT.md`
|
||||
- `service/question/QuestionContentSafety.java`
|
||||
- `service/question/QuestionCatalogServiceImpl.java`
|
||||
- `service/practice/PracticeSessionServiceImpl.java`
|
||||
- `service/practice/SessionResponseAssembler.java`
|
||||
- corresponding focused tests
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Invalid option-backed content fails closed.
|
||||
- [x] Valid optionless content may have no options.
|
||||
- [x] `reading` and unknown types fail closed until modeled.
|
||||
- [x] Safe responses and option snapshot JSON exclude correctness and explanation fields.
|
||||
- [x] Malformed persisted snapshots do not become empty valid options.
|
||||
- [x] Disabled/unavailable providers and invisible questions cannot create sessions.
|
||||
- [x] Focused safety tests pass.
|
||||
- [x] Required compile and diff checks pass.
|
||||
|
||||
## Follow-up coverage
|
||||
|
||||
- Add a clean JavaCatalogProvider public-seam/PostgreSQL contract test when the native catalog test harness is established.
|
||||
- Add explicit cross-tenant and `tenant_id=0` PUBLIC graph tests in the native catalog/graph-integrity slice.
|
||||
- Do not test private provider parsing through reflection.
|
||||
|
||||
## Verification recorded
|
||||
|
||||
```text
|
||||
Focused tests: 112 run, 0 failures, 0 errors
|
||||
git diff --check: passed
|
||||
yudao-server clean compile: BUILD SUCCESS
|
||||
PostgreSQL migration: not applicable and not executed
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# EDU-002 — Restore the full Practice regression baseline
|
||||
|
||||
- **Status:** completed as test-context repair; PostgreSQL persistence coverage moved to EDU-016
|
||||
- **Type:** test-enablement vertical slice
|
||||
- **Phase:** 0 / Phase 2 prerequisite
|
||||
- **Blockers:** EDU-001
|
||||
|
||||
## Outcome
|
||||
|
||||
The complete Practice test set starts reliably and distinguishes test-context failures from real behavior regressions across create, answer, restore, submit, report, wrong-question, and favorite flows.
|
||||
|
||||
## Why this is next
|
||||
|
||||
The direct EDU-001 tests pass, but broader Practice tests currently fail during Spring test-context creation because test configurations that import `PracticeSessionServiceImpl` do not consistently provide its current `ScoringService` dependency. Some tests also use name-based `@Resource` injection against Mapper proxies, producing type mismatches. Continuing core-loop work without this feedback loop would hide regressions.
|
||||
|
||||
## Existing code and data
|
||||
|
||||
- No legacy capability is being newly migrated.
|
||||
- No database object changes are required.
|
||||
- Existing Education test SQL and Mapper test infrastructure are reused.
|
||||
|
||||
## Scope
|
||||
|
||||
1. Inventory every test that imports, instantiates, or indirectly creates `PracticeSessionServiceImpl`.
|
||||
2. For each test context, choose one explicit dependency strategy:
|
||||
- import the real `ScoringServiceImpl` when scoring behavior is under test; or
|
||||
- provide `@MockitoBean ScoringService` when the test is outside the scoring seam.
|
||||
3. Replace ambiguous name-based Mapper injection only where it currently prevents the target tests from starting.
|
||||
4. Run the complete focused Practice regression set.
|
||||
5. Classify remaining failures as:
|
||||
- test assembly defect;
|
||||
- existing product defect;
|
||||
- expected contract change from EDU-001;
|
||||
- unrelated dirty-worktree issue.
|
||||
6. Fix only test-assembly defects in this ticket. Create separate tickets for product defects.
|
||||
|
||||
## Reuse boundaries
|
||||
|
||||
- Reuse `BaseDbUnitTest`, existing Education test SQL, Spring `@Import`, and `@MockitoBean`.
|
||||
- Do not create a parallel test framework.
|
||||
- Do not modify System, Member, database schema, or production state machines.
|
||||
- Do not weaken assertions merely to make tests green.
|
||||
|
||||
## Target test set
|
||||
|
||||
```text
|
||||
PracticeSessionServiceImplTest
|
||||
PracticeAnswerServiceImplTest
|
||||
PracticeSubmitServiceImplTest
|
||||
PracticeSubmitProjectionIntegrationTest
|
||||
PracticeSessionControllerHttpTest
|
||||
PracticeAnswerControllerHttpTest
|
||||
PracticeSessionControllerSubmitHttpTest
|
||||
WrongQuestionServiceImplTest
|
||||
FavoriteServiceImplTest
|
||||
```
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Every target class starts its Spring/JUnit context.
|
||||
- [ ] No target class fails because `ScoringService` is missing.
|
||||
- [ ] No target class fails from avoidable Mapper bean-name/type injection ambiguity.
|
||||
- [ ] EDU-001 safe-content assertions remain green.
|
||||
- [ ] Any actual behavior failure is documented with reproducible command and assigned a separate ticket.
|
||||
- [ ] No production behavior or database schema is changed unless a failing regression proves it is necessary and the ticket is explicitly amended.
|
||||
|
||||
## Test command
|
||||
|
||||
```bash
|
||||
mvn -pl yudao-module-education \
|
||||
-Dtest='PracticeSessionServiceImplTest,PracticeAnswerServiceImplTest,PracticeSubmitServiceImplTest,PracticeSubmitProjectionIntegrationTest,PracticeSessionControllerHttpTest,PracticeAnswerControllerHttpTest,PracticeSessionControllerSubmitHttpTest,WrongQuestionServiceImplTest,FavoriteServiceImplTest' \
|
||||
-Dsurefire.failIfNoSpecifiedTests=false \
|
||||
test
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
mvn -pl yudao-server -am -DskipTests clean compile
|
||||
```
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** Low production risk; medium risk of exposing pre-existing behavior defects.
|
||||
- **Rollback:** Revert only this ticket's test assembly changes. There is no database rollback.
|
||||
|
||||
## Completion result
|
||||
|
||||
Test-context assembly was repaired:
|
||||
|
||||
- `ScoringService` is now explicitly mocked in Practice contexts that are not testing scoring itself.
|
||||
- `PracticeQuestionMapper` fields use type-based injection where name-based `@Resource` resolved the wrong MyBatis proxy.
|
||||
- Controller tests and `PracticeSessionServiceImplTest` start and pass.
|
||||
|
||||
The expanded regression run then exposed a separate infrastructure limitation rather than a remaining Spring context defect: H2 cannot execute the production PostgreSQL `ON CONFLICT` statements, and the unified `education_idempotency` test table was missing. A temporary H2 table definition was added so table absence no longer masks the dialect issue, but PostgreSQL conflict semantics cannot be made truthful on H2. The required follow-up is [`EDU-016`](EDU-016-postgresql-persistence-tests.md).
|
||||
|
||||
## Verification result
|
||||
|
||||
- Controller Practice tests: 39 passed.
|
||||
- `PracticeSessionServiceImplTest`: 24 passed before PostgreSQL-dialect persistence paths were included.
|
||||
- Full targeted suite starts after dependency/injection repair, then fails on confirmed H2/PostgreSQL dialect mismatch and downstream assertions.
|
||||
- No production behavior was changed by EDU-002.
|
||||
@@ -0,0 +1,160 @@
|
||||
# EDU-003 — Decide tenant resolution and student-principal policy
|
||||
|
||||
- **Status:** done — corrected policy and EDU-004 test seams are implementation-ready
|
||||
- **Type:** decision
|
||||
- **Phase:** 1
|
||||
- **Blockers:** EDU-000
|
||||
|
||||
## Decision outcome
|
||||
|
||||
Public tenant resolution accepts caller-supplied locator claims and is not an authentication boundary. Browser `Origin`/`Referer` evidence improves browser-context consistency but is forgeable by non-browser clients. The accepted contract therefore documents tenant-existence disclosure, uses one redacted unavailable response for unknown/disabled/expired tenants, requires abuse controls, and reserves authenticated/signed locators for deployments that require spoof resistance. Authenticated Education context is Member-only. This ticket records policy and tests-to-write only; it changes no production behavior.
|
||||
|
||||
## Domain language
|
||||
|
||||
- A **Tenant Locator Claim** is an unauthenticated pre-login value used to request tenant selection: either a browser-context hostname claim or an explicit Public Tenant Handle. It is not proof of caller identity or tenant authorization.
|
||||
- **Browser-context evidence** is a normalized host derived from `Origin`, falling back to `Referer`. It can bind browser UX inputs consistently, but any HTTP client can forge it.
|
||||
- A **Public Tenant Handle** is the current System tenant's unique `name` used as an exact public lookup key because the target has no separate stable tenant-code capability. It is not called a Tenant Code. It is case-sensitive, must match `^[A-Za-z0-9._-]{2,64}$`, and administrators must treat it as immutable after publication. A future mutable display label must be a separate field.
|
||||
- A **Student Principal** is an authenticated identity whose `LoginUser.userType` is `UserTypeEnum.MEMBER`. A generic authenticated account is not necessarily a Student Principal.
|
||||
- **Public Tenant Resolution** maps a Tenant Locator Claim to minimal login-routing fields. It intentionally discloses existence when a claim succeeds; it does not disclose whether a failed tenant is unknown, disabled, or expired.
|
||||
|
||||
## Evidence reviewed
|
||||
|
||||
- Legacy `apps/api/src/features/tenant/locator.ts` parses and compares `Origin`, `Referer`, request hosts, and a tenant code, but does not authenticate header provenance.
|
||||
- Legacy `resolver.ts` compares the source `slug` with the explicit code, proving the source Tenant Code was distinct from its display name.
|
||||
- The target has no System-owned stable tenant-code field or API. `system_tenant.name` is unique and mutable through administration; it is the only current exact generic lookup key.
|
||||
- Current `EducationTenantController` accepts arbitrary public `hostname` or `tenantName`, preserves ports, returns status and Education-configured `loginMethods`, and exposes distinct unknown/disabled/expired errors.
|
||||
- `EducationProperties.hostnameTenantMap` documents lowercase host-only keys without ports, while current implementation and tests preserve ports.
|
||||
- `system_tenant.websites` is an exact string-list lookup and existing target tests demonstrate values containing a scheme. No normalization seam currently makes those values host-only.
|
||||
- `EducationContextController` derives IDs from framework contexts but reads only the user ID and therefore does not reject an authenticated ADMIN principal.
|
||||
- `TenantSecurityWebFilter` already fills a missing request tenant from the authenticated principal, rejects authenticated principal/request-tenant mismatch, requires a tenant for non-ignored URLs, and validates tenant availability.
|
||||
- `TenantCommonApi` exposes generic System-owned tenant lookup methods; `TenantApiImpl` implements them, but the interface currently hides missing adapters behind `UnsupportedOperationException` defaults and has no focused owning-module contract test.
|
||||
- No verified Member public interface advertises enabled login methods. `MemberConfigApi` currently exposes points configuration only.
|
||||
|
||||
## ADR: public tenant-resolution and student-principal policy
|
||||
|
||||
### Status
|
||||
|
||||
Accepted for EDU-004.
|
||||
|
||||
### Context and trade-off
|
||||
|
||||
The resolver is public and cannot authenticate `Origin`, `Referer`, or ordinary query/header values. Browser headers are useful for consistent browser routing, not identity. A successful lookup necessarily distinguishes an available tenant from a failed candidate when it returns routing fields. The contract can hide lifecycle state among failures, but cannot honestly promise general non-enumeration without an unguessable or signed locator.
|
||||
|
||||
The target also lacks the source system's distinct stable tenant code. Adding one would require a separately designed System-owned capability and likely data work. For the current slice, the existing unique System tenant `name` is explicitly exposed as a constrained Public Tenant Handle; it is no longer mislabeled as a Tenant Code.
|
||||
|
||||
### Decision
|
||||
|
||||
1. **Production browser-context binding, not trusted identity**
|
||||
- Derive browser-context evidence from a valid HTTP(S) `Origin`; if absent, use a valid HTTP(S) `Referer`.
|
||||
- A supplied `hostname` may only confirm that evidence. A mismatch is a public locator conflict.
|
||||
- `Origin` and `Referer` are untrusted caller claims. Proxy preservation and forwarding-header controls do not make them authentic and are not cited as spoofing protection.
|
||||
- A non-browser/headless caller can forge either header and probe hostnames. This accepted threat is handled through the public disclosure policy and abuse controls below.
|
||||
- A deployment requiring spoof resistance must replace this public mode with an authenticated/signed locator or a host value supplied through a separately designed trusted-proxy boundary. That stronger mode is not implemented by EDU-004.
|
||||
|
||||
2. **Explicit headless handle and legacy query compatibility**
|
||||
- A headless client may submit `tenantHandle`, defined above as the existing System tenant unique `name` under a constrained public contract.
|
||||
- The legacy public `tenantName` query is unsupported and must be rejected, not silently aliased. EDU-004 adds a compatibility test for its rejection/removal.
|
||||
- A browser domain claim and explicit `tenantHandle` may be supplied together only when both resolve to the same tenant; disagreement is a public locator conflict.
|
||||
|
||||
3. **Local-development activation seam**
|
||||
- The sole authority is `yudao.education.tenant-resolution.local-development-enabled`.
|
||||
- Its secure default is `false`; absence means production-safe behavior. Spring profile names and environment names do not implicitly enable it.
|
||||
- Only developer workstations and automated tests may set it to `true`; shared, staging, and production deployments must keep it `false`.
|
||||
- When enabled, a configured local request host (`localhost`, `*.localhost`, loopback IPv4, `0.0.0.0`, or `::1`) may resolve without a handle. If `tenantHandle` is also present, the explicit handle takes precedence.
|
||||
- EDU-004 tests code-less local host, local host plus handle, and rejection of local/request-host fallback when the flag is absent or false.
|
||||
|
||||
4. **Hostname identity and normalization**
|
||||
- Tenant hostname identity is host-only: trim whitespace, lowercase, remove one trailing dot, remove IPv6 brackets, and discard default or non-default ports.
|
||||
- Accept valid DNS hosts, IPv4, and IPv6; reject credentials, paths, comma-separated/multi-value input, malformed authorities, and unsupported schemes.
|
||||
- `localhost:48080` normalizes to `localhost`.
|
||||
- Canonical `system_tenant.websites` entries used for this resolver are host-only values in the same normalized form. Entries containing a scheme, path, credentials, comma-separated values, or a port are legacy/non-canonical configuration and are not matched by Public Tenant Resolution.
|
||||
- EDU-004 implements canonical exact lookup and focused tests; it does not silently normalize legacy stored candidates at read time. Tenant administrators must correct non-canonical website configuration before enabling domain resolution. If later inventory requires automated data correction, that becomes a separately scoped Flyway/data ticket using `flyway-postgresql`; EDU-004 must not claim such correction.
|
||||
|
||||
5. **Authenticated Education context**
|
||||
- `/education/context` obtains the full `LoginUser`, rejects missing authentication, and rejects `userType != UserTypeEnum.MEMBER`.
|
||||
- User and tenant IDs continue to come only from security and tenant contexts.
|
||||
- EDU-004 preserves and does not duplicate or bypass `TenantSecurityWebFilter` mismatch and availability checks.
|
||||
|
||||
6. **Login-method metadata ownership**
|
||||
- Login-method metadata belongs to Member authentication, not System tenant metadata and not Education.
|
||||
- EDU-004 removes `loginMethods` from Education resolution and deprecates Education configuration/documentation that presents it as authoritative.
|
||||
- If later routing proves it necessary, introduce only a minimal Member-owned public interface with focused Member tests; do not create tenant-specific auth configuration in Education.
|
||||
|
||||
7. **Exact external wire contract**
|
||||
- The target framework represents business failures as HTTP `200 OK` with a `CommonResult` envelope. EDU-004 keeps that convention; tests assert both transport status and envelope.
|
||||
- Malformed, missing, locally forbidden, or otherwise unsupported locator claim: HTTP `200`; `CommonResult.code = 1005001003`; `msg = "租户识别请求无效"`; `data = null`.
|
||||
- Domain/handle or browser-evidence/requested-host conflict: HTTP `200`; `CommonResult.code = 1005001008` (new stable Education business code); `msg = "租户识别信息冲突"`; `data = null`.
|
||||
- Unknown, disabled, or expired tenant: HTTP `200`; `CommonResult.code = 1005001004`; `msg = "当前租户不可用"`; `data = null`.
|
||||
- Messages contain no rejected host/handle, lifecycle status, System exception text, or lookup detail. Logs may record a reason category and correlation metadata but must not log secrets or echo unsanitized header values.
|
||||
- Unknown, disabled, and expired paths must have identical status, code, message, JSON field set, null-data shape, and no intentional timing distinction. System errors remain internal.
|
||||
- Success is HTTP `200`, `code = 0`, `msg = ""`, and data contains only `tenantId` and `displayName`. `displayName` currently comes from the System tenant `name`; because that same field is the current Public Tenant Handle, an exact handle lookup necessarily returns the submitted handle as `displayName`. A future non-echoing mutable label requires a separate System-owned public display field. The response contains no separate handle field, raw status, websites, expiry, package, private configuration, internal lifecycle detail, or `loginMethods`.
|
||||
|
||||
8. **Disclosure and abuse threat model**
|
||||
- The resolver is not generally non-enumerating: a valid Public Tenant Handle or domain claim yields success with tenant ID/display name, while an unavailable candidate yields the generic failure.
|
||||
- The accepted guarantee is only unknown/disabled/expired indistinguishability.
|
||||
- EDU-004 must attach the public resolver to the repository's existing public API rate-limiting/ingress mechanism where available, emit structured success/failure-category security metrics, and document alerting for sustained candidate probing. If no reusable limiter seam exists, EDU-004 records that operational blocker rather than inventing an Education-only limiter.
|
||||
|
||||
9. **System seam**
|
||||
- Retain `TenantCommonApi` as the generic System-owned seam; do not add Education-specific locator, branding, redaction, or login-method concepts.
|
||||
- Replace `UnsupportedOperationException` lookup defaults with required abstract methods and add focused `TenantApiImpl` contract tests.
|
||||
- `EducationTenantController` remains the public adapter applying claim consistency, canonical website policy, availability coarsening, exact errors, and redaction.
|
||||
|
||||
### Rejected alternatives
|
||||
|
||||
- Treating `Origin` or `Referer` as authenticated tenant identity.
|
||||
- Claiming forwarding-header ingress controls authenticate browser headers.
|
||||
- Claiming general non-enumeration while successful lookup returns identifying fields.
|
||||
- Silently aliasing System tenant `name` to the distinct Tenant Code domain term.
|
||||
- Continuing the legacy `tenantName` public query.
|
||||
- Silently normalizing scheme/path/port-bearing stored website values during lookup.
|
||||
- Port-sensitive tenant identity.
|
||||
- ADMIN accepted as Student Principal.
|
||||
- Education-owned login methods or an Education-specific System API.
|
||||
|
||||
### Consequences
|
||||
|
||||
- EDU-004 intentionally changes current query, response, error, local-mode, website, and port behavior.
|
||||
- Published Public Tenant Handles use the System tenant unique `name`; renaming one is a breaking login-routing change until a genuine stable System-owned code exists.
|
||||
- Non-canonical website entries require configuration correction before domain resolution is enabled; no database change is authorized here.
|
||||
- Public existence disclosure is accepted and must be monitored and throttled. Strong spoof resistance requires a future signed/authenticated locator design.
|
||||
|
||||
## EDU-004 exact test matrix
|
||||
|
||||
| Scenario | Exact expected behavior | Owning test seam |
|
||||
|---|---|---|
|
||||
| Valid production `Origin` | Resolve normalized domain claim; success HTTP 200/code 0 | Education controller HTTP test |
|
||||
| Missing `Origin`, valid `Referer` | Resolve normalized Referer host; success HTTP 200/code 0 | Education controller HTTP test |
|
||||
| Forged but syntactically valid browser header | Documented as accepted untrusted claim; no authenticity assertion | Education controller test name/documentation |
|
||||
| Malformed `Origin` | HTTP 200/code 1005001003/generic message/null data; no fallback | Education controller HTTP test |
|
||||
| Origin/requested-host mismatch | HTTP 200/code 1005001008/generic conflict/null data | Education controller HTTP test |
|
||||
| Arbitrary production hostname without browser evidence | HTTP 200/code 1005001003 | Education controller HTTP test |
|
||||
| Explicit `tenantHandle` | Exact case-sensitive constrained System-name lookup | Education controller HTTP test |
|
||||
| Legacy `tenantName` query | Rejected/unsupported; HTTP 200/code 1005001003 | Education controller compatibility HTTP test |
|
||||
| Unknown, disabled, expired | Identical HTTP 200/code 1005001004/message/JSON/null data | Education controller HTTP parameterized test |
|
||||
| Host case/trailing dot/IPv4/IPv6/ports | Canonical host-only identity; all ports discarded | Education normalization/HTTP tests |
|
||||
| Non-canonical stored website candidate | Not matched; generic unavailable response | System adapter fixture plus Education HTTP test |
|
||||
| Local flag absent/false | Local/request-host fallback rejected with code 1005001003 | Education controller HTTP test |
|
||||
| Local flag true, code-less local host | Configured local host may resolve | Education controller HTTP test |
|
||||
| Local flag true, local host plus handle | Explicit handle takes precedence | Education controller HTTP test |
|
||||
| Domain/handle agreement | Resolve one tenant | Education controller HTTP test |
|
||||
| Domain/handle conflict | HTTP 200/code 1005001008 | Education controller HTTP test |
|
||||
| Anonymous `/education/context` | Existing unauthorized contract | Education context HTTP test |
|
||||
| Missing tenant or authenticated mismatch | Existing filter behavior remains active | Framework `TenantSecurityWebFilter` tests |
|
||||
| ADMIN/MEMBER principal | ADMIN rejected; MEMBER accepted with context-derived IDs | Education context HTTP tests |
|
||||
| Public field redaction | Success has only tenantId/displayName; failure has code/msg/data only | Education controller HTTP test |
|
||||
| `TenantCommonApi` adapters | Required methods delegate/map; no unsupported defaults | System `TenantApiImpl` contract test |
|
||||
| Abuse controls | Reused limiter/ingress attachment and structured category metric proven, or blocker recorded | Configuration/integration test where seam exists |
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Browser headers are described as forgeable consistency evidence, not trusted identity.
|
||||
- [x] Public existence disclosure and the narrower lifecycle-indistinguishability guarantee are explicit.
|
||||
- [x] Public Tenant Handle is distinguished from the source Tenant Code and has exact mutability/case/format semantics.
|
||||
- [x] Local behavior has one named, secure-default configuration seam and unambiguous precedence.
|
||||
- [x] Canonical stored website compatibility policy is selected without claiming data migration.
|
||||
- [x] Every error category has exact HTTP status, stable `CommonResult` code, message, data shape, and redaction rules.
|
||||
- [x] EDU-004 has exact production/test seams and legacy `tenantName` compatibility coverage.
|
||||
|
||||
## Verification
|
||||
|
||||
Static design review only. Reviewed legacy `locator.ts`/`resolver.ts`, current Education controller/properties/error codes, `CommonResult` and global error handling, `TenantSecurityWebFilter`, `TenantCommonApi`/`TenantApiImpl`, System tenant name/website storage and tests, Member public interfaces, the completed EDU-016 ticket, tracker, and dirty working tree. No production implementation, build, database connection, Flyway execution, or migration was performed by EDU-003.
|
||||
@@ -0,0 +1,139 @@
|
||||
# EDU-004 — Enforce tenant resolution and student identity boundaries
|
||||
|
||||
- **Status:** done — accepted tenant-locator and Member-principal policy implemented and verified; ingress/IP-only probing throttle remains an operational blocker
|
||||
- **Type:** implementation
|
||||
- **Phase:** 1
|
||||
- **Blockers:** EDU-003
|
||||
|
||||
## Selected policy from EDU-003
|
||||
|
||||
- `Origin` then `Referer` supplies forgeable browser-context evidence, not trusted identity. It binds browser UX inputs but does not prevent non-browser spoofing.
|
||||
- Headless clients use `tenantHandle`, the existing System tenant unique `name` under an exact constrained public contract; do not call it a Tenant Code.
|
||||
- Reject/remove the legacy public `tenantName` query as a separate compatibility behavior.
|
||||
- Production domain/handle agreement is checked; disagreement is a locator conflict.
|
||||
- Host identity removes case, whitespace, trailing dot, IPv6 brackets, and all ports.
|
||||
- Local fallback is controlled only by `yudao.education.tenant-resolution.local-development-enabled`, default `false`; profiles do not implicitly enable it.
|
||||
- Canonical System website values for this resolver are normalized host-only strings. Scheme/path/port-bearing stored candidates are not silently normalized and require configuration correction or a separate future Flyway/data ticket.
|
||||
- `/education/context` accepts only an authenticated `UserTypeEnum.MEMBER` Student Principal.
|
||||
- Unknown/disabled/expired failures are identical, but successful resolution still discloses tenant existence. Reuse rate limiting/ingress controls and emit abuse-monitoring metrics.
|
||||
- Login-method metadata is Member-owned; remove/deprecate Education `loginMethods` unless a minimal Member public interface is first proven necessary.
|
||||
- Retain generic `TenantCommonApi`, make its lookup methods required, and add System-owned `TenantApiImpl` contract tests.
|
||||
|
||||
See [`EDU-003-tenant-resolution-decision.md`](EDU-003-tenant-resolution-decision.md) for rationale and exact threat model.
|
||||
|
||||
## Implementation result
|
||||
|
||||
Implemented the accepted public HTTP contracts for tenant resolution and Member-only Education context. The resolver treats `Origin`, `Referer`, `hostname`, and `tenantHandle` only as forgeable locator claims; successful responses expose only `tenantId` and `displayName`, and unknown/disabled/expired tenants share the exact unavailable envelope. Local fallback is controlled exclusively by the secure-default property, including browser-derived loopback hosts, and only configured local hosts use the Education mapping. Production domains always use the canonical System website lookup. `TenantCommonApi` lookup methods are now required and System-owned adapter tests prove exact delegation and DTO mapping. Existing `TenantSecurityWebFilter` production behavior was unchanged and is covered by focused filter-boundary regression tests.
|
||||
|
||||
No adequate reusable candidate-probing rate-limit attachment was found. The existing `ClientIpRateLimiterKeyResolver` includes attacker-controlled method arguments in its key, so attaching it would create per-candidate limits rather than an IP-only probing limit. No Education-only limiter was introduced; ingress/IP-only throttling and alerting remain an operational blocker. The Education module also has no direct generic Micrometer dependency seam, so structured resolver metrics remain part of the same operational blocker rather than adding an Education-only dependency or abstraction.
|
||||
|
||||
System currently has no separate public tenant display field: its unique `name` is both the Public Tenant Handle and the only public label available through `TenantCommonApi`. Therefore handle-based success returns that same value as `displayName`; tests now model this real exact-name adapter behavior. Suppressing that value requires a future generic System-owned display-field capability, not an Education workaround.
|
||||
|
||||
No database or Flyway change was made or executed.
|
||||
|
||||
## Student outcome
|
||||
|
||||
A student receives minimal login-routing data for an available tenant, while authenticated Education endpoints reject wrong tenants and non-Member principals. The public resolver does not claim that caller-supplied locator headers authenticate tenant identity.
|
||||
|
||||
## Scope
|
||||
|
||||
- browser-context domain claim selection from `Origin`, then `Referer`;
|
||||
- requested-host confirmation and domain/handle conflict handling;
|
||||
- explicit `tenantHandle` exact lookup and legacy `tenantName` rejection/removal;
|
||||
- host-only normalization and canonical stored-website behavior;
|
||||
- secure-default local-development configuration seam and precedence;
|
||||
- Member/student principal enforcement for `/education/context`;
|
||||
- exact public `CommonResult` contract and safe response fields;
|
||||
- minimal generic `TenantCommonApi` adjustment with System-owned tests;
|
||||
- reuse of public resolver rate limiting/ingress controls and structured abuse metrics where an existing seam is available.
|
||||
|
||||
## Exact external contract
|
||||
|
||||
All business outcomes use the framework convention of HTTP `200 OK` with `CommonResult`:
|
||||
|
||||
| Category | HTTP | `CommonResult.code` | `msg` | `data` |
|
||||
|---|---:|---:|---|---|
|
||||
| Malformed/missing/untrusted/locally forbidden locator | 200 | `1005001003` | `租户识别请求无效` | `null` |
|
||||
| Requested-host/domain/handle conflict | 200 | `1005001008` | `租户识别信息冲突` | `null` |
|
||||
| Unknown, disabled, or expired tenant | 200 | `1005001004` | `当前租户不可用` | `null` |
|
||||
| Success | 200 | `0` | empty string | object containing only `tenantId`, `displayName`; current `displayName` is System tenant `name` and therefore equals a successful handle claim |
|
||||
|
||||
Failure messages and JSON shape never contain the rejected host/handle, lifecycle state, System exception detail, or lookup reason. Unknown, disabled, and expired paths must be byte-shape equivalent after normal serialization and have no intentional timing distinction.
|
||||
|
||||
## Reuse boundaries
|
||||
|
||||
- Reuse `TenantSecurityWebFilter`, `TenantContextHolder`, System Tenant public APIs, Member/System security context, `UserTypeEnum`, and an existing public rate-limit/ingress seam if present.
|
||||
- Do not duplicate tenant tables, token logic, login methods, RBAC, or a generic rate-limiter in Education.
|
||||
- Education must not depend on System internal Services, Mappers, or DOs.
|
||||
- A non-Education change must be generic, minimal, backward-compatible, and tested in its owning module.
|
||||
- If no reusable abuse-control seam exists, record the operational blocker; do not invent an Education-only infrastructure abstraction.
|
||||
|
||||
## Required TDD tests
|
||||
|
||||
### Education tenant controller HTTP tests
|
||||
|
||||
- valid production `Origin`; valid `Referer` fallback;
|
||||
- syntactically valid forged header is treated only as an untrusted claim, with no authenticity assertion;
|
||||
- malformed Origin and no attacker-selected fallback: exact HTTP/code/msg/data;
|
||||
- Origin/requested-host mismatch: exact conflict contract;
|
||||
- arbitrary production hostname without browser evidence: exact invalid contract;
|
||||
- explicit case-sensitive `tenantHandle` with `^[A-Za-z0-9._-]{2,64}$` validation;
|
||||
- legacy `tenantName` query rejected/removed independently;
|
||||
- domain/handle agreement and conflict;
|
||||
- unknown/disabled/expired exact identical wire shape;
|
||||
- case, trailing dot, DNS, IPv4, bracketed IPv6, default and non-default port normalization;
|
||||
- local flag absent/false rejects local/request-host fallback;
|
||||
- local flag true permits code-less configured local host;
|
||||
- local flag true plus handle gives the handle precedence;
|
||||
- non-canonical stored website candidate does not match;
|
||||
- success exposes only `tenantId` and System tenant `name` as `displayName`; for handle lookup this necessarily equals the submitted handle until System owns a separate public display field; no separate handle field, status, websites, expiry, package, private config, or `loginMethods`.
|
||||
|
||||
### Education context HTTP tests
|
||||
|
||||
- unauthenticated context rejected;
|
||||
- ADMIN principal rejected;
|
||||
- MEMBER principal accepted and IDs derived only from security/tenant contexts.
|
||||
|
||||
### System contract tests
|
||||
|
||||
- `TenantCommonApi` lookup methods are required, not optional unsupported defaults;
|
||||
- `TenantApiImpl` exact name and canonical website lookups delegate and map DTOs;
|
||||
- non-canonical website candidates are not silently normalized by the Public Tenant Resolution path.
|
||||
|
||||
### Framework/configuration tests
|
||||
|
||||
- existing missing-tenant and authenticated tenant/header mismatch filter behavior remains active;
|
||||
- local-development flag defaults false and is not inferred from a Spring profile;
|
||||
- reusable limiter/ingress attachment and structured success/failure-category metric are verified where an existing seam is identified.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Client `tenantId` and `userId` are never authoritative.
|
||||
- [x] Browser headers are documented and implemented as forgeable context claims, not authentication.
|
||||
- [x] Public existence disclosure is accepted; only unknown/disabled/expired status is indistinguishable.
|
||||
- [x] `tenantHandle` is not mislabeled as Tenant Code; mutability, case, and format match EDU-003.
|
||||
- [x] Legacy `tenantName` is rejected/removed and covered by a compatibility test.
|
||||
- [x] Local fallback uses the named secure-default property and unambiguous precedence.
|
||||
- [x] Canonical website compatibility policy is implemented without silent legacy normalization.
|
||||
- [x] Exact HTTP/CommonResult code/message/data contracts are asserted.
|
||||
- [x] Authenticated Student context enforces Member principal type.
|
||||
- [x] Existing framework mismatch checks remain active and are not bypassed.
|
||||
- [x] Public abuse-control gap is truthfully recorded; no inadequate or Education-only limiter was introduced.
|
||||
- [x] Every non-Education modification has a necessity explanation and focused owning-module tests.
|
||||
- [x] API documentation matches implementation and tests.
|
||||
|
||||
## Verification
|
||||
|
||||
Run focused Education, System, framework, and configuration tests as applicable, then:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
mvn -pl yudao-server -am -DskipTests clean compile
|
||||
```
|
||||
|
||||
No database change is in scope. If investigation proves automated website data correction is required, stop that part, create a separate blocked data/Flyway ticket, and invoke `flyway-postgresql` before any schema/data work.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** High security, disclosure, and login-routing impact.
|
||||
- **Rollback:** Application/configuration rollback to the previous resolver adapter; keep local mode disabled by default and do not weaken authenticated tenant checks. No destructive tenant-data operation.
|
||||
@@ -0,0 +1,160 @@
|
||||
# EDU-005 — Decide PostgreSQL/Flyway takeover strategy
|
||||
|
||||
- **Status:** done — forward-only takeover and EDU-006 version plan accepted
|
||||
- **Type:** decision
|
||||
- **Phase:** 1 / Phase 2 prerequisite
|
||||
- **Blockers:** EDU-002
|
||||
|
||||
## Decision outcome
|
||||
|
||||
Education schema ownership moves exclusively to module-owned PostgreSQL Flyway migrations under `yudao-module-education/src/main/resources/db/migration/education/`. The existing root `sql/postgresql/education/` files are classified as manual bootstrap/design history, not Flyway history. The MySQL files are obsolete archival artifacts and must not remain in operational instructions.
|
||||
|
||||
`V4010__initialize_education_flyway.sql` and `V4020__create_native_catalog.sql` are uncommitted working-tree resources in this checkout, and the inspected local disposable PostgreSQL database has no `flyway_schema_history` table and no Education tables. This proves neither migration ran in that database, but it does not prove they never ran in another environment. To avoid assigning a second meaning to a potentially distributed version, EDU-006 must preserve both files byte-for-byte and allocate new work from `V4030`.
|
||||
|
||||
No migration or production schema change was executed by EDU-005.
|
||||
|
||||
## Evidence and classification
|
||||
|
||||
| Artifact | Verified state | Classification | Forward disposition |
|
||||
|---|---|---|---|
|
||||
| `V4010__initialize_education_flyway.sql` | Untracked module resource; contains only `SELECT 1`; packaged in current `target/classes` | Potentially distributed Flyway history; execution unverified | Freeze byte-for-byte; do not repurpose |
|
||||
| `V4020__create_native_catalog.sql` | Untracked module resource; owns 11 native catalog tables; differs materially from manual `008` | Potentially distributed Flyway history; execution unverified | Freeze byte-for-byte; do not replace with manual `008` |
|
||||
| `sql/postgresql/education/002`–`005`, `007`, `009` | Untracked manual scripts implementing the current Practice/report/wrong/favorite/unified-idempotency model in stages | Manual bootstrap/design history, not active Flyway | Consolidate the approved final state into higher Flyway versions; do not copy the obsolete intermediate tables as fresh schema |
|
||||
| `sql/postgresql/education/008` | Manual native-catalog design predating/diverging from V4020 | Superseded manual design | V4020 remains the only intended Flyway owner of native catalog schema |
|
||||
| `sql/postgresql/education/000`–`001` | Placeholder schema plus menu/tenant seed scripts | Manual bootstrap/seed history | Do not create a separate `education` schema; evaluate the still-used `education:capability` menu seed separately |
|
||||
| `sql/mysql/education/**` | Historical MySQL schema, seeds, and rollback scripts | Obsolete archive | Remove from runbooks; retain only if explicitly labeled non-operational archive |
|
||||
| `src/test/resources/sql/postgresql/create_tables.sql` | EDU-016 disposable test bridge; 140 PostgreSQL persistence tests passed against it | Temporary test fixture | Replace with Flyway-driven test setup after EDU-006 proves equivalent schema |
|
||||
| Docker init mounts for manual Education SQL | Dirty Docker configuration mounts `000`–`009` directly | Obsolete delivery path | Remove Education manual mounts after Flyway takeover; the server owns migration execution |
|
||||
|
||||
## Approved schema owner map
|
||||
|
||||
### V4020 owner — unchanged
|
||||
|
||||
V4020 exclusively owns the native catalog tables:
|
||||
|
||||
- `education_region`, `education_school`, `education_major`, `education_subject`, `education_category`;
|
||||
- `education_content_entry`, `education_content_node`;
|
||||
- `education_question_collection`, `education_question`, `education_practice_blueprint`;
|
||||
- `education_question_collection_question`.
|
||||
|
||||
EDU-006 does not fold Practice schema into V4020 and does not silently substitute manual `008`.
|
||||
|
||||
### EDU-006 new owner — Practice final state
|
||||
|
||||
The new Practice migration owns the final runtime shape of:
|
||||
|
||||
- `education_practice_session` and `education_practice_question`;
|
||||
- `education_practice_report` and `education_practice_report_detail`;
|
||||
- `education_wrong_question` and `education_wrong_question_idempotency`;
|
||||
- `education_favorite`;
|
||||
- `education_idempotency`.
|
||||
|
||||
The approved fresh schema does **not** create `education_answer_idempotency` or `education_submit_idempotency`. Current production services use `IdempotencyStoreMapper` and `education_idempotency`; the old DOs/Mappers are unused compatibility residue and must be removed or explicitly isolated during EDU-006.
|
||||
|
||||
The migration must include all columns already required by runtime code and the proven EDU-016 bridge, including `client_sequence`, `last_client_sequence`, `review_fingerprint`, protected answer snapshots, report content snapshots, JSONB fields, tenant IDs, logical-delete fields, and observed conflict/query indexes.
|
||||
|
||||
## Version plan for EDU-006
|
||||
|
||||
Version numbers are project-wide. With V4020 frozen, the next allocated version is:
|
||||
|
||||
1. **V4030 — Practice core-loop final schema and adoption**
|
||||
- Create the final tables, columns, constraints, and indexes listed above.
|
||||
- Be adoption-aware for databases that contain manually bootstrapped Practice tables.
|
||||
- Validate existing column types and required uniqueness before treating existing objects as compatible; fail closed on incompatible shapes rather than silently accepting them.
|
||||
- Backfill the unified idempotency table from legacy answer/submit tables when those tables exist.
|
||||
- Preserve old idempotency tables during the initial adoption migration; do not make data destruction a prerequisite for application rollout.
|
||||
2. **V4040 — deterministic Education capability seed, only if still approved**
|
||||
- Seed menu IDs `6800`/`6801` idempotently if the existing `EducationCapabilityController` remains an exposed administrator capability.
|
||||
- Keep role assignment outside the migration.
|
||||
- If the capability endpoint/menu is retired before EDU-006, omit this migration rather than seeding dead UI.
|
||||
3. **Later forward cleanup migration**
|
||||
- Drop legacy `education_answer_idempotency` and `education_submit_idempotency` only after every adopted environment has verified backfill counts, the application no longer contains active references, and a separately reviewed forward cleanup is approved.
|
||||
|
||||
If repository-wide migration inventory changes before implementation, EDU-006 must re-run the version scan and use the next unused project-wide version instead of blindly taking V4030/V4040.
|
||||
|
||||
## Existing-environment takeover classes
|
||||
|
||||
`baseline-on-migrate=true` with baseline `4009` is an adoption aid, not proof that Education objects match Flyway.
|
||||
|
||||
1. **Empty or platform-only database, no Education tables**
|
||||
- Use baseline `4009` only when the non-empty platform schema requires adoption.
|
||||
- Run V4010, V4020, then V4030+ normally.
|
||||
2. **Manual Practice tables exist, native catalog tables do not**
|
||||
- Baseline `4009` may be used.
|
||||
- V4020 creates catalog objects; V4030 validates/adopts Practice objects and performs required backfills.
|
||||
3. **Manual catalog tables equivalent to V4020 already exist, no Flyway history**
|
||||
- Do not run V4020 into colliding tables.
|
||||
- First compare the actual schema with the frozen V4020 contract.
|
||||
- For a verified equivalent environment, use a one-time environment-specific baseline at `4020`, then run V4030+. This records adoption, not execution of V4020, and must be documented per environment.
|
||||
- If the schema is not equivalent, correct it through an explicit higher-version adoption path; do not falsify history or edit V4020.
|
||||
4. **Flyway history already contains V4010 and/or V4020**
|
||||
- Compare script/checksum/success with the frozen resources.
|
||||
- Never edit an executed script. Any mismatch or failed row blocks rollout until an environment-specific repair decision is reviewed.
|
||||
5. **Unknown shared environment**
|
||||
- No migration rollout is authorized until its Education tables and `flyway_schema_history` are inventoried.
|
||||
|
||||
After all existing environments carry an explicit baseline/history record, changing `baseline-on-migrate` to `false` is a separate reviewed configuration ticket. `validate-on-migrate=true`, `clean-disabled=true`, and `out-of-order=false` remain mandatory.
|
||||
|
||||
## Data compatibility and backfill rules
|
||||
|
||||
- Copy legacy answer and submit idempotency rows into `education_idempotency` with deterministic operation values and `ON CONFLICT ... DO NOTHING` only after verifying duplicate-key/request-hash compatibility.
|
||||
- Preserve original IDs only if required by references; otherwise allow identity allocation and verify semantic row counts by operation.
|
||||
- Existing Practice tables must be compared with the final DO/Mapper contract, not merely checked for table-name existence.
|
||||
- JSON snapshot fields use PostgreSQL `JSONB` where current runtime/test behavior expects JSONB normalization.
|
||||
- Tenant-scoped uniqueness includes `tenant_id` where the business key is tenant-local. `education_practice_report` uses `(tenant_id, session_id)` as the final unique report key.
|
||||
- Do not copy Supabase RLS. Framework tenant isolation remains primary; database constraints enforce integrity and idempotency.
|
||||
- No destructive rollback SQL is delivered. Recovery is application rollback plus a higher-version forward correction.
|
||||
|
||||
## Documentation and operational corrections
|
||||
|
||||
EDU-006 must update operational documentation in the same slice:
|
||||
|
||||
- replace `yudao-module-education/README.md` MySQL apply/rollback commands with Flyway/PostgreSQL forward-only instructions;
|
||||
- remove the manual Education SQL mounts from `script/docker/docker-compose.yml` so a fresh Docker database is not initialized outside Flyway before server startup;
|
||||
- label `sql/postgresql/education/` and `sql/mysql/education/` as non-operational history or move them to an explicitly archival location without rewriting history;
|
||||
- replace the EDU-016 temporary PostgreSQL schema bridge with Flyway-driven setup after equivalence is proven;
|
||||
- state per environment whether Flyway was actually run, validated, or only packaged/compiled.
|
||||
|
||||
## Required EDU-006 verification
|
||||
|
||||
Static and build gates:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
mvn -pl yudao-module-education -am -DskipTests clean package
|
||||
find yudao-module-education/target/classes/db/migration/education -type f -print
|
||||
mvn -pl yudao-server -am -DskipTests clean compile
|
||||
```
|
||||
|
||||
Real disposable PostgreSQL gate:
|
||||
|
||||
1. initialize a disposable platform database or approved baseline fixture;
|
||||
2. run Flyway migrate using the server's exact migration locations and PostgreSQL driver;
|
||||
3. run Flyway validate;
|
||||
4. inspect `flyway_schema_history` with version, script, checksum, and success;
|
||||
5. inspect all approved tables, columns, constraints, indexes, and backfill counts;
|
||||
6. run the EDU-016 PostgreSQL persistence suite against the migrated schema;
|
||||
7. test at least the empty/platform-only path and one representative manually bootstrapped adoption path.
|
||||
|
||||
Only these real successful executions may be reported as migration success.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] No published or potentially distributed migration is authorized for editing.
|
||||
- [x] Every required table/index/constraint/seed has one intended Flyway owner and version range.
|
||||
- [x] Manual SQL is not silently treated as executed history.
|
||||
- [x] The plan includes migration packaging and real PostgreSQL execution evidence requirements.
|
||||
- [x] Documentation correction scope is explicit.
|
||||
|
||||
## Verification performed by EDU-005
|
||||
|
||||
- Read project Flyway rules, local/dev Flyway configuration, server dependencies, all active migration locations, manual PostgreSQL/MySQL artifacts, core-loop DOs/Mappers, PostgreSQL test bridge, Docker initialization, Git history, and dirty-tree state.
|
||||
- Inspected the reachable disposable `postgresdb` container. Target identity was database `postgres`, user `postgres`, schema `public`; it contained no `flyway_schema_history` relation and no Education tables. Container startup logs state that `/docker-entrypoint-initdb.d/*` was ignored because the volume was already initialized.
|
||||
- Confirmed V4010/V4020 are currently packaged under `target/classes/db/migration/education/` from a prior build.
|
||||
- Did not modify migration SQL, application code, server configuration, Docker configuration, or any database object.
|
||||
- Did not run Flyway migrate/validate and does not claim a successful database migration.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** High. Existing manually initialized databases may have partially overlapping or divergent table shapes, and a false baseline can hide incompatibility.
|
||||
- **Rollback:** No rollback is required for this decision-only ticket. EDU-006 uses forward migrations, preserves legacy idempotency tables during first adoption, and supports application rollback without `flyway clean` or destructive down scripts.
|
||||
@@ -0,0 +1,83 @@
|
||||
# EDU-006 — Deliver Practice schema through module-owned Flyway
|
||||
|
||||
- **Status:** done — V4030/V4040 delivered and verified; V4050 adds forward-only column documentation
|
||||
- **Type:** database implementation
|
||||
- **Phase:** 2 prerequisite
|
||||
- **Blockers:** EDU-005
|
||||
|
||||
## Implementation result
|
||||
|
||||
`V4030__create_and_adopt_practice_schema.sql` now owns the final Practice session/question, report/detail, wrong-question/idempotency, favorite, and unified idempotency schema. Fresh databases do not create legacy answer/submit idempotency tables. Compatible manually initialized Practice tables receive missing final columns and JSONB alignment; legacy answer/submit idempotency rows are backfilled into `education_idempotency` while the source tables remain untouched. A conflicting request hash fails the migration transactionally.
|
||||
|
||||
The EDU-016 test seam now runs the real Flyway chain in a random disposable PostgreSQL schema. Its temporary `create_tables.sql` bridge was removed. The established 140 persistence tests and five migration-contract scenarios pass together.
|
||||
|
||||
Manual Education SQL mounts were removed from Docker Compose, and the active Education README and Pilot runbook now describe PostgreSQL/Flyway forward-only delivery. V4010/V4020 remained byte-for-byte unchanged. V4040 adds the submit-claim token and lease timestamp required by EDU-009 crash recovery; it also normalizes adopted successful submit rows without changing V4030 release history.
|
||||
|
||||
No shared or production database was migrated. Successful migration evidence applies only to the isolated no-volume PostgreSQL container and random schemas used by the tests.
|
||||
|
||||
## Scope
|
||||
|
||||
Implement only the objects approved in EDU-005, potentially covering:
|
||||
|
||||
- practice sessions and question snapshots;
|
||||
- answer and submit idempotency;
|
||||
- reports and report details;
|
||||
- wrong questions and favorites;
|
||||
- tenant-scoped unique constraints;
|
||||
- indexes required by verified query paths;
|
||||
- necessary menu/permission seed data;
|
||||
- compatible backfills for existing development data.
|
||||
|
||||
Exact objects and versions are determined by EDU-005 and `flyway-postgresql`.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
- Use PostgreSQL dialect only.
|
||||
- Tenant-scoped uniqueness includes `tenant_id` where required.
|
||||
- Database uniqueness is the final idempotency guard.
|
||||
- Use `ON CONFLICT` where approved by the design.
|
||||
- Do not copy Supabase auth/RLS as the application isolation model.
|
||||
- Do not add destructive rollback migrations.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] New migrations use versions allocated by `flyway-postgresql`.
|
||||
- [x] V4010/V4020 remain unchanged as potentially distributed history.
|
||||
- [x] Migrations are packaged under `target/classes/db/migration/education/`.
|
||||
- [x] Annotated SQL and Mapper behavior match PostgreSQL constraints.
|
||||
- [x] Focused repository/integration tests cover uniqueness and tenant scope.
|
||||
- [x] Real PostgreSQL Flyway migrate/validate succeeds in an isolated disposable test environment.
|
||||
- [x] Operational docs no longer instruct users to apply MySQL or manual Education SQL for these objects.
|
||||
|
||||
## Verification
|
||||
|
||||
At minimum:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
mvn -pl yudao-module-education -am -DskipTests clean package
|
||||
find yudao-module-education/target/classes/db/migration/education -type f
|
||||
mvn -pl yudao-server -am -DskipTests clean compile
|
||||
```
|
||||
|
||||
Also run the PostgreSQL commands prescribed by `flyway-postgresql` when an authorized database is available.
|
||||
|
||||
## Verification performed
|
||||
|
||||
Against an isolated PostgreSQL container bound only to `127.0.0.1`, the focused Flyway suite exercised fresh migration, baseline-4009 adoption, compatible session/question plus legacy-idempotency adoption, conflict with existing unified history, and conflicting duplicate legacy keys. Together with the existing persistence suite: 145 tests passed, 0 failures, 0 errors, 0 skipped. Every test schema was dropped and the no-volume container was stopped.
|
||||
|
||||
Also completed:
|
||||
|
||||
```text
|
||||
mvn -pl yudao-module-education -am -DskipTests clean package — BUILD SUCCESS
|
||||
V4010, V4020, V4030, V4040 present under target/classes/db/migration/education/ at the recorded verification point; V4050 must be included in the next verification run
|
||||
mvn -pl yudao-server -am -DskipTests clean compile — BUILD SUCCESS
|
||||
git diff --check — passed
|
||||
```
|
||||
|
||||
V4010 and V4020 retained their pre-ticket SHA-256 values. V4050 was added later as a metadata-only forward migration to document protected snapshot and idempotency state columns. V4060 validates every adopted Practice unique index against its required uniqueness and ordered key columns, failing closed when a stale same-named index would weaken tenant or idempotency guarantees. The corrected Education package, PostgreSQL Flyway suite, persistence suite, and server compile are rerun after each forward migration. No shared or production PostgreSQL database was changed.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** High data compatibility and deployment-order risk.
|
||||
- **Rollback:** Forward correction migration plus application rollback. Never use `clean` or destructive rollback in shared environments.
|
||||
@@ -0,0 +1,57 @@
|
||||
# EDU-007 — Verify tenant-scoped practice creation and restoration
|
||||
|
||||
- **Status:** done — bounded create/restore contract verified against V4030 PostgreSQL
|
||||
- **Type:** implementation/verification
|
||||
- **Phase:** 2
|
||||
- **Blockers:** EDU-004, EDU-006
|
||||
|
||||
## Implementation result
|
||||
|
||||
The existing create/restore aggregate was retained and re-verified rather than rebuilt. Practice endpoints now require a `UserTypeEnum.MEMBER` Student Principal and continue deriving user/tenant only from the authenticated principal. Concurrent creation now uses the existing PostgreSQL `ON CONFLICT DO NOTHING` mapper seam, avoiding a query inside an aborted duplicate-key transaction. Real PostgreSQL tests prove identical concurrent requests return one session and conflicting fingerprints produce one winner plus one idempotency mismatch.
|
||||
|
||||
Focused tests also prove provider failure leaves no partial state and restore uses the persisted Question Snapshot after source content changes. The service create/restore suite now runs on the V4030 Flyway-owned PostgreSQL schema.
|
||||
|
||||
PUBLIC catalog read predicates and provider-neutral safety remain unchanged. Cross-scope PUBLIC graph-integrity semantics remain an explicit later architecture decision; EDU-007 does not claim or invent those constraints. Legacy entitlement/quota, timed practice, rich blueprint/random/review modes, and discovery of multiple active sessions are outside this bounded ticket.
|
||||
|
||||
## Scope
|
||||
|
||||
- Verify or complete session creation idempotency.
|
||||
- Verify session ownership and tenant isolation.
|
||||
- Restore immutable safe question snapshots.
|
||||
- Preserve provider-neutral question safety from EDU-001.
|
||||
- Verify PUBLIC catalog reads continue using the existing explicit scope predicates; tenant-consistent PUBLIC graph constraints remain blocked on the graph decision.
|
||||
- Remove no existing core-loop behavior unless a regression proves it invalid.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Current user and tenant derive from a Member security principal at the controller boundary.
|
||||
- [x] Duplicate client session ID with identical fingerprint returns the existing session.
|
||||
- [x] Conflicting fingerprint, user, or tenant does not expose the existing session.
|
||||
- [x] Underfilled, invisible, malformed, disabled, and unavailable content fails closed.
|
||||
- [x] Restored content remains stable after source content changes.
|
||||
- [x] Cross-tenant and wrong-user access is denied.
|
||||
- [x] Provider-neutral question safety and existing PUBLIC read predicates are preserved; graph-integrity enforcement remains blocked on the recorded architecture decision.
|
||||
- [x] PostgreSQL uniqueness and transaction behavior are tested against the EDU-006 schema.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused create/restore service, Mapper, controller, and PostgreSQL tests; then required diff/compile gates.
|
||||
|
||||
## Verification performed
|
||||
|
||||
Against an isolated PostgreSQL database using the V4010/V4020/V4030 Flyway chain:
|
||||
|
||||
```text
|
||||
PracticeSessionControllerHttpTest: 15 passed
|
||||
PracticeSessionServiceImplTest: 25 passed
|
||||
PracticeSessionServicePostgreSqlIntegrationTest: 2 passed
|
||||
Total: 42 passed
|
||||
Failures/errors/skipped: 0
|
||||
```
|
||||
|
||||
The PostgreSQL concurrency tests use bounded latches and prove both identical and conflicting fingerprint races. No database migration was added or changed by EDU-007.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** Medium session-ownership and compatibility risk.
|
||||
- **Rollback:** Application rollback; retain forward-compatible schema.
|
||||
@@ -0,0 +1,53 @@
|
||||
# EDU-008 — Verify idempotent answer saving
|
||||
|
||||
- **Status:** done — answer idempotency and rollback contract verified on PostgreSQL
|
||||
- **Type:** implementation/verification
|
||||
- **Phase:** 2
|
||||
- **Blockers:** EDU-007
|
||||
|
||||
## Implementation result
|
||||
|
||||
The existing unified PostgreSQL idempotency claim remains the final guard. Matching keys replay only a complete, valid stored response; null, blank, malformed, or structurally incomplete replay data now fails closed without re-executing the answer mutation. Completion of a newly claimed response must update exactly one idempotency row before session/question state changes.
|
||||
|
||||
Answer saving remains limited to Option-backed Questions. Optionless/free-text behavior is explicitly rejected until a separate subjective-answer contract is designed. The answer HTTP seam now uses the EDU-007 Member principal and TenantContextHolder boundary and rejects ADMIN principals.
|
||||
|
||||
PostgreSQL tests prove same-key/same-payload replay, same-key/different-payload conflict including concurrent requests, different-key CAS serialization, stale version/sequence rejection, user/tenant/state/question ownership checks, full rollback of answer/session/claim state, refresh recovery, and no answer-key/explanation leakage. No migration was added or executed by EDU-008.
|
||||
|
||||
## Scope
|
||||
|
||||
- Same key and same canonical payload replays the original result.
|
||||
- Same key and different payload returns a conflict.
|
||||
- Database uniqueness is the final idempotency guard.
|
||||
- Session version and client sequence prevent stale updates.
|
||||
- Selected options are validated against the safe snapshot contract.
|
||||
- Optionless answer behavior remains blocked until explicitly designed; do not pretend it is option-backed.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Same-payload duplicate semantics are explicit and tested.
|
||||
- [x] Conflicting payload is rejected.
|
||||
- [x] Concurrent same-key and different-key behavior is tested on PostgreSQL.
|
||||
- [x] Stale session version and stale per-session command sequence are rejected.
|
||||
- [x] Wrong user, tenant, session state, or question membership is rejected.
|
||||
- [x] Responses and restored sessions contain no answer key or explanation.
|
||||
- [x] Failure does not partially update answer, sequence, session version, or the idempotency claim.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused answer service/controller/Mapper tests, PostgreSQL concurrency tests, EDU-001 regressions, and required diff/compile gates.
|
||||
|
||||
## Verification performed
|
||||
|
||||
```text
|
||||
PracticeAnswerControllerHttpTest: 16 passed
|
||||
PracticeAnswerServiceImplTest: 37 passed on PostgreSQL/V4030
|
||||
Total focused: 53 passed
|
||||
Failures/errors/skipped: 0
|
||||
```
|
||||
|
||||
The service suite includes concurrent same-key identical and conflicting payloads plus exact different-key winner/loser assertions. Incomplete replay rows fail closed, and rollback assertions cover session version, session sequence, question state, and claim removal.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** Medium-to-high concurrency and offline-retry risk.
|
||||
- **Rollback:** Application rollback with schema retained; forward migration for any constraint correction.
|
||||
@@ -0,0 +1,69 @@
|
||||
# EDU-009 — Atomic submit, immutable report, wrong questions, and favorites
|
||||
|
||||
- **Status:** done
|
||||
- **Type:** implementation
|
||||
- **Phase:** 2
|
||||
- **Blockers:** EDU-008 (done)
|
||||
|
||||
## Student outcome
|
||||
|
||||
A student can retry submission safely, receive exactly one immutable report, and see consistent wrong-question and favorite projections.
|
||||
|
||||
## Core defect to resolve
|
||||
|
||||
The current submit path has evidence of check-then-insert idempotency. This ticket must define and implement an atomic initial claim with crash recovery before treating submission as complete.
|
||||
|
||||
## Scope
|
||||
|
||||
- Atomic submit-key reservation using PostgreSQL uniqueness/`ON CONFLICT` or the approved project mechanism.
|
||||
- Explicit processing/completed/failed or equivalent recovery semantics.
|
||||
- Same-key replay and conflicting-payload behavior.
|
||||
- Single state transition from active session to submitted.
|
||||
- Immutable scoring/report snapshot.
|
||||
- Duplicate-safe wrong-question projection.
|
||||
- Favorite behavior remains independent and tenant/user scoped.
|
||||
- No external calls inside a long database transaction.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Concurrent same-key same-payload requests converge on one report.
|
||||
- [x] Same key with different payload is rejected.
|
||||
- [x] Different keys racing on one session produce at most one committed submit.
|
||||
- [x] A crash after claim has a documented retry/recovery result.
|
||||
- [x] Report content remains stable after question mutation.
|
||||
- [x] Wrong-question projection is idempotent.
|
||||
- [x] Report and history access enforce user and tenant ownership.
|
||||
- [x] Pre-submit responses never expose protected answer data; post-submit response follows the approved report contract.
|
||||
|
||||
## Recovery contract
|
||||
|
||||
- The submit key is serialized with a PostgreSQL transaction-level advisory lock, then reserved with
|
||||
`INSERT ... ON CONFLICT DO NOTHING` before session/report writes.
|
||||
- The claim uses `PROCESSING` with a unique token and lease timestamp; report, report detail,
|
||||
wrong-question projection, session CAS, and token-checked claim completion run in the same transaction.
|
||||
- A normal processing failure rolls the full transaction back, including a newly inserted claim. If a previously
|
||||
committed/manual `PROCESSING` claim exists (for example after legacy partial persistence), a retry can take over
|
||||
the matching claim after the 120-second lease expires. A mismatched payload can never take over the claim.
|
||||
- A completed claim stores `report_id` and the complete immutable response as `COMPLETED`; same-key retries
|
||||
replay only a structurally complete matching response. Malformed or incomplete committed rows fail closed.
|
||||
- A different key that loses the session race is completed against the immutable winner report, so retries of
|
||||
either accepted key remain stable.
|
||||
|
||||
No external provider call occurs in the submit transaction: scoring uses the persisted session question snapshot.
|
||||
V4040 adds the claim token/timestamp columns and normalizes migrated successful `SUBMIT_SESSION` rows from
|
||||
legacy `ACCEPTED` to `COMPLETED` without changing V4030 release history.
|
||||
|
||||
## Verification
|
||||
|
||||
PostgreSQL concurrency tests are mandatory, along with service/controller/projection tests and required diff/compile gates.
|
||||
|
||||
Focused PostgreSQL verification on 2026-07-30:
|
||||
|
||||
- `PracticeSubmitServiceImplTest`: 38 passed.
|
||||
- Submit/report controller, projection, wrong-question, and favorite suites: 129 passed total.
|
||||
- Failures, errors, skipped: 0.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** High state-machine and data-consistency risk.
|
||||
- **Rollback:** Disable practice writes or roll back application version; repair through forward migration only.
|
||||
@@ -0,0 +1,33 @@
|
||||
# EDU-010 — Tenant content publication and graph integrity
|
||||
|
||||
- **Status:** blocked
|
||||
- **Type:** implementation program
|
||||
- **Phase:** 3
|
||||
- **Blockers:** EDU-004, EDU-009, provider-authority decision, PUBLIC graph-semantics decision
|
||||
|
||||
## Tenant-admin outcome
|
||||
|
||||
Authorized tenant administrators can author, classify, publish, archive, and retire education content without creating cross-tenant or invalid PUBLIC/tenant relationships, and student reads remain consistent with publication state.
|
||||
|
||||
## Scope
|
||||
|
||||
- Question banks, questions, versions, classifications, catalogs, collections, blueprints, and bindings.
|
||||
- Draft/published/archived lifecycle.
|
||||
- System RBAC/DataPermission enforcement.
|
||||
- Tenant-consistent graph constraints or equivalent transactional enforcement.
|
||||
- Provider consistency between authoring source and student reads.
|
||||
- Safe projections preserved from EDU-001.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Admin permission and data-scope matrix is explicit.
|
||||
- [ ] Cross-tenant graph relationships cannot be persisted.
|
||||
- [ ] PUBLIC and tenant-owned reference rules are enforced and tested.
|
||||
- [ ] Unpublished/archived content is never student-visible.
|
||||
- [ ] Publication is transactional and auditable.
|
||||
- [ ] Database changes use `flyway-postgresql` and forward migrations.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** High content-integrity and authorization risk.
|
||||
- **Rollback:** Disable authoring/publishing and roll back application; preserve data and correct forward.
|
||||
@@ -0,0 +1,32 @@
|
||||
# EDU-011 — Content import, export, assets, and scanning
|
||||
|
||||
- **Status:** blocked
|
||||
- **Type:** implementation program
|
||||
- **Phase:** 3 / 6
|
||||
- **Blockers:** EDU-010, Infra File contract, scanner ownership decision, durable job claim decision
|
||||
|
||||
## Tenant-admin outcome
|
||||
|
||||
Administrators can import and export education content through durable, duplicate-safe jobs, with secure files, malware scanning, tenant propagation, audit, retries, and partial-failure reporting.
|
||||
|
||||
## Reuse
|
||||
|
||||
- Education owns import/export business state and content validation.
|
||||
- Infra owns File, Job/MQ, locks, logging, and audit primitives.
|
||||
- Scanner integration sits behind a clear adapter; Education does not implement generic storage or scheduling.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Preview and execute are distinct states.
|
||||
- [ ] Jobs use atomic claim/lease/heartbeat/recovery semantics.
|
||||
- [ ] At-least-once retries are duplicate-safe.
|
||||
- [ ] File type, size, object key, access, and retention are enforced.
|
||||
- [ ] Scanning fails closed.
|
||||
- [ ] Tenant context propagates into asynchronous handlers.
|
||||
- [ ] Exports redact answers and private fields according to authorization.
|
||||
- [ ] Partial failures and dead letters are visible and auditable.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** High operational and file-security risk.
|
||||
- **Rollback:** Disable job handlers and preserve job/business state for forward recovery.
|
||||
@@ -0,0 +1,33 @@
|
||||
# EDU-012 — Classes and education relationships
|
||||
|
||||
- **Status:** blocked
|
||||
- **Type:** implementation program
|
||||
- **Phase:** 4
|
||||
- **Blockers:** EDU-004, education relationship model decision, Member relationship contract, System data-scope policy
|
||||
|
||||
## Tenant-admin outcome
|
||||
|
||||
Tenant administrators manage classes, student education relationships, invitations, and supervision within explicit tenant and row-level scopes while Member/System remain the owners of generic users and roles.
|
||||
|
||||
## Scope
|
||||
|
||||
- Education class entity and membership relationships.
|
||||
- Student/teacher/class domain roles without duplicating System RBAC.
|
||||
- Invitations and duplicate-safe acceptance.
|
||||
- Education profile extensions.
|
||||
- Supervision relationships and data scopes if retained.
|
||||
- Audit and operation logging.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Generic account, password, token, tenant, and role tables are not duplicated.
|
||||
- [ ] Student/teacher/class permission matrix is documented and tested.
|
||||
- [ ] Cross-class and cross-tenant access is denied.
|
||||
- [ ] Invitation acceptance is idempotent and auditable.
|
||||
- [ ] Platform-admin tenant-ignore operations are explicit and permission guarded.
|
||||
- [ ] Database changes use `flyway-postgresql`.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** High authorization and relationship-integrity risk.
|
||||
- **Rollback:** Disable management endpoints and correct relationships through audited forward operations.
|
||||
33
docs/education/migration/issues/EDU-013-commercialization.md
Normal file
33
docs/education/migration/issues/EDU-013-commercialization.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# EDU-013 — Education commercialization binding
|
||||
|
||||
- **Status:** blocked
|
||||
- **Type:** decision and implementation program
|
||||
- **Phase:** 5
|
||||
- **Blockers:** product/entitlement model, Mall/Pay API assessment, Member entitlement decision, EDU-004
|
||||
|
||||
## Outcome
|
||||
|
||||
Education products and access rights are connected to Mall, Pay, Member, and CRM without creating a parallel product, order, payment, refund, membership, or financial ledger in Education.
|
||||
|
||||
## Education ownership
|
||||
|
||||
Education may own only domain bindings and fulfillment orchestration, such as:
|
||||
|
||||
- education product to course/exam/content binding;
|
||||
- entitlement scope and education-resource association;
|
||||
- duplicate-safe fulfillment event state where no platform facility exists.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Mall/Pay/Member/CRM public contracts are mapped before implementation.
|
||||
- [ ] Payment callbacks and refunds remain in Pay.
|
||||
- [ ] Generic products/orders remain in Mall where applicable.
|
||||
- [ ] Entitlement issuance, revocation, expiry, and refund effects are explicit and idempotent.
|
||||
- [ ] Paid/private practice remains inaccessible until entitlement checks are complete.
|
||||
- [ ] Reconciliation and commission/referral ownership is explicit.
|
||||
- [ ] Financial and authorization tests cover duplicate callbacks and cross-tenant access.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** Very high financial and access-control risk.
|
||||
- **Rollback:** Disable fulfillment handlers and paid access; preserve financial ledgers in their owning modules.
|
||||
37
docs/education/migration/issues/EDU-014-extended-learning.md
Normal file
37
docs/education/migration/issues/EDU-014-extended-learning.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# EDU-014 — Extended student and secondary learning waves
|
||||
|
||||
- **Status:** blocked
|
||||
- **Type:** decision map followed by implementation tickets
|
||||
- **Phase:** 5
|
||||
- **Blockers:** explicit product scope, EDU-004, entitlement model, AI/File/Member/System/Infra contract assessment
|
||||
|
||||
## Outcome
|
||||
|
||||
Every legacy Auth/Profile/extended Learning, scoreline, vocabulary, video, AI, notification, badge, feedback, and exam-date capability receives a traceable conclusion: replaced, migrated, retired, deferred, or product decision required.
|
||||
|
||||
## Required decomposition
|
||||
|
||||
Do not implement this as one large ticket. Create one child ticket per selected capability family after ownership is decided. At minimum assess:
|
||||
|
||||
- Auth compatibility and phone/OAuth binding;
|
||||
- profile and education profile extensions;
|
||||
- vocabulary learning/review;
|
||||
- scoreline and admissions content;
|
||||
- video entitlement and progress;
|
||||
- recommendation and AI generation;
|
||||
- notifications and reminders;
|
||||
- points, badges, check-ins, feedback, exam countdowns;
|
||||
- learning analytics, leaderboard, trends, and reports.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Each family has target owner, reusable public capability, data disposition, API conclusion, priority, and tests.
|
||||
- [ ] Member/System/Infra/AI capabilities are reused rather than copied.
|
||||
- [ ] Sensitive reports and exports are redacted.
|
||||
- [ ] Media and AI access follows entitlement and tenant rules.
|
||||
- [ ] Retired capabilities have compatibility and data-retention conclusions.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** Medium-to-high scope and entitlement risk.
|
||||
- **Rollback:** Per child ticket; this parent is a planning gate.
|
||||
@@ -0,0 +1,42 @@
|
||||
# EDU-015 — Operational independence and legacy exit
|
||||
|
||||
- **Status:** blocked
|
||||
- **Type:** integration and deployment program
|
||||
- **Phase:** 6
|
||||
- **Blockers:** EDU-011, EDU-013, EDU-014 child decisions, all temporary-adapter owners and exit plans
|
||||
|
||||
## Outcome
|
||||
|
||||
The target backend runs its selected education capabilities without depending on the old NestJS API, worker, Supabase auth/storage, or asset-scanner deployment, except for explicitly time-bounded adapters with owners and exit dates.
|
||||
|
||||
## Scope
|
||||
|
||||
- Replace selected Worker jobs with Infra Job/MQ and owning-domain handlers.
|
||||
- Complete retries, dead letters, audit, notifications, and observability.
|
||||
- Complete file/scanner deployment or approved alternative.
|
||||
- Remove or disable temporary Scalar/legacy adapters according to provider strategy.
|
||||
- Reconcile migrated data and operational runbooks.
|
||||
- Prove deployment, startup, Flyway, and core user flows.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Every temporary legacy dependency has an owner, telemetry, failure policy, and exit date.
|
||||
- [ ] At-least-once consumers are duplicate-safe.
|
||||
- [ ] Job retries/dead letters and scanner health are observable.
|
||||
- [ ] PostgreSQL migrations are actually executed and validated in an authorized environment.
|
||||
- [ ] Student and selected admin E2E flows pass against the target only.
|
||||
- [ ] Runbooks contain no active MySQL/manual-SQL or obsolete NestJS startup requirement.
|
||||
- [ ] Rollback and incident procedures are documented.
|
||||
|
||||
## Verification
|
||||
|
||||
- Application startup and health.
|
||||
- Flyway history and migration execution.
|
||||
- Worker/job deployment smoke tests.
|
||||
- Playwright student harness and selected admin flows.
|
||||
- Logs, metrics, traces, retry/dead-letter, and scanner health checks.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** High deployment and production reliability risk.
|
||||
- **Rollback:** Per capability using application/configuration rollback while preserving forward database history.
|
||||
@@ -0,0 +1,64 @@
|
||||
# EDU-016 — Move PostgreSQL-specific Education persistence tests off H2
|
||||
|
||||
- **Status:** done — focused PostgreSQL persistence suite passes against the reachable `postgresdb` seam
|
||||
- **Type:** test infrastructure / PostgreSQL integration
|
||||
- **Phase:** 0 / database prerequisite
|
||||
- **Blockers:** EDU-002, EDU-005 for final schema ownership
|
||||
|
||||
## Confirmed defect
|
||||
|
||||
Education unit tests use H2 with `MODE=MYSQL`, while production Mapper SQL intentionally uses PostgreSQL `ON CONFLICT`. H2 rejects the annotated SQL for favorites, wrong-question idempotency, unified answer/submit idempotency, and review-session conflict handling. Switching H2 to PostgreSQL mode does not solve this: H2 still rejects `ON CONFLICT` and also exposes Boolean/integer compatibility differences.
|
||||
|
||||
This prevents the full Practice/Wrong/Favorite regression suite from exercising production persistence semantics.
|
||||
|
||||
## Outcome
|
||||
|
||||
PostgreSQL-specific persistence behavior runs against real ephemeral PostgreSQL in the test suite, while fast database-independent tests may remain on H2 where their SQL is portable.
|
||||
|
||||
## Scope
|
||||
|
||||
- Select the repository-standard PostgreSQL integration-test mechanism, preferably Testcontainers or an existing project fixture.
|
||||
- Move tests that execute `ON CONFLICT`, PostgreSQL JSON/JSONB, identity, Boolean, or concurrency semantics onto PostgreSQL.
|
||||
- Keep controller and pure domain tests database-independent.
|
||||
- Align test schema with module-owned Flyway after EDU-005/EDU-006; avoid maintaining a divergent hand-written full schema long term.
|
||||
- Cover at least:
|
||||
- `IdempotencyStoreMapper.insertIgnore`;
|
||||
- wrong-question idempotency insert;
|
||||
- favorite upsert;
|
||||
- review-session insert-ignore;
|
||||
- concurrent answer and submit claims.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] No test relies on H2 to validate PostgreSQL `ON CONFLICT` semantics.
|
||||
- [x] PostgreSQL tests execute the same Mapper SQL as production.
|
||||
- [x] Test database is isolated and disposable.
|
||||
- [x] Schema setup uses Flyway or an explicitly temporary bridge with an exit ticket.
|
||||
- [x] Concurrency tests are stable and prove unique-constraint behavior.
|
||||
- [x] CI prerequisites and local commands are documented.
|
||||
|
||||
## Verification
|
||||
|
||||
Completed against the existing reachable `postgresdb` service with credentials supplied only through `EDU_TEST_POSTGRES_*` environment variables:
|
||||
|
||||
```bash
|
||||
mvn -pl yudao-module-education \
|
||||
-Dtest=PracticeSessionMapperTest,FavoriteServiceImplTest,PracticeAnswerServiceImplTest,PracticeSubmitServiceImplTest,PracticeSubmitProjectionIntegrationTest,WrongQuestionServiceImplTest test
|
||||
```
|
||||
|
||||
Result: 140 tests passed, 0 failures, 0 errors. The same 140-test suite also passed with JUnit class parallelism explicitly enabled, proving the shared schema resource lock prevents class-level collisions. The suite executes production Mapper SQL on PostgreSQL, including `ON CONFLICT` and JSONB behavior. A JUnit resource lock serializes PostgreSQL test classes that share one random JVM-scoped schema; each class closes its Spring context before the inherited lifecycle drops and recreates that schema, preventing cached contexts from reusing a dropped schema.
|
||||
|
||||
The temporary schema bridge was removed by EDU-006. PostgreSQL persistence tests now create their disposable random schema through the module-owned Flyway chain (`V4010`, `V4020`, `V4030`) and retain only `clean.sql` for per-test data isolation.
|
||||
|
||||
Local/CI prerequisites:
|
||||
|
||||
- a reachable disposable PostgreSQL database;
|
||||
- PostgreSQL JDBC connectivity from the Maven process;
|
||||
- non-blank `EDU_TEST_POSTGRES_HOST`, `EDU_TEST_POSTGRES_PORT`, `EDU_TEST_POSTGRES_DB`, `EDU_TEST_POSTGRES_USER`, and `EDU_TEST_POSTGRES_PASSWORD` values.
|
||||
|
||||
No Testcontainers or other external dependency was added. No PostgreSQL Flyway migration was executed or authorized by this ticket.
|
||||
|
||||
## Risk and rollback
|
||||
|
||||
- **Risk:** Medium CI/runtime cost; high value for persistence confidence.
|
||||
- **Rollback:** Keep prior fast tests temporarily, but do not restore false H2 coverage claims for PostgreSQL SQL.
|
||||
59
docs/education/migration/issues/README.md
Normal file
59
docs/education/migration/issues/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Education Migration Tickets
|
||||
|
||||
This directory turns [`GOAL.md`](../GOAL.md) into executable, blocker-aware vertical slices.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Work only tickets whose blockers are complete.
|
||||
2. Start each implementation ticket in a fresh context after reading `GOAL.md`, the ticket, relevant decisions, and current Git status.
|
||||
3. Preserve the dirty working tree. Implementation is serial unless an isolated worktree and integration plan are explicit.
|
||||
4. Apply TDD at the ticket's declared seams.
|
||||
5. Use `flyway-postgresql` for every schema, index, constraint, seed, baseline, backfill, or Flyway configuration change.
|
||||
6. Close with focused tests, `git diff --check`, and `mvn -pl yudao-server -am -DskipTests clean compile`.
|
||||
7. Report exact commands and results. Never report PostgreSQL migration success without an actual successful PostgreSQL run.
|
||||
|
||||
## Status vocabulary
|
||||
|
||||
- `done`: implemented and verified to the ticket's current acceptance criteria.
|
||||
- `in-progress`: currently being implemented.
|
||||
- `ready`: all blockers complete and no unresolved decision prevents work.
|
||||
- `blocked`: depends on another ticket or product decision.
|
||||
- `decision`: produces a recorded decision rather than production behavior.
|
||||
|
||||
## Ticket graph
|
||||
|
||||
```text
|
||||
EDU-000 Phase 0 artifacts done
|
||||
├── EDU-001 Safe question content done, follow-up coverage remains
|
||||
├── EDU-002 Practice regression baseline done
|
||||
│ ├── EDU-016 PostgreSQL persistence tests done; temporary bridge blocked on EDU-005/EDU-006
|
||||
│ └── EDU-006 Practice schema Flyway done
|
||||
│ ├── EDU-007 Create/restore practice done
|
||||
│ ├── EDU-008 Idempotent answer save done
|
||||
│ └── EDU-009 Atomic submit/report done
|
||||
├── EDU-003 Tenant resolution decision done
|
||||
│ └── EDU-004 Tenant/identity security done; ingress/IP-only probing throttle remains operational blocker
|
||||
└── EDU-005 PostgreSQL/Flyway takeover decision done
|
||||
|
||||
EDU-009 + provider/content decisions
|
||||
└── EDU-010 Tenant content publication blocked
|
||||
└── EDU-011 Import/export/assets/scanning blocked
|
||||
|
||||
EDU-004
|
||||
└── EDU-012 Classes and education relationships blocked
|
||||
|
||||
Commerce ownership decisions
|
||||
└── EDU-013 Education commercialization blocked
|
||||
|
||||
All owner/contract decisions
|
||||
└── EDU-014 Extended learning waves blocked
|
||||
└── EDU-015 Operational independence blocked
|
||||
```
|
||||
|
||||
## Recommended execution order
|
||||
|
||||
1. Select the next unblocked content-management decision/ticket after EDU-009.
|
||||
|
||||
## Phase 0 completion caveat
|
||||
|
||||
Phase 0 artifacts exist, but several architecture and product decisions remain open. `EDU-000` is considered complete as an inventory deliverable, not as resolution of every decision it discovered.
|
||||
@@ -28,8 +28,8 @@ yudao:
|
||||
|
||||
## 3. 发布步骤
|
||||
|
||||
1. 备份 Education 相关表,并记录应用版本与数据库版本。
|
||||
2. 执行尚未应用的正向 SQL;不得执行 rollback SQL。
|
||||
1. 备份 Education 相关表,并记录应用版本与 `flyway_schema_history`。
|
||||
2. 使用 Server 配置的 PostgreSQL Flyway 执行 migrate 和 validate,检查版本、脚本、checksum 与 success;不得手工应用 Education SQL 或执行 rollback SQL。
|
||||
3. 先以 `catalog-read-enabled=false`、`practice-write-enabled=false` 部署应用。
|
||||
4. 验证 System、Infra、Member 基础 smoke。
|
||||
5. 仅对 Pilot 租户开启题库读取,完成 Scalar 只读 smoke。
|
||||
@@ -65,7 +65,7 @@ yudao:
|
||||
1. 设置 `catalog-read-enabled=false`,停止新的 Scalar 读取。
|
||||
2. 保持 `enabled=true`,使已有会话、报告、错题和收藏仍可访问。
|
||||
3. 如需冻结新写入,再设置 `practice-write-enabled=false`。
|
||||
4. 验证 Education MySQL 表行数和历史查询均未减少。
|
||||
4. 验证 Education PostgreSQL 表行数和历史查询均未减少。
|
||||
|
||||
### 练习写入熔断
|
||||
|
||||
@@ -78,11 +78,11 @@ yudao:
|
||||
### 应用回滚
|
||||
|
||||
1. 将应用回滚到上一已验证版本。
|
||||
2. 保留所有 Education 表和数据,不执行 `sql/mysql/education/*-rollback.sql`。
|
||||
3. 若旧版本与新 schema 不兼容,保持功能关闭并前滚修复;不得通过删表恢复服务。
|
||||
2. 保留所有 Education 表和数据,不执行 `flyway clean`、手工删除或任何 `*-rollback.sql`。
|
||||
3. 若旧版本与新 schema 不兼容,保持功能关闭并通过更高版本 Flyway migration 前滚修复;不得通过删表恢复服务。
|
||||
4. 重新验证 Member 登录、System 租户和 Infra 日志功能。
|
||||
|
||||
> `*-rollback.sql` 是显式数据销毁工具,不是常规应用版本回滚步骤。
|
||||
> 历史 `*-rollback.sql` 是数据销毁工具且不属于当前交付机制,不是常规应用版本回滚步骤。
|
||||
|
||||
## 6. 可观测性
|
||||
|
||||
|
||||
225
docs/education/scalar-contract-from-source.md
Normal file
225
docs/education/scalar-contract-from-source.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Scalar (tiku-backend) 接口契约 —— 从源码提取
|
||||
|
||||
日期:2026-07-28
|
||||
来源:`/Users/tiku1/code/tiku-backend` 仓库 NestJS Controller、DTO 和装饰器
|
||||
状态:源码级契约冻结,真实 endpoint 验证需启动完整运行时(Supabase + API server)
|
||||
|
||||
## 运行时信息
|
||||
|
||||
- 框架:NestJS + Fastify
|
||||
- OpenAPI 路径:`/openapi.json`(仅非生产环境)
|
||||
- Scalar 文档:`/docs`(仅非生产环境)
|
||||
- 认证:Bearer Token(JWT)+ `x-tenant-id` header
|
||||
- 响应 envelope:`{ items/item, meta: { requestId } }`
|
||||
|
||||
## RuoYi Education 实际调用的路径
|
||||
|
||||
以下为 RuoYi `ScalarCatalogProvider` 使用的只读 GET 路径:
|
||||
|
||||
### 1. GET /api/catalog/regions
|
||||
|
||||
- **安全方案**: `x-tenant-id` header (`@ApiSecurity('tenant-id')`, `@TenantAccess()`)
|
||||
- **查询参数**: `regionId?` (UUID, 可选)
|
||||
- **响应**: `{ items: CatalogRegionResponseDto[], meta: { requestId } }`
|
||||
- **CatalogRegionResponseDto 字段**:
|
||||
- `id` (uuid, 必填)
|
||||
- `legacyId` (string, nullable)
|
||||
- `name` (string, 必填)
|
||||
- `code` (string, nullable)
|
||||
- `shortName` (string, nullable)
|
||||
- `fullName` (string, nullable)
|
||||
- `icon` (string, nullable)
|
||||
- `pinyin` (string, nullable)
|
||||
- `isHot` (boolean, 必填)
|
||||
- `isActive` (boolean, 必填)
|
||||
- `order` (number, 必填)
|
||||
|
||||
### 2. GET /api/catalog/region-modules
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**: `regionId?` (UUID)
|
||||
- **响应**: `{ items: CatalogRegionModuleResponseDto[], meta }`
|
||||
- **字段**: id, legacyId, regionId, name, type, icon, color, textColor, description, route, isPrimarySchoolModule, isActive, order
|
||||
|
||||
### 3. GET /api/catalog/module-nodes
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**:
|
||||
- `regionId?` (UUID)
|
||||
- `moduleId?` (UUID)
|
||||
- `parentId?` (string, "root" 表示根节点)
|
||||
- **响应**: `{ items: CatalogEntityDto[], meta }` — 通用实体列表
|
||||
|
||||
### 4. GET /api/catalog/schools
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**: `regionId?`, `schoolId?`
|
||||
- **响应**: `{ items: CatalogSchoolResponseDto[], meta }`
|
||||
- **字段**: id, legacyId, regionId, moduleId, name, professionalExamDate, metadata, createdAt, updatedAt
|
||||
|
||||
### 5. GET /api/catalog/majors
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**: `regionId?`, `schoolId?`, `majorId?`, `moduleId?`, `type?`
|
||||
- **响应**: `{ items: CatalogMajorResponseDto[], meta }`
|
||||
|
||||
### 6. GET /api/catalog/subjects
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**: `regionId?`, `schoolId?`, `majorId?` (UUID), `moduleId?` (UUID), `type?` (string)
|
||||
- **响应**: `{ items: CatalogEntityDto[], meta }`
|
||||
|
||||
### 7. GET /api/catalog/categories
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**: `subjectId?` (UUID), `nodeId?` (UUID, 旧导航节点)
|
||||
- **响应**: `{ items: CatalogEntityDto[], meta }`
|
||||
|
||||
### 8. GET /api/catalog/questions
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**:
|
||||
- `subjectId?` (UUID)
|
||||
- `categoryId?` (UUID)
|
||||
- `nodeId?` (UUID, 旧导航节点)
|
||||
- `entryId?` (UUID)
|
||||
- `contentNodeId?` (UUID)
|
||||
- `collectionId?` (UUID)
|
||||
- `questionIds?` (string | string[], 逗号分隔或重复传参)
|
||||
- `limit?` (int, 1-2000)
|
||||
- **响应**: `{ items: QuestionResponseDto[], meta }`
|
||||
- **QuestionResponseDto 字段** (extends CatalogEntityDto):
|
||||
- `id` (uuid)
|
||||
- `type` (string, 必填 — 题型)
|
||||
- `typeLabel` (string, nullable)
|
||||
- `difficulty` (number, nullable)
|
||||
- `content` (unknown, 题干)
|
||||
- `options` (array, 选项列表)
|
||||
- `explanation` (string, nullable — **敏感字段**)
|
||||
- `hasVideoExplanation` (boolean)
|
||||
- 继承字段: legacyId, name, title, regionId, order, isActive, description, metadata
|
||||
- **注意**: `options` 中包含正确选项标记、`explanation` 包含答案解析。RuoYi 在返回学生端 DTO 前必须剥离这些字段。
|
||||
|
||||
### 9. GET /api/catalog/content-entries
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**:
|
||||
- `regionId?` (UUID)
|
||||
- `entryType?` (string)
|
||||
- `includeHidden?` (boolean, default false)
|
||||
- **响应**: `{ items: ContentEntryResponseDto[], meta }`
|
||||
|
||||
### 10. GET /api/catalog/content-nodes
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**:
|
||||
- `entryId` (UUID, **必填**)
|
||||
- `parentId?` (string, "root" 表示根节点)
|
||||
- `mode?` ('children' | 'flat', default 'children')
|
||||
- `includeInactive?` (boolean, default false)
|
||||
- `markerType?` (string)
|
||||
- **响应**: `{ items: ContentNodeResponseDto[], meta }`
|
||||
|
||||
### 11. GET /api/catalog/question-collections
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**:
|
||||
- `regionId?` (UUID)
|
||||
- `entryId?` (UUID)
|
||||
- `nodeId?` (UUID)
|
||||
- `collectionType?` (string)
|
||||
- `limit?` (int, 1-2000)
|
||||
- **响应**: `{ items: QuestionCollectionResponseDto[], meta }`
|
||||
|
||||
### 12. GET /api/catalog/question-collections/questions
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**:
|
||||
- `collectionId` (UUID, **必填**)
|
||||
- `limit?` (int, 1-2000)
|
||||
- **响应**: `{ items: QuestionResponseDto[], meta }`
|
||||
|
||||
### 13. GET /api/catalog/practice-blueprints
|
||||
|
||||
- **安全方案**: `x-tenant-id`
|
||||
- **查询参数**:
|
||||
- `entryId?` (UUID)
|
||||
- `nodeId?` (UUID)
|
||||
- `collectionId?` (UUID)
|
||||
- `mode?` (string)
|
||||
- `limit?` (int, 1-2000)
|
||||
- **响应**: `{ items: PracticeBlueprintResponseDto[], meta }`
|
||||
- **字段**: id, mode, entryId, nodeId, collectionId, questionLimit, durationMinutes + CatalogEntityDto 继承字段
|
||||
|
||||
## 认证机制
|
||||
|
||||
### 租户识别
|
||||
- 所有 catalog 路径使用 `@TenantAccess()` 装饰器 → `AccessPolicy { kind: 'tenant' }`
|
||||
- 租户 ID 从 `x-tenant-id` 请求头提取(CORS 白名单包含此头)
|
||||
- `Principal` 装饰器从请求上下文提取 `principal.tenant.tenantId`
|
||||
|
||||
### Bearer Token
|
||||
- catalog 的大多数端点不需要 Bearer(只读、租户级访问)
|
||||
- `assets`、`assets/download`、`assets/preview` 需要 `@ApiBearerAuth()`
|
||||
- 学习写入路径 (`/api/learning/*`) 需要 `@ApiBearerAuth()` + `@TenantUserAccess()`
|
||||
|
||||
## 响应格式
|
||||
|
||||
### 成功
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"meta": { "requestId": "uuid" }
|
||||
}
|
||||
```
|
||||
或
|
||||
```json
|
||||
{
|
||||
"item": {...},
|
||||
"meta": { "requestId": "uuid" }
|
||||
}
|
||||
```
|
||||
|
||||
### 错误
|
||||
```json
|
||||
{
|
||||
"error": "面向调用方的错误信息",
|
||||
"code": "REQUIRED_FIELD",
|
||||
"requestId": "uuid",
|
||||
"meta": { "requestId": "uuid" }
|
||||
}
|
||||
```
|
||||
|
||||
## 与 RuoYi adapter 的差异
|
||||
|
||||
| 项目 | RuoYi (Java) 假设 | Scalar (tiku-backend) 实际 |
|
||||
|------|-------------------|---------------------------|
|
||||
| 基础路径 | 配置的 `base-url` | `/api/catalog/*` |
|
||||
| 认证头 | `Authorization: Bearer <token>` | 大多数 catalog 端点只需 `x-tenant-id`,不需要 Bearer |
|
||||
| 租户头 | `x-tenant-id` | `x-tenant-id` ✅ 一致 |
|
||||
| 分页 | `page` + `pageSize` | `limit` (1-2000),无 page 参数! |
|
||||
| 题目过滤 | `published=true&hidden=false` 由 RuoYi 追加 | Scalar 端已有 `isActive` 过滤,但无 `published`/`hidden` query 参数 |
|
||||
| 响应 envelope | 预期 `items` + 可能的 `total` | `items` + `meta.requestId`,无 `total` 字段! |
|
||||
| 正确答案 | RuoYi 在返回学生端前剥离 | `QuestionResponseDto.options` 包含正确选项标记 |
|
||||
|
||||
## ⚠️ 关键差异
|
||||
|
||||
1. **分页**: RuoYi `ScalarCatalogProvider` 使用 `page` + `pageSize` query 参数,但 Scalar 只接受 `limit`。Java 端第 526-540 行固定追加 `page` 和 `pageSize` —— 这些参数在 Scalar controller 中不存在,会被忽略。
|
||||
2. **total 字段**: RuoYi adapter 期望服务端返回 `total` 用于分页,但 Scalar 响应没有此字段。如果 RuoYi 依赖 `total` 做前端分页计算,需要确认 adapter 如何处理。
|
||||
3. **published/hidden**: RuoYi 端固定追加 `published=true&hidden=false`,但这些参数在 Scalar controller DTO 中未定义。Scalar 的过滤逻辑在 repository 层而非 query 参数层。
|
||||
|
||||
## OpenAPI 生成方式
|
||||
|
||||
```bash
|
||||
# 需要 Docker + Supabase 运行
|
||||
cd tiku-backend
|
||||
npm run supabase:start
|
||||
npm run dev:api
|
||||
curl http://127.0.0.1:8787/openapi.json > /tmp/tiku-openapi.json
|
||||
|
||||
# 或通过测试套件(也会启动真实服务器)
|
||||
BACKEND_TEST_SKIP_DATABASE=true npm run test:backend:migration
|
||||
# 生成文件:/tmp/tiku-openapi.json
|
||||
```
|
||||
|
||||
当前环境不具备 Supabase/Docker,无法生成运行时 OpenAPI JSON。源码级契约已在此文档冻结。
|
||||
@@ -45,5 +45,5 @@ docker compose --env-file docker.env up -d
|
||||
|
||||
- admin ui: http://localhost:8080
|
||||
- api server: http://localhost:48080
|
||||
- mysql: root/123456, port: 3306
|
||||
- postgresql: root/123456, port: 5432
|
||||
- redis: port: 6379
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
version: "3.4"
|
||||
|
||||
name: yudao-system
|
||||
|
||||
services:
|
||||
mysql:
|
||||
container_name: yudao-mysql
|
||||
image: mysql:8
|
||||
postgres:
|
||||
container_name: yudao-postgres
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
tty: true
|
||||
ports:
|
||||
- "3306:3306"
|
||||
- "5432:5432"
|
||||
environment:
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-ruoyi-vue-pro}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-ruoyi-vue-pro}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-root}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
|
||||
volumes:
|
||||
- mysql:/var/lib/mysql/
|
||||
- ./sql/mysql/ruoyi-vue-pro.sql:/docker-entrypoint-initdb.d/ruoyi-vue-pro.sql:ro
|
||||
- postgres:/var/lib/postgresql/data/
|
||||
- ../../sql/postgresql/ruoyi-vue-pro.sql:/docker-entrypoint-initdb.d/000-ruoyi-vue-pro.sql:ro
|
||||
|
||||
redis:
|
||||
container_name: yudao-redis
|
||||
@@ -44,15 +42,15 @@ services:
|
||||
-Djava.security.egd=file:/dev/./urandom
|
||||
}
|
||||
ARGS:
|
||||
--spring.datasource.dynamic.datasource.master.url=${MASTER_DATASOURCE_URL:-jdbc:mysql://yudao-mysql:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true}
|
||||
--spring.datasource.dynamic.datasource.master.url=${MASTER_DATASOURCE_URL:-jdbc:postgresql://yudao-postgres:5432/ruoyi-vue-pro}
|
||||
--spring.datasource.dynamic.datasource.master.username=${MASTER_DATASOURCE_USERNAME:-root}
|
||||
--spring.datasource.dynamic.datasource.master.password=${MASTER_DATASOURCE_PASSWORD:-123456}
|
||||
--spring.datasource.dynamic.datasource.slave.url=${SLAVE_DATASOURCE_URL:-jdbc:mysql://yudao-mysql:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true}
|
||||
--spring.datasource.dynamic.datasource.slave.url=${SLAVE_DATASOURCE_URL:-jdbc:postgresql://yudao-postgres:5432/ruoyi-vue-pro}
|
||||
--spring.datasource.dynamic.datasource.slave.username=${SLAVE_DATASOURCE_USERNAME:-root}
|
||||
--spring.datasource.dynamic.datasource.slave.password=${SLAVE_DATASOURCE_PASSWORD:-123456}
|
||||
--spring.data.redis.host=${REDIS_HOST:-yudao-redis}
|
||||
depends_on:
|
||||
- mysql
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
admin:
|
||||
@@ -78,7 +76,7 @@ services:
|
||||
- server
|
||||
|
||||
volumes:
|
||||
mysql:
|
||||
postgres:
|
||||
driver: local
|
||||
redis:
|
||||
driver: local
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
## mysql
|
||||
MYSQL_DATABASE=ruoyi-vue-pro
|
||||
MYSQL_ROOT_PASSWORD=123456
|
||||
## postgresql
|
||||
POSTGRES_DB=ruoyi-vue-pro
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=123456
|
||||
|
||||
## server
|
||||
JAVA_OPTS=-Xms512m -Xmx512m -Djava.security.egd=file:/dev/./urandom
|
||||
|
||||
MASTER_DATASOURCE_URL=jdbc:mysql://yudao-mysql:3306/${MYSQL_DATABASE}?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
|
||||
MASTER_DATASOURCE_USERNAME=root
|
||||
MASTER_DATASOURCE_PASSWORD=${MYSQL_ROOT_PASSWORD}
|
||||
MASTER_DATASOURCE_URL=jdbc:postgresql://yudao-postgres:5432/${POSTGRES_DB}
|
||||
MASTER_DATASOURCE_USERNAME=${POSTGRES_USER}
|
||||
MASTER_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD}
|
||||
SLAVE_DATASOURCE_URL=${MASTER_DATASOURCE_URL}
|
||||
SLAVE_DATASOURCE_USERNAME=${MASTER_DATASOURCE_USERNAME}
|
||||
SLAVE_DATASOURCE_PASSWORD=${MASTER_DATASOURCE_PASSWORD}
|
||||
|
||||
@@ -31,9 +31,7 @@ public interface TenantCommonApi {
|
||||
* @param id 租户编号
|
||||
* @return 租户信息,不存在时返回 null
|
||||
*/
|
||||
default TenantRespDTO getTenant(Long id) {
|
||||
throw new UnsupportedOperationException("getTenant is not implemented");
|
||||
}
|
||||
TenantRespDTO getTenant(Long id);
|
||||
|
||||
/**
|
||||
* 根据租户名获得租户信息
|
||||
@@ -41,9 +39,7 @@ public interface TenantCommonApi {
|
||||
* @param name 租户名
|
||||
* @return 租户信息,不存在时返回 null
|
||||
*/
|
||||
default TenantRespDTO getTenantByName(String name) {
|
||||
throw new UnsupportedOperationException("getTenantByName is not implemented");
|
||||
}
|
||||
TenantRespDTO getTenantByName(String name);
|
||||
|
||||
/**
|
||||
* 根据域名获得租户信息
|
||||
@@ -51,8 +47,6 @@ public interface TenantCommonApi {
|
||||
* @param website 域名
|
||||
* @return 租户信息,不存在时返回 null
|
||||
*/
|
||||
default TenantRespDTO getTenantByWebsite(String website) {
|
||||
throw new UnsupportedOperationException("getTenantByWebsite is not implemented");
|
||||
}
|
||||
TenantRespDTO getTenantByWebsite(String website);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package cn.iocoder.yudao.framework.tenant.core.security;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.config.TenantProperties;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.service.TenantFrameworkService;
|
||||
import cn.iocoder.yudao.framework.web.config.WebProperties;
|
||||
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import java.util.HashSet;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class TenantSecurityWebFilterTest {
|
||||
|
||||
private TenantSecurityWebFilter filter;
|
||||
private TenantFrameworkService tenantFrameworkService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
WebProperties webProperties = new WebProperties();
|
||||
TenantProperties tenantProperties = new TenantProperties();
|
||||
tenantProperties.setIgnoreUrls(new HashSet<>());
|
||||
tenantFrameworkService = mock(TenantFrameworkService.class);
|
||||
filter = new TenantSecurityWebFilter(webProperties, tenantProperties, new HashSet<>(),
|
||||
new GlobalExceptionHandler("test", mock(ApiErrorLogCommonApi.class)), tenantFrameworkService);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingTenantOnProtectedRequestIsRejected() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/education/context");
|
||||
MockHttpServletResponse response = doFilter(request);
|
||||
assertEquals(400, jsonCode(response));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedTenantMismatchIsRejected() throws Exception {
|
||||
setLoginUser(1L, 10L);
|
||||
TenantContextHolder.setTenantId(20L);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/education/context");
|
||||
MockHttpServletResponse response = doFilter(request);
|
||||
assertEquals(403, jsonCode(response));
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedTenantFillsMissingRequestTenant() throws Exception {
|
||||
setLoginUser(1L, 10L);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/app-api/education/context");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
filter.doFilter(request, response, chain);
|
||||
assertEquals(10L, TenantContextHolder.getTenantId());
|
||||
verify(tenantFrameworkService).validTenant(10L);
|
||||
assertEquals(request, chain.getRequest());
|
||||
}
|
||||
|
||||
private MockHttpServletResponse doFilter(MockHttpServletRequest request) throws Exception {
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(request, response, new MockFilterChain());
|
||||
return response;
|
||||
}
|
||||
|
||||
private static int jsonCode(MockHttpServletResponse response) throws Exception {
|
||||
String content = response.getContentAsString();
|
||||
int start = content.indexOf("\"code\":") + 7;
|
||||
int end = content.indexOf(',', start);
|
||||
return Integer.parseInt(content.substring(start, end));
|
||||
}
|
||||
|
||||
private static void setLoginUser(Long userId, Long tenantId) {
|
||||
LoginUser user = new LoginUser();
|
||||
user.setId(userId);
|
||||
user.setTenantId(tenantId);
|
||||
user.setUserType(UserTypeEnum.MEMBER.getValue());
|
||||
SecurityFrameworkUtils.setLoginUser(user, new MockHttpServletRequest());
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,6 @@
|
||||
</dependency>
|
||||
|
||||
<!-- DB 相关 -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.oracle.database.jdbc</groupId>
|
||||
<artifactId>ojdbc8</artifactId>
|
||||
@@ -41,7 +37,6 @@
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.microsoft.sqlserver</groupId>
|
||||
|
||||
@@ -42,7 +42,7 @@ public class DesensitizeTest {
|
||||
DesensitizeDemo d = JsonUtils.parseObject(JsonUtils.toJsonString(desensitizeDemo), DesensitizeDemo.class);
|
||||
// 断言
|
||||
assertNotNull(d);
|
||||
assertEquals("芋***", d.getNickname());
|
||||
assertEquals("恭***", d.getNickname());
|
||||
assertEquals("998800********31", d.getBankCard());
|
||||
assertEquals("粤A6***6", d.getCarLicense());
|
||||
assertEquals("0108*****22", d.getFixedPhone());
|
||||
|
||||
61
yudao-module-education/CONTEXT.md
Normal file
61
yudao-module-education/CONTEXT.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Education
|
||||
|
||||
The Education context owns the language for educational content, practice, assessment, and student learning state.
|
||||
|
||||
## Language
|
||||
|
||||
**Question Content**:
|
||||
The complete versioned educational prompt presented for answering, including its stem and any answer choices required by its question type.
|
||||
_Avoid_: Payload, raw question
|
||||
|
||||
**Option-backed Question**:
|
||||
A question whose answer must be selected from a finite, student-visible set of labeled options. The supported family is `choice`, `multi`, `multi_choice`, `judge`, and `image`.
|
||||
_Avoid_: Objective question when referring only to storage shape
|
||||
|
||||
**Optionless Question**:
|
||||
A question answered without selecting from a finite option list. The recognized family includes `fill`, `text`, `terms`, `short_answer`, `composition`, `discuss`, `translation`, `case_analysis`, `brief_analysis`, `calculation`, `analysis_design`, `combination`, and `solution`.
|
||||
_Avoid_: Subjective question when the distinction being made is only whether options are present
|
||||
|
||||
**Composite Question**:
|
||||
A container question, such as `reading`, whose answerable units are sub-questions rather than top-level options. It is not a valid standalone practice question until the sub-question model is supported by the target contract.
|
||||
_Avoid_: Reading question when other composite forms may use the same model
|
||||
|
||||
**Safe Question**:
|
||||
A student-visible projection of Question Content that contains only display fields and never contains an answer, explanation, analysis, scoring rule, correctness flag, or administrative metadata.
|
||||
_Avoid_: Sanitized DO, frontend question
|
||||
|
||||
**Question Option**:
|
||||
One distinct choice in an Option-backed Question, identified by a non-blank label and containing non-blank display content. Labels are unique within the question.
|
||||
_Avoid_: Answer, choice answer
|
||||
|
||||
**Question Snapshot**:
|
||||
The immutable, student-visible Question Content captured when a practice session is created so that later content edits do not change that session. It excludes answers and explanations; protected scoring data is not part of this projection even when stored beside it.
|
||||
_Avoid_: Question cache
|
||||
|
||||
**Protected Answer Key**:
|
||||
Server-only correctness and explanation data captured for stable scoring of a practice session. It is never included in a Safe Question, Question Snapshot JSON, or pre-submit response.
|
||||
_Avoid_: Question Snapshot, frontend answer
|
||||
|
||||
**Unavailable Question Content**:
|
||||
Question Content that cannot safely be displayed or restored because its type, required options, option structure, publication state, or snapshot encoding is invalid or unsupported.
|
||||
_Avoid_: Empty question, best-effort question
|
||||
|
||||
**Tenant Locator Claim**:
|
||||
An unauthenticated pre-login value used to request tenant selection: either a browser-context hostname claim or an explicit Public Tenant Handle. It is not proof of caller identity or tenant authorization.
|
||||
_Avoid_: Trusted identity, authenticated hostname, tenant ID
|
||||
|
||||
**Browser-context Evidence**:
|
||||
A hostname claim derived from `Origin`, falling back to `Referer`, and used to keep browser login-routing inputs consistent. Any HTTP client can forge it, so it is not an authentication boundary.
|
||||
_Avoid_: Trusted browser identity, verified origin
|
||||
|
||||
**Public Tenant Handle**:
|
||||
The exact, published pre-login handle used by a headless client to request one tenant. In the current target it is the System tenant's unique name under a constrained, case-sensitive, operationally immutable contract; it is not the source system's distinct Tenant Code.
|
||||
_Avoid_: Tenant Code, display-name search, tenantName query
|
||||
|
||||
**Student Principal**:
|
||||
An authenticated Member identity acting as a student. An administrator or generic authenticated account is not a Student Principal.
|
||||
_Avoid_: User, account, logged-in principal
|
||||
|
||||
**Public Tenant Resolution**:
|
||||
The unauthenticated pre-login mapping of a Tenant Locator Claim to minimal login-routing fields. A successful result discloses that an available tenant exists; failed unknown, disabled, and expired tenants remain indistinguishable.
|
||||
_Avoid_: Tenant authentication, tenant administration lookup, non-enumerating discovery
|
||||
@@ -18,7 +18,7 @@
|
||||
- 题目安全过滤(答案/解析绝不暴露到前端)
|
||||
- 独立的功能开关配置 + Scalar 数据源配置
|
||||
- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习)
|
||||
- 权限与菜单种子数据
|
||||
- 权限注解(`education:capability`);菜单种子尚未纳入正式 Flyway,管理员授权需在后续产品决策后交付
|
||||
|
||||
## 功能配置
|
||||
|
||||
@@ -40,7 +40,7 @@ yudao:
|
||||
灰度与回滚约束:
|
||||
|
||||
- `enabled=false`:移除 Education HTTP 能力,不执行任何数据删除。
|
||||
- `catalog-read-enabled=false`:停止 Scalar 题库读取;已有会话、报告、错题和收藏仍保存在 MySQL。
|
||||
- `catalog-read-enabled=false`:停止题库数据源读取;已有会话、报告、错题和收藏仍保存在 PostgreSQL。
|
||||
- `practice-write-enabled=false`:拒绝新建练习、保存答案和交卷;会话恢复、报告与历史查询保持可用。
|
||||
- `pilot-tenant-ids`:非空时仅允许列表内租户使用题库和练习写入能力。
|
||||
- 应用回滚只回滚应用版本或开关;不得执行 `*-rollback.sql`。SQL 回滚脚本仅用于明确的数据销毁场景。
|
||||
@@ -169,35 +169,37 @@ mvn package -pl yudao-server -am -DskipTests
|
||||
3. 访问 Swagger UI 查看 `education` 分组
|
||||
4. 调用 `GET /admin-api/education/capability`
|
||||
|
||||
## SQL 应用
|
||||
## 数据库迁移
|
||||
|
||||
```bash
|
||||
# 应用基础种子数据(菜单 + 权限定义;执行后由管理员为目标租户角色授权)
|
||||
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/000-education-seed.sql
|
||||
Education 运行时 Schema 仅通过模块内 PostgreSQL Flyway migration 交付:
|
||||
|
||||
# 应用租户识别种子数据
|
||||
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/001-education-tenant-seed.sql
|
||||
|
||||
# 回滚
|
||||
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/000-education-rollback.sql
|
||||
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/001-education-tenant-rollback.sql
|
||||
```text
|
||||
yudao-module-education/src/main/resources/db/migration/education/
|
||||
```
|
||||
|
||||
## SQL 交付约定
|
||||
- `V4010` 和 `V4020` 是不可变迁移历史,不得修改。
|
||||
- `V4030` 创建或接管 Practice 核心闭环表,并在存在旧答案/交卷幂等表时向统一 `education_idempotency` 回填数据。
|
||||
- 旧 `education_answer_idempotency`、`education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。
|
||||
- `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。
|
||||
- 禁止使用 `flyway clean` 或 `*-rollback.sql` 回退共享环境。应用回滚后如有 Schema 兼容问题,通过更高版本向前修复。
|
||||
|
||||
- 所有 Education SQL 文件存放在 `sql/mysql/education/` 目录下
|
||||
- 文件命名:`NNN-描述.sql`(NNN 为三位递增序号)
|
||||
- 每个正向脚本应有对应的回滚脚本
|
||||
- schema 文件仅包含 DDL,seed 文件仅包含 DML
|
||||
- **不修改**项目根目录的 `ruoyi-vue-pro.sql` 巨量全量转储
|
||||
- Ticket #5 为只读/预览操作,无新增数据库 schema 或 DML
|
||||
首次接管已有 PostgreSQL 平台库时使用既定的 `4009` baseline。任何已存在 Education 表的环境都必须先核对实际表结构和 `flyway_schema_history`,不能仅凭表名视为兼容,也不能伪造 V4010/V4020 执行历史。
|
||||
|
||||
编译后确认 migration 已打包:
|
||||
|
||||
```bash
|
||||
mvn -pl yudao-module-education -am -DskipTests clean package
|
||||
find yudao-module-education/target/classes/db/migration/education -type f -print
|
||||
```
|
||||
|
||||
只有真实 PostgreSQL 上的 Flyway migrate、validate 和历史检查成功后,才能报告数据库迁移成功。
|
||||
|
||||
## 前端状态
|
||||
|
||||
**当前工作区未检出完整的前端源码。** `yudao-ui/yudao-ui-admin-vue3/` 仅包含部分 MES 相关文件(`src/api/mes/`、`src/views/mes/`),缺少 `package.json`、`router/`、`store/`、`config/` 等核心框架文件。
|
||||
|
||||
因此:
|
||||
- **管理后台教育菜单项**:基础 SQL 仅注册了 `system_menu` 记录(ID 6800-6801)。租户解析与当前上下文属于学生端接口,不创建虚假的后台权限菜单。前端无路由/页面组件可渲染,菜单在管理后台不会显示。
|
||||
- **管理后台教育菜单项**:当前仅保留 `education:capability` 权限契约,正式 Flyway 尚未写入菜单种子;前端无路由/页面组件可渲染,因此不声明已有可见教育菜单。
|
||||
- **Student Web/H5 应用外壳**:前端源码不存在,无法建立。
|
||||
|
||||
### Student 端前端集成契约
|
||||
|
||||
@@ -31,6 +31,21 @@
|
||||
<artifactId>yudao-spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -31,7 +31,7 @@ public class EducationProperties {
|
||||
|
||||
/**
|
||||
* 题库目录数据源模式。
|
||||
* 默认 SCALAR_READ;JAVA_READ 为预留模式(当前不支持)。
|
||||
* 默认 SCALAR_READ;JAVA_READ 使用本地 PostgreSQL 目录数据源。
|
||||
*/
|
||||
private CatalogProviderMode catalogMode = CatalogProviderMode.SCALAR_READ;
|
||||
|
||||
@@ -51,18 +51,33 @@ public class EducationProperties {
|
||||
private List<Long> pilotTenantIds = List.of();
|
||||
|
||||
/**
|
||||
* 精确主机名到租户名的映射,用于 DNS 与 system_tenant.websites 不一致的场景。
|
||||
* key = 标准化后的主机名(小写、无端口),value = 租户名。
|
||||
* 示例:{ "staging.school.com": "demo-school" }
|
||||
*
|
||||
* 映射优先级高于 system_tenant.websites 字段匹配。
|
||||
* 租户识别配置。
|
||||
*/
|
||||
private TenantResolution tenantResolution = new TenantResolution();
|
||||
|
||||
@Data
|
||||
public static class TenantResolution {
|
||||
|
||||
/**
|
||||
* 是否允许开发工作站和自动化测试使用本地主机名识别租户。
|
||||
*/
|
||||
private boolean localDevelopmentEnabled = false;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地开发主机名到公开租户句柄的精确映射,仅在 local-development-enabled=true 时使用。
|
||||
* key = 标准化后的本地主机名(小写、无端口),value = 公开租户句柄。
|
||||
* 生产域名始终由 System 的 canonical website 查找负责。
|
||||
*/
|
||||
private Map<String, String> hostnameTenantMap = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 学生端全局开放的登录方式。它描述当前部署启用的 Member 登录入口,
|
||||
* 不是租户级 OAuth 提供方探测结果。
|
||||
* 学生端登录方式由 Member 认证模块负责;该配置仅为旧配置绑定兼容,不参与公开租户识别响应。
|
||||
*
|
||||
* @deprecated 请在 Member 认证入口配置登录方式
|
||||
*/
|
||||
@Deprecated
|
||||
private List<String> loginMethods = List.of("PASSWORD", "SMS");
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package cn.iocoder.yudao.module.education.controller.app;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
|
||||
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.education.controller.app.vo.EducationContextRespVO;
|
||||
@@ -42,11 +44,12 @@ public class EducationContextController {
|
||||
@Operation(summary = "获取当前教育上下文",
|
||||
description = "根据当前认证用户和租户上下文返回教育业务信息。需要登录态。")
|
||||
public CommonResult<EducationContextRespVO> getContext() {
|
||||
// 1. 从安全上下文获取用户编号(不信任请求参数)
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
if (userId == null) {
|
||||
// 1. 从安全上下文获取完整主体,只接受 Member 学生主体
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null || !UserTypeEnum.MEMBER.getValue().equals(loginUser.getUserType())) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
Long userId = loginUser.getId();
|
||||
|
||||
// 2. 获取当前租户并验证状态(由 TenantSecurityWebFilter 前置完成,此处二次验证)
|
||||
Long tenantId = TenantContextHolder.getRequiredTenantId();
|
||||
|
||||
@@ -6,6 +6,9 @@ import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.catalog.vo.*;
|
||||
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.CatalogService;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
|
||||
import cn.iocoder.yudao.module.education.controller.app.question.vo.SafeQuestionRespVO;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -37,9 +40,33 @@ public class CatalogController {
|
||||
@Resource
|
||||
private CatalogService catalogService;
|
||||
|
||||
@Resource
|
||||
private QuestionCatalogService questionCatalogService;
|
||||
|
||||
@Resource
|
||||
private EducationAccessService educationAccessService;
|
||||
|
||||
@GetMapping("/schools")
|
||||
@Operation(summary = "查询院校目录")
|
||||
public CommonResult<List<CatalogSchoolRespVO>> listSchools(
|
||||
@RequestParam(required = false) String regionId,
|
||||
@RequestParam(required = false) String schoolId) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listSchools(regionId, schoolId));
|
||||
}
|
||||
|
||||
@GetMapping("/majors")
|
||||
@Operation(summary = "查询专业目录")
|
||||
public CommonResult<List<CatalogMajorRespVO>> listMajors(
|
||||
@RequestParam(required = false) String regionId,
|
||||
@RequestParam(required = false) String schoolId,
|
||||
@RequestParam(required = false) String majorId,
|
||||
@RequestParam(required = false) String moduleId,
|
||||
@RequestParam(required = false) String type) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listMajors(regionId, schoolId, majorId, moduleId, type));
|
||||
}
|
||||
|
||||
@GetMapping("/regions")
|
||||
@Operation(summary = "查询可用地区列表")
|
||||
public CommonResult<List<CatalogRegionRespVO>> listRegions() {
|
||||
@@ -82,10 +109,9 @@ public class CatalogController {
|
||||
@Operation(summary = "查询内容入口")
|
||||
public CommonResult<List<CatalogContentEntryRespVO>> listContentEntries(
|
||||
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
|
||||
@Parameter(description = "内容入口类型") @RequestParam(required = false) String entryType,
|
||||
@Parameter(description = "是否包含隐藏入口") @RequestParam(defaultValue = "false") boolean includeHidden) {
|
||||
@Parameter(description = "内容入口类型") @RequestParam(required = false) String entryType) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listContentEntries(regionId, entryType, includeHidden));
|
||||
return success(catalogService.listContentEntries(regionId, entryType, false));
|
||||
}
|
||||
|
||||
@GetMapping("/content-nodes")
|
||||
@@ -94,10 +120,9 @@ public class CatalogController {
|
||||
@Parameter(description = "内容入口 ID", required = true) @RequestParam String entryId,
|
||||
@Parameter(description = "父节点 ID(传 root 表示根节点)") @RequestParam(required = false) String parentId,
|
||||
@Parameter(description = "查询模式(children/flat)") @RequestParam(defaultValue = "children") String mode,
|
||||
@Parameter(description = "是否包含停用节点") @RequestParam(defaultValue = "false") boolean includeInactive,
|
||||
@Parameter(description = "节点标记类型") @RequestParam(required = false) String markerType) {
|
||||
assertAuthenticated();
|
||||
return success(catalogService.listContentNodes(entryId, parentId, mode, includeInactive, markerType));
|
||||
return success(catalogService.listContentNodes(entryId, parentId, mode, false, markerType));
|
||||
}
|
||||
|
||||
@GetMapping("/question-collections")
|
||||
@@ -112,6 +137,18 @@ public class CatalogController {
|
||||
return success(catalogService.listQuestionCollections(regionId, entryId, nodeId, collectionType, limit));
|
||||
}
|
||||
|
||||
@GetMapping("/question-collections/{id}/questions")
|
||||
@Operation(summary = "查询题集内题目", description = "仅返回学生可见字段,不包含答案、解析或正确性标记。")
|
||||
public CommonResult<PageResult<SafeQuestionRespVO>> listCollectionQuestions(
|
||||
@Parameter(description = "题集 ID", required = true) @PathVariable String id,
|
||||
@Parameter(description = "题型") @RequestParam(required = false) String type,
|
||||
@Parameter(description = "难度") @RequestParam(required = false) String difficulty,
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Integer pageNo,
|
||||
@Parameter(description = "每页条数") @RequestParam(defaultValue = "20") Integer pageSize) {
|
||||
assertAuthenticated();
|
||||
return success(questionCatalogService.listCollectionQuestions(id, type, difficulty, pageNo, pageSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前学生的租户上下文和题库读取灰度开关。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "专业目录项")
|
||||
public class CatalogMajorRespVO {
|
||||
private String id;
|
||||
private String name;
|
||||
private String regionId;
|
||||
private String schoolId;
|
||||
private Double order;
|
||||
private Boolean active;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.catalog.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "院校目录项")
|
||||
public class CatalogSchoolRespVO {
|
||||
private String id;
|
||||
private String name;
|
||||
private String regionId;
|
||||
private String moduleId;
|
||||
private Double order;
|
||||
private Boolean active;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.practice;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeAnswerRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionCreateReqVO;
|
||||
@@ -31,7 +33,7 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
* 练习会话 Controller — 学生端已认证接口。
|
||||
*
|
||||
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
|
||||
* <p>会话和答题数据写入 MySQL,从不经过 Scalar。</p>
|
||||
* <p>会话和答题数据写入 PostgreSQL,从不经过 Scalar。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@@ -122,24 +124,21 @@ public class PracticeSessionController {
|
||||
|
||||
// ========== security helpers ==========
|
||||
|
||||
private Long getUserId() {
|
||||
private LoginUser getStudentPrincipal() {
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null) {
|
||||
if (loginUser == null || !UserTypeEnum.MEMBER.getValue().equals(loginUser.getUserType())) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
return loginUser.getId();
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
private Long getUserId() {
|
||||
return getStudentPrincipal().getId();
|
||||
}
|
||||
|
||||
private Long getTenantId() {
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
Long tenantId = loginUser.getTenantId();
|
||||
if (tenantId == null) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
return tenantId;
|
||||
getStudentPrincipal();
|
||||
return TenantContextHolder.getRequiredTenantId();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,23 +18,27 @@ import jakarta.annotation.security.PermitAll;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.net.IDN;
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_LOCATOR_CONFLICT;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_NOT_ACTIVE;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.EDUCATION_TENANT_RESOLVE_FAILED;
|
||||
|
||||
/**
|
||||
* 教育租户识别 Controller — 学生端入口
|
||||
* 教育租户识别 Controller — 学生端入口。
|
||||
*
|
||||
* <p>用于在登录前通过主机名或租户名解析当前租户,返回引导信息。
|
||||
* 该端点无需认证(@PermitAll),忽略租户上下文(@TenantIgnore)。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
* <p>请求中的浏览器上下文和租户句柄都是可伪造的租户定位声明,不构成身份认证。</p>
|
||||
*/
|
||||
@Tag(name = "用户 APP - 教育租户识别")
|
||||
@RestController
|
||||
@@ -43,139 +47,251 @@ import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class EducationTenantController {
|
||||
|
||||
private static final Pattern TENANT_HANDLE_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{2,64}$");
|
||||
private static final Pattern DNS_LABEL_PATTERN = Pattern.compile("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$");
|
||||
private static final Pattern IPV4_PATTERN = Pattern.compile("^(?:\\d{1,3}\\.){3}\\d{1,3}$");
|
||||
|
||||
@Resource
|
||||
private TenantCommonApi tenantCommonApi;
|
||||
|
||||
@Resource
|
||||
private EducationProperties educationProperties;
|
||||
|
||||
@GetMapping("/resolve")
|
||||
@PermitAll
|
||||
@TenantIgnore
|
||||
@Operation(summary = "解析租户",
|
||||
description = "通过主机名或租户名解析租户信息,返回登录引导所需的基础字段。" +
|
||||
"hostname 和 tenantName 至少提供一个。")
|
||||
@Operation(summary = "解析租户", description = "根据可伪造的浏览器上下文声明或公开租户句柄返回最小登录路由信息。")
|
||||
@Parameters({
|
||||
@Parameter(name = "hostname", description = "标准化主机名(小写、无端口、无协议)", example = "school.example.com"),
|
||||
@Parameter(name = "tenantName", description = "租户名", example = "demo-school")
|
||||
@Parameter(name = "Origin", description = "优先使用的 HTTP(S) 浏览器上下文声明"),
|
||||
@Parameter(name = "Referer", description = "Origin 缺失时使用的 HTTP(S) 浏览器上下文声明"),
|
||||
@Parameter(name = "hostname", description = "用于确认浏览器上下文的主机名;本地开发模式下可作为本地主机声明"),
|
||||
@Parameter(name = "tenantHandle", description = "区分大小写的公开租户句柄")
|
||||
})
|
||||
public CommonResult<EducationTenantRespVO> resolve(
|
||||
@RequestHeader(value = "Origin", required = false) String origin,
|
||||
@RequestHeader(value = "Referer", required = false) String referer,
|
||||
@RequestParam(value = "hostname", required = false) String hostname,
|
||||
@RequestParam(value = "tenantHandle", required = false) String tenantHandle,
|
||||
@RequestParam(value = "tenantName", required = false) String tenantName) {
|
||||
|
||||
// 1. 校验输入:至少提供一个
|
||||
if (StrUtil.isBlank(hostname) && StrUtil.isBlank(tenantName)) {
|
||||
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 和 tenantName 不能同时为空");
|
||||
if (tenantName != null) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
TenantRespDTO byHandle = resolveByHandle(tenantHandle);
|
||||
String browserHost = resolveBrowserHost(origin, referer);
|
||||
if (browserHost != null && isLocalHost(browserHost)
|
||||
&& !educationProperties.getTenantResolution().isLocalDevelopmentEnabled()) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
String requestedHost = normalizeHost(hostname);
|
||||
if (browserHost != null && requestedHost != null && !browserHost.equals(requestedHost)) {
|
||||
throw exception(EDUCATION_TENANT_LOCATOR_CONFLICT);
|
||||
}
|
||||
|
||||
// 2. 主机名标准化
|
||||
String normalizedHostname = normalizeHostname(hostname);
|
||||
|
||||
// 3. 解析租户并验证一致性
|
||||
TenantRespDTO tenant = resolveTenantAndEnsureConsistency(normalizedHostname, tenantName);
|
||||
|
||||
// 4. 验证租户状态
|
||||
if (tenant == null) {
|
||||
throw exception(EDUCATION_TENANT_NOT_FOUND);
|
||||
TenantRespDTO byDomain = null;
|
||||
String domainClaim = browserHost;
|
||||
if (domainClaim == null && requestedHost != null
|
||||
&& educationProperties.getTenantResolution().isLocalDevelopmentEnabled()
|
||||
&& isLocalHost(requestedHost)) {
|
||||
domainClaim = requestedHost;
|
||||
}
|
||||
if (CommonStatusEnum.isDisable(tenant.getStatus())) {
|
||||
throw exception(EDUCATION_TENANT_DISABLED);
|
||||
if (domainClaim != null && !(byHandle != null && isLocalHost(domainClaim))) {
|
||||
byDomain = resolveByHostname(domainClaim);
|
||||
}
|
||||
if (DateUtils.isExpired(tenant.getExpireTime())) {
|
||||
|
||||
if (byHandle != null && byDomain != null && !byHandle.getId().equals(byDomain.getId())) {
|
||||
throw exception(EDUCATION_TENANT_LOCATOR_CONFLICT);
|
||||
}
|
||||
if (StrUtil.isNotBlank(tenantHandle) && byHandle == null) {
|
||||
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
|
||||
}
|
||||
|
||||
// 5. 构建响应
|
||||
EducationTenantRespVO resp = EducationTenantRespVO.builder()
|
||||
.tenantId(tenant.getId())
|
||||
.tenantName(tenant.getName())
|
||||
.displayName(tenant.getName())
|
||||
.status("ACTIVE")
|
||||
.loginMethods(new ArrayList<>(educationProperties.getLoginMethods()))
|
||||
.build();
|
||||
return success(resp);
|
||||
if (browserHost != null && byDomain == null) {
|
||||
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
|
||||
}
|
||||
if (browserHost == null && byHandle == null && byDomain == null) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
return success(toResponse(requireAvailable(byHandle != null ? byHandle : byDomain)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级解析租户:
|
||||
* 1. 如果提供了 tenantName,直接通过租户名查询
|
||||
* 2. 如果提供了 hostname:
|
||||
* a. 先查 EducationProperties.hostnameTenantMap 配置映射
|
||||
* b. 再通过 system_tenant.websites 字段匹配
|
||||
*
|
||||
* @param hostname 标准化后的主机名,可能为 null
|
||||
* @param tenantName 租户名,可能为 null
|
||||
* @return 租户 DTO,未找到返回 null
|
||||
*/
|
||||
private TenantRespDTO resolveTenantAndEnsureConsistency(String hostname, String tenantName) {
|
||||
TenantRespDTO byName = StrUtil.isNotBlank(tenantName)
|
||||
? tenantCommonApi.getTenantByName(tenantName.trim()) : null;
|
||||
TenantRespDTO byHostname = StrUtil.isNotBlank(hostname) ? resolveTenantByHostname(hostname) : null;
|
||||
if (byName != null && byHostname != null && !byName.getId().equals(byHostname.getId())) {
|
||||
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 与 tenantName 指向不同租户");
|
||||
}
|
||||
if (StrUtil.isNotBlank(tenantName) && byName == null) {
|
||||
private TenantRespDTO resolveByHandle(String tenantHandle) {
|
||||
if (StrUtil.isBlank(tenantHandle)) {
|
||||
return null;
|
||||
}
|
||||
return byName != null ? byName : byHostname;
|
||||
}
|
||||
|
||||
private TenantRespDTO resolveTenantByHostname(String hostname) {
|
||||
String mappedTenantName = educationProperties.getHostnameTenantMap().entrySet().stream()
|
||||
.filter(entry -> hostname.equals(normalizeHostname(entry.getKey())))
|
||||
.map(entry -> entry.getValue())
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (StrUtil.isNotBlank(mappedTenantName)) {
|
||||
return tenantCommonApi.getTenantByName(mappedTenantName);
|
||||
if (!TENANT_HANDLE_PATTERN.matcher(tenantHandle).matches()) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
return tenantCommonApi.getTenantByWebsite(hostname);
|
||||
return tenantCommonApi.getTenantByName(tenantHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主机名标准化:转为小写并拒绝协议、路径和非法端口。
|
||||
* 端口会被保留,因为 system_tenant.websites 使用精确 authority 匹配。
|
||||
*
|
||||
* @param hostname 原始主机名
|
||||
* @return 标准化后的主机名,输入为空时返回 null
|
||||
* @throws cn.iocoder.yudao.framework.common.exception.ServiceException 格式不合法时
|
||||
*/
|
||||
static String normalizeHostname(String hostname) {
|
||||
if (StrUtil.isBlank(hostname)) {
|
||||
private String resolveBrowserHost(String origin, String referer) {
|
||||
if (origin != null) {
|
||||
return parseHttpHeaderHost(origin, false);
|
||||
}
|
||||
if (referer != null) {
|
||||
return parseHttpHeaderHost(referer, true);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String parseHttpHeaderHost(String value, boolean allowPath) {
|
||||
try {
|
||||
if (StrUtil.isBlank(value) || value.contains(",")) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
URI uri = URI.create(value.trim());
|
||||
if (!("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
|
||||
|| uri.getHost() == null || uri.getUserInfo() != null || uri.getFragment() != null
|
||||
|| (!allowPath && !(StrUtil.isEmpty(uri.getPath()) || "/".equals(uri.getPath())))
|
||||
|| (!allowPath && uri.getQuery() != null)) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
return normalizeHost(uri.getRawAuthority());
|
||||
} catch (RuntimeException ex) {
|
||||
if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) {
|
||||
throw serviceException;
|
||||
}
|
||||
throw invalidLocator();
|
||||
}
|
||||
}
|
||||
|
||||
private TenantRespDTO resolveByHostname(String hostname) {
|
||||
if (!isLocalHost(hostname)) {
|
||||
return tenantCommonApi.getTenantByWebsite(hostname);
|
||||
}
|
||||
String mappedTenantHandle = educationProperties.getHostnameTenantMap().entrySet().stream()
|
||||
.filter(entry -> hostname.equals(normalizeConfiguredHost(entry.getKey())))
|
||||
.map(Map.Entry::getValue)
|
||||
.findFirst().orElse(null);
|
||||
return StrUtil.isNotBlank(mappedTenantHandle) ? tenantCommonApi.getTenantByName(mappedTenantHandle) : null;
|
||||
}
|
||||
|
||||
private String normalizeConfiguredHost(String host) {
|
||||
try {
|
||||
return normalizeHost(host);
|
||||
} catch (RuntimeException ignored) {
|
||||
return null;
|
||||
}
|
||||
String normalized = hostname.trim();
|
||||
|
||||
// 拒绝含协议的输入(如 http://example.com)
|
||||
if (normalized.contains("://")) {
|
||||
throw exception(EDUCATION_TENANT_RESOLVE_FAILED,
|
||||
"hostname 不应包含协议,收到: " + hostname);
|
||||
}
|
||||
// 拒绝含路径的输入
|
||||
if (normalized.contains("/")) {
|
||||
throw exception(EDUCATION_TENANT_RESOLVE_FAILED,
|
||||
"hostname 不应包含路径,收到: " + hostname);
|
||||
}
|
||||
|
||||
// 校验端口。保留合法端口,确保与 system_tenant.websites 的精确值一致。
|
||||
int colonIdx = normalized.lastIndexOf(':');
|
||||
if (colonIdx > 0 && !normalized.startsWith("[")) {
|
||||
String afterColon = normalized.substring(colonIdx + 1);
|
||||
if (afterColon.isEmpty() || !afterColon.chars().allMatch(Character::isDigit)) {
|
||||
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
|
||||
}
|
||||
int port;
|
||||
try {
|
||||
port = Integer.parseInt(afterColon);
|
||||
} catch (NumberFormatException ex) {
|
||||
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 端口不合法");
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
private TenantRespDTO requireAvailable(TenantRespDTO tenant) {
|
||||
if (tenant == null || !CommonStatusEnum.ENABLE.getStatus().equals(tenant.getStatus())
|
||||
|| DateUtils.isExpired(tenant.getExpireTime())) {
|
||||
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private EducationTenantRespVO toResponse(TenantRespDTO tenant) {
|
||||
return EducationTenantRespVO.builder().tenantId(tenant.getId()).displayName(tenant.getName()).build();
|
||||
}
|
||||
|
||||
static String normalizeHost(String value) {
|
||||
if (StrUtil.isBlank(value)) {
|
||||
return null;
|
||||
}
|
||||
if (value.contains(",") || value.contains("/") || value.contains("@")
|
||||
|| value.contains("?") || value.contains("#") || value.contains("://")) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
String candidate = value.trim();
|
||||
validateAuthorityPort(candidate);
|
||||
String host;
|
||||
try {
|
||||
String authority = candidate.indexOf(':') != candidate.lastIndexOf(':') && !candidate.startsWith("[")
|
||||
? "[" + candidate + "]" : candidate;
|
||||
URI uri = URI.create("http://" + authority);
|
||||
if (uri.getUserInfo() != null || uri.getHost() == null || uri.getQuery() != null || uri.getFragment() != null
|
||||
|| !(StrUtil.isEmpty(uri.getPath()) || "/".equals(uri.getPath()))) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
host = uri.getHost();
|
||||
if (host.startsWith("[") && host.endsWith("]")) {
|
||||
host = host.substring(1, host.length() - 1);
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
if (ex instanceof cn.iocoder.yudao.framework.common.exception.ServiceException serviceException) {
|
||||
throw serviceException;
|
||||
}
|
||||
throw invalidLocator();
|
||||
}
|
||||
host = host.toLowerCase(Locale.ROOT);
|
||||
if (host.endsWith(".")) {
|
||||
host = host.substring(0, host.length() - 1);
|
||||
}
|
||||
validateHost(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
private static void validateAuthorityPort(String authority) {
|
||||
int portSeparator;
|
||||
if (authority.startsWith("[")) {
|
||||
int bracketEnd = authority.indexOf(']');
|
||||
if (bracketEnd < 0) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
if (bracketEnd == authority.length() - 1) {
|
||||
return;
|
||||
}
|
||||
if (authority.charAt(bracketEnd + 1) != ':') {
|
||||
throw invalidLocator();
|
||||
}
|
||||
portSeparator = bracketEnd + 1;
|
||||
} else {
|
||||
int firstColon = authority.indexOf(':');
|
||||
if (firstColon < 0 || firstColon != authority.lastIndexOf(':')) {
|
||||
return;
|
||||
}
|
||||
portSeparator = firstColon;
|
||||
}
|
||||
String port = authority.substring(portSeparator + 1);
|
||||
if (port.isEmpty() || !port.chars().allMatch(Character::isDigit)) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
try {
|
||||
if (Integer.parseInt(port) > 65535) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
} catch (NumberFormatException ex) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateHost(String host) {
|
||||
if (host.contains(":")) { // URI#getHost 已验证 IPv6 authority
|
||||
return;
|
||||
}
|
||||
if (IPV4_PATTERN.matcher(host).matches()) {
|
||||
String[] parts = host.split("\\.");
|
||||
for (String part : parts) {
|
||||
if (Integer.parseInt(part) > 255) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
String ascii;
|
||||
try {
|
||||
ascii = IDN.toASCII(host);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
if (ascii.length() > 253) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
for (String label : ascii.split("\\.")) {
|
||||
if (!DNS_LABEL_PATTERN.matcher(label).matches()) {
|
||||
throw invalidLocator();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLocalHost(String host) {
|
||||
if ("localhost".equals(host) || host.endsWith(".localhost") || "0.0.0.0".equals(host) || "::1".equals(host)) {
|
||||
return true;
|
||||
}
|
||||
return host.startsWith("127.") && IPV4_PATTERN.matcher(host).matches();
|
||||
}
|
||||
|
||||
private static cn.iocoder.yudao.framework.common.exception.ServiceException invalidLocator() {
|
||||
return exception(EDUCATION_TENANT_RESOLVE_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Schema(description = "用户 APP - 教育租户识别 Response VO")
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@@ -18,18 +16,7 @@ public class EducationTenantRespVO {
|
||||
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
private Long tenantId;
|
||||
|
||||
@Schema(description = "租户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "demo-school")
|
||||
private String tenantName;
|
||||
|
||||
@Schema(description = "租户显示名称", example = "Demo School")
|
||||
@Schema(description = "租户显示名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "Demo School")
|
||||
private String displayName;
|
||||
|
||||
@Schema(description = "租户状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "ACTIVE",
|
||||
allowableValues = {"ACTIVE", "DISABLED", "EXPIRED"})
|
||||
private String status;
|
||||
|
||||
@Schema(description = "支持的登录方式", requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
example = "[\"PASSWORD\", \"SMS\"]")
|
||||
private List<String> loginMethods;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 答案命令幂等记录 DO。
|
||||
*
|
||||
* <p>同一 (tenant, user, operation, idempotencyKey) 的唯一约束保证幂等性。
|
||||
* requestHash 用于检测相同键不同载荷的冲突。
|
||||
* responseJson 存储首次成功响应,用于超时重试重放。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_answer_idempotency")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AnswerIdempotencyDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 答题用户编号 */
|
||||
private Long userId;
|
||||
|
||||
/** 操作类型:SUBMIT_ANSWER */
|
||||
private String operation;
|
||||
|
||||
/** 客户端幂等键(UUID) */
|
||||
private String idempotencyKey;
|
||||
|
||||
/** 请求载荷 SHA-256 哈希 */
|
||||
private String requestHash;
|
||||
|
||||
/** 会话 ID */
|
||||
private Long sessionId;
|
||||
|
||||
/** 题目 ID */
|
||||
private String questionId;
|
||||
|
||||
/** 学生已选答案 */
|
||||
private String selectedAnswer;
|
||||
|
||||
/** 状态:ACCEPTED / CONFLICT */
|
||||
private String status;
|
||||
|
||||
/** 首次成功响应 JSON(用于重试重放) */
|
||||
private String responseJson;
|
||||
|
||||
}
|
||||
@@ -2,14 +2,16 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 收藏夹 DO — 学生收藏记录。
|
||||
*
|
||||
* <p>同一 (tenant, user, target_type, target_id) 唯一一条。
|
||||
* 逻辑删除:取消收藏设置 deleted=1;重新收藏通过 ON DUPLICATE KEY UPDATE 恢复。</p>
|
||||
* 逻辑删除:取消收藏设置 deleted=true;重新收藏通过 PostgreSQL ON CONFLICT DO UPDATE 恢复。</p>
|
||||
*
|
||||
* <p>快照字段(stem/type/difficulty/options/contentVersion)来自收藏时
|
||||
* 题目的安全视图,保留题目当时状态。available 标记源资源当前是否可用。</p>
|
||||
@@ -18,11 +20,13 @@ import lombok.*;
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_favorite")
|
||||
@TableName(value = "education_favorite", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@KeySequence("education_favorite_seq")
|
||||
public class EducationFavoriteDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 幂等记录 DO。
|
||||
*
|
||||
* <p>统一 {@code education_idempotency} 表,合并原先 AnswerIdempotencyDO 与 SubmitIdempotencyDO。
|
||||
* 通过 {@code operation} 字段区分操作类型:{@code SUBMIT_ANSWER} / {@code SUBMIT_SESSION}。
|
||||
* 同一 (tenant, user, operation, idempotencyKey) 的唯一约束保证幂等性。
|
||||
* requestHash 用于检测相同键不同载荷的冲突。
|
||||
* responseJson 存储首次成功响应 JSON,用于超时重试重放。
|
||||
* business_payload(JSONB)为操作特有的扩展数据预留。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName(value = "education_idempotency", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@KeySequence("education_idempotency_seq")
|
||||
public class IdempotencyDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 用户编号 */
|
||||
private Long userId;
|
||||
|
||||
/** 操作类型:SUBMIT_ANSWER / SUBMIT_SESSION */
|
||||
private String operation;
|
||||
|
||||
/** 客户端幂等键(UUID) */
|
||||
private String idempotencyKey;
|
||||
|
||||
/** 请求载荷 SHA-256 哈希 */
|
||||
private String requestHash;
|
||||
|
||||
/** 会话 ID */
|
||||
private Long sessionId;
|
||||
|
||||
/** 题目 ID(SUBMIT_ANSWER 时有值,SUBMIT_SESSION 时为 null) */
|
||||
private String questionId;
|
||||
|
||||
/** 学生已选答案(SUBMIT_ANSWER 时有值,SUBMIT_SESSION 时为 null) */
|
||||
private String selectedAnswer;
|
||||
|
||||
/** 关联的报告 ID(SUBMIT_SESSION 成功时有值,SUBMIT_ANSWER 时为 null) */
|
||||
private Long reportId;
|
||||
|
||||
/** 状态:答案保存使用 ACCEPTED;交卷使用 PROCESSING / COMPLETED */
|
||||
private String status;
|
||||
|
||||
/** 首次成功响应 JSON(用于重试重放) */
|
||||
private String responseJson;
|
||||
|
||||
/** 业务扩展载荷(JSONB),存储操作特有的扩展数据 */
|
||||
private String businessPayload;
|
||||
|
||||
/** 当前处理租约令牌(SUBMIT_SESSION 的 PROCESSING 状态使用) */
|
||||
private String claimToken;
|
||||
|
||||
/** 当前处理租约开始时间 */
|
||||
private java.time.LocalDateTime claimStartedAt;
|
||||
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 练习会话题目快照 DO。
|
||||
@@ -13,11 +15,13 @@ import lombok.*;
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_practice_question")
|
||||
@TableName(value = "education_practice_question", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@KeySequence("education_practice_question_seq")
|
||||
public class PracticeQuestionDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
|
||||
@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 练习报告 DO — 会话级评分结果。
|
||||
@@ -13,11 +15,13 @@ import lombok.*;
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_practice_report")
|
||||
@TableName(value = "education_practice_report", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@KeySequence("education_practice_report_seq")
|
||||
public class PracticeReportDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
|
||||
@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 练习报告明细 DO — 逐题评分结果。
|
||||
@@ -13,11 +15,13 @@ import lombok.*;
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_practice_report_detail")
|
||||
@TableName(value = "education_practice_report_detail", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@KeySequence("education_practice_report_detail_seq")
|
||||
public class PracticeReportDetailDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
|
||||
@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 练习会话 DO。
|
||||
@@ -13,11 +15,13 @@ import lombok.*;
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_practice_session")
|
||||
@TableName(value = "education_practice_session", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@KeySequence("education_practice_session_seq")
|
||||
public class PracticeSessionDO extends TenantBaseDO {
|
||||
|
||||
/** 会话主键 */
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 交卷幂等记录 DO。
|
||||
*
|
||||
* <p>同一 (tenant, user, operation, idempotencyKey) 的唯一约束保证幂等性。
|
||||
* requestHash 用于检测相同键不同载荷的冲突。
|
||||
* responseJson 存储首次成功的完整报告 JSON,用于超时重试重放。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_submit_idempotency")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SubmitIdempotencyDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 交卷用户编号 */
|
||||
private Long userId;
|
||||
|
||||
/** 操作类型:SUBMIT_SESSION */
|
||||
private String operation;
|
||||
|
||||
/** 客户端幂等键(UUID) */
|
||||
private String idempotencyKey;
|
||||
|
||||
/** 请求载荷 SHA-256 哈希 */
|
||||
private String requestHash;
|
||||
|
||||
/** 会话 ID */
|
||||
private Long sessionId;
|
||||
|
||||
/** 关联的报告 ID(成功时有值) */
|
||||
private Long reportId;
|
||||
|
||||
/** 状态:ACCEPTED / CONFLICT */
|
||||
private String status;
|
||||
|
||||
/** 首次成功响应 JSON(用于重试重放) */
|
||||
private String responseJson;
|
||||
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@@ -18,11 +20,13 @@ import java.time.LocalDateTime;
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_wrong_question")
|
||||
@TableName(value = "education_wrong_question", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@KeySequence("education_wrong_question_seq")
|
||||
public class WrongQuestionDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
|
||||
@@ -2,8 +2,10 @@ package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 错题流水幂等 DO。
|
||||
@@ -13,11 +15,13 @@ import lombok.*;
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_wrong_question_idempotency")
|
||||
@TableName(value = "education_wrong_question_idempotency", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
@KeySequence("education_wrong_question_idempotency_seq")
|
||||
public class WrongQuestionIdempotencyDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 目录内容范围基础对象。
|
||||
*
|
||||
* <p>PUBLIC 数据固定使用 tenantId=0;TENANT_OWNED 数据归属当前租户。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public abstract class CatalogScopeDO extends TenantBaseDO {
|
||||
|
||||
/** 内容范围:PUBLIC、TENANT_OWNED */
|
||||
private String scope;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
@TableName(value = "education_category", autoResultMap = true)
|
||||
@KeySequence("education_category_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CategoryDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long subjectId;
|
||||
private String legacyNodeId;
|
||||
private String name;
|
||||
private Boolean isActive;
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
@TableName(value = "education_content_entry", autoResultMap = true)
|
||||
@KeySequence("education_content_entry_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ContentEntryDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private String legacyId;
|
||||
private Long regionId;
|
||||
private String entryKey;
|
||||
private String name;
|
||||
private String entryType;
|
||||
private String icon;
|
||||
private String route;
|
||||
private String description;
|
||||
private String accessRules;
|
||||
private String layoutConfig;
|
||||
private Boolean isHidden;
|
||||
private Boolean isActive;
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
@TableName(value = "education_content_node", autoResultMap = true)
|
||||
@KeySequence("education_content_node_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ContentNodeDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long entryId;
|
||||
private Long parentId;
|
||||
private String name;
|
||||
private String title;
|
||||
private String nodeType;
|
||||
private String markerType;
|
||||
private Integer depth;
|
||||
private Boolean isLeaf;
|
||||
private Boolean isSelectable;
|
||||
private Boolean isHidden;
|
||||
private Boolean isActive;
|
||||
private Integer sortOrder;
|
||||
private String metadata;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
@TableName(value = "education_major", autoResultMap = true)
|
||||
@KeySequence("education_major_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MajorDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private String legacyId;
|
||||
private Long regionId;
|
||||
private Long schoolId;
|
||||
private String name;
|
||||
private String description;
|
||||
private String studyTips;
|
||||
private Boolean isActive;
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
@TableName(value = "education_practice_blueprint", autoResultMap = true)
|
||||
@KeySequence("education_practice_blueprint_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PracticeBlueprintDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private String mode;
|
||||
private Long entryId;
|
||||
private Long nodeId;
|
||||
private Long collectionId;
|
||||
private Integer questionLimit;
|
||||
private Integer durationMinutes;
|
||||
private Integer eligibleCount;
|
||||
private Integer totalCount;
|
||||
private String availableTypes;
|
||||
private String availableDifficulties;
|
||||
private Integer minQuestions;
|
||||
private Integer maxQuestions;
|
||||
private Integer suggestedCount;
|
||||
private Boolean isActive;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
@TableName(value = "education_question_collection", autoResultMap = true)
|
||||
@KeySequence("education_question_collection_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class QuestionCollectionDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long entryId;
|
||||
private Long nodeId;
|
||||
private String name;
|
||||
private String title;
|
||||
private String collectionType;
|
||||
private Integer questionCount;
|
||||
private Integer durationMinutes;
|
||||
private String accessRules;
|
||||
private Boolean isHidden;
|
||||
private Boolean isActive;
|
||||
private Integer sortOrder;
|
||||
private String metadata;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 题集-题目关联 DO(多对多,题集成员关系的唯一事实源)。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName(value = "education_question_collection_question", autoResultMap = true)
|
||||
@KeySequence("education_question_collection_question_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class QuestionCollectionQuestionDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 题集 ID */
|
||||
private Long collectionId;
|
||||
|
||||
/** 题目 ID */
|
||||
private Long questionId;
|
||||
|
||||
/** 排序值 */
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 题目 DO — 核心实体。
|
||||
* correctAnswer、explanation、analysis 为敏感字段,学生端 DTO 绝不可包含。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName(value = "education_question", autoResultMap = true)
|
||||
@KeySequence("education_question_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class QuestionDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 内容版本号(递增) */
|
||||
private Integer contentVersion;
|
||||
|
||||
/** 题干 */
|
||||
private String stem;
|
||||
|
||||
/** 题型 */
|
||||
private String type;
|
||||
|
||||
/** 题型显示名称 */
|
||||
private String typeLabel;
|
||||
|
||||
/** 难度 */
|
||||
private String difficulty;
|
||||
|
||||
/** 题干结构化内容(JSON) */
|
||||
private String questionContent;
|
||||
|
||||
/** 选项 JSON [{label, content, isCorrect, order}] */
|
||||
private String options;
|
||||
|
||||
/** 正确答案(敏感) */
|
||||
private String correctAnswer;
|
||||
|
||||
/** 答案解析(敏感) */
|
||||
private String explanation;
|
||||
|
||||
/** 深度解析(敏感) */
|
||||
private String analysis;
|
||||
|
||||
/** 题目状态 */
|
||||
private String status;
|
||||
|
||||
/** 是否已发布 */
|
||||
private Boolean isPublished;
|
||||
|
||||
/** 所属科目 ID */
|
||||
private Long subjectId;
|
||||
|
||||
/** 所属内容节点 ID */
|
||||
private Long nodeId;
|
||||
|
||||
/** 标签 JSON 数组 */
|
||||
private String tags;
|
||||
|
||||
/** 显示排序值 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 扩展元数据 */
|
||||
private String metadata;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* 地区 DO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName(value = "education_region", autoResultMap = true)
|
||||
@KeySequence("education_region_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class RegionDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 旧系统地区 ID */
|
||||
private String legacyId;
|
||||
|
||||
/** 地区名称 */
|
||||
private String name;
|
||||
|
||||
/** 地区编码 */
|
||||
private String code;
|
||||
|
||||
/** 地区简称 */
|
||||
private String shortName;
|
||||
|
||||
/** 地区全称 */
|
||||
private String fullName;
|
||||
|
||||
/** 地区图标 URL */
|
||||
private String icon;
|
||||
|
||||
/** 地区拼音 */
|
||||
private String pinyin;
|
||||
|
||||
/** 是否热门地区 */
|
||||
private Boolean isHot;
|
||||
|
||||
/** 是否启用 */
|
||||
private Boolean isActive;
|
||||
|
||||
/** 显示排序值 */
|
||||
private Integer sortOrder;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
@TableName(value = "education_school", autoResultMap = true)
|
||||
@KeySequence("education_school_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SchoolDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private String legacyId;
|
||||
private Long regionId;
|
||||
private Long moduleId;
|
||||
private String name;
|
||||
private String professionalExamDate;
|
||||
private String metadata;
|
||||
private Boolean isActive;
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject.catalog;
|
||||
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
import lombok.Builder;
|
||||
|
||||
@TableName(value = "education_subject", autoResultMap = true)
|
||||
@KeySequence("education_subject_seq")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SubjectDO extends CatalogScopeDO {
|
||||
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long regionId;
|
||||
private Long schoolId;
|
||||
private Long majorId;
|
||||
private Long moduleId;
|
||||
private String name;
|
||||
private String type;
|
||||
private Boolean isActive;
|
||||
private Integer sortOrder;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.AnswerIdempotencyDO;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
|
||||
/**
|
||||
* 答案命令幂等记录 Mapper。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Mapper
|
||||
public interface AnswerIdempotencyMapper extends BaseMapperX<AnswerIdempotencyDO> {
|
||||
|
||||
/**
|
||||
* 按租户、用户、操作、幂等键查找记录。
|
||||
*/
|
||||
default AnswerIdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) {
|
||||
return selectOne(new LambdaQueryWrapperX<AnswerIdempotencyDO>()
|
||||
.eq(AnswerIdempotencyDO::getTenantId, tenantId)
|
||||
.eq(AnswerIdempotencyDO::getUserId, userId)
|
||||
.eq(AnswerIdempotencyDO::getOperation, operation)
|
||||
.eq(AnswerIdempotencyDO::getIdempotencyKey, idempotencyKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate was silently ignored.
|
||||
* Safe for concurrent same-key resolution without DuplicateKeyException.
|
||||
*/
|
||||
@Insert("INSERT IGNORE INTO education_answer_idempotency " +
|
||||
"(tenant_id, user_id, operation, idempotency_key, request_hash, session_id, " +
|
||||
"question_id, selected_answer, status, response_json, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{operation}, #{idempotencyKey}, #{requestHash}, " +
|
||||
"#{sessionId}, #{questionId}, #{selectedAnswer}, #{status}, #{responseJson}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(AnswerIdempotencyDO record);
|
||||
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import java.util.List;
|
||||
public interface EducationFavoriteMapper extends BaseMapperX<EducationFavoriteDO> {
|
||||
|
||||
/**
|
||||
* INSERT ... ON DUPLICATE KEY UPDATE — upsert a favorite entry.
|
||||
* INSERT ... ON CONFLICT ... DO UPDATE — upsert a favorite entry.
|
||||
*
|
||||
* <p>If the (tenant, user, target_type, target_id) row already exists
|
||||
* (including soft-deleted rows), reactivate it by setting deleted=0 and
|
||||
@@ -35,16 +35,16 @@ public interface EducationFavoriteMapper extends BaseMapperX<EducationFavoriteDO
|
||||
"VALUES (#{tenantId}, #{userId}, #{targetType}, #{targetId}, " +
|
||||
"#{stem}, #{type}, #{difficulty}, #{options}, #{contentVersion}, #{available}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
|
||||
"ON DUPLICATE KEY UPDATE " +
|
||||
"id = LAST_INSERT_ID(id), " +
|
||||
"ON CONFLICT (tenant_id, user_id, target_type, target_id) DO UPDATE SET " +
|
||||
"deleted = FALSE, " +
|
||||
"stem = VALUES(stem), " +
|
||||
"type = VALUES(type), " +
|
||||
"difficulty = VALUES(difficulty), " +
|
||||
"options = VALUES(options), " +
|
||||
"content_version = VALUES(content_version), " +
|
||||
"available = VALUES(available), " +
|
||||
"update_time = VALUES(update_time)")
|
||||
"stem = EXCLUDED.stem, " +
|
||||
"type = EXCLUDED.type, " +
|
||||
"difficulty = EXCLUDED.difficulty, " +
|
||||
"options = EXCLUDED.options, " +
|
||||
"content_version = EXCLUDED.content_version, " +
|
||||
"available = EXCLUDED.available, " +
|
||||
"updater = EXCLUDED.updater, " +
|
||||
"update_time = EXCLUDED.update_time")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int upsert(EducationFavoriteDO record);
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.IdempotencyDO;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 统一幂等存储 Mapper。
|
||||
*
|
||||
* <p>替换原先 AnswerIdempotencyMapper 与 SubmitIdempotencyMapper,统一操作
|
||||
* {@code education_idempotency} 表。通过 {@code operation} 字段区分
|
||||
* {@code SUBMIT_ANSWER} 与 {@code SUBMIT_SESSION} 两种操作类型,
|
||||
* 利用 PostgreSQL {@code ON CONFLICT DO NOTHING} 实现无锁幂等插入。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Mapper
|
||||
public interface IdempotencyStoreMapper extends BaseMapperX<IdempotencyDO> {
|
||||
|
||||
/**
|
||||
* 按租户、用户、操作、幂等键查找幂等记录。
|
||||
*
|
||||
* @param tenantId 租户编号
|
||||
* @param userId 用户编号
|
||||
* @param operation 操作类型(SUBMIT_ANSWER / SUBMIT_SESSION)
|
||||
* @param idempotencyKey 客户端幂等键(UUID)
|
||||
* @return 幂等记录;未找到时返回 {@code null}
|
||||
*/
|
||||
default IdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) {
|
||||
return selectOne(new LambdaQueryWrapperX<IdempotencyDO>()
|
||||
.eq(IdempotencyDO::getTenantId, tenantId)
|
||||
.eq(IdempotencyDO::getUserId, userId)
|
||||
.eq(IdempotencyDO::getOperation, operation)
|
||||
.eq(IdempotencyDO::getIdempotencyKey, idempotencyKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试插入幂等记录;使用 {@code ON CONFLICT DO NOTHING} 避免唯一约束异常。
|
||||
*
|
||||
* <p>唯一约束为 {@code (tenant_id, user_id, operation, idempotency_key)}。
|
||||
* 插入成功后通过 {@code @Options} 回填自增主键 {@code id}。
|
||||
* 并发场景下同一键的多个请求安全竞争:仅一条成功插入(返回 1),
|
||||
* 其余静默忽略(返回 0)。调用方根据返回值判断是否为首个请求。</p>
|
||||
*
|
||||
* @param record 幂等记录
|
||||
* @return 1 表示插入成功(首个请求),0 表示键冲突(重复请求)
|
||||
*/
|
||||
@Insert("INSERT INTO education_idempotency " +
|
||||
"(tenant_id, user_id, operation, idempotency_key, request_hash, session_id, " +
|
||||
"question_id, selected_answer, report_id, status, response_json, business_payload, " +
|
||||
"claim_token, claim_started_at, creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{operation}, #{idempotencyKey}, #{requestHash}, " +
|
||||
"#{sessionId}, #{questionId}, #{selectedAnswer}, #{reportId}, #{status}, #{responseJson}, #{businessPayload}, " +
|
||||
"#{claimToken}, #{claimStartedAt}, #{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
|
||||
"ON CONFLICT (tenant_id, user_id, operation, idempotency_key) DO NOTHING")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(IdempotencyDO record);
|
||||
|
||||
/**
|
||||
* 对同一交卷幂等业务键加 PostgreSQL 事务级 advisory lock。
|
||||
*/
|
||||
@Select("SELECT 1 FROM (SELECT pg_advisory_xact_lock(hashtextextended(" +
|
||||
"CONCAT(CAST(#{tenantId} AS TEXT), ':', CAST(#{userId} AS TEXT), " +
|
||||
"':SUBMIT_SESSION:', CAST(#{idempotencyKey} AS TEXT)), 0))) AS locked")
|
||||
Long lockSubmitKey(@Param("tenantId") Long tenantId,
|
||||
@Param("userId") Long userId,
|
||||
@Param("idempotencyKey") String idempotencyKey);
|
||||
|
||||
/**
|
||||
* 超时租约接管;仅匹配请求哈希的 PROCESSING 记录可以被新令牌接管。
|
||||
*/
|
||||
@Select("UPDATE education_idempotency SET claim_token = #{claimToken}, claim_started_at = CURRENT_TIMESTAMP " +
|
||||
"WHERE id = #{id} AND operation = 'SUBMIT_SESSION' AND status = 'PROCESSING' " +
|
||||
"AND request_hash = #{requestHash} AND (claim_started_at IS NULL " +
|
||||
"OR claim_started_at < CURRENT_TIMESTAMP - CAST(#{leaseSeconds} || ' seconds' AS INTERVAL)) " +
|
||||
"RETURNING *")
|
||||
IdempotencyDO takeoverExpiredSubmit(@Param("id") Long id,
|
||||
@Param("requestHash") String requestHash,
|
||||
@Param("claimToken") String claimToken,
|
||||
@Param("leaseSeconds") long leaseSeconds);
|
||||
|
||||
/**
|
||||
* 更新幂等记录的题目编号与完整响应 JSON。
|
||||
*
|
||||
* <p>用于首次处理完成后回写完整结果:
|
||||
* 初始插入时仅写入关键字段,业务处理成功后调用本方法将
|
||||
* 题目编号与完整响应 JSON 更新到幂等行中,
|
||||
* 供后续重试请求直接重放响应。</p>
|
||||
*
|
||||
* @param id 幂等记录主键
|
||||
* @param questionId 题目编号
|
||||
* @param responseJson 完整响应 JSON 字符串
|
||||
* @return 受影响行数
|
||||
*/
|
||||
@Update("UPDATE education_idempotency " +
|
||||
"SET question_id = #{questionId}, response_json = #{responseJson} " +
|
||||
"WHERE id = #{id}")
|
||||
int updateResponse(@Param("id") Long id,
|
||||
@Param("questionId") String questionId,
|
||||
@Param("responseJson") String responseJson);
|
||||
|
||||
/**
|
||||
* 完成交卷幂等占位并返回当前行,便于并发重放等待提交完成。
|
||||
*/
|
||||
@Select("UPDATE education_idempotency " +
|
||||
"SET report_id = #{reportId}, status = 'COMPLETED', response_json = #{responseJson}, " +
|
||||
"claim_token = NULL, claim_started_at = NULL " +
|
||||
"WHERE id = #{id} AND operation = 'SUBMIT_SESSION' AND status = 'PROCESSING' " +
|
||||
"AND claim_token = #{claimToken} RETURNING *")
|
||||
IdempotencyDO completeSubmit(@Param("id") Long id,
|
||||
@Param("claimToken") String claimToken,
|
||||
@Param("reportId") Long reportId,
|
||||
@Param("responseJson") String responseJson);
|
||||
|
||||
}
|
||||
@@ -36,16 +36,18 @@ public interface PracticeReportMapper extends BaseMapperX<PracticeReportDO> {
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate (uk_report_session) was silently ignored.
|
||||
* Safe for concurrent submit race resolution without DuplicateKeyException.
|
||||
* INSERT … ON CONFLICT DO NOTHING — attempt insertion; returns 1 if inserted, 0 if duplicate on
|
||||
* (tenant_id, session_id) was silently ignored. Safe for concurrent submit race resolution without
|
||||
* DuplicateKeyException.
|
||||
*/
|
||||
@Insert("INSERT IGNORE INTO education_practice_report " +
|
||||
@Insert("INSERT INTO education_practice_report " +
|
||||
"(tenant_id, user_id, session_id, question_count, answered_count, unanswered_count, " +
|
||||
"correct_count, incorrect_count, score, status, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{sessionId}, #{questionCount}, #{answeredCount}, #{unansweredCount}, " +
|
||||
"#{correctCount}, #{incorrectCount}, #{score}, #{status}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
|
||||
"ON CONFLICT (tenant_id, session_id) DO NOTHING")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(PracticeReportDO record);
|
||||
|
||||
|
||||
@@ -64,19 +64,37 @@ public interface PracticeSessionMapper extends BaseMapperX<PracticeSessionDO> {
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT IGNORE — attempt session creation for idempotency.
|
||||
* Used by review session creation to resolve concurrent create races
|
||||
* without catching DuplicateKeyException inside a transaction.
|
||||
* CAS 交卷:仅允许一次 ACTIVE → SUBMITTED 状态迁移。
|
||||
*
|
||||
* @return 1 if inserted, 0 if duplicate was silently ignored
|
||||
* @return 受影响行数(1 = 成功,0 = CAS 失败)
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Insert("INSERT IGNORE INTO education_practice_session " +
|
||||
default int casSubmit(Long id, Long tenantId, Long userId, Integer expectedVersion) {
|
||||
return update(null,
|
||||
new LambdaUpdateWrapper<PracticeSessionDO>()
|
||||
.eq(PracticeSessionDO::getId, id)
|
||||
.eq(PracticeSessionDO::getTenantId, tenantId)
|
||||
.eq(PracticeSessionDO::getUserId, userId)
|
||||
.eq(PracticeSessionDO::getStatus, "ACTIVE")
|
||||
.eq(PracticeSessionDO::getVersion, expectedVersion)
|
||||
.set(PracticeSessionDO::getStatus, "SUBMITTED")
|
||||
.setSql("version = version + 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* ON CONFLICT DO NOTHING — attempt session creation for idempotency.
|
||||
* Used by review session creation to resolve concurrent create races
|
||||
* without catching duplicate key violations inside a transaction.
|
||||
*
|
||||
* @return 1 if inserted, 0 if conflict was silently ignored
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Insert("INSERT INTO education_practice_session " +
|
||||
"(tenant_id, user_id, client_session_id, status, question_count, " +
|
||||
"collection_id, node_id, type, difficulty, version, review_fingerprint, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{clientSessionId}, #{status}, #{questionCount}, " +
|
||||
"#{collectionId}, #{nodeId}, #{type}, #{difficulty}, #{version}, #{reviewFingerprint}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
|
||||
"ON CONFLICT (tenant_id, client_session_id) DO NOTHING")
|
||||
@org.apache.ibatis.annotations.Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(PracticeSessionDO record);
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.SubmitIdempotencyDO;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
|
||||
/**
|
||||
* 交卷幂等记录 Mapper。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Mapper
|
||||
public interface SubmitIdempotencyMapper extends BaseMapperX<SubmitIdempotencyDO> {
|
||||
|
||||
/**
|
||||
* 按租户、用户、操作、幂等键查找记录。
|
||||
*/
|
||||
default SubmitIdempotencyDO selectByKey(Long tenantId, Long userId, String operation, String idempotencyKey) {
|
||||
return selectOne(new LambdaQueryWrapperX<SubmitIdempotencyDO>()
|
||||
.eq(SubmitIdempotencyDO::getTenantId, tenantId)
|
||||
.eq(SubmitIdempotencyDO::getUserId, userId)
|
||||
.eq(SubmitIdempotencyDO::getOperation, operation)
|
||||
.eq(SubmitIdempotencyDO::getIdempotencyKey, idempotencyKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT IGNORE — attempt insertion; returns 1 if inserted, 0 if duplicate was silently ignored.
|
||||
* Safe for concurrent same-key resolution without DuplicateKeyException.
|
||||
*/
|
||||
@Insert("INSERT IGNORE INTO education_submit_idempotency " +
|
||||
"(tenant_id, user_id, operation, idempotency_key, request_hash, session_id, " +
|
||||
"report_id, status, response_json, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{operation}, #{idempotencyKey}, #{requestHash}, " +
|
||||
"#{sessionId}, #{reportId}, #{status}, #{responseJson}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(SubmitIdempotencyDO record);
|
||||
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Options;
|
||||
/**
|
||||
* 错题流水幂等 Mapper。
|
||||
*
|
||||
* <p>INSERT IGNORE 提供 (wrong_question_id, report_id) 的 at-most-once 保障。
|
||||
* <p>ON CONFLICT DO NOTHING 提供 (tenant_id, user_id, question_id, report_id) 的 at-most-once 保障。
|
||||
* 返回 1 = 已插入(可以 upsert wrong question);返回 0 = 该 report 已贡献过。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
@@ -18,16 +18,17 @@ import org.apache.ibatis.annotations.Options;
|
||||
public interface WrongQuestionIdempotencyMapper extends BaseMapperX<WrongQuestionIdempotencyDO> {
|
||||
|
||||
/**
|
||||
* INSERT IGNORE — attempt idempotency guard insertion.
|
||||
* ON CONFLICT DO NOTHING — attempt idempotency guard insertion.
|
||||
*
|
||||
* @return 1 if inserted (first time for this wrong_question+report),
|
||||
* @return 1 if inserted (first time for this question+report),
|
||||
* 0 if duplicate was silently ignored
|
||||
*/
|
||||
@Insert("INSERT IGNORE INTO education_wrong_question_idempotency " +
|
||||
@Insert("INSERT INTO education_wrong_question_idempotency " +
|
||||
"(tenant_id, user_id, wrong_question_id, report_id, question_id, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{wrongQuestionId}, #{reportId}, #{questionId}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
|
||||
"ON CONFLICT (tenant_id, user_id, question_id, report_id) DO NOTHING")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(WrongQuestionIdempotencyDO record);
|
||||
|
||||
|
||||
@@ -18,14 +18,17 @@ import org.apache.ibatis.annotations.Options;
|
||||
public interface WrongQuestionMapper extends BaseMapperX<WrongQuestionDO> {
|
||||
|
||||
/**
|
||||
* INSERT ... ON DUPLICATE KEY UPDATE — upsert a wrong question entry.
|
||||
* INSERT ... ON CONFLICT DO UPDATE — upsert a wrong question entry.
|
||||
* On conflict (tenant_id, user_id, question_id), increments wrong_count,
|
||||
* updates snapshot fields, and advances last_wrong_time.
|
||||
*
|
||||
* <p>This is the ONLY write path for wrong questions. Callers MUST first
|
||||
* guard with WrongQuestionIdempotencyMapper to ensure at-most-once per report.</p>
|
||||
*
|
||||
* @return 1 = inserted, 2 = updated (MySQL convention for ON DUPLICATE KEY UPDATE)
|
||||
* <p><b>PostgreSQL convention:</b> {@code ON CONFLICT} with {@code EXCLUDED} references.
|
||||
* {@code first_wrong_time} is set only on initial insert (not updated on conflict).</p>
|
||||
*
|
||||
* @return 1 = inserted or updated
|
||||
*/
|
||||
@Insert("INSERT INTO education_wrong_question " +
|
||||
"(tenant_id, user_id, question_id, stem, type, difficulty, options, content_version, " +
|
||||
@@ -38,20 +41,20 @@ public interface WrongQuestionMapper extends BaseMapperX<WrongQuestionDO> {
|
||||
"#{firstWrongTime}, #{lastWrongTime}, #{wrongCount}, #{masterStatus}, " +
|
||||
"#{lastReportId}, #{lastSessionId}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
|
||||
"ON DUPLICATE KEY UPDATE " +
|
||||
"id = LAST_INSERT_ID(id), " +
|
||||
"stem = VALUES(stem), " +
|
||||
"type = VALUES(type), " +
|
||||
"difficulty = VALUES(difficulty), " +
|
||||
"options = VALUES(options), " +
|
||||
"content_version = VALUES(content_version), " +
|
||||
"latest_correct_answer = VALUES(latest_correct_answer), " +
|
||||
"latest_explanation = VALUES(latest_explanation), " +
|
||||
"last_wrong_time = VALUES(last_wrong_time), " +
|
||||
"wrong_count = wrong_count + 1, " +
|
||||
"last_report_id = VALUES(last_report_id), " +
|
||||
"last_session_id = VALUES(last_session_id), " +
|
||||
"update_time = VALUES(update_time)")
|
||||
"ON CONFLICT (tenant_id, user_id, question_id) DO UPDATE SET " +
|
||||
"stem = EXCLUDED.stem, " +
|
||||
"type = EXCLUDED.type, " +
|
||||
"difficulty = EXCLUDED.difficulty, " +
|
||||
"options = EXCLUDED.options, " +
|
||||
"content_version = EXCLUDED.content_version, " +
|
||||
"latest_correct_answer = EXCLUDED.latest_correct_answer, " +
|
||||
"latest_explanation = EXCLUDED.latest_explanation, " +
|
||||
"last_wrong_time = EXCLUDED.last_wrong_time, " +
|
||||
"wrong_count = education_wrong_question.wrong_count + 1, " +
|
||||
"last_report_id = EXCLUDED.last_report_id, " +
|
||||
"last_session_id = EXCLUDED.last_session_id, " +
|
||||
"updater = EXCLUDED.updater, " +
|
||||
"update_time = EXCLUDED.update_time")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int upsert(WrongQuestionDO record);
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CatalogScopeDO;
|
||||
|
||||
/** Adds the explicit catalog scope predicate while tenant SQL isolation is temporarily bypassed. */
|
||||
final class CatalogScopeQuery {
|
||||
|
||||
private CatalogScopeQuery() {
|
||||
}
|
||||
|
||||
static <T extends CatalogScopeDO> void apply(LambdaQueryWrapper<T> wrapper,
|
||||
SFunction<T, ?> tenantGetter,
|
||||
SFunction<T, ?> scopeGetter,
|
||||
Long tenantId) {
|
||||
wrapper.and(w -> w.and(owned -> owned.eq(tenantGetter, tenantId)
|
||||
.eq(scopeGetter, "TENANT_OWNED"))
|
||||
.or(publicScope -> publicScope.eq(tenantGetter, 0L)
|
||||
.eq(scopeGetter, "PUBLIC")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.CategoryDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CategoryMapper extends BaseMapperX<CategoryDO> {
|
||||
default List<CategoryDO> selectActiveList(Long tenantId, Long subjectId, String legacyNodeId) {
|
||||
LambdaQueryWrapperX<CategoryDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, CategoryDO::getTenantId, CategoryDO::getScope, tenantId);
|
||||
w.eq(CategoryDO::getIsActive, true).orderByAsc(CategoryDO::getSortOrder);
|
||||
if (subjectId != null) w.eq(CategoryDO::getSubjectId, subjectId);
|
||||
if (legacyNodeId != null && !legacyNodeId.isBlank()) w.eq(CategoryDO::getLegacyNodeId, legacyNodeId);
|
||||
return selectList(w);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentEntryDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContentEntryMapper extends BaseMapperX<ContentEntryDO> {
|
||||
default List<ContentEntryDO> selectActiveList(Long tenantId, Long regionId, String entryType, boolean includeHidden) {
|
||||
LambdaQueryWrapperX<ContentEntryDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, ContentEntryDO::getTenantId, ContentEntryDO::getScope, tenantId);
|
||||
w.eq(ContentEntryDO::getIsActive, true).orderByAsc(ContentEntryDO::getSortOrder);
|
||||
if (!includeHidden) w.eq(ContentEntryDO::getIsHidden, false);
|
||||
if (regionId != null) w.eq(ContentEntryDO::getRegionId, regionId);
|
||||
if (entryType != null && !entryType.isBlank()) w.eq(ContentEntryDO::getEntryType, entryType);
|
||||
return selectList(w);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.ContentNodeDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ContentNodeMapper extends BaseMapperX<ContentNodeDO> {
|
||||
default List<ContentNodeDO> selectChildren(Long tenantId, Long entryId, Long parentId, boolean includeInactive,
|
||||
String markerType) {
|
||||
LambdaQueryWrapperX<ContentNodeDO> w = base(tenantId, entryId, includeInactive, markerType);
|
||||
if (parentId == null) w.isNull(ContentNodeDO::getParentId); else w.eq(ContentNodeDO::getParentId, parentId);
|
||||
return selectList(w);
|
||||
}
|
||||
default List<ContentNodeDO> selectAllByEntryId(Long tenantId, Long entryId, boolean includeInactive, String markerType) {
|
||||
return selectList(base(tenantId, entryId, includeInactive, markerType));
|
||||
}
|
||||
private LambdaQueryWrapperX<ContentNodeDO> base(Long tenantId, Long entryId, boolean includeInactive, String markerType) {
|
||||
LambdaQueryWrapperX<ContentNodeDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, ContentNodeDO::getTenantId, ContentNodeDO::getScope, tenantId);
|
||||
w.eq(ContentNodeDO::getEntryId, entryId).eq(ContentNodeDO::getIsHidden, false)
|
||||
.orderByAsc(ContentNodeDO::getSortOrder);
|
||||
if (!includeInactive) w.eq(ContentNodeDO::getIsActive, true);
|
||||
if (markerType != null && !markerType.isBlank()) w.eq(ContentNodeDO::getMarkerType, markerType);
|
||||
return w;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.MajorDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MajorMapper extends BaseMapperX<MajorDO> {
|
||||
default List<MajorDO> selectActiveList(Long tenantId, Long regionId, Long schoolId, Long majorId) {
|
||||
LambdaQueryWrapperX<MajorDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, MajorDO::getTenantId, MajorDO::getScope, tenantId);
|
||||
w.eq(MajorDO::getIsActive, true).orderByAsc(MajorDO::getSortOrder);
|
||||
if (regionId != null) w.eq(MajorDO::getRegionId, regionId);
|
||||
if (schoolId != null) w.eq(MajorDO::getSchoolId, schoolId);
|
||||
if (majorId != null) w.eq(MajorDO::getId, majorId);
|
||||
return selectList(w);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.PracticeBlueprintDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PracticeBlueprintMapper extends BaseMapperX<PracticeBlueprintDO> {
|
||||
default PracticeBlueprintDO selectByCollectionOrNode(Long tenantId, Long collectionId, Long nodeId,
|
||||
String mode, String type, String difficulty) {
|
||||
if (collectionId == null && nodeId == null) return null;
|
||||
LambdaQueryWrapperX<PracticeBlueprintDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, PracticeBlueprintDO::getTenantId, PracticeBlueprintDO::getScope, tenantId);
|
||||
w.eq(PracticeBlueprintDO::getIsActive, true).and(x -> {
|
||||
if (collectionId != null) x.eq(PracticeBlueprintDO::getCollectionId, collectionId);
|
||||
if (nodeId != null) x.or().eq(PracticeBlueprintDO::getNodeId, nodeId);
|
||||
}).orderByAsc(PracticeBlueprintDO::getId).last("LIMIT 1");
|
||||
return selectOne(w);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionCollectionMapper extends BaseMapperX<QuestionCollectionDO> {
|
||||
default List<QuestionCollectionDO> selectActiveList(Long tenantId, Long entryId, Long nodeId, String collectionType, Integer limit) {
|
||||
LambdaQueryWrapperX<QuestionCollectionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, QuestionCollectionDO::getTenantId, QuestionCollectionDO::getScope, tenantId);
|
||||
w.eq(QuestionCollectionDO::getIsActive, true).eq(QuestionCollectionDO::getIsHidden, false)
|
||||
.orderByAsc(QuestionCollectionDO::getSortOrder);
|
||||
if (entryId != null) w.eq(QuestionCollectionDO::getEntryId, entryId);
|
||||
if (nodeId != null) w.eq(QuestionCollectionDO::getNodeId, nodeId);
|
||||
if (collectionType != null && !collectionType.isBlank()) w.eq(QuestionCollectionDO::getCollectionType, collectionType);
|
||||
if (limit != null && limit > 0) w.last("LIMIT " + Math.min(limit, 200));
|
||||
return selectList(w);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionCollectionQuestionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionCollectionQuestionMapper extends BaseMapperX<QuestionCollectionQuestionDO> {
|
||||
default List<QuestionCollectionQuestionDO> selectByCollectionId(Long tenantId, Long collectionId) {
|
||||
LambdaQueryWrapperX<QuestionCollectionQuestionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, QuestionCollectionQuestionDO::getTenantId,
|
||||
QuestionCollectionQuestionDO::getScope, tenantId);
|
||||
return selectList(w.eq(QuestionCollectionQuestionDO::getCollectionId, collectionId)
|
||||
.orderByAsc(QuestionCollectionQuestionDO::getSortOrder)
|
||||
.orderByAsc(QuestionCollectionQuestionDO::getId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.QuestionDO;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface QuestionMapper extends BaseMapperX<QuestionDO> {
|
||||
default IPage<QuestionDO> selectPublishedPage(IPage<QuestionDO> page, Long tenantId, Long nodeId,
|
||||
String type, String difficulty) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId);
|
||||
if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId);
|
||||
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
|
||||
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
|
||||
return selectPage(page, w.orderByAsc(QuestionDO::getSortOrder).orderByAsc(QuestionDO::getId));
|
||||
}
|
||||
|
||||
default IPage<QuestionDO> selectPublishedPageByIds(IPage<QuestionDO> page, Long tenantId, List<Long> ids,
|
||||
String type, String difficulty) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
page.setTotal(0);
|
||||
page.setRecords(Collections.emptyList());
|
||||
return page;
|
||||
}
|
||||
return selectPublishedPageByCollectionOrder(page, tenantId, ids, type, difficulty);
|
||||
}
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT q.*
|
||||
FROM education_question q
|
||||
JOIN unnest(ARRAY[
|
||||
<foreach collection="ids" item="id" separator=",">#{id}</foreach>
|
||||
]::BIGINT[]) WITH ORDINALITY ordered(question_id, position)
|
||||
ON ordered.question_id = q.id
|
||||
WHERE q.deleted = false
|
||||
AND q.is_published = true
|
||||
AND q.status = 'PUBLISHED'
|
||||
AND ((q.tenant_id = #{tenantId} AND q.scope = 'TENANT_OWNED')
|
||||
OR (q.tenant_id = 0 AND q.scope = 'PUBLIC'))
|
||||
<if test="type != null and type != ''">AND q.type = #{type}</if>
|
||||
<if test="difficulty != null and difficulty != ''">AND q.difficulty = #{difficulty}</if>
|
||||
ORDER BY ordered.position
|
||||
</script>
|
||||
""")
|
||||
IPage<QuestionDO> selectPublishedPageByCollectionOrder(IPage<QuestionDO> page,
|
||||
@Param("tenantId") Long tenantId, @Param("ids") List<Long> ids,
|
||||
@Param("type") String type, @Param("difficulty") String difficulty);
|
||||
|
||||
default long countPublishedByIds(Long tenantId, List<Long> ids, String type, String difficulty) {
|
||||
if (ids == null || ids.isEmpty()) return 0L;
|
||||
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId).in(QuestionDO::getId, ids);
|
||||
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
|
||||
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
|
||||
return selectCount(w);
|
||||
}
|
||||
|
||||
default long countPublished(Long tenantId, Long nodeId, String type, String difficulty) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = visible(tenantId);
|
||||
if (nodeId != null) w.eq(QuestionDO::getNodeId, nodeId);
|
||||
if (type != null && !type.isBlank()) w.eq(QuestionDO::getType, type);
|
||||
if (difficulty != null && !difficulty.isBlank()) w.eq(QuestionDO::getDifficulty, difficulty);
|
||||
return selectCount(w);
|
||||
}
|
||||
|
||||
default QuestionDO selectPublishedById(Long tenantId, Long id) {
|
||||
return selectOne(visible(tenantId).eq(QuestionDO::getId, id));
|
||||
}
|
||||
private LambdaQueryWrapperX<QuestionDO> visible(Long tenantId) {
|
||||
LambdaQueryWrapperX<QuestionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, QuestionDO::getTenantId, QuestionDO::getScope, tenantId);
|
||||
return w.eq(QuestionDO::getIsPublished, true).eq(QuestionDO::getStatus, "PUBLISHED");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.RegionDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface RegionMapper extends BaseMapperX<RegionDO> {
|
||||
default List<RegionDO> selectActiveList(Long tenantId) {
|
||||
LambdaQueryWrapperX<RegionDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, RegionDO::getTenantId, RegionDO::getScope, tenantId);
|
||||
return selectList(w.eq(RegionDO::getIsActive, true).orderByAsc(RegionDO::getSortOrder));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.SchoolDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface SchoolMapper extends BaseMapperX<SchoolDO> {
|
||||
default List<SchoolDO> selectActiveList(Long tenantId, Long regionId, Long schoolId) {
|
||||
LambdaQueryWrapperX<SchoolDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, SchoolDO::getTenantId, SchoolDO::getScope, tenantId);
|
||||
w.eq(SchoolDO::getIsActive, true).orderByAsc(SchoolDO::getSortOrder);
|
||||
if (regionId != null) w.eq(SchoolDO::getRegionId, regionId);
|
||||
if (schoolId != null) w.eq(SchoolDO::getId, schoolId);
|
||||
return selectList(w);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql.catalog;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.catalog.SubjectDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface SubjectMapper extends BaseMapperX<SubjectDO> {
|
||||
default List<SubjectDO> selectActiveList(Long tenantId, Long regionId, Long schoolId, Long majorId,
|
||||
Long moduleId, String type) {
|
||||
LambdaQueryWrapperX<SubjectDO> w = new LambdaQueryWrapperX<>();
|
||||
CatalogScopeQuery.apply(w, SubjectDO::getTenantId, SubjectDO::getScope, tenantId);
|
||||
w.eq(SubjectDO::getIsActive, true).orderByAsc(SubjectDO::getSortOrder);
|
||||
if (regionId != null) w.eq(SubjectDO::getRegionId, regionId);
|
||||
if (schoolId != null) w.eq(SubjectDO::getSchoolId, schoolId);
|
||||
if (majorId != null) w.eq(SubjectDO::getMajorId, majorId);
|
||||
if (moduleId != null) w.eq(SubjectDO::getModuleId, moduleId);
|
||||
if (type != null && !type.isBlank()) w.eq(SubjectDO::getType, type);
|
||||
return selectList(w);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ package cn.iocoder.yudao.module.education.enums;
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code SCALAR_READ} — 使用 Scalar API 读取题库目录数据</li>
|
||||
* <li>{@code JAVA_READ} — 使用 Java 本地数据源(预留,当前不支持)</li>
|
||||
* <li>{@code JAVA_READ} — 使用 Java 本地 PostgreSQL 数据源</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author 恭学教育
|
||||
|
||||
@@ -15,11 +15,12 @@ public interface ErrorCodeConstants {
|
||||
// ========== 租户识别 1-005-001-001 ~ 1-005-001-010 ==========
|
||||
ErrorCode EDUCATION_TENANT_NOT_FOUND = new ErrorCode(1_005_001_001, "租户不存在");
|
||||
ErrorCode EDUCATION_TENANT_DISABLED = new ErrorCode(1_005_001_002, "租户已被禁用");
|
||||
ErrorCode EDUCATION_TENANT_RESOLVE_FAILED = new ErrorCode(1_005_001_003, "租户识别失败:{}");
|
||||
ErrorCode EDUCATION_TENANT_NOT_ACTIVE = new ErrorCode(1_005_001_004, "当前租户不可用,请联系管理员");
|
||||
ErrorCode EDUCATION_TENANT_RESOLVE_FAILED = new ErrorCode(1_005_001_003, "租户识别请求无效");
|
||||
ErrorCode EDUCATION_TENANT_NOT_ACTIVE = new ErrorCode(1_005_001_004, "当前租户不可用");
|
||||
ErrorCode EDUCATION_TENANT_NOT_IN_PILOT = new ErrorCode(1_005_001_005, "当前租户尚未开放教育 Pilot 能力");
|
||||
ErrorCode EDUCATION_CATALOG_READ_DISABLED = new ErrorCode(1_005_001_006, "题库读取能力已关闭,请稍后重试");
|
||||
ErrorCode EDUCATION_PRACTICE_WRITE_DISABLED = new ErrorCode(1_005_001_007, "练习写入能力已关闭,历史数据仍可查看");
|
||||
ErrorCode EDUCATION_TENANT_LOCATOR_CONFLICT = new ErrorCode(1_005_001_008, "租户识别信息冲突");
|
||||
|
||||
// ========== Catalog 目录 1-005-002-000 ~ 1-005-002-009 ==========
|
||||
ErrorCode CATALOG_DATA_SOURCE_DISABLED = new ErrorCode(1_005_002_000, "题库数据源未启用,请联系管理员");
|
||||
@@ -36,6 +37,8 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode CATALOG_UPSTREAM_MALFORMED = new ErrorCode(1_005_002_011, "上游题库返回数据格式异常,请稍后重试");
|
||||
ErrorCode CATALOG_UPSTREAM_UNAVAILABLE = new ErrorCode(1_005_002_012, "上游题库服务不可达,请稍后重试");
|
||||
|
||||
ErrorCode CATALOG_INVALID_IDENTIFIER = new ErrorCode(1_005_002_013, "目录标识符格式无效:{}");
|
||||
|
||||
// ========== 题目与练习 1-005-003-000 ~ 1-005-003-009 ==========
|
||||
ErrorCode QUESTION_NOT_FOUND = new ErrorCode(1_005_003_001, "题目不存在或不可见");
|
||||
ErrorCode INVALID_PRACTICE_CONFIG = new ErrorCode(1_005_003_002, "无效的练习配置:{}");
|
||||
@@ -68,6 +71,8 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode ANSWER_STALE_CROSS_QUESTION_SEQUENCE = new ErrorCode(1_005_003_022, "客户端命令序号过期(跨题目),当前序号 {} 不大于会话已接受的 {}");
|
||||
ErrorCode CATALOG_UPSTREAM_OPTIONS_MALFORMED = new ErrorCode(1_005_003_023, "题目选项数据格式异常,请稍后重试");
|
||||
ErrorCode ANSWER_FIELD_TOO_LONG = new ErrorCode(1_005_003_024, "请求字段过长:{}");
|
||||
ErrorCode ANSWER_IDEMPOTENCY_REPLAY_INVALID = new ErrorCode(1_005_003_025, "答案幂等记录不完整,无法安全重放,请刷新会话后使用新的幂等键");
|
||||
ErrorCode ANSWER_TYPE_UNSUPPORTED = new ErrorCode(1_005_003_026, "当前题型暂不支持答案保存");
|
||||
|
||||
// ========== 交卷提交 1-005-003-030 ~ 1-005-003-039 ==========
|
||||
ErrorCode SUBMIT_SESSION_NOT_ACTIVE = new ErrorCode(1_005_003_030, "会话不是进行中状态,无法交卷");
|
||||
@@ -77,6 +82,7 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode REPORT_NOT_FOUND = new ErrorCode(1_005_003_034, "报告不存在");
|
||||
ErrorCode REPORT_NOT_OWN = new ErrorCode(1_005_003_035, "无权访问该报告");
|
||||
ErrorCode REPORT_SESSION_NOT_SUBMITTED = new ErrorCode(1_005_003_036, "会话尚未提交,报告不可用");
|
||||
ErrorCode SUBMIT_IDEMPOTENCY_REPLAY_INVALID = new ErrorCode(1_005_003_037, "交卷幂等记录不完整,无法安全重放,请刷新后重试");
|
||||
|
||||
// ========== 错题本 1-005-003-040 ~ 1-005-003-059 ==========
|
||||
ErrorCode WRONG_QUESTION_NOT_FOUND = new ErrorCode(1_005_003_040, "错题不存在");
|
||||
|
||||
@@ -7,16 +7,17 @@ import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvide
|
||||
import cn.iocoder.yudao.module.education.service.catalog.ScalarCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.UnsupportedModeCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.question.UnsupportedModeQuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Scalar 数据源自动配置。
|
||||
* Catalog 数据源自动配置。
|
||||
* 根据 yudao.education.catalog-mode 选择 CatalogProvider 和 QuestionCatalogProvider 实现。
|
||||
* SCALAR_READ 模式创建 ScalarCatalogProvider(同时实现两个接口);
|
||||
* JAVA_READ 模式创建 UnsupportedModeCatalogProvider 和 UnsupportedModeQuestionCatalogProvider。
|
||||
* JAVA_READ 模式使用 JavaCatalogProvider(直连 PostgreSQL)。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@@ -31,8 +32,12 @@ public class ScalarAutoConfiguration {
|
||||
return new ScalarCatalogProvider(scalarProperties);
|
||||
}
|
||||
|
||||
// JAVA_READ mode is handled by JavaCatalogProvider @Component
|
||||
// (see cn.iocoder.yudao.module.education.service.catalog.provider.JavaCatalogProvider)
|
||||
|
||||
// Legacy unsupported-mode beans — kept for backward compatibility with standalone tests
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "JAVA_READ")
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "UNSUPPORTED")
|
||||
public CatalogProvider unsupportedModeCatalogProvider(EducationProperties educationProperties) {
|
||||
CatalogProviderMode mode = educationProperties.getCatalogMode() != null
|
||||
? educationProperties.getCatalogMode() : CatalogProviderMode.JAVA_READ;
|
||||
@@ -40,7 +45,7 @@ public class ScalarAutoConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "JAVA_READ")
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "catalog-mode", havingValue = "UNSUPPORTED")
|
||||
public QuestionCatalogProvider unsupportedModeQuestionCatalogProvider(EducationProperties educationProperties) {
|
||||
CatalogProviderMode mode = educationProperties.getCatalogMode() != null
|
||||
? educationProperties.getCatalogMode() : CatalogProviderMode.JAVA_READ;
|
||||
|
||||
@@ -20,6 +20,12 @@ public interface CatalogProvider {
|
||||
*/
|
||||
boolean isEnabled();
|
||||
|
||||
/** 查询院校 */
|
||||
List<CatalogEntityDTO> listSchools(String regionId, String schoolId);
|
||||
|
||||
/** 查询专业 */
|
||||
List<CatalogEntityDTO> listMajors(String regionId, String schoolId, String majorId, String moduleId, String type);
|
||||
|
||||
/** 查询地区列表 */
|
||||
List<CatalogEntityDTO> listRegions();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user