10 Commits

178 changed files with 20225 additions and 8 deletions

View File

@@ -0,0 +1,115 @@
# Education Pilot 验收与回滚手册
## 1. 范围
本文覆盖学生核心学习闭环后端的 Pilot 发布、验证、监控和应用回滚。完整 Student Web/H5 源码当前不在本工作区,因此浏览器 E2E、桌面/H5 截图和前端构建验收仍是明确阻塞项,不能以 HTTP 或单元测试替代。
## 2. Pilot 配置
```yaml
yudao:
education:
enabled: true
catalog-read-enabled: true
practice-write-enabled: true
pilot-tenant-ids: [<pilot-tenant-id>]
catalog-mode: SCALAR_READ
scalar:
enabled: true
base-url: ${EDUCATION_SCALAR_BASE_URL}
token: ${EDUCATION_SCALAR_TOKEN}
```
要求:
- `pilot-tenant-ids` 在 Pilot 环境必须显式配置,不能使用空列表。
- Scalar token 只能通过密钥管理或环境变量注入,不写入仓库、日志或测试报告。
- 发布前调用管理端 `/admin-api/education/capability`,核对模块、题库读取、练习写入和 Pilot 租户数量。
## 3. 发布步骤
1. 备份 Education 相关表,并记录应用版本与数据库版本。
2. 执行尚未应用的正向 SQL不得执行 rollback SQL。
3. 先以 `catalog-read-enabled=false``practice-write-enabled=false` 部署应用。
4. 验证 System、Infra、Member 基础 smoke。
5. 仅对 Pilot 租户开启题库读取,完成 Scalar 只读 smoke。
6. 对 Pilot 租户开启练习写入,完成会话、答案、交卷、报告、错题和收藏 smoke。
7. 观察错误率、延迟和数据库写入后再扩大租户列表。
## 4. Smoke 清单
### 基础与身份
- [ ] 非 Pilot 租户访问题库和练习写入被拒绝。
- [ ] Pilot 租户可完成 tenant resolve、Member 登录、refresh、logout 和 Education context。
- [ ] 错误 `tenant-id` 被租户安全过滤器拒绝。
### 核心闭环
- [ ] 目录及题目只经 RuoYi API 返回,响应不含答案或解析。
- [ ] 创建练习后刷新可恢复相同会话、题序和已保存答案。
- [ ] 相同答案幂等键重试返回首次结果;旧版本和旧序号被拒绝。
- [ ] 交卷只生成一个报告,交卷后答案不可修改。
- [ ] 错题投影、错题复习和收藏操作仅对当前学生可见。
### 隔离
- [ ] tenant A / student A 不能读取或修改 tenant A / student B 的记录。
- [ ] tenant A 不能读取或修改 tenant B 的记录,即使资源 ID 被猜中。
- [ ] 对 session、report、wrong question、favorite 分别留存拒绝结果证据。
## 5. 故障与回滚
### Scalar 故障
1. 设置 `catalog-read-enabled=false`,停止新的 Scalar 读取。
2. 保持 `enabled=true`,使已有会话、报告、错题和收藏仍可访问。
3. 如需冻结新写入,再设置 `practice-write-enabled=false`
4. 验证 Education MySQL 表行数和历史查询均未减少。
### 练习写入熔断
设置 `practice-write-enabled=false` 后:
- 新建练习、保存答案和交卷必须被拒绝;
- 当前会话恢复、指定会话读取、报告和报告历史仍应可读;
- 不执行清理、归档或 rollback SQL。
### 应用回滚
1. 将应用回滚到上一已验证版本。
2. 保留所有 Education 表和数据,不执行 `sql/mysql/education/*-rollback.sql`
3. 若旧版本与新 schema 不兼容,保持功能关闭并前滚修复;不得通过删表恢复服务。
4. 重新验证 Member 登录、System 租户和 Infra 日志功能。
> `*-rollback.sql` 是显式数据销毁工具,不是常规应用版本回滚步骤。
## 6. 可观测性
发布窗口至少观察:
- Scalar 请求成功率、4xx/5xx/timeout、P95/P99 延迟;
- 练习创建成功/冲突数;
- 答案保存成功、幂等重放、版本冲突和旧序号拒绝数;
- 交卷成功、并发冲突和事务失败数;
- Pilot 租户拒绝数;
- JVM、数据库连接池、HTTP 错误率和接口延迟。
Scalar 日志只能记录脱敏路径、tenant ID、上游 request ID、状态、耗时和错误分类不得记录 Authorization、Scalar token、学生答案、正确答案或完整响应体。RuoYi access/error log 中的 trace ID 用于关联入口请求;验收时需保存一条从入口日志到 Scalar request ID 的关联证据。
## 7. 验证命令
```bash
mvn -pl yudao-module-education -am test
mvn -pl yudao-server -am package -DskipTests
```
前端源码归位后还必须执行其 lint、类型检查、测试、生产构建及浏览器 E2E。
## 8. 已知限制
- 当前工作区缺少完整 Student Web/H5 前端源码。
- 尚不能在本仓库完成浏览器 Network 无直连 Scalar 断言。
- 尚不能完成桌面和 H5 视觉截图对比。
- 真实 Scalar smoke 依赖部署环境、固定上游版本和有效只读凭据。
- Pilot 租户列表属于部署配置,修改后需要按配置刷新机制重新加载或重启应用。

View File

@@ -0,0 +1,216 @@
# 恭学教育学生核心学习闭环 PRD
## Problem Statement
当前恭学教育系统基于 RuoYi-Vue-Pro已经具备成熟的租户、后台用户、会员、鉴权、支付、文件、短信、邮件、站内信、权限、字典、定时任务和审计基础设施但仓库内尚无生产级教育/题库/学习模块,完整前端源码也尚未纳入当前工作区。
另一个已经运行的 Scalar API 提供了题库、练习、资料、视频、会员和运营等大量教育接口;用户同时提供了学生学习中心、租户运营后台、平台管理后台的功能原型和效果图。若前端直接接入 Scalar或在 RuoYi 中再次独立实现身份、会员、支付等基础能力,会形成双鉴权、双租户、双订单和双数据源,造成权限不一致、数据难迁移、跨租户风险以及长期维护成本。
用户首先需要一个能够真实上线和验证的学生学习核心闭环:学生在正确租户下使用现有账号登录,浏览题库,创建练习,稳定保存答案,提交试卷,查看报告,并继续使用错题本和收藏夹。该闭环需要以 RuoYi 为统一入口和最终数据权威,同时允许尚未迁移的只读题库内容暂时经后端适配层来自 Scalar。实现还必须为后续会员支付、私有资料、视频、租户运营后台和平台治理留出清晰边界但不能让这些后续范围阻塞第一阶段交付。
## Solution
在 RuoYi-Vue-Pro 中新增独立的 Education 业务模块,以 RuoYi 作为所有前端请求、身份、租户、个人学习数据和未来支付权益的统一边界。学生 Web/H5 和 Vue 3 管理后台只能调用 RuoYi API不得直接访问 Scalar。
第一阶段交付以下纵向学习闭环:
1. 根据访问域名或受控租户参数识别租户。
2. 复用现有 Member 登录、短信登录、令牌刷新和退出能力。
3. 通过 Education 内部目录/题目接口读取题库;尚未迁移的数据由服务器端 Scalar 防腐适配层转换。
4. 在 RuoYi/MySQL 中创建归属于当前学生和租户的练习会话,并固定题目顺序与版本。
5. 使用幂等键、客户端序号和服务端版本安全地自动保存答案,支持刷新、断网和请求重试恢复。
6. 以原子状态转换提交试卷,保存稳定的评分结果和历史快照。
7. 生成练习报告、错题记录、收藏和基础学习进度。
8. 通过一个最高层的学生核心闭环 E2E 接缝验收整体行为,并使用较低层测试补足租户隔离、所有权、幂等、并发和 Scalar 契约等不可完全由单条 E2E 覆盖的风险。
后续阶段在同一模块边界内扩展个人中心、词汇、手册、分数线、AI 推荐、资料、视频、会员支付、权益、租户运营和平台治理,并逐项把 Scalar 内容迁移到 Java/MySQL。
## User Stories
1. As a student, I want the application to identify the correct school or tenant from my entry point, so that I enter the right branded learning environment.
2. As a student, I want a clear error when no valid tenant can be resolved, so that I do not accidentally sign in to the wrong organization.
3. As a student, I want to be blocked when a tenant is disabled, so that the platform does not expose inactive tenant data.
4. As a student, I want to sign in with my existing mobile number and password, so that I do not need a separate education account.
5. As a student, I want to sign in with an SMS verification code, so that I can recover access without remembering a password.
6. As a student, I want supported social or WeChat login methods to keep working, so that education does not replace the platforms existing authentication options.
7. As a student, I want my session to refresh securely, so that a long learning session is not lost when an access token expires.
8. As a student, I want to log out from the education application, so that another person using the device cannot access my learning data.
9. As a student, I want to return to the page I originally requested after login, so that authentication does not interrupt my intended task.
10. As a student, I want the application to display my existing nickname and avatar, so that my education profile is consistent with my member account.
11. As a student, I want the application to preserve the tenant context after login, so that subsequent requests cannot drift into another tenant.
12. As a student, I want to see a learning home page with a clear entry into the question bank, so that I can start studying quickly.
13. As a student, I want to resume an unfinished practice session from the learning home page, so that a refresh or temporary interruption does not discard my work.
14. As a student, I want to browse question banks by subject, category, region, major, or other supported catalog dimensions, so that I can find relevant material.
15. As a student, I want catalog filters to preserve their selected state while I navigate, so that I can compare and refine content efficiently.
16. As a student, I want clear loading, empty, unavailable, and permission-denied states in the catalog, so that I understand why content is not displayed.
17. As a student, I want only published and permitted question banks to appear, so that I do not see draft or unauthorized content.
18. As a student, I want question counts and practice configuration to be accurate, so that I understand what will be included before starting.
19. As a student, I want to create a practice session from selected criteria, so that the server prepares a stable set of questions for me.
20. As a student, I want the question order to remain stable throughout a practice session, so that refreshing does not reorder my work.
21. As a student, I want historical practice to preserve the version of each question I answered, so that later question edits do not change my old result.
22. As a student, I want question content to render correctly on desktop and mobile widths, so that I can learn on either device.
23. As a student, I want formulas and rich question content to render correctly, so that mathematical and technical questions remain understandable.
24. As a student, I want answer options to be easy to select using touch or mouse, so that answering is efficient and accessible.
25. As a student, I want to move to the previous or next question, so that I can navigate the practice naturally.
26. As a student, I want an answer-card overview, so that I can see answered, unanswered, and current questions.
27. As a student, I want my answer to save automatically, so that I do not lose progress if I leave the page unexpectedly.
28. As a student, I want to see whether an answer is saving, saved, retrying, or failed, so that I know whether my progress is safe.
29. As a student, I want a failed autosave to retry safely, so that network instability does not create duplicate or corrupted answers.
30. As a student, I want an older delayed save request to be rejected rather than overwrite my newer answer, so that request reordering cannot corrupt progress.
31. As a student, I want refreshing the page to restore the latest server-accepted answers, so that the server remains the durable source of truth.
32. As a student, I want duplicate clicks or requests to have one effective result, so that accidental repetition does not change my practice incorrectly.
33. As a student, I want to be prevented from answering a submitted, expired, cancelled, or foreign session, so that session state remains trustworthy.
34. As a student, I want correct answers and explanations hidden before submission, so that the practice cannot be cheated through API inspection.
35. As a student, I want a confirmation before final submission when unanswered questions remain, so that I can choose whether to review them.
36. As a student, I want submitting a practice session to be atomic, so that I never receive a partially scored report.
37. As a student, I want repeated submission after a timeout to return the original result, so that I do not create duplicate reports.
38. As a student, I want a clear score, correct count, incorrect count, and completion summary after submission, so that I understand my performance.
39. As a student, I want question-level result details after submission, so that I can learn from mistakes.
40. As a student, I want permitted explanations to appear after submission, so that I can understand the correct reasoning.
41. As a student, I want my practice history ordered and paginated, so that I can revisit previous work.
42. As a student, I want a report to remain stable even if an administrator later edits a question, so that historical records are auditable.
43. As a student, I want incorrectly answered questions added to my wrong-question book, so that I can focus future review.
44. As a student, I want repeated mistakes on the same question to increase its error count rather than create duplicate rows, so that the wrong-question book remains useful.
45. As a student, I want to mark a wrong question as mastered without deleting its history, so that progress remains visible.
46. As a student, I want to create a review practice from wrong questions, so that I can close knowledge gaps.
47. As a student, I want to favorite a question, so that I can return to important material later.
48. As a student, I want favoriting the same question repeatedly to remain idempotent, so that duplicate actions do not create duplicate records.
49. As a student, I want to remove a favorite, so that my collection remains relevant.
50. As a student, I want wrong questions and favorites to be paginated and filterable, so that large collections remain manageable.
51. As a student, I want another student to be unable to read or mutate my sessions, reports, wrong questions, or favorites, so that my learning data remains private.
52. As a student, I want another tenant to be unable to access my tenants private question banks or learning records, so that organizations remain isolated.
53. As a student, I want a traceable support reference when an upstream content service fails, so that support can investigate without exposing sensitive details.
54. As a tenant operator, I want student authentication to reuse the platforms member system, so that I do not manage duplicate accounts.
55. As a tenant operator, I want education data automatically scoped to my tenant, so that I cannot accidentally view another tenants students or content.
56. As a tenant operator, I want permission-controlled access to future education management screens, so that roles can be assigned through the existing menu and role system.
57. As a tenant operator, I want student learning reports to be based on immutable practice snapshots, so that supervision data remains trustworthy.
58. As a tenant operator, I want education actions to appear in existing access, error, and operation logs, so that incidents can be investigated centrally.
59. As a platform operator, I want public and tenant-owned content represented explicitly, so that public sharing does not require disabling tenant isolation globally.
60. As a platform operator, I want Scalar-backed capabilities to be visible through configuration and metrics, so that migration progress and dependency risk are measurable.
61. As a platform operator, I want to enable the new learning flow for pilot tenants first, so that production risk is contained.
62. As a platform operator, I want independent feature switches for catalog reads, practice creation, payments, private media, and imports, so that failures can be isolated.
63. As a platform operator, I want rollback to preserve practice history and idempotency records, so that deployment rollback does not lose student work.
64. As a support engineer, I want requests correlated by request or trace ID across RuoYi and Scalar, so that cross-system failures are diagnosable.
65. As a support engineer, I want logs to exclude tokens, phone numbers, correct answers, payment secrets, and signed URLs, so that observability does not create a data leak.
66. As a developer, I want one internal education contract independent of Scalar DTOs, so that the external provider can be changed or retired safely.
67. As a developer, I want Scalar errors translated consistently rather than converted to successful empty data, so that frontend and monitoring behavior is honest.
68. As a developer, I want contract tests for the Scalar envelope and errors, so that upstream changes fail before deployment.
69. As a developer, I want all personal learning writes to go directly to Java/MySQL, so that there is no dual-write reconciliation problem.
70. As a developer, I want existing member, tenant, permission, file, notification, and later payment APIs reused, so that the education module remains focused on education behavior.
71. As a developer, I want the education module to expose narrow module APIs, so that other modules do not import its mappers or data objects.
72. As a developer, I want schema changes delivered as ordered, reversible or explicitly non-reversible scripts, so that database releases can be operated safely.
73. As a QA engineer, I want one high-level E2E scenario to cover the entire student core loop, so that the released experience is tested from the users perspective.
74. As a QA engineer, I want targeted integration tests for tenant isolation, ownership, idempotency, concurrency, and adapter behavior, so that security and consistency failures are exercised deterministically.
75. As a product owner, I want the first release limited to the student core learning loop, so that value can be validated before building every prototype screen.
76. As a product owner, I want later membership, payment, private media, tenant operations, and platform governance to fit the same architecture, so that the first release does not become a dead end.
77. As a product owner, I want visual acceptance against the supplied concept images on desktop and H5 widths, so that functional completion also meets the intended experience.
78. As a product owner, I want incomplete future features clearly labeled rather than represented with mock data, so that release status is transparent.
## Implementation Decisions
- RuoYi is the unified application boundary and final source of truth. Frontends will not call Scalar directly.
- A new Education business module will own education-specific behavior and data. It will follow the repositorys controller, service, conversion, data-object, mapper, enum, job, and module-API conventions.
- The existing Member module will own student credentials, login, token refresh, logout, mobile number, nickname, avatar, level, points, tags, and other generic member data. Education-specific profile data will reference the member ID instead of duplicating account fields.
- The existing System module will own tenant administration, admin users, roles, menus, permissions, dictionaries, configuration, notifications, email, SMS, and audit facilities.
- The existing Infra module will own file records and storage. Education will own the authorization decision for paid or private resources.
- The Pay module will remain disabled during the first student-core release and will be activated in a later payment slice. Education orders and entitlements will be projections linked to Pay orders rather than an independent payment engine.
- The first release will activate Member and Education in the Maven reactor and server. Unrelated modules will remain disabled to limit build and runtime scope.
- The Scalar integration will be a server-side anti-corruption layer. External DTOs, enum values, pagination, errors, timestamps, identifiers, and metadata will be converted to internal education contracts before reaching services or controllers.
- Scalar will initially provide only explicitly approved read-only content capabilities. Student practice sessions, answers, reports, wrong questions, favorites, progress, future orders, entitlements, and private-resource decisions will never be written to Scalar.
- Each capability will have an explicit source state such as `SCALAR_READ`, `JAVA_NATIVE`, or `MIGRATED`. The system will not silently fall back between providers.
- Scalar failures will be mapped to explicit domain errors. An unavailable upstream must not appear as an empty successful catalog.
- Scalar requests will receive tenant context derived from the authenticated server context. The frontend cannot override authorization, tenant, user, or platform identity headers.
- Scalar authentication will use an approved server credential or token-exchange mechanism. Forwarding a frontend token is not permitted unless the frozen contract explicitly requires it and it passes security review.
- The public student API will use the repositorys existing app API conventions, standard success envelope, and page representation. A compatibility facade may preserve `/api` paths if the restored frontend requires them, but it will delegate to the same services rather than duplicate logic.
- Student IDs and tenant IDs for protected resources will be derived from the security context. Request-body user or tenant IDs will not be trusted.
- Tenant-scoped education data will use the platforms tenant-aware base object and database interceptor by default.
- Public content will use an explicit ownership scope or public marker. It will not be implemented by broadly disabling the tenant interceptor.
- Any tenant bypass will be isolated to a narrow platform service, documented, permission-protected, and covered by cross-tenant tests.
- The initial content model will include question banks, hierarchical catalog nodes, questions, options, source identifiers, publication state, content versions, and appropriate tenant-aware indexes.
- Correct answers and explanations will be treated as protected fields. Pre-submission student DTOs will not contain them.
- A practice session will belong to one tenant and one member. It will include a client-generated session identifier, lifecycle state, content selection, question count, score, timestamps, and a concurrency version.
- Starting a practice will freeze the question sequence and version. The system will retain enough snapshot data to keep historical reports stable after content changes.
- Answer autosave will require an idempotency key, a client command sequence, and the latest known server session version.
- Replaying the same idempotency key with the same request will return the original result. Reusing it for a different payload will return a conflict.
- Stale sequence or version updates will be rejected instead of overwriting newer accepted answers.
- Session submission will be an atomic, one-way state transition. Retrying a successfully committed submission will return the original result.
- Session ownership and active state will be checked in the service layer even when a controller is authenticated.
- Wrong questions will use one record per tenant, student, and question, with accumulated error count and mastery state. Marking as mastered will not erase history.
- Favorites will use one record per tenant, student, target type, and target ID and will support idempotent add/remove behavior.
- Basic learning progress will be stored as reliable aggregates. Expensive trends and summaries may later be calculated asynchronously through the existing job system.
- External resource mappings will preserve provider, external resource type, external ID, local ID, source version, synchronization state, and last synchronization time.
- Import and synchronization operations will use durable jobs and issue records rather than executing large migrations in a web request.
- Database changes will be delivered as ordered education SQL scripts with preconditions, verification queries, rollback SQL where safe, explicit rollback limitations, and lock-impact notes. The project will not pretend that Flyway or Liquibase exists when it does not.
- Permission names will follow the established `education:<resource>:<action>` pattern and will be seeded with menus and dictionaries rather than hardcoded only in the frontend.
- Stable business state machines will use Java enums and centralized transition validation. Dictionaries will provide configurable display values.
- Existing notification templates, mail accounts, SMS services, and in-app notification services will be reused. Education services will provide template codes and parameters rather than implementing a second delivery engine.
- Existing API access logs, API error logs, operation logs, login logs, and job logs will be reused. Education will add domain records only where business history must survive general log retention.
- Private media will not rely on the generic public and tenant-ignored file download route. A future Education access endpoint will authenticate the caller, validate tenant and resource state, check entitlement or operator permission, issue a short-lived URL, and audit the decision.
- The administration frontend will use the restored Vue 3 and Element Plus codebase and its existing request, route, store, permission, layout, form, table, pagination, upload, and theme conventions.
- The student frontend will be a responsive Web/H5 experience using the restored production frontend baseline. The static prototype is an acceptance reference, not a replacement architecture.
- The first release will cover tenant resolution, authentication shell, question-bank browsing, practice creation, answer autosave and recovery, submission, report, history, wrong questions, and favorites.
- Vocabulary, handbook, scorelines, AI recommendations, resources, videos, messages, growth, membership, payments, entitlements, tenant operations, platform governance, and full Scalar retirement will be implemented as later vertical slices.
- Feature flags will independently control Scalar catalog reads, Java content reads, practice creation, future payments, private media, imports, and frontend route exposure.
- Initial production rollout will use a pilot tenant. The release sequence will expand schema first, deploy disabled code, verify existing modules, enable read paths, then enable learning writes.
- Rollback will preserve practice history, reports, idempotency records, future orders, and entitlements. User-specific data will never roll back to Scalar.
- Observability will include request/trace ID, tenant, actor, use case, provider, upstream request ID, endpoint, latency, result, practice session, future order/import job, and authorization decision. Sensitive values will be redacted.
- The complete frontend sources, exact commits, machine-readable Scalar OpenAPI contract, production database version, Scalar availability expectations, and stable external identifier semantics are prerequisites to implementation.
## Testing Decisions
- Tests will assert externally observable behavior rather than private method calls, mapper invocation counts, or implementation-specific object construction.
- The primary acceptance seam will be one browser-level student core-loop E2E: resolve tenant, authenticate, browse a question bank, create a practice, save answers, refresh and recover, retry one simulated failed save, submit, inspect the report, and visit wrong questions and favorites.
- The E2E will also assert that browser network traffic contains no direct request to Scalar.
- The E2E will run at both representative desktop and H5 viewport sizes and capture key screenshots for comparison with the supplied concepts.
- Authentication tests will reuse the highest existing authentication seams: login endpoints, refresh, logout, and current-member behavior. Education will not unit-test the internals of Member authentication.
- Tenant isolation tests will create at least two tenants and overlapping-looking resource identifiers. They will assert that cross-tenant catalog, session, report, wrong-question, favorite, and future media access is denied.
- Ownership tests will create at least two students in one tenant and assert that one student cannot read, update, submit, or replay another students practice.
- Scalar adapter contract tests will cover single-item and paginated envelopes, request metadata, missing optional fields, additional fields, malformed required fields, 400, 401, 403, 404, 409, 429, timeout, and 5xx behavior.
- Scalar adapter tests will assert that failures are not converted to empty successes and that sensitive headers are not accepted from callers.
- Practice creation tests will assert stable question order, content version retention, ownership, tenant scope, and idempotent handling of a repeated client session identifier.
- Autosave tests will assert normal save, identical replay, payload mismatch conflict, stale sequence rejection, stale server-version rejection, delayed request ordering, refresh recovery, inactive-session rejection, and cross-user rejection.
- Submission tests will assert atomic scoring, unanswered questions, repeated submission, a timeout after commit, content edits after session creation, and stable historical reports.
- Wrong-question tests will assert unique upsert behavior, accumulated error count, mastery without history deletion, and review selection.
- Favorite tests will assert idempotent add, idempotent remove, tenant and owner filtering, and pagination.
- Response-security tests will assert that pre-submission DTOs and error logs do not contain correct answers or explanations.
- Logging tests will focus on the observable presence of correlation fields and absence of secrets, not exact log-line formatting.
- Database tests will follow the projects existing Spring and database test foundations and test real constraints for unique tenant/source mappings, sessions, answers, wrong questions, favorites, and idempotency records.
- Build verification will include the Education module with dependencies, the server package with activated Member/Education modules, and the restored frontends actual lint, type-check, test, and production build commands.
- Smoke tests against the real Scalar deployment will be read-only and version-pinned. They will run before enabling an adapter-backed feature in a target environment.
- Release verification will check existing System, Infra, and Member behavior for regressions before enabling any Education feature flag.
- Future payment tests will cover duplicate provider callbacks, status polling, browser return URLs that disagree with server state, refund replay, entitlement projection, and refund-access semantics.
- Future private-media tests will cover unauthenticated requests, wrong tenant, wrong student, expired entitlement, unpublished asset, short-lived URL generation, and audit records.
- Test fixtures will not contain production tokens, real student personal data, provider secrets, or licensed content not approved for test storage.
## Out of Scope
- Implementing all 18 student screens in the first release.
- Implementing all 34 tenant operations pages in the first release.
- Implementing platform tenant lifecycle, plans, subscriptions, public-bank governance, alerts, dunning, invoices, usage, and platform permissions in the first release.
- Activating payment, refunds, wallet checkout, membership entitlements, coupons, or activation codes in the first release.
- Implementing private paid-resource delivery or protected video playback in the first release.
- Implementing vocabulary study, knowledge handbook, historical scorelines, AI school recommendations, downloadable resources, messages, badges, check-in, tasks, or growth features in the first release.
- Implementing generic course and lesson management. The supplied product is initially modeled as an exam-prep catalog, question-bank, and practice system.
- Replacing the existing Member, System, Pay, Infra, notification, email, SMS, dictionary, role, menu, job, or audit infrastructure.
- Direct frontend integration with Scalar or persistence of Scalar/Supabase credentials in browser storage.
- Dual-writing personal learning data to RuoYi and Scalar.
- Treating the static prototypes CSS, state management, or mock data as production source code.
- Building the production frontend before the complete frontend repository and exact revision are provided.
- Claiming DRM, anti-download, watermarking, or advanced video protection without a separately approved media-security design.
- Introducing a new migration framework as part of the first Education slice. Database scripts will follow an explicit ordered-script process unless a separate migration decision is approved.
- Supporting every database vendor present in the repository in the first release. MySQL is the working assumption pending production confirmation.
- Migrating all Scalar content or decommissioning Scalar in the first release.
- Sending private student profile data to an AI provider.
- Building new email administration APIs unless a later frontend requirement demonstrates that the existing template and account capabilities are insufficient.
## Further Notes
- The currently checked-out frontend directories are incomplete. Implementation must pause at the frontend boundary until the production Vue 3 admin and student Web/H5 sources, branches, and exact commits are available.
- The Scalar share page is usable for discovery, but a machine-readable OpenAPI JSON or YAML export must be frozen before adapter implementation.
- The Scalar contract currently models education mainly as catalog nodes, content entries, question collections, practice blueprints, and questions rather than generic courses and lessons. The domain language in implementation should follow the exam-prep product unless product requirements change.
- Known Scalar uncertainties include management question list/detail reads, platform login, payment return and polling semantics, entitlement-resource relationships, answer autosave idempotency, and asynchronous media/import job states.
- The existing generic file download route is public and tenant-ignored. It must not be reused as the authorization boundary for paid education content.
- The root build currently leaves Member and Pay disabled. Member is required for the first release; Pay should be activated only when the payment slice starts.
- The desired execution order for an implementation agent is: module activation, tenant/auth shell, Scalar catalog adapter and contract tests, question read facade, practice creation, autosave and recovery, atomic submission and report, wrong questions/favorites, then full E2E and visual acceptance.
- Each implementation change set should contain schema, seed data, domain implementation, tests, API documentation, one complete frontend slice, and verified commands. Mock data or an uncalled endpoint must not be reported as complete.
- The issue tracker is the projects self-hosted Gitea instance. This spec should be labeled `ready-for-agent` once published.

View File

@@ -15,7 +15,8 @@
<!-- 各种 module 拓展 --> <!-- 各种 module 拓展 -->
<module>yudao-module-system</module> <module>yudao-module-system</module>
<module>yudao-module-infra</module> <module>yudao-module-infra</module>
<!-- <module>yudao-module-member</module>--> <module>yudao-module-member</module>
<module>yudao-module-education</module>
<!-- <module>yudao-module-bpm</module>--> <!-- <module>yudao-module-bpm</module>-->
<!-- <module>yudao-module-report</module>--> <!-- <module>yudao-module-report</module>-->
<!-- <module>yudao-module-mp</module>--> <!-- <module>yudao-module-mp</module>-->
@@ -32,7 +33,7 @@
</modules> </modules>
<name>恭学教育</name> <name>恭学教育</name>
<description>恭学教育 - 让教育更简单。基于 Spring Boot + MyBatis Plus + Vue & Element 的后台管理系统。</description> <description>恭学教育 - 让教育更简单。基于 Spring Boot + MyBatis Plus + Vue &amp; Element 的后台管理系统。</description>
<url>https://www.gongxue.com</url> <url>https://www.gongxue.com</url>
<properties> <properties>

View File

@@ -0,0 +1,8 @@
-- =============================================
-- Education 模块种子数据回滚
-- =============================================
DELETE FROM `system_menu`
WHERE `id` = 6801 AND `permission` = 'education:capability' AND `parent_id` = 6800;
DELETE FROM `system_menu`
WHERE `id` = 6800 AND `path` = '/education' AND `name` = '教育管理';

View File

@@ -0,0 +1,5 @@
-- =============================================
-- Education 模块 DDL
-- 当前为应用外壳阶段,无业务表;后续票据在此追加 CREATE TABLE 语句。
-- =============================================
-- 占位education 模块当前无业务表

View File

@@ -0,0 +1,15 @@
-- =============================================
-- Education 模块种子数据
-- 菜单 ID 范围6800-6899
-- 权限标识前缀education:
-- 可重复执行;角色授权由管理员按租户完成
-- =============================================
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 6800, '教育管理', '', 1, 50, 0, '/education', 'ep:school', NULL, NULL, 0, b'1', b'1', b'1', 'admin', NOW(), 'admin', NOW(), b'0'
WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `id` = 6800);
INSERT INTO `system_menu` (`id`, `name`, `permission`, `type`, `sort`, `parent_id`, `path`, `icon`, `component`, `component_name`, `status`, `visible`, `keep_alive`, `always_show`, `creator`, `create_time`, `updater`, `update_time`, `deleted`)
SELECT 6801, '能力查询', 'education:capability', 3, 1, 6800, '', '', '', NULL, 0, b'1', b'1', b'1', 'admin', NOW(), 'admin', NOW(), b'0'
WHERE EXISTS (SELECT 1 FROM `system_menu` WHERE `id` = 6800 AND `path` = '/education' AND `deleted` = b'0')
AND NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `id` = 6801);

View File

@@ -0,0 +1 @@
-- Ticket #3 creates no database records, so rollback is intentionally a no-op.

View File

@@ -0,0 +1,3 @@
-- Ticket #3 adds no administrator permission.
-- Tenant resolution is @PermitAll and current education context only requires an authenticated Member session.
-- Therefore no system_menu rows are required for this vertical slice.

View File

@@ -0,0 +1,28 @@
-- =============================================
-- Education 模块 — 练习会话与题目快照回滚
-- Ticket #6 / Migration 002
-- =============================================
--
-- WARNING: This file contains NO executable SQL.
-- Destructive rollback (DROP TABLE) requires manual operator verification.
--
-- Manual rollback procedure (operator must execute):
-- 1. Verify no other tables depend on these tables:
-- SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME
-- FROM information_schema.KEY_COLUMN_USAGE
-- WHERE REFERENCED_TABLE_NAME IN ('education_practice_session', 'education_practice_question')
-- AND TABLE_SCHEMA = DATABASE();
-- Result MUST be empty before proceeding.
--
-- 2. Verify the tables contain only data from this migration:
-- SELECT COUNT(*) AS session_count FROM education_practice_session;
-- SELECT COUNT(*) AS question_count FROM education_practice_question;
-- Operator must confirm these counts are acceptable to destroy.
--
-- 3. After verification, execute:
-- DROP TABLE IF EXISTS education_practice_question;
-- DROP TABLE IF EXISTS education_practice_session;
--
-- DO NOT uncomment or execute the lines below without operator verification.
-- -- DROP TABLE IF EXISTS education_practice_question;
-- -- DROP TABLE IF EXISTS education_practice_session;

View File

@@ -0,0 +1,101 @@
-- =============================================
-- Education 模块 — 练习会话与题目快照 DDL
-- Ticket #6: 练习会话创建、题目快照、恢复与状态机
-- Migration: 002
-- Prerequisites: 000-education-schema.sql (database creation)
-- 001-education-tenant-seed.sql (tenant seed data)
-- =============================================
-- =============================================
-- Preconditions
-- =============================================
-- This migration MUST fail if either table already exists (no IF NOT EXISTS).
-- Operator is expected to verify:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_practice_session', 'education_practice_question');
-- Result MUST be 0 before executing this migration.
-- =============================================
-- 练习会话表
-- =============================================
-- Indexes:
-- uk_tenant_client_session — per-tenant uniqueness for clientSessionId idempotency.
-- Used by: selectByTenantAndClientSessionId (idempotent create check),
-- DuplicateKeyException catch for concurrent-create race resolution.
-- idx_tenant_user_status — covers getCurrentSession (latest ACTIVE by tenant+user)
-- and ownership queries. Column order: (tenant_id, user_id, status) so the
-- index supports both filtering by tenant+user and tenant+user+status.
-- Lock impact: INSERT acquires next-key lock on uk_tenant_client_session unique key;
-- concurrent inserts with same (tenant_id, client_session_id) serialize naturally.
-- No additional table-level locks required.
CREATE TABLE `education_practice_session` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '会话主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT 'Member 用户编号',
`client_session_id` VARCHAR(36) NOT NULL COMMENT '客户端生成的会话标识UUID用于幂等创建',
`status` VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
COMMENT '会话状态ACTIVE-进行中, SUBMITTED-已提交, EXPIRED-已过期, CANCELLED-已取消',
`question_count` INT NOT NULL DEFAULT 0 COMMENT '题目总数',
`collection_id` VARCHAR(64) DEFAULT NULL COMMENT '源题集 ID',
`node_id` VARCHAR(64) DEFAULT NULL COMMENT '源目录节点 ID',
`type` VARCHAR(32) DEFAULT NULL COMMENT '筛选题型',
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '筛选难度',
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_client_session` (`tenant_id`, `client_session_id`),
KEY `idx_tenant_user_status` (`tenant_id`, `user_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习会话';
-- =============================================
-- 练习会话题目快照表
-- =============================================
-- Indexes:
-- uk_session_sequence — per-session uniqueness for question sequence numbers.
-- Used by: insertBatch to ensure no duplicate sequences within a session.
-- idx_session_id — covers selectBySessionIdOrderBySequence (load all questions
-- for a session, ordered by sequence). Also used by cascade delete lookups.
-- Lock impact: INSERT acquires gap locks within session_id range on uk_session_sequence;
-- concurrent inserts into different sessions are independent.
-- Options column: JSON data type stores only label, content, order — never isCorrect.
-- Application layer (optionsToSafeJson) strips correctness before storage.
CREATE TABLE `education_practice_question` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
`sequence` INT NOT NULL COMMENT '题目序号1-based服务端固定',
`question_id` VARCHAR(64) NOT NULL COMMENT '原始题目 ID',
`content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '快照时的题目内容版本',
`stem` TEXT NOT NULL COMMENT '题干快照',
`type` VARCHAR(32) NOT NULL COMMENT '题型快照',
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
`options` JSON NOT NULL COMMENT '选项快照 JSON不含 isCorrect',
`selected_answer` TEXT DEFAULT NULL COMMENT '学生已选答案',
`is_answered` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否已作答',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_sequence` (`session_id`, `sequence`),
KEY `idx_session_id` (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习会话题目快照';
-- =============================================
-- Post-migration verification queries
-- =============================================
-- Verify tables exist with correct structure:
-- SHOW CREATE TABLE education_practice_session;
-- SHOW CREATE TABLE education_practice_question;
-- Verify unique keys are enforced:
-- SHOW INDEX FROM education_practice_session WHERE Key_name = 'uk_tenant_client_session';
-- SHOW INDEX FROM education_practice_question WHERE Key_name = 'uk_session_sequence';
-- Verify no orphan data (should be 0 after fresh migration):
-- SELECT COUNT(*) FROM education_practice_session;
-- SELECT COUNT(*) FROM education_practice_question;

View File

@@ -0,0 +1,30 @@
-- =============================================
-- Education 模块 — 答案保存幂等性回滚
-- Ticket #7 / Migration 003
-- =============================================
--
-- WARNING: This file contains NO executable SQL.
-- Destructive rollback requires manual operator verification.
--
-- Manual rollback procedure (operator must execute):
-- 1. Verify no other tables depend on education_answer_idempotency:
-- SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME
-- FROM information_schema.KEY_COLUMN_USAGE
-- WHERE REFERENCED_TABLE_NAME = 'education_answer_idempotency'
-- AND TABLE_SCHEMA = DATABASE();
-- Result MUST be empty before proceeding.
--
-- 2. Verify the table contains only data from this migration:
-- SELECT COUNT(*) AS idempotency_count FROM education_answer_idempotency;
-- Operator must confirm this count is acceptable to destroy.
--
-- 3. Verify no application code depends on client_sequence column:
-- Search codebase for 'clientSequence' / 'client_sequence' references.
--
-- 4. After verification, execute:
-- DROP TABLE IF EXISTS education_answer_idempotency;
-- ALTER TABLE education_practice_question DROP COLUMN client_sequence;
--
-- DO NOT uncomment or execute the lines below without operator verification.
-- -- DROP TABLE IF EXISTS education_answer_idempotency;
-- -- ALTER TABLE education_practice_question DROP COLUMN client_sequence;

View File

@@ -0,0 +1,97 @@
-- =============================================
-- Education 模块 — 答案保存幂等性 DDL
-- Ticket #7: 答案命令幂等、乐观锁并发控制、答案恢复
-- Migration: 003
-- Prerequisites: 002-education-practice-session.sql (session + question snapshots)
-- =============================================
-- =============================================
-- Preconditions
-- =============================================
-- Operator is expected to verify:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name = 'education_answer_idempotency';
-- Result MUST be 0 before executing this migration.
--
-- Verify prerequisite tables exist:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_practice_session', 'education_practice_question');
-- Result MUST be 2.
-- =============================================
-- 答案命令幂等表
-- =============================================
-- Purpose: Provide durable idempotency for answer save commands.
-- Same (tenant, user, operation, idempotency_key) + same request_hash → replay original response.
-- Same key + different request_hash → conflict.
-- Concurrent same-key inserts are resolved by unique constraint race handling.
--
-- Indexes:
-- uk_answer_idempotency — per-tenant, per-actor, per-operation uniqueness for idempotency key.
-- INSERT during answer save. DuplicateKeyException catch for concurrent-create race resolution.
-- idx_tenant_session — covers lookup by session for audit/debug.
--
-- response_json: Stores the serialized answer response for replay after network timeout/retry.
-- request_hash: SHA-256 of canonical payload (sorted JSON fields) for content-based dedup.
CREATE TABLE `education_answer_idempotency` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '答题用户编号',
`operation` VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_ANSWER'
COMMENT '操作类型SUBMIT_ANSWER',
`idempotency_key` VARCHAR(64) NOT NULL COMMENT '客户端幂等键UUID',
`request_hash` VARCHAR(64) NOT NULL COMMENT '请求载荷 SHA-256 哈希',
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
`question_id` VARCHAR(64) NOT NULL COMMENT '题目 ID',
`selected_answer` TEXT DEFAULT NULL COMMENT '学生已选答案',
`status` VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED'
COMMENT '状态ACCEPTED-已接受, CONFLICT-冲突',
`response_json` TEXT NOT NULL COMMENT '首次成功响应 JSON用于重试重放',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_answer_idempotency` (`tenant_id`, `user_id`, `operation`, `idempotency_key`),
KEY `idx_tenant_session` (`tenant_id`, `session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-答案命令幂等记录';
-- =============================================
-- PracticeQuestionDO: add client_sequence column
-- =============================================
-- Purpose: Track the last accepted client command sequence per question.
-- Rejects stale clientSequence: only sequences strictly greater than the
-- stored value are accepted (monotonic forward progression).
-- NULL means no answer has been accepted yet.
ALTER TABLE `education_practice_question`
ADD COLUMN `client_sequence` INT DEFAULT NULL COMMENT '最后接受的客户端命令序号',
ADD INDEX `idx_client_sequence` (`client_sequence`);
-- =============================================
-- PracticeSessionDO: add last_client_sequence column
-- =============================================
-- Purpose: Session-wide monotonic counter for client commands.
-- Rejects stale clientSequence across questions (not just per-question).
-- CAS incrementVersion now updates this column alongside version.
-- NULL means no answer has been accepted yet for this session.
ALTER TABLE `education_practice_session`
ADD COLUMN `last_client_sequence` INT DEFAULT NULL COMMENT '会话级最后接受的客户端命令序号(跨题目)';
-- =============================================
-- Post-migration verification queries
-- =============================================
-- Verify new table exists:
-- SHOW CREATE TABLE education_answer_idempotency;
-- Verify unique key is enforced:
-- SHOW INDEX FROM education_answer_idempotency WHERE Key_name = 'uk_answer_idempotency';
-- Verify column added to question table:
-- SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE()
-- AND TABLE_NAME = 'education_practice_question'
-- AND COLUMN_NAME = 'client_sequence';
-- Verify no orphan data (should be 0 after fresh migration):
-- SELECT COUNT(*) FROM education_answer_idempotency;

View File

@@ -0,0 +1,42 @@
-- =============================================
-- Education 模块 — Ticket #8 迁移回滚
-- 004-education-submit-report-rollback.sql
-- =============================================
--
-- WARNING: This file contains NO executable SQL.
-- Destructive rollback requires manual operator verification.
--
-- Manual rollback procedure (operator must execute):
-- 1. Verify no other tables depend on these tables:
-- SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME
-- FROM information_schema.KEY_COLUMN_USAGE
-- WHERE REFERENCED_TABLE_NAME IN ('education_submit_idempotency',
-- 'education_practice_report', 'education_practice_report_detail')
-- AND TABLE_SCHEMA = DATABASE();
-- Result MUST be empty before proceeding.
--
-- 2. Verify columns are not referenced by application code:
-- Search codebase for 'correct_answer' / 'explanation' references in
-- education_practice_question to confirm no other consumers.
--
-- 3. Verify the tables contain only data from this migration:
-- SELECT COUNT(*) AS idempotency_count FROM education_submit_idempotency;
-- SELECT COUNT(*) AS report_count FROM education_practice_report;
-- SELECT COUNT(*) AS detail_count FROM education_practice_report_detail;
-- Operator must confirm these counts are acceptable to destroy.
--
-- 4. After verification, execute:
-- DROP TABLE IF EXISTS education_practice_report_detail;
-- DROP TABLE IF EXISTS education_practice_report;
-- DROP TABLE IF EXISTS education_submit_idempotency;
-- ALTER TABLE education_practice_question
-- DROP COLUMN correct_answer,
-- DROP COLUMN explanation;
--
-- DO NOT uncomment or execute the lines below without operator verification.
-- -- DROP TABLE IF EXISTS education_practice_report_detail;
-- -- DROP TABLE IF EXISTS education_practice_report;
-- -- DROP TABLE IF EXISTS education_submit_idempotency;
-- -- ALTER TABLE education_practice_question
-- -- DROP COLUMN correct_answer,
-- -- DROP COLUMN explanation;

View File

@@ -0,0 +1,164 @@
-- =============================================
-- Education 模块 — 交卷提交与成绩报告 DDL
-- Ticket #8: 交卷 CAS、保护性答案快照、评分与报告
-- Migration: 004
-- Prerequisites: 003-education-answer-idempotency.sql
-- =============================================
-- =============================================
-- Preconditions
-- =============================================
-- Operator is expected to verify:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_submit_idempotency',
-- 'education_practice_report',
-- 'education_practice_report_detail');
-- Result MUST be 0 before executing this migration.
--
-- Verify prerequisite tables exist:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_practice_session',
-- 'education_practice_question',
-- 'education_answer_idempotency');
-- Result MUST be 3.
-- =============================================
-- PracticeQuestionDO: add protected answer snapshot columns
-- =============================================
-- Purpose: At session creation, snapshot correct_answer and explanation
-- from the full CatalogQuestionDTO. These fields are NEVER exposed
-- before submission (enforced by SafeQuestionRespVO allow-list and
-- PracticeQuestionRespVO which does not include them).
ALTER TABLE `education_practice_question`
ADD COLUMN `correct_answer` TEXT DEFAULT NULL COMMENT '正确答案快照(不可在交卷前暴露)',
ADD COLUMN `explanation` TEXT DEFAULT NULL COMMENT '解析快照(不可在交卷前暴露)';
-- =============================================
-- 交卷幂等表
-- =============================================
-- Purpose: Provide durable idempotency for submit-session commands.
-- Same (tenant, user, operation, idempotency_key) + same request_hash → replay original report.
-- Same key + different request_hash → conflict.
-- Concurrent same-key inserts resolved by unique constraint race handling.
--
-- Indexes:
-- uk_submit_idempotency — per-tenant, per-actor, per-operation uniqueness for idempotency key.
-- INSERT during submit. DuplicateKeyException catch for concurrent-create race resolution.
-- idx_submit_session — covers lookup by session for audit/debug.
CREATE TABLE `education_submit_idempotency` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '交卷用户编号',
`operation` VARCHAR(32) NOT NULL DEFAULT 'SUBMIT_SESSION'
COMMENT '操作类型SUBMIT_SESSION',
`idempotency_key` VARCHAR(64) NOT NULL COMMENT '客户端幂等键UUID',
`request_hash` VARCHAR(64) NOT NULL COMMENT '请求载荷 SHA-256 哈希',
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
`report_id` BIGINT DEFAULT NULL COMMENT '关联的报告 ID成功时有值',
`status` VARCHAR(20) NOT NULL DEFAULT 'ACCEPTED'
COMMENT '状态ACCEPTED-已接受, CONFLICT-冲突',
`response_json` TEXT NOT NULL COMMENT '首次成功响应 JSON用于重试重放',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_submit_idempotency` (`tenant_id`, `user_id`, `operation`, `idempotency_key`),
KEY `idx_submit_session` (`tenant_id`, `session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-交卷幂等记录';
-- =============================================
-- 练习报告表(会话级)
-- =============================================
-- Purpose: Store the computed scoring result for a submitted session.
-- One report per session. Immutable after creation.
-- Question snapshots (stem, selectedAnswer, correctAnswer, explanation)
-- are stored in report_details so source question edits don't affect history.
--
-- Indexes:
-- uk_report_session — one report per session (unique).
-- idx_report_tenant_user — covers paginated history queries for current tenant+user.
-- idx_report_create_time — covers time-sorted listing.
CREATE TABLE `education_practice_report` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '用户编号',
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
`question_count` INT NOT NULL COMMENT '题目总数',
`answered_count` INT NOT NULL DEFAULT 0 COMMENT '已答题数',
`unanswered_count` INT NOT NULL DEFAULT 0 COMMENT '未答题数',
`correct_count` INT NOT NULL DEFAULT 0 COMMENT '正确题数',
`incorrect_count` INT NOT NULL DEFAULT 0 COMMENT '错误题数',
`score` INT NOT NULL DEFAULT 0 COMMENT '得分(整数,满分 100 为基准)',
`status` VARCHAR(20) NOT NULL DEFAULT 'SUBMITTED'
COMMENT '报告状态SUBMITTED',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_report_session` (`session_id`),
KEY `idx_report_tenant_user` (`tenant_id`, `user_id`),
KEY `idx_report_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习报告';
-- =============================================
-- 练习报告明细表(逐题结果)
-- =============================================
-- Purpose: Store per-question scoring results at submission time.
-- Includes snapshot of stem, selectedAnswer, correctAnswer, and explanation
-- so that history is stable even if source questions are later edited.
--
-- Indexes:
-- uk_report_sequence — per-report uniqueness for question sequence.
-- idx_detail_session — covers lookup by session for report assembly.
CREATE TABLE `education_practice_report_detail` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '用户编号',
`report_id` BIGINT NOT NULL COMMENT '报告 ID',
`session_id` BIGINT NOT NULL COMMENT '会话 ID',
`question_id` VARCHAR(64) NOT NULL COMMENT '原始题目 ID',
`sequence` INT NOT NULL COMMENT '题目序号1-based',
`stem` TEXT NOT NULL COMMENT '题干快照',
`type` VARCHAR(32) NOT NULL COMMENT '题型快照',
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
`selected_answer` TEXT DEFAULT NULL COMMENT '学生已选答案',
`correct_answer` TEXT DEFAULT NULL COMMENT '正确答案快照',
`is_correct` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否正确',
`explanation` TEXT DEFAULT NULL COMMENT '解析快照',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_report_sequence` (`report_id`, `sequence`),
KEY `idx_detail_session` (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-练习报告明细';
-- =============================================
-- Post-migration verification queries
-- =============================================
-- Verify new tables exist:
-- SHOW CREATE TABLE education_submit_idempotency;
-- SHOW CREATE TABLE education_practice_report;
-- SHOW CREATE TABLE education_practice_report_detail;
-- Verify columns added to question table:
-- SELECT COLUMN_NAME, DATA_TYPE, COLUMN_DEFAULT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE()
-- AND TABLE_NAME = 'education_practice_question'
-- AND COLUMN_NAME IN ('correct_answer', 'explanation');
-- Verify unique keys are enforced:
-- SHOW INDEX FROM education_submit_idempotency WHERE Key_name = 'uk_submit_idempotency';
-- SHOW INDEX FROM education_practice_report WHERE Key_name = 'uk_report_session';
-- SHOW INDEX FROM education_practice_report_detail WHERE Key_name = 'uk_report_sequence';
-- Verify no orphan data (should be 0 after fresh migration):
-- SELECT COUNT(*) FROM education_submit_idempotency;
-- SELECT COUNT(*) FROM education_practice_report;
-- SELECT COUNT(*) FROM education_practice_report_detail;

View File

@@ -0,0 +1,25 @@
-- =============================================
-- Education 模块 — 错题本 DDL Rollback
-- Migration: 005
-- =============================================
-- IMPORTANT: This is a documentation-only rollback.
-- No DROP/ALTER/DELETE statements are executed. The wrong_question
-- table is provenance-safe: it only accumulates data and mastering
-- is a status flag. Dropping these tables would lose student error
-- history with no recovery path.
--
-- What this migration created:
-- - education_wrong_question (new table)
-- - education_wrong_question_idempotency (new table)
-- - education_practice_report_detail.options (new column)
-- - education_practice_session.review_fingerprint (new column)
--
-- Manual rollback requires:
-- 1. Verified database backup before rollback
-- 2. Operator approval (DBA sign-off)
-- 3. Provenance of all wrong-question records preserved (exported)
-- 4. Soft-delete via deleted = b'1' before any hard drop
--
-- These tables are NOT deleted by this script. Wrong history is
-- retained; if deletion is required by external policy, consult
-- the DBA for a verified rollback procedure.

View File

@@ -0,0 +1,154 @@
-- =============================================
-- Education 模块 — 错题本 DDL
-- Ticket #9: 错题自动收集、复习练习创建
-- Migration: 005
-- Prerequisites: 004-education-submit-report.sql (report + detail tables)
-- =============================================
-- =============================================
-- Preconditions
-- =============================================
-- Operator is expected to verify:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_wrong_question',
-- 'education_wrong_question_idempotency');
-- Result MUST be 0 before executing this migration.
--
-- Verify prerequisite tables exist:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_practice_report',
-- 'education_practice_report_detail');
-- Result MUST be 2.
-- =============================================
-- PracticeReportDetailDO: add options snapshot column
-- =============================================
-- PracticeReportDetailDO: add content_version + options snapshot columns
-- =============================================
-- Purpose: At submit time, snapshot question content version and options
-- (without isCorrect) so wrong-question book and review sessions have
-- stable display data. The options are already stripped of isCorrect
-- by the submit flow.
ALTER TABLE `education_practice_report_detail`
ADD COLUMN `content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '题目内容版本快照',
ADD COLUMN `options` TEXT DEFAULT NULL COMMENT '选项快照 JSON不含 isCorrect';
-- =============================================
-- PracticeSessionDO: add review fingerprint column
-- =============================================
-- Purpose: Persist the canonical fingerprint of wrong-question IDs used
-- to create a review session. On idempotent replay, the fingerprint
-- is compared: same tenant+clientSessionId+sameUser+sortedIDs match
-- returns the existing session; different ID set returns
-- SESSION_IDEMPOTENCY_MISMATCH.
ALTER TABLE `education_practice_session`
ADD COLUMN `review_fingerprint` VARCHAR(64) DEFAULT NULL COMMENT '复习会话题目指纹SHA-256 of sorted unique wrongQuestionIds';
-- 错题表
-- =============================================
-- Purpose: Persistent wrong-question book per student.
-- Each (tenant, user, question) is a unique entry.
-- Repeated wrong answers on the SAME question increment wrong_count
-- and update last_wrong_time. The idempotency guard table ensures
-- each (tenant, user, question, report) can upsert at most once.
--
-- master_status values: 'PENDING' (default) | 'MASTERED'
-- Marking mastered retains the full history and count; it does NOT
-- delete or archive the record. Students can optionally un-master.
--
-- Snapshot fields (stem, type, difficulty, options, content_version):
-- populated from the latest report detail that touched this question.
-- These are for listing/detail display without joining report details.
--
-- latest_correct_answer, latest_explanation:
-- also from the latest report detail; available for detail display
-- post-submit (not exposed in review session creation pre-submit).
--
-- Indexes:
-- uk_tenant_user_question — per-tenant, per-user, per-question uniqueness.
-- INSERT ... ON DUPLICATE KEY UPDATE is the primary write path.
-- idx_tenant_user_status — covers filtered list queries (page with status filter).
-- idx_tenant_user_last_wrong — covers time-sorted listing.
CREATE TABLE `education_wrong_question` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '学生用户编号',
`question_id` VARCHAR(64) NOT NULL COMMENT '原始题目 ID',
-- snapshot fields for listing / detail (from latest report detail)
`stem` TEXT NOT NULL COMMENT '题干快照(最新)',
`type` VARCHAR(32) NOT NULL COMMENT '题型快照',
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
`options` JSON NOT NULL COMMENT '选项快照 JSON不含 isCorrect',
`content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '题目内容版本',
`latest_correct_answer` TEXT DEFAULT NULL COMMENT '正确答案快照(最新,供详情展示)',
`latest_explanation` TEXT DEFAULT NULL COMMENT '解析快照(最新,供详情展示)',
-- timing & count
`first_wrong_time` DATETIME NOT NULL COMMENT '首次错误时间',
`last_wrong_time` DATETIME NOT NULL COMMENT '最近错误时间',
`wrong_count` INT NOT NULL DEFAULT 1 COMMENT '累计错误次数',
-- mastery
`master_status` VARCHAR(20) NOT NULL DEFAULT 'PENDING'
COMMENT '掌握状态PENDING-待掌握, MASTERED-已掌握',
`mastered_time` DATETIME DEFAULT NULL COMMENT '标记掌握时间',
-- provenance
`last_report_id` BIGINT DEFAULT NULL COMMENT '最近关联的报告 ID',
`last_session_id` BIGINT DEFAULT NULL COMMENT '最近关联的会话 ID',
-- audit
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_user_question` (`tenant_id`, `user_id`, `question_id`),
KEY `idx_tenant_user_status` (`tenant_id`, `user_id`, `master_status`),
KEY `idx_tenant_user_last_wrong` (`tenant_id`, `user_id`, `last_wrong_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-错题本';
-- =============================================
-- 错题流水幂等表
-- =============================================
-- Purpose: Ensure each (tenant, user, question, report) upserts the
-- wrong-question book exactly once. The submitSession transaction
-- INSERT IGNOREs into this table BEFORE the wrong question upsert;
-- a duplicate means this report already contributed to the count.
-- This guards against:
-- - Replayed submit (idempotent resubmit) double-counting
-- - Concurrent submit races where both threads evaluate the
-- same report details
--
-- Indexes:
-- uk_tenant_user_question_report — per (tenant, user, question, report) uniqueness.
-- INSERT IGNORE provides the idempotency guard BEFORE upserting.
-- wrong_question_id is filled after upsert for audit purposes.
-- idx_report — fast lookup by report for audit/debug.
CREATE TABLE `education_wrong_question_idempotency` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '学生用户编号',
`wrong_question_id` BIGINT DEFAULT NULL COMMENT '错题记录 IDupsert 后填充)',
`report_id` BIGINT NOT NULL COMMENT '报告 ID',
`question_id` VARCHAR(64) NOT NULL COMMENT '题目 ID',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_user_question_report` (`tenant_id`, `user_id`, `question_id`, `report_id`),
KEY `idx_report` (`report_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-错题流水幂等';
-- =============================================
-- Post-migration verification queries
-- =============================================
-- Verify new tables exist:
-- SHOW CREATE TABLE education_wrong_question;
-- SHOW CREATE TABLE education_wrong_question_idempotency;
-- Verify unique keys are enforced:
-- SHOW INDEX FROM education_wrong_question WHERE Key_name = 'uk_tenant_user_question';
-- SHOW INDEX FROM education_wrong_question_idempotency WHERE Key_name = 'uk_tenant_user_question_report';
-- Verify no orphan data (should be 0 after fresh migration):
-- SELECT COUNT(*) FROM education_wrong_question;
-- SELECT COUNT(*) FROM education_wrong_question_idempotency;

View File

@@ -0,0 +1,22 @@
-- =============================================
-- Education 模块 — 收藏夹 DDL Rollback
-- Migration: 007
-- =============================================
-- IMPORTANT: This is a documentation-only rollback.
-- No DROP/ALTER/DELETE statements are executed. The favorite
-- table is provenance-safe: it only accumulates user preference
-- data. Dropping this table would lose student favorites with
-- no recovery path.
--
-- What this migration created:
-- - education_favorite (new table)
--
-- Manual rollback requires:
-- 1. Verified database backup before rollback
-- 2. Operator approval (DBA sign-off)
-- 3. Provenance of all favorite records preserved (exported)
-- 4. Soft-delete via deleted = b'1' before any hard drop
--
-- These tables are NOT deleted by this script. Favorite history is
-- retained; if deletion is required by external policy, consult
-- the DBA for a verified rollback procedure.

View File

@@ -0,0 +1,77 @@
-- =============================================
-- Education 模块 — 收藏夹 DDL
-- Ticket #10: 学生收藏题目
-- Migration: 007
-- Prerequisites: 000-education-schema.sql (base tables)
-- =============================================
-- =============================================
-- Preconditions
-- =============================================
-- Operator is expected to verify:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name = 'education_favorite';
-- Result MUST be 0 before executing this migration.
-- =============================================
-- 收藏表
-- =============================================
-- Purpose: Student favorites for questions with safe snapshots.
-- Each (tenant, user, target_type, target_id) is a unique entry.
-- Logical deletion: setting deleted=1 marks as unfavorited.
-- Re-adding after deletion reactivates the row via ON DUPLICATE KEY UPDATE.
--
-- target_type values: 'QUESTION' (extensible enum)
--
-- Snapshot fields (stem, type, difficulty, options, content_version):
-- populated at creation time from the visible question's safe fields.
-- These snapshots preserve the question state as it appeared when favorited,
-- and remain stable even if the source question later changes or becomes unavailable.
--
-- available flag:
-- FALSE when the source question becomes hidden/unpublished after being
-- favorited. Existing favorites with available=FALSE remain listable but
-- display an "unavailable" indicator. New favorites cannot be created for
-- unavailable resources.
--
-- Indexes:
-- uk_tenant_user_target — per (tenant, user, target_type, target_id) uniqueness.
-- INSERT ... ON DUPLICATE KEY UPDATE is the primary reactivation path.
-- idx_tenant_user — covers listing queries filtered by current tenant+user.
-- idx_tenant_user_target_type — covers target-type-filtered listing.
CREATE TABLE `education_favorite` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '学生用户编号',
`target_type` VARCHAR(32) NOT NULL COMMENT '目标类型QUESTION',
`target_id` VARCHAR(64) NOT NULL COMMENT '目标 ID题目 ID',
-- safe snapshot fields
`stem` TEXT DEFAULT NULL COMMENT '题干快照',
`type` VARCHAR(32) DEFAULT NULL COMMENT '题型快照',
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
`options` JSON DEFAULT NULL COMMENT '选项快照 JSON不含 isCorrect',
`content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '题目内容版本',
-- availability
`available` BIT(1) NOT NULL DEFAULT b'1' COMMENT '源资源是否可用',
-- audit
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_user_target` (`tenant_id`, `user_id`, `target_type`, `target_id`),
KEY `idx_tenant_user` (`tenant_id`, `user_id`),
KEY `idx_tenant_user_target_type` (`tenant_id`, `user_id`, `target_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-收藏夹';
-- =============================================
-- Post-migration verification queries
-- =============================================
-- Verify new table exists:
-- SHOW CREATE TABLE education_favorite;
-- Verify unique key is enforced:
-- SHOW INDEX FROM education_favorite WHERE Key_name = 'uk_tenant_user_target';
-- Verify no orphan data (should be 0 after fresh migration):
-- SELECT COUNT(*) FROM education_favorite;

View File

@@ -0,0 +1,5 @@
artifacts/
*.har
*.log
.DS_Store
node_modules/

View File

@@ -0,0 +1,22 @@
# Browser acceptance harness
This directory contains a local-only student client and an acceptance suite. It has no lockfile or vendored browser binaries: do not install from the network during normal repository checks.
## Commands
From this directory:
```bash
npm run smoke # dependency-free route smoke test
npm run contract # dependency-free HTTP and adapter tests
npm run browser:if-available # runs Playwright only when it is already resolvable
npm run browser # explicit Playwright command, requires an existing install
```
The browser suite starts `server.js` itself, uses Chromium headlessly, and writes screenshots/traces to `artifacts/` (gitignored). It is intentionally not reported as passing when Playwright or its browser binary is unavailable.
To run against a real application instead of the deterministic local server, set `BASE_URL`; the server is then not started and the supplied token must be accepted by that application.
Required coverage includes desktop and H5 viewport core loops, timeout-after-commit with same-key retry, reload/current recovery, submit/report/wrong/favorite, logout, tenant/student isolation, and a request guard installed before navigation. The guard aborts every non-loopback request and any URL containing Scalar, Supabase, or provider-token patterns.
The dependency-free smoke route uses only Node built-ins and starts the local harness on loopback. It is the minimum check for environments without Playwright.

View File

@@ -0,0 +1,143 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const token = (name) => name;
const LOOPBACK = /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?(?:\/|$)/i;
const FORBIDDEN = /(scalar|supabase|(?:sk|pk|anon|service)[_-]?key|api[_-]?key|access[_-]?token|provider[_-]?token|anthropic|openai|gemini|deepseek)/i;
function installRequestGuard(page) {
const blocked = [];
const allowedViolations = [];
page.route('**/*', async (route) => {
const url = route.request().url();
if (!LOOPBACK.test(url) || FORBIDDEN.test(url)) {
blocked.push(`${route.request().method()} ${url}`);
await route.abort('blockedbyclient');
return;
}
await route.continue();
});
return (expectedBlocked = 0) => {
expect(allowedViolations, `unexpected request guard violations: ${allowedViolations.join(', ')}`).toEqual([]);
expect(blocked.length, `expected ${expectedBlocked} blocked requests, saw ${blocked.length}`).toBe(expectedBlocked);
};
}
async function connect(page, student) {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.getByLabel('Local access token').fill(token(student));
await page.getByTestId('connect').click();
await expect(page.getByTestId('status')).toContainText(/Catalog ready|Session recovered|No active session/);
await expect(page.getByTestId('identity-chip')).toContainText(student.includes('tenant-a') ? 'Student A1' : 'Student B1');
}
async function start(page) {
await page.getByRole('button', { name: 'Start practice' }).click();
await expect(page.getByTestId('practice')).toContainText('Q1');
}
test.describe('education student core loop', () => {
test('request guard aborts external and provider-token URLs', async ({ page }) => {
const checkGuard = installRequestGuard(page);
await page.goto('/');
await page.waitForLoadState('networkidle');
const blocked = await page.evaluate(async () => {
const urls = ['https://example.invalid/scalar', 'https://provider.invalid/api?access_token=redacted'];
return Promise.all(urls.map(async (url) => {
try { await fetch(url); return false; } catch (_) { return true; }
}));
});
checkGuard(2);
});
test('desktop recovery, submit, wrong questions, and favorites', async ({ page }) => {
const checkGuard = installRequestGuard(page);
await connect(page, 'tenant-a-student-1');
await start(page);
const requests = [];
page.on('request', (request) => {
if (request.url().includes('/practice-session/answer')) requests.push(request);
});
await page.getByLabel('Database').check();
await expect(page.getByTestId('practice')).toContainText(/Saved|Ready/);
expect(requests.length).toBeGreaterThan(0);
await page.waitForLoadState('networkidle');
await page.getByLabel('Local access token').fill('tenant-a-student-1');
await page.getByTestId('connect').click();
await expect(page.getByTestId('status')).toContainText(/Session recovered|Catalog ready/);
await expect(page.getByTestId('practice')).toContainText('Database');
await page.getByLabel('Random delay').check();
await page.getByLabel('Version check').check();
await page.getByRole('button', { name: 'Submit practice' }).click();
await expect(page.getByTestId('status')).toContainText(/Submitted|Wrong questions loaded/);
await page.getByTestId('load-wrong').click();
await expect(page.getByTestId('wrong')).toBeVisible();
const favorite = await page.evaluate(async () => (await fetch('/app-api/education/favorite/create', { method: 'POST', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ targetType: 'QUESTION', targetId: 'q-a-1' }) })).json());
expect(favorite.code).toBe(0);
await page.getByTestId('load-favorites').click();
await expect(page.getByTestId('favorites-list')).toBeVisible();
await expect(page.getByTestId('favorites-list')).not.toContainText('No favorites yet.');
await page.screenshot({ path: 'artifacts/desktop-core-loop.png', fullPage: true });
checkGuard();
});
test('H5 viewport core loop', async ({ page }) => {
const checkGuard = installRequestGuard(page);
await page.setViewportSize({ width: 390, height: 844 });
await connect(page, 'tenant-a-student-1');
await start(page);
await page.getByLabel('Controller').check();
await expect(page.getByTestId('practice')).toContainText(/Saved|Ready/);
await page.screenshot({ path: 'artifacts/h5-core-loop.png', fullPage: true });
checkGuard();
});
test('timeout after commit retries with the same idempotency key', async ({ page }) => {
const checkGuard = installRequestGuard(page);
await connect(page, 'tenant-a-student-1');
const result = await page.evaluate(async () => {
const create = await fetch('/app-api/education/practice-session/create', {
method: 'POST', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' },
body: JSON.stringify({ clientSessionId: `browser-timeout-${crypto.randomUUID()}`, collectionId: 'col-a-core', questionCount: 1 }),
});
const session = (await create.json()).data;
const body = JSON.stringify({ sessionId: session.id, questionSequence: 1, selectedAnswer: 'A', idempotencyKey: 'same-key', clientSequence: 1, expectedSessionVersion: 0 });
const first = await fetch('/app-api/education/practice-session/answer?fault=answer-timeout-after-commit', { method: 'PUT', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body });
const retry = await fetch('/app-api/education/practice-session/answer', { method: 'PUT', headers: { Authorization: 'Bearer tenant-a-student-1', 'Content-Type': 'application/json' }, body });
return { first: first.status, retry: retry.status, retryBody: await retry.json() };
});
expect(result.first).toBe(504);
expect(result.retry).toBe(200);
expect(result.retryBody.data.selectedAnswer).toBe('A');
checkGuard();
});
test('logout clears the in-memory student session', async ({ page }) => {
const checkGuard = installRequestGuard(page);
await connect(page, 'tenant-a-student-1');
await page.getByTestId('logout').click();
await expect(page.getByTestId('identity-chip')).toHaveText('Offline');
await expect(page.getByTestId('status')).toHaveText('Logged out');
checkGuard();
});
test('two tenants and two students cannot see each other resources', async ({ page, request }) => {
const checkGuard = installRequestGuard(page);
const a = await request.get('/app-api/education/context', { headers: { Authorization: 'Bearer tenant-a-student-1' } });
const b = await request.get('/app-api/education/context', { headers: { Authorization: 'Bearer tenant-b-student-1' } });
expect((await a.json()).data.userId).toBe('student-a1');
expect((await b.json()).data.userId).toBe('student-b1');
const create = await request.post('/app-api/education/practice-session/create', { headers: { Authorization: 'Bearer tenant-a-student-1' }, data: { clientSessionId: 'isolation', collectionId: 'col-a-core', questionCount: 1 } });
const session = (await create.json()).data;
const stolen = await request.get(`/app-api/education/practice-session/get?id=${session.id}`, { headers: { Authorization: 'Bearer tenant-a-student-2' } });
expect(stolen.status()).toBe(404);
const otherTenant = await request.get('/app-api/education/questions/page?collectionId=col-a-core', { headers: { Authorization: 'Bearer tenant-b-student-1' } });
expect((await otherTenant.json()).data.list).toHaveLength(0);
checkGuard();
});
});

View File

@@ -0,0 +1,34 @@
'use strict';
const API_PREFIX = '/app-api';
function buildRequest(path, options = {}, accessToken = '') {
const headers = {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
};
return { url: `${API_PREFIX}${path}`, options: { ...options, headers } };
}
function answerCommand(session, questionSequence, selectedAnswer, idempotencyKey, clientSequence) {
return {
sessionId: session.id,
questionSequence,
selectedAnswer,
idempotencyKey,
clientSequence,
expectedSessionVersion: session.sessionVersion,
};
}
function applyAnswerResult(session, result) {
return {
...session,
sessionVersion: result.sessionVersion,
serverVersion: result.serverVersion ?? result.sessionVersion,
acceptedSequence: Math.max(session.acceptedSequence || 0, result.acceptedSequence || 0),
};
}
module.exports = { buildRequest, answerCommand, applyAnswerResult };

View File

@@ -0,0 +1,19 @@
'use strict';
const assert = require('assert');
const { buildRequest, answerCommand, applyAnswerResult } = require('./adapter');
const request = buildRequest('/education/context', { method: 'GET' }, 'memory-token');
assert.equal(request.url, '/app-api/education/context');
assert.equal(request.options.headers.Authorization, 'Bearer memory-token');
assert.equal(request.options.headers.Accept, 'application/json');
const session = { id: 's-1', sessionVersion: 4, acceptedSequence: 2 };
const command = answerCommand(session, 3, 'B', 'answer-key-1', 3);
assert.deepEqual(command, { sessionId: 's-1', questionSequence: 3, selectedAnswer: 'B', idempotencyKey: 'answer-key-1', clientSequence: 3, expectedSessionVersion: 4 });
const advanced = applyAnswerResult(session, { sessionVersion: 5, acceptedSequence: 3 });
assert.equal(advanced.sessionVersion, 5);
assert.equal(advanced.acceptedSequence, 3);
assert.equal(applyAnswerResult(advanced, { sessionVersion: 6, acceptedSequence: 2 }).acceptedSequence, 3);
process.stdout.write('education student adapter unit tests passed\n');

View File

@@ -0,0 +1,45 @@
const API_PREFIX = '/app-api';
let accessToken = '';
let tenant = null;
let currentSession = null;
let clientSequence = 0;
let expectedSessionVersion = 0;
let saveState = 'idle';
const $ = (id) => document.getElementById(id);
const tokenFor = () => accessToken;
export function buildRequest(path, options = {}, token = accessToken) {
const headers = { Accept: 'application/json', ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) };
return { url: `${API_PREFIX}${path}`, options: { ...options, headers } };
}
export function nextAnswerCommand(session, questionSequence, selectedAnswer, key = crypto.randomUUID()) {
return { sessionId: session.id, questionSequence, selectedAnswer, idempotencyKey: key, clientSequence: (session.acceptedSequence || 0) + 1, expectedSessionVersion: session.sessionVersion ?? session.serverVersion ?? 0 };
}
export function applyAnswerState(session, result) { return { ...session, sessionVersion: result.sessionVersion, serverVersion: result.serverVersion ?? result.sessionVersion, acceptedSequence: Math.max(session.acceptedSequence || 0, result.acceptedSequence || 0) }; }
function setStatus(text, tone = 'neutral') { $('status').textContent = text; $('status').dataset.tone = tone; $('connection-dot').dataset.tone = tone; }
function setSaveState(state, text) { saveState = state; const node = $('save-state'); if (node) { node.textContent = text; node.dataset.state = state; } }
function requestId(response) { const id = response.headers.get('x-request-id') || response.headers.get('x-trace-id'); if (id) $('request-id').textContent = `req ${id}`; }
function query(params = {}) { const value = new URLSearchParams(); Object.entries(params).forEach(([key, item]) => { if (item !== undefined && item !== null && item !== '') value.set(key, item); }); const result = value.toString(); return result ? `?${result}` : ''; }
async function api(path, options = {}) { const request = buildRequest(path, options); const response = await fetch(request.url, request.options); requestId(response); const payload = await response.json().catch(() => ({})); if (!response.ok || (payload.code !== undefined && payload.code !== 0)) { const error = new Error(payload.msg || `Request failed (${response.status})`); error.status = response.status; error.data = payload.data; throw error; } return payload.data; }
function escapeHtml(value) { return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c])); }
function button(label, handler, className = 'button button-outline') { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.className = className; b.addEventListener('click', handler); return b; }
function renderList(target, list, emptyText, render) { const node = $(target); node.innerHTML = ''; if (!list?.length) { node.innerHTML = `<p class="empty-state">${escapeHtml(emptyText)}</p>`; return; } list.forEach((item) => node.appendChild(render(item))); }
function item(title, detail, action) { const node = document.createElement('article'); node.className = 'list-item'; node.innerHTML = `<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(detail || '')}</span></div>`; if (action) node.append(action); return node; }
function renderContext(data) { tenant = data; $('identity-chip').textContent = `${data.tenantName || data.tenantId} · ${data.displayName || data.userId}`; $('context').innerHTML = `<div><dt>Tenant</dt><dd>${escapeHtml(data.tenantName || data.tenantId)}</dd></div><div><dt>Student</dt><dd>${escapeHtml(data.displayName || data.userId)}</dd></div>`; }
async function resolveTenant() { return api('/education/tenant/resolve'); }
async function connect(event) { event?.preventDefault(); accessToken = $('token').value.trim(); if (!accessToken) { const mobile = $('mobile').value.trim(); const password = $('password').value; if (!mobile || !password) { setStatus('Enter member credentials or a local token', 'bad'); return; } try { setStatus('Logging in…'); const login = await api('/member/auth/login', { method: 'POST', body: JSON.stringify({ mobile, password }) }, ''); accessToken = login?.accessToken || login?.token || ''; } catch (error) { setStatus(error.message, 'bad'); return; } } try { setStatus('Resolving tenant…'); await resolveTenant(); const context = await api('/education/context'); renderContext(context); setStatus('Connected', 'good'); await loadCatalog(); await loadCurrent(); } catch (error) { setStatus(error.message, 'bad'); } }
async function loginWithCredentials() { return null; }
async function logout() { try { if (accessToken) await api('/member/auth/logout', { method: 'POST' }); } catch (_) { /* local memory is still cleared */ } accessToken = ''; tenant = null; currentSession = null; $('identity-chip').textContent = 'Offline'; $('context').innerHTML = '<div><dt>Tenant</dt><dd>Not resolved</dd></div><div><dt>Student</dt><dd>Not authenticated</dd></div>'; renderPractice(); setStatus('Logged out', 'neutral'); }
async function loadCatalog() { try { setStatus('Loading catalog…'); const [collections, subjects] = await Promise.all([api('/education/catalog/question-collections?limit=20'), api('/education/catalog/subjects')]); const select = $('subject-filter'); select.innerHTML = '<option value="">All subjects</option>' + (subjects || []).map((x) => `<option value="${escapeHtml(x.id)}">${escapeHtml(x.name || x.title || x.id)}</option>`).join(''); renderList('collections', collections, 'No permitted collections returned.', collectionCard); setStatus('Catalog ready', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
function collectionCard(collection) { const article = document.createElement('article'); article.className = 'collection-card'; article.innerHTML = `<div class="collection-index">SET</div><h3>${escapeHtml(collection.name || collection.title || collection.id)}</h3><p>${escapeHtml(collection.description || 'A focused set for your next study pass.')}</p><div class="collection-meta"><span>${collection.questionCount ?? '?'} questions</span><span>${escapeHtml(collection.status || 'available')}</span></div>`; article.append(button('Start practice', () => createPractice(collection), 'button button-dark')); return article; }
async function createPractice(collection) { try { setStatus('Creating practice…'); currentSession = await api('/education/practice-session/create', { method: 'POST', body: JSON.stringify({ clientSessionId: crypto.randomUUID(), collectionId: collection.id, questionCount: Math.min(collection.questionCount || 5, 5) }) }); clientSequence = currentSession.acceptedSequence || 0; expectedSessionVersion = currentSession.sessionVersion || 0; renderPractice(); setStatus('Practice active', 'good'); location.hash = 'practice'; } catch (error) { setStatus(error.message, 'bad'); } }
async function loadCurrent() { try { setStatus('Checking your session…'); currentSession = await api('/education/practice-session/current'); if (currentSession) { clientSequence = currentSession.acceptedSequence || 0; expectedSessionVersion = currentSession.sessionVersion || 0; } renderPractice(); setStatus(currentSession ? 'Session recovered' : 'No active session', currentSession ? 'good' : 'neutral'); } catch (error) { setStatus(error.message, 'bad'); } }
function renderPractice() { const host = $('practice'); host.innerHTML = ''; $('state-readout').textContent = currentSession ? `${currentSession.status} · v${currentSession.sessionVersion ?? 0}` : 'No session'; if (!currentSession) { host.innerHTML = '<p class="empty-state">No active session. Start one above.</p>'; return; } const heading = document.createElement('div'); heading.className = 'practice-head'; heading.innerHTML = `<div><span class="session-badge">${escapeHtml(currentSession.status)}</span><strong>${currentSession.questionCount || currentSession.questions?.length || 0} questions</strong></div><span id="save-state" class="save-state" data-state="idle">Ready</span>`; host.append(heading); (currentSession.questions || []).forEach((question) => { const field = document.createElement('fieldset'); field.className = 'question'; field.innerHTML = `<legend><span>Q${question.sequence}</span>${escapeHtml(question.stem || question.questionId)}</legend><div class="options">${(question.options || []).map((option, index) => { const value = String.fromCharCode(65 + index); return `<label class="option"><input type="radio" name="q-${question.sequence}" value="${value}" ${question.selectedAnswer === value ? 'checked' : ''}><span><b>${value}</b>${escapeHtml(option)}</span></label>`; }).join('')}</div>`; field.querySelectorAll('input').forEach((input) => input.addEventListener('change', () => saveAnswer(question, input.value))); host.append(field); }); if (currentSession.status === 'ACTIVE') { const actions = document.createElement('div'); actions.className = 'practice-actions'; actions.append(button('Submit practice', submitPractice, 'button button-dark')); host.append(actions); } }
async function saveAnswer(question, answer) { const command = nextAnswerCommand({ ...currentSession, acceptedSequence: clientSequence, sessionVersion: expectedSessionVersion }, question.sequence, answer, question.pendingKey || crypto.randomUUID()); question.pendingKey = command.idempotencyKey; setSaveState('saving', 'Saving…'); try { const result = await api('/education/practice-session/answer', { method: 'PUT', body: JSON.stringify(command) }); currentSession = applyAnswerState(currentSession, result); clientSequence = currentSession.acceptedSequence; expectedSessionVersion = currentSession.sessionVersion; question.selectedAnswer = answer; setSaveState('saved', 'Saved'); $('state-readout').textContent = `${currentSession.status} · v${expectedSessionVersion}`; } catch (error) { if (error.status === 504 || error.status >= 500) { setSaveState('retrying', 'Retrying…'); try { const result = await api('/education/practice-session/answer', { method: 'PUT', body: JSON.stringify(command) }); currentSession = applyAnswerState(currentSession, result); clientSequence = currentSession.acceptedSequence; expectedSessionVersion = currentSession.sessionVersion; question.selectedAnswer = answer; setSaveState('saved', 'Saved after retry'); return; } catch (_) {} } setSaveState('failed', 'Save failed — retry by changing this answer'); setStatus(error.message, 'bad'); } }
async function submitPractice() { if (!currentSession) return; try { setStatus('Submitting…'); const result = await api('/education/practice-session/submit', { method: 'POST', body: JSON.stringify({ sessionId: currentSession.id, idempotencyKey: crypto.randomUUID(), expectedSessionVersion }) }); currentSession.status = 'SUBMITTED'; currentSession.sessionVersion = result.sessionVersion || expectedSessionVersion + 1; expectedSessionVersion = currentSession.sessionVersion; renderPractice(); setStatus(`Submitted · ${result.score ?? '—'} correct`, 'good'); await loadWrong(); } catch (error) { setStatus(error.message, 'bad'); } }
async function loadWrong() { try { const data = await api('/education/wrong-question/page?pageNo=1&pageSize=20'); renderList('wrong', data?.list, 'No wrong questions yet.', (x) => item(x.questionStem || x.stem || x.questionId || x.id, `${x.errorCount ?? 0} ${x.errorCount === 1 ? 'miss' : 'misses'} · ${x.masterStatus || 'unmastered'}`, x.masterStatus !== 'MASTERED' ? button('Mark mastered', () => masterWrong(x), 'button button-small') : null)); setStatus('Wrong questions loaded', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
async function masterWrong(wrong) { try { await api('/education/wrong-question/master', { method: 'PUT', body: JSON.stringify({ id: wrong.id }) }); await loadWrong(); } catch (error) { setStatus(error.message, 'bad'); } }
async function loadFavorites() { try { const data = await api('/education/favorite/page?pageNo=1&pageSize=20'); renderList('favorites-list', data?.list, 'No favorites yet.', (x) => item(x.questionStem || x.stem || x.targetId, x.targetType || 'QUESTION', button('Remove', () => removeFavorite(x), 'button button-small'))); setStatus('Favorites loaded', 'good'); } catch (error) { setStatus(error.message, 'bad'); } }
async function removeFavorite(favorite) { try { await api('/education/favorite/delete', { method: 'DELETE', body: JSON.stringify({ id: favorite.id, targetId: favorite.targetId }) }); await loadFavorites(); } catch (error) { setStatus(error.message, 'bad'); } }
$('auth-form').addEventListener('submit', connect); $('logout').addEventListener('click', logout); $('load-catalog').addEventListener('click', loadCatalog); $('load-current').addEventListener('click', loadCurrent); $('load-wrong').addEventListener('click', loadWrong); $('load-favorites').addEventListener('click', loadFavorites);

View File

@@ -0,0 +1,26 @@
# Education student harness verification
Date: 2026-07-28
## Results
- PASS — `npm run smoke` (dependency-free loopback route smoke test).
- PASS — `npm run contract` (dependency-free HTTP/adapter tests).
- PASS — Node syntax checks for all harness JavaScript, including `acceptance.spec.js`.
- PASS — `npm run browser:if-available`; Playwright Chromium was installed locally and all six acceptance tests passed.
- PASS — `git diff --check` for harness and workflow documentation paths.
## Security boundary review
- PASS — harness server binds to `127.0.0.1`; browser guard blocks non-loopback URLs and Scalar/provider-token patterns.
- PASS — no downloaded code, vendored binaries, copied prototype assets/classes, or external runtime requests found.
- PASS — no Scalar URL/token or provider secret found; screenshots/logs/trace artifacts are gitignored.
- PASS — identity and tenant are derived from bearer-token server context; resource ownership checks cover tenant and student.
- PASS — pre-submit question responses omit answer and explanation; submitted reports expose them only after submission.
- PASS — production backend files were not changed by this harness workflow (existing unrelated production changes remain outside this review scope).
## Remaining limitations
- Local deterministic harness browser acceptance is complete; it is not a substitute for the real Student Web/H5 application.
- Real Student Web/H5 lint, type checking, tests, production build, and browser E2E remain blocked because those sources are not in this workspace.
- Real Scalar read-only smoke, Pilot deployment configuration, production database migration, rollback, and trace-to-upstream observability evidence require a deployment environment and approved credentials.

View File

@@ -0,0 +1,36 @@
# Endpoint matrix
All paths below are browser-relative `/app-api` routes. The server derives authenticated user and tenant context; the harness never sends those as business fields.
| Capability | Method | Relative route | Request/query used by harness | Expected data shape | Notes |
|---|---|---|---|---|---|
| Tenant resolution | GET | `/education/tenant/resolve` | deployment-specific resolver query; not called automatically | tenant resolution object | Use server entry-point/domain policy; do not accept a client tenant override. |
| Education context | GET | `/education/context` | none | `{ userId, tenantId, tenantName, displayName }` | Authenticated; verifies active tenant. |
| Regions | GET | `/education/catalog/regions` | none | array of region objects | Catalog read gate applies. |
| Categories | GET | `/education/catalog/categories` | `subjectId`, optional `nodeId` | array | Catalog read gate applies. |
| Subjects | GET | `/education/catalog/subjects` | optional `regionId`, `schoolId`, `majorId`, `moduleId`, `type` | array | Catalog read gate applies. |
| Question collections | GET | `/education/catalog/question-collections` | optional `regionId`, `entryId`, `nodeId`, `collectionType`, `limit` | array of collections | Harness uses this as the practice start list. |
| Safe question page | GET | `/education/questions/page` | `collectionId`, `pageNo`, `pageSize` | page result `{ list, total }` | Must not include answers or explanations. |
| Practice preview | GET | `/education/practice-config/preview` | request VO query fields | preview object | Validates criteria without creating a session. |
| Create practice | POST | `/education/practice-session/create` | `{ clientSessionId, collectionId, nodeId?, type?, difficulty?, questionCount }` | practice session | Idempotent by client session ID. |
| Current practice | GET | `/education/practice-session/current` | none | session or `null` | Used for refresh recovery. |
| Practice by ID | GET | `/education/practice-session/get` | `id` | session | Ownership and tenant checks are server-side. |
| Save answer | PUT | `/education/practice-session/answer` | `{ sessionId, questionSequence, selectedAnswer, idempotencyKey, clientSequence, expectedSessionVersion }` | answer save result with version | Idempotent and stale-write resistant. |
| Submit practice | POST | `/education/practice-session/submit` | `{ sessionId, idempotencyKey, expectedSessionVersion }` | submit/report result | Atomic one-way transition; safe retry. |
| Report | GET | `/education/practice-session/report` | `sessionId` | report with details | Correct answers/explanations only after submit. |
| Report history | GET | `/education/practice-session/reports` | `pageNo`, `pageSize` | page result | Current student only. |
| Wrong questions | GET | `/education/wrong-question/page` | `pageNo`, `pageSize`, optional `masterStatus` | page result | Current student only. |
| Wrong question detail | GET | `/education/wrong-question/get` | `id` | detail | Includes answer/explanation after failure is recorded. |
| Mark mastered | PUT | `/education/wrong-question/master` | `id` | boolean | Idempotent. |
| Unmark mastered | PUT | `/education/wrong-question/unmaster` | `id` | boolean | Idempotent. |
| Wrong-question review | POST | `/education/wrong-question/review-session` | `{ clientSessionId, wrongQuestionIds[] }` | practice session | Server validates ownership. |
| Favorites | GET | `/education/favorite/page` | `pageNo`, `pageSize`, optional `targetType` | page result | Current student only. |
| Favorite create | POST | `/education/favorite/create` | `{ targetType: 'QUESTION', targetId }` | favorite item | Idempotent. |
| Favorite delete | DELETE | `/education/favorite/delete` | `{ id? or targetType, targetId? }` | boolean | Logical/idempotent removal. |
| Favorite status | POST | `/education/favorite/status` | `{ questionIds[] }` | `{ questionIds }` | Batch status probe. |
## Envelope and failures
The project convention is a common result envelope. Successful payloads are expected under `data`; page payloads generally contain `list` and `total`. Errors should remain errors rather than becoming empty success data. Capture the server-provided request/trace ID for local investigation, but never record authorization headers or full sensitive response bodies.
The route prefix is intentionally `/app-api`, not a direct Scalar URL. If the local server uses another deployment prefix, adapt the reverse proxy rather than changing the harness to call Scalar.

View File

@@ -0,0 +1,60 @@
# Fixture schemas
Fixtures are synthetic documentation examples, not default application data and not copies of prototype data. They model the stable fields the harness reads.
## `context.json`
```json
{
"userId": 1001,
"tenantId": 2001,
"tenantName": "Local Pilot School",
"displayName": "Local Pilot School"
}
```
## `question-collection.json`
```json
{
"id": "collection-local-001",
"name": "Synthetic practice collection",
"collectionType": "QUESTION_BANK",
"questionCount": 3,
"status": "PUBLISHED"
}
```
## `practice-session.json`
```json
{
"id": 9001,
"clientSessionId": "local-session-001",
"status": "ACTIVE",
"questionCount": 3,
"sessionVersion": 1,
"questions": [
{
"sequence": 1,
"questionId": "question-local-001",
"contentVersion": "v1",
"stem": "Synthetic question content",
"type": "choice",
"options": [{ "label": "A", "content": "Synthetic option" }],
"selectedAnswer": null
}
]
}
```
## `page.json`
```json
{
"list": [],
"total": 0
}
```
Do not add `correctAnswer`, `explanation`, access tokens, phone numbers, real names, provider identifiers, or licensed question text to pre-submission fixtures. Post-submission report examples may include answer/explanation fields only when explicitly needed to document the permitted post-submit response boundary.

View File

@@ -0,0 +1,6 @@
{
"userId": 1001,
"tenantId": 2001,
"tenantName": "Local Pilot School",
"displayName": "Local Pilot School"
}

View File

@@ -0,0 +1,4 @@
{
"list": [],
"total": 0
}

View File

@@ -0,0 +1,18 @@
{
"id": 9001,
"clientSessionId": "local-session-001",
"status": "ACTIVE",
"questionCount": 3,
"sessionVersion": 1,
"questions": [
{
"sequence": 1,
"questionId": "question-local-001",
"contentVersion": "v1",
"stem": "Synthetic question content",
"type": "choice",
"options": [{ "label": "A", "content": "Synthetic option" }],
"selectedAnswer": null
}
]
}

View File

@@ -0,0 +1,7 @@
{
"id": "collection-local-001",
"name": "Synthetic practice collection",
"collectionType": "QUESTION_BANK",
"questionCount": 3,
"status": "PUBLISHED"
}

View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Local-only student learning loop browser harness">
<title>Study loop / education harness</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="topbar">
<a class="wordmark" href="./" aria-label="Study loop home"><span class="wordmark-mark" aria-hidden="true"></span><span>study loop</span></a>
<div class="topbar-actions"><span id="identity-chip" class="identity-chip" data-testid="identity-chip">Offline</span><button id="logout" class="quiet-button" type="button" data-testid="logout">Log out</button></div>
</header>
<main class="page-shell">
<section class="intro" aria-labelledby="page-title">
<div><p class="kicker">STUDENT / CORE LOOP</p><h1 id="page-title">Make one good<br><em>pass through.</em></h1><p class="intro-copy">A small, honest browser seam for finding a set, practising, and learning from the misses.</p></div>
<div class="connection-card" aria-live="polite"><span class="connection-dot" id="connection-dot"></span><span id="status" data-testid="status">Not connected</span><span id="request-id" class="request-id"></span></div>
</section>
<div class="safety-note" role="note"><span aria-hidden="true"></span><span><strong>Local harness.</strong> Calls stay on relative <code>/app-api</code> routes. Your token lives in memory and your tenant is always server-derived.</span></div>
<section class="auth-panel" id="auth-panel" aria-labelledby="auth-title">
<div class="section-label"><span>01</span><span>Entry</span></div>
<div class="auth-main"><div><h2 id="auth-title">Connect your study space</h2><p>Resolve the tenant, then use an existing member account.</p></div><form id="auth-form"><label for="mobile">Member login</label><div class="login-fields"><input id="mobile" type="tel" autocomplete="username" placeholder="Mobile number"><input id="password" type="password" autocomplete="current-password" placeholder="Password"><button class="button button-dark" type="submit" data-testid="connect">Log in</button></div><p class="field-help">Local stub accepts <code>tenant-a-student-1</code> as a token below, or use the server's member credentials.</p><label class="token-label" for="token">Local access token <span>(memory only, test fallback)</span></label><input id="token" type="password" autocomplete="off" placeholder="tenant-a-student-1"></form></div>
<dl class="identity-grid" id="context" data-testid="context"><div><dt>Tenant</dt><dd>Not resolved</dd></div><div><dt>Student</dt><dd>Not authenticated</dd></div></dl>
</section>
<div class="workspace">
<nav class="side-nav" aria-label="Learning loop sections"><p class="nav-title">Your loop</p><a href="#discover" class="nav-link active"><span>01</span>Find a set</a><a href="#practice" class="nav-link"><span>02</span>Practice</a><a href="#review" class="nav-link"><span>03</span>Review</a><a href="#favorites" class="nav-link"><span>04</span>Keep close</a><p class="nav-foot">Server truth<br><span id="state-readout">No session</span></p></nav>
<div class="content-column">
<section class="content-section" id="discover" aria-labelledby="discover-title"><div class="section-label"><span>02</span><span>Discover</span></div><div class="section-heading"><div><h2 id="discover-title">Choose a question set</h2><p>Only published collections permitted for your space appear here.</p></div><button class="button button-outline" id="load-catalog" type="button" data-testid="load-catalog">Load catalog</button></div><fieldset class="filters"><legend class="sr-only">Catalog filters</legend><label>Subject<select id="subject-filter" data-testid="subject-filter"><option value="">All subjects</option></select></label><label>Category<select id="category-filter"><option value="">All categories</option></select></label></fieldset><div id="collections" class="collection-grid" data-testid="collections"><p class="empty-state">Connect first, then load your permitted sets.</p></div></section>
<section class="content-section practice-section" id="active-practice" aria-labelledby="practice-title"><div class="section-label"><span>03</span><span>Active work</span></div><div class="section-heading"><div><h2 id="practice-title">Practice, without losing your place</h2><p id="practice-subtitle">Your latest accepted answer is the durable one.</p></div><button class="button button-outline" id="load-current" type="button" data-testid="reload-current">Reload current</button></div><div id="practice" class="practice-card" data-testid="practice"><p class="empty-state">No active session. Start one above.</p></div></section>
<section class="content-section result-grid" id="review"><div class="result-panel"><div class="section-label"><span>04</span><span>Review</span></div><div class="section-heading"><div><h2>Wrong questions</h2><p>Turn a miss into the next pass.</p></div><button class="button button-outline" id="load-wrong" type="button" data-testid="load-wrong">Load</button></div><div id="wrong" class="item-list" data-testid="wrong"><p class="empty-state">Not loaded.</p></div></div><div class="result-panel" id="favorites"><div class="section-label"><span>05</span><span>Keep close</span></div><div class="section-heading"><div><h2>Favorites</h2><p>A short list worth returning to.</p></div><button class="button button-outline" id="load-favorites" type="button" data-testid="load-favorites">Load</button></div><div id="favorites-list" class="item-list" data-testid="favorites-list"><p class="empty-state">Not loaded.</p></div></div></section>
</div>
</div>
</main>
<footer><span>Education student harness</span><a href="endpoint-matrix.md">Endpoint matrix</a><a href="fixtures/README.md">Fixture schemas</a></footer>
<script type="module" src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,76 @@
{
"name": "education-student-harness",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "education-student-harness",
"devDependencies": {
"@playwright/test": "^1.52.0"
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "education-student-harness",
"private": true,
"description": "Offline-safe browser acceptance harness for the education student core loop",
"scripts": {
"smoke": "node smoke-route.test.js",
"contract": "node test.js && node adapter.test.js",
"test": "npm run smoke && npm run contract && npm run browser:if-available",
"browser": "playwright test",
"browser:if-available": "node run-playwright-if-available.js"
},
"devDependencies": {
"@playwright/test": "^1.52.0"
}
}

View File

@@ -0,0 +1,25 @@
// @ts-check
const { defineConfig } = require('@playwright/test');
const port = process.env.PW_PORT || '4197';
module.exports = defineConfig({
testDir: '.',
testMatch: /acceptance\.spec\.js$/,
timeout: 30_000,
fullyParallel: false,
reporter: [['list'], ['json', { outputFile: 'artifacts/playwright-results.json' }]],
use: {
baseURL: process.env.BASE_URL || `http://127.0.0.1:${port}`,
headless: true,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'off',
},
webServer: process.env.BASE_URL ? undefined : {
command: `PORT=${port} node server.js`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer: false,
timeout: 10_000,
},
});

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env node
'use strict';
const { spawnSync } = require('child_process');
const path = require('path');
const cwd = __dirname;
const result = spawnSync(process.execPath, ['-e', "try { require.resolve('@playwright/test'); require.resolve('playwright'); } catch (_) { process.exit(2); }"], { cwd, stdio: 'inherit' });
if (result.status === 2) {
process.stdout.write('Playwright unavailable; dependency-free smoke/contract checks remain available.\n');
process.exit(0);
}
const command = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const run = spawnSync(command, ['playwright', 'test'], { cwd, stdio: 'inherit' });
process.exit(run.status == null ? 1 : run.status);

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT = __dirname;
const PORT = Number(process.env.PORT || 4173);
const HOST = '127.0.0.1';
const TOKENS = {
'tenant-a-student-1': { tenantId: 'tenant-a', userId: 'student-a1', displayName: 'Student A1', tenantName: 'Tenant Alpha' },
'tenant-a-student-2': { tenantId: 'tenant-a', userId: 'student-a2', displayName: 'Student A2', tenantName: 'Tenant Alpha' },
'tenant-b-student-1': { tenantId: 'tenant-b', userId: 'student-b1', displayName: 'Student B1', tenantName: 'Tenant Beta' },
'tenant-b-student-2': { tenantId: 'tenant-b', userId: 'student-b2', displayName: 'Student B2', tenantName: 'Tenant Beta' },
};
const QUESTION_DATA = [
{ id: 'q-a-1', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'Which layer owns the API contract?', type: 'SINGLE', options: ['Controller', 'Database', 'Browser'], answer: 'A', explanation: 'The controller owns the API boundary.' },
{ id: 'q-a-2', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'What prevents a stale answer overwrite?', type: 'SINGLE', options: ['Version check', 'Random delay', 'Client tenant ID'], answer: 'A', explanation: 'The session version is checked atomically.' },
{ id: 'q-a-3', tenantId: 'tenant-a', collectionId: 'col-a-core', stem: 'Which response shape is paged?', type: 'SINGLE', options: ['PageResult', 'String', 'Token'], answer: 'A', explanation: 'PageResult carries list and total.' },
{ id: 'q-b-1', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'Which boundary carries tenant context?', type: 'SINGLE', options: ['Auth context', 'Question stem', 'Answer text'], answer: 'A', explanation: 'Tenant context comes from authentication.' },
{ id: 'q-b-2', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'When are explanations visible?', type: 'SINGLE', options: ['After submit', 'Before auth', 'Never'], answer: 'A', explanation: 'Reports reveal explanations after submission.' },
{ id: 'q-b-3', tenantId: 'tenant-b', collectionId: 'col-b-core', stem: 'Which operation is idempotent?', type: 'SINGLE', options: ['Save answer', 'Changing tenant', 'Reading a secret'], answer: 'A', explanation: 'Answer saves use an idempotency key.' },
];
let state;
function resetState() {
state = { sessions: new Map(), answers: new Map(), reports: new Map(), wrong: new Map(), favorites: new Map(), next: 1 };
}
resetState();
const id = (prefix) => `${prefix}-${state.next++}`;
const json = (res, status, data, msg = '成功') => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Request-Id': id('req') }); res.end(JSON.stringify({ code: status >= 400 ? status : 0, msg, data: data === undefined ? null : data })); };
const safe = (q) => { const { answer, explanation, ...result } = q; return result; };
const page = (list, query) => ({ list, total: list.length, pageNo: Number(query.get('pageNo') || 1), pageSize: Number(query.get('pageSize') || list.length || 10) });
function auth(req) {
const match = /^Bearer\s+(.+)$/.exec(req.headers.authorization || '');
return match && TOKENS[match[1]] ? TOKENS[match[1]] : null;
}
function body(req) { return new Promise((resolve, reject) => { let raw = ''; req.on('data', c => { raw += c; if (raw.length > 1024 * 1024) reject(new Error('body too large')); }); req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('invalid json')); } }); req.on('error', reject); }); }
function fault(req, name) { return req.headers['x-harness-fault'] === name || new URL(req.url, 'http://127.0.0.1').searchParams.get('fault') === name; }
function own(ctx, resource) { return resource && resource.tenantId === ctx.tenantId && resource.userId === ctx.userId; }
function sessionView(session, submitted = false) { return { id: session.id, clientSessionId: session.clientSessionId, tenantId: session.tenantId, userId: session.userId, collectionId: session.collectionId, status: session.status, sessionVersion: session.version, serverVersion: session.version, acceptedSequence: session.acceptedSequence, questionCount: session.questions.length, questions: session.questions.map(q => ({ sequence: q.sequence, questionId: q.questionId, stem: q.stem, type: q.type, options: q.options, selectedAnswer: q.selectedAnswer || null, ...(submitted ? { answer: q.answer, explanation: q.explanation, isCorrect: q.selectedAnswer === q.answer } : {}) })) }; }
function findSession(ctx, value) { const s = state.sessions.get(String(value)); return own(ctx, s) ? s : null; }
function findQuestion(ctx, qid) { return QUESTION_DATA.find(q => q.id === String(qid) && q.tenantId === ctx.tenantId); }
async function handler(req, res) {
const url = new URL(req.url, `http://${HOST}`); const p = url.pathname;
if (p === '/' || p === '/index.html') return serve(res, p === '/' ? '/index.html' : p);
if (p === '/styles.css' || p === '/app.js' || p.startsWith('/fixtures/') || p === '/endpoint-matrix.md') return serve(res, p);
if (!p.startsWith('/app-api/')) return json(res, 404, null, 'Not found');
if (p === '/app-api/education/tenant/resolve' && req.method === 'GET') return json(res, 200, { tenantId: 'tenant-a', tenantName: 'Tenant Alpha', resolved: true });
const ctx = auth(req); if (!ctx) return json(res, 401, null, '未认证');
if (p === '/app-api/education/context' && req.method === 'GET') return json(res, 200, ctx);
if (fault(req, 'upstream-failure') && p.includes('/catalog/')) return json(res, 503, null, 'upstream failure');
if (p === '/app-api/education/catalog/regions' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-region-1`, name: ctx.tenantName + ' Region' }]);
if (p === '/app-api/education/catalog/categories' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-category-1`, name: 'Core' }]);
if (p === '/app-api/education/catalog/subjects' && req.method === 'GET') return json(res, 200, [{ id: `${ctx.tenantId}-subject-1`, name: 'Engineering' }]);
if (p === '/app-api/education/catalog/question-collections' && req.method === 'GET') return json(res, 200, [{ id: `col-${ctx.tenantId.slice(-1)}-core`, name: 'Core Loop', questionCount: 3, status: 'AVAILABLE', tenantId: ctx.tenantId }]);
if (p === '/app-api/education/questions/page' && req.method === 'GET') { const list = QUESTION_DATA.filter(q => q.tenantId === ctx.tenantId && (!url.searchParams.get('collectionId') || q.collectionId === url.searchParams.get('collectionId'))).map(safe); return json(res, 200, page(list, url.searchParams)); }
if (p === '/app-api/education/practice-config/preview' && req.method === 'GET') return json(res, 200, { valid: true, questionCount: Math.min(Number(url.searchParams.get('questionCount') || 3), 3), collectionId: url.searchParams.get('collectionId') || `col-${ctx.tenantId.slice(-1)}-core` });
if (p === '/app-api/education/practice-session/create' && req.method === 'POST') { const b = await body(req); if (!b.clientSessionId || !b.collectionId) return json(res, 400, null, 'clientSessionId and collectionId required'); const existing = [...state.sessions.values()].find(s => own(ctx, s) && s.clientSessionId === b.clientSessionId); if (existing) return json(res, 200, sessionView(existing)); const qs = QUESTION_DATA.filter(q => q.tenantId === ctx.tenantId && q.collectionId === b.collectionId).slice(0, Math.max(1, Math.min(Number(b.questionCount || 3), 3))); if (!qs.length) return json(res, 404, null, 'collection not found'); const s = { id: id('session'), tenantId: ctx.tenantId, userId: ctx.userId, clientSessionId: b.clientSessionId, collectionId: b.collectionId, status: 'ACTIVE', version: 0, acceptedSequence: 0, questions: qs.map((q, i) => ({ ...q, questionId: q.id, sequence: i + 1, selectedAnswer: null })) }; state.sessions.set(s.id, s); return json(res, 200, sessionView(s)); }
if (p === '/app-api/education/practice-session/current' && req.method === 'GET') { const s = [...state.sessions.values()].reverse().find(s => own(ctx, s) && s.status === 'ACTIVE'); return json(res, 200, s ? sessionView(s) : null); }
if (p === '/app-api/education/practice-session/get' && req.method === 'GET') { const s = findSession(ctx, url.searchParams.get('id')); return s ? json(res, 200, sessionView(s, s.status === 'SUBMITTED')) : json(res, 404, null, 'session not found'); }
if (p === '/app-api/education/practice-session/answer' && req.method === 'PUT') { const b = await body(req); const s = findSession(ctx, b.sessionId); if (!s) return json(res, 404, null, 'session not found'); if (s.status !== 'ACTIVE') return json(res, 409, null, 'submitted session is immutable'); const key = `${s.id}:${b.idempotencyKey}`; if (state.answers.has(key)) { if (fault(req, 'answer-timeout-after-commit')) return json(res, 504, null, 'timeout after commit'); return json(res, 200, state.answers.get(key)); } if (b.expectedSessionVersion !== s.version) return json(res, 409, { currentVersion: s.version }, 'stale session version'); const q = s.questions.find(q => q.sequence === Number(b.questionSequence)); if (!q || typeof b.selectedAnswer !== 'string') return json(res, 400, null, 'invalid answer'); q.selectedAnswer = b.selectedAnswer; s.version++; s.acceptedSequence = Math.max(s.acceptedSequence, Number(b.clientSequence) || 0); const result = { sessionId: s.id, questionSequence: q.sequence, selectedAnswer: q.selectedAnswer, sessionVersion: s.version, serverVersion: s.version, acceptedSequence: s.acceptedSequence }; state.answers.set(key, result); if (fault(req, 'answer-timeout-after-commit')) return json(res, 504, null, 'timeout after commit'); return json(res, 200, result); }
if (p === '/app-api/education/practice-session/submit' && req.method === 'POST') { const b = await body(req); const s = findSession(ctx, b.sessionId); if (!s) return json(res, 404, null, 'session not found'); if (s.status === 'SUBMITTED') return json(res, 200, state.reports.get(s.id)); if (b.expectedSessionVersion !== s.version) return json(res, 409, { currentVersion: s.version }, 'stale session version'); if (!b.idempotencyKey) return json(res, 400, null, 'idempotencyKey required'); const details = s.questions.map(q => ({ questionId: q.questionId, selectedAnswer: q.selectedAnswer, answer: q.answer, explanation: q.explanation, isCorrect: q.selectedAnswer === q.answer })); const report = { id: id('report'), sessionId: s.id, score: details.filter(x => x.isCorrect).length, total: details.length, details }; s.status = 'SUBMITTED'; s.version++; state.reports.set(s.id, report); details.filter(x => !x.isCorrect).forEach(x => { const k = `${ctx.tenantId}:${ctx.userId}:${x.questionId}`; const w = state.wrong.get(k) || { id: id('wrong'), tenantId: ctx.tenantId, userId: ctx.userId, questionId: x.questionId, questionStem: QUESTION_DATA.find(q => q.id === x.questionId)?.stem, stem: QUESTION_DATA.find(q => q.id === x.questionId)?.stem, errorCount: 0, masterStatus: 'UNMASTERED' }; w.errorCount++; state.wrong.set(k, w); }); return json(res, 200, { ...report, sessionVersion: s.version, status: s.status }); }
if (p === '/app-api/education/practice-session/report' && req.method === 'GET') { const s = findSession(ctx, url.searchParams.get('sessionId')); const r = s && state.reports.get(s.id); return r ? json(res, 200, r) : json(res, 404, null, 'report not found'); }
if (p === '/app-api/education/practice-session/reports' && req.method === 'GET') return json(res, 200, page([...state.reports].map(([sid, r]) => { const s = state.sessions.get(sid); return own(ctx, s) ? r : null; }).filter(Boolean), url.searchParams));
if (p === '/app-api/education/wrong-question/page' && req.method === 'GET') { let list = [...state.wrong.values()].filter(w => own(ctx, w)); if (url.searchParams.get('masterStatus')) list = list.filter(w => w.masterStatus === url.searchParams.get('masterStatus')); return json(res, 200, page(list, url.searchParams)); }
if (p === '/app-api/education/wrong-question/get' && req.method === 'GET') { const w = [...state.wrong.values()].find(w => own(ctx, w) && w.id === url.searchParams.get('id')); const q = w && findQuestion(ctx, w.questionId); return w && q ? json(res, 200, { ...w, questionId: q.id, stem: q.stem, answer: q.answer, explanation: q.explanation }) : json(res, 404, null, 'wrong question not found'); }
if ((p.endsWith('/master') || p.endsWith('/unmaster')) && req.method === 'PUT') { const b = await body(req); const w = [...state.wrong.values()].find(w => own(ctx, w) && w.id === String(b.id || url.searchParams.get('id'))); if (!w) return json(res, 404, null, 'wrong question not found'); w.masterStatus = p.endsWith('/master') ? 'MASTERED' : 'UNMASTERED'; return json(res, 200, { mastered: w.masterStatus === 'MASTERED', masterStatus: w.masterStatus }); }
if (p === '/app-api/education/wrong-question/review-session' && req.method === 'POST') { const b = await body(req); const ids = Array.isArray(b.wrongQuestionIds) ? b.wrongQuestionIds : []; const qs = ids.map(x => [...state.wrong.values()].find(w => own(ctx, w) && w.id === String(x))).filter(Boolean).map(w => findQuestion(ctx, w.questionId)).filter(Boolean); if (!qs.length) return json(res, 400, null, 'no owned wrong questions'); const s = { id: id('session'), tenantId: ctx.tenantId, userId: ctx.userId, clientSessionId: b.clientSessionId || id('client'), collectionId: 'wrong-review', status: 'ACTIVE', version: 0, acceptedSequence: 0, questions: qs.map((q, i) => ({ ...q, questionId: q.id, sequence: i + 1, selectedAnswer: null })) }; state.sessions.set(s.id, s); return json(res, 200, sessionView(s)); }
if (p === '/app-api/education/favorite/page' && req.method === 'GET') return json(res, 200, page([...state.favorites.values()].filter(f => own(ctx, f)), url.searchParams));
if (p === '/app-api/education/favorite/create' && req.method === 'POST') { const b = await body(req); const q = findQuestion(ctx, b.targetId); if (b.targetType !== 'QUESTION' || !q) return json(res, 404, null, 'question not found'); const key = `${ctx.tenantId}:${ctx.userId}:${q.id}`; const f = state.favorites.get(key) || { id: id('favorite'), tenantId: ctx.tenantId, userId: ctx.userId, targetType: 'QUESTION', targetId: q.id, questionId: q.id, questionStem: q.stem, status: 'ACTIVE' }; f.status = 'ACTIVE'; state.favorites.set(key, f); return json(res, 200, f); }
if (p === '/app-api/education/favorite/delete' && req.method === 'DELETE') { const b = await body(req); const f = [...state.favorites.values()].find(f => own(ctx, f) && (b.id && f.id === String(b.id) || b.targetId && f.targetId === String(b.targetId))); if (f) f.status = 'DELETED'; return json(res, 200, { deleted: true }); }
if (p === '/app-api/education/favorite/status' && req.method === 'POST') { const b = await body(req); const ids = (b.questionIds || []).filter(qid => findQuestion(ctx, qid)); return json(res, 200, { questionIds: ids.filter(qid => [...state.favorites.values()].some(f => own(ctx, f) && f.status === 'ACTIVE' && f.targetId === String(qid))) }); }
return json(res, 404, null, 'Not found');
}
function serve(res, requestPath) { const file = path.resolve(ROOT, requestPath.slice(1)); if (!file.startsWith(path.resolve(ROOT)) || !fs.existsSync(file) || !fs.statSync(file).isFile()) return json(res, 404, null, 'Not found'); const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json' }; res.writeHead(200, { 'Content-Type': types[path.extname(file)] || 'application/octet-stream' }); fs.createReadStream(file).pipe(res); }
function createServer() { return http.createServer((req, res) => { const original = req.headers.authorization; if (original) req.headers.authorization = original; handler(req, res).catch(err => json(res, 400, null, err.message)); }); }
if (require.main === module) createServer().listen(PORT, HOST, () => process.stdout.write(`education harness listening on http://${HOST}:${PORT}\n`));
module.exports = { createServer, resetState, TOKENS };

View File

@@ -0,0 +1,38 @@
#!/usr/bin/env node
'use strict';
const assert = require('assert');
const http = require('http');
const { createServer, resetState } = require('./server');
const port = Number(process.env.SMOKE_PORT || 4188);
function request(method, path, token, body) {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const req = http.request({ hostname: '127.0.0.1', port, path, method, headers: { Authorization: `Bearer ${token}`, ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}) } }, (res) => {
let raw = '';
res.on('data', (chunk) => { raw += chunk; });
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(raw) }));
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
async function main() {
resetState();
const server = createServer().listen(port, '127.0.0.1');
try {
let response = await request('GET', '/app-api/education/context', 'tenant-a-student-1');
assert.equal(response.status, 200);
assert.equal(response.body.data.tenantId, 'tenant-a');
response = await request('GET', '/app-api/education/questions/page?collectionId=col-a-core', 'tenant-a-student-1');
assert.equal(response.body.data.list.length, 3);
assert.equal(response.body.data.list[0].answer, undefined);
process.stdout.write('education student harness smoke route passed\n');
} finally {
server.close();
}
}
main().catch((error) => { process.stderr.write(`${error.stack}\n`); process.exitCode = 1; });

View File

@@ -0,0 +1,44 @@
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
--ink: #18232b; --muted: #5d696f; --paper: #f5f7f4; --panel: #ffffff; --line: #d9e0dc;
--leaf: #245c4d; --deep: #183d3d; --gold: #9a6b20; --wash: #e6efea; --danger: #913d38;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: var(--paper); line-height: 1.5;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; background: var(--paper); }
button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid #d5a550; outline-offset: 3px; }
.topbar { height: 70px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.82); display: flex; align-items: center; justify-content: space-between; padding: 0 clamp(18px, 5vw, 72px); position: sticky; top: 0; z-index: 3; backdrop-filter: blur(12px); }
.wordmark { display: inline-flex; align-items: center; gap: 10px; color: var(--deep); text-decoration: none; font-size: 15px; font-weight: 760; letter-spacing: -.03em; }
.wordmark-mark { display: grid; place-items: center; width: 29px; height: 29px; color: white; background: var(--deep); border-radius: 50%; font-size: 21px; line-height: 1; }
.topbar-actions { display: flex; align-items: center; gap: 14px; }
.identity-chip { padding: 6px 10px; color: var(--leaf); background: var(--wash); border-radius: 99px; font-size: 11px; font-weight: 750; }
.quiet-button { border: 0; color: var(--muted); background: transparent; font-size: 12px; padding: 8px; }
.page-shell { width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 74px 0 80px; }
.intro { display: flex; align-items: end; justify-content: space-between; gap: 30px; margin-bottom: 37px; }
.kicker, .section-label, .nav-title { margin: 0; color: var(--leaf); font-size: 10px; font-weight: 800; letter-spacing: .17em; text-transform: uppercase; }
h1, h2, h3, p { margin-top: 0; } h1 { margin: 13px 0 16px; color: var(--deep); font-family: Georgia, "Times New Roman", serif; font-size: clamp(48px, 7.4vw, 92px); font-weight: 400; letter-spacing: -.07em; line-height: .88; } h1 em { color: var(--gold); font-style: italic; } h2 { margin-bottom: 6px; font-size: 22px; letter-spacing: -.04em; line-height: 1.1; } h3 { margin: 17px 0 8px; font-size: 17px; letter-spacing: -.03em; }
.intro-copy { max-width: 395px; margin-bottom: 0; color: var(--muted); font-size: 14px; }
.connection-card { display: flex; align-items: center; gap: 9px; align-self: start; min-width: 180px; padding: 11px 13px; border: 1px solid var(--line); background: white; color: var(--leaf); font-size: 12px; font-weight: 700; }
.connection-dot { width: 7px; height: 7px; background: var(--gold); border-radius: 50%; } .connection-dot[data-tone="good"] { background: var(--leaf); } .connection-dot[data-tone="bad"] { background: var(--danger); }
.request-id { margin-left: auto; color: #a5afb0; font: 10px ui-monospace, monospace; font-weight: 400; }
.safety-note { display: flex; gap: 12px; align-items: start; margin-bottom: 32px; padding: 13px 16px; border-left: 2px solid var(--gold); background: #fbf7ee; color: #755f43; font-size: 12px; } .safety-note > span:first-child { color: var(--gold); font-size: 17px; line-height: 1; } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
.auth-panel, .content-section { border-top: 1px solid var(--line); padding-top: 18px; } .auth-panel { display: grid; grid-template-columns: 95px 1fr; gap: 35px; padding-bottom: 38px; }
.section-label { display: flex; gap: 11px; color: #8c9896; } .section-label span:first-child { color: var(--gold); }
.auth-main { display: grid; grid-template-columns: 1fr minmax(300px, 410px); gap: 32px; } .auth-main p, .section-heading p { color: var(--muted); font-size: 13px; margin-bottom: 0; } form label { display: block; margin-bottom: 7px; color: var(--ink); font-size: 12px; font-weight: 700; } form label span { color: var(--muted); font-weight: 400; }
.login-fields { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; } .token-label { margin-top: 14px; } .token-label + input { max-width: 280px; }
.input-action { display: flex; gap: 8px; } input, select { width: 100%; min-height: 42px; border: 1px solid var(--line); border-radius: 2px; color: var(--ink); background: #fbfcfb; padding: 9px 11px; } .field-help { color: var(--muted); font-size: 11px !important; margin-top: 7px !important; }
.button { min-height: 39px; padding: 8px 14px; border: 1px solid var(--line); border-radius: 2px; font-size: 12px; font-weight: 750; white-space: nowrap; transition: transform .15s ease, background .15s ease, border-color .15s ease; } .button:hover { transform: translateY(-1px); } .button-dark { border-color: var(--deep); color: white; background: var(--deep); } .button-outline { color: var(--leaf); background: white; } .button-small { min-height: 31px; padding: 5px 9px; font-size: 11px; }
.identity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; grid-column: 2; margin: 26px 0 0; } .identity-grid div { padding: 11px 13px; background: var(--wash); } dt { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; } dd { margin: 2px 0 0; font-weight: 700; font-size: 13px; }
.workspace { display: grid; grid-template-columns: 160px 1fr; gap: 52px; } .side-nav { border-top: 1px solid var(--line); padding-top: 18px; } .nav-title { margin-bottom: 22px; color: #8c9896; } .nav-link { display: flex; gap: 11px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line); color: var(--muted); text-decoration: none; font-size: 12px; } .nav-link span { color: var(--gold); font: 10px ui-monospace, monospace; } .nav-link.active { color: var(--deep); font-weight: 750; } .nav-foot { margin-top: 45px; color: #8d9997; font-size: 10px; line-height: 1.6; } .nav-foot span { color: var(--leaf); }
.content-column { min-width: 0; } .content-section { margin-bottom: 52px; } .section-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; margin: 18px 0 20px; } .filters { display: flex; gap: 9px; max-width: 460px; margin-bottom: 20px; } .filters label { flex: 1; color: var(--muted); font-size: 11px; } .filters select { display: block; margin-top: 5px; }
.collection-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } .collection-card { min-height: 225px; display: flex; flex-direction: column; padding: 19px; border: 1px solid var(--line); background: white; } .collection-index { color: var(--gold); font: 10px ui-monospace, monospace; letter-spacing: .15em; } .collection-card p { min-height: 42px; color: var(--muted); font-size: 12px; } .collection-meta { display: flex; justify-content: space-between; margin: auto 0 16px; color: var(--muted); font: 10px ui-monospace, monospace; text-transform: uppercase; }
.practice-section { scroll-margin-top: 90px; } .practice-card { border: 1px solid var(--line); background: white; } .empty-state { padding: 25px 0; margin: 0; color: var(--muted); font-size: 13px; } .practice-card > .empty-state, .item-list > .empty-state { padding: 25px; } .practice-head { display: flex; justify-content: space-between; align-items: center; padding: 15px 18px; border-bottom: 1px solid var(--line); } .practice-head strong { margin-left: 10px; font-size: 12px; } .session-badge { color: var(--leaf); font: 10px ui-monospace, monospace; letter-spacing: .1em; } .save-state { color: var(--muted); font-size: 11px; } .save-state[data-state="saved"] { color: var(--leaf); } .save-state[data-state="retrying"] { color: var(--gold); } .save-state[data-state="failed"] { color: var(--danger); }
.question { border: 0; border-bottom: 1px solid var(--line); margin: 0; padding: 22px 22px 20px; } .question legend { display: flex; gap: 12px; width: 100%; margin-bottom: 15px; font-size: 14px; font-weight: 700; } .question legend span { color: var(--gold); font: 11px ui-monospace, monospace; } .options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .option { position: relative; } .option input { position: absolute; opacity: 0; } .option span { display: block; min-height: 44px; padding: 11px 12px; border: 1px solid var(--line); color: var(--muted); font-size: 12px; cursor: pointer; } .option b { margin-right: 8px; color: var(--gold); font: 11px ui-monospace, monospace; } .option input:checked + span { border-color: var(--leaf); color: var(--deep); background: var(--wash); } .practice-actions { display: flex; justify-content: flex-end; padding: 18px 22px; }
.result-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; } .result-panel { min-width: 0; border-top: 1px solid var(--line); padding-top: 18px; } .item-list { border: 1px solid var(--line); background: white; } .list-item { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 13px 15px; border-bottom: 1px solid var(--line); } .list-item:last-child { border-bottom: 0; } .list-item strong, .list-item span { display: block; } .list-item strong { font-size: 12px; } .list-item span { margin-top: 3px; color: var(--muted); font-size: 11px; }
footer { display: flex; gap: 19px; width: min(1180px, calc(100% - 36px)); margin: 0 auto; padding: 20px 0 30px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; } footer a { color: var(--leaf); }
@media (max-width: 820px) { .page-shell { padding-top: 48px; } .intro { display: block; } .connection-card { width: fit-content; margin-top: 24px; } .auth-panel { grid-template-columns: 1fr; gap: 18px; } .auth-main { grid-template-columns: 1fr; gap: 22px; } .identity-grid { grid-column: 1; margin-top: 0; } .workspace { grid-template-columns: 1fr; gap: 25px; } .side-nav { display: flex; gap: 14px; align-items: center; overflow-x: auto; } .nav-title, .nav-foot { display: none; } .nav-link { border-bottom: 0; white-space: nowrap; } .collection-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .topbar { height: 62px; padding: 0 17px; } .identity-chip { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .page-shell { width: min(100% - 28px, 500px); padding-top: 38px; } h1 { font-size: 57px; } .auth-main, .section-heading { display: block; } .section-heading .button { margin-top: 16px; } .login-fields { grid-template-columns: 1fr; } .input-action { display: grid; grid-template-columns: 1fr; } .filters, .options, .collection-grid, .result-grid { grid-template-columns: 1fr; display: grid; max-width: none; } .collection-card { min-height: 0; } .question { padding: 19px 15px; } footer { width: min(100% - 28px, 500px); flex-wrap: wrap; } }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { transition-duration: .01ms !important; } }

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env node
'use strict';
const assert = require('assert');
const http = require('http');
const { createServer, resetState } = require('./server');
const port = 4187;
let server;
function request(method, path, token, body, headers = {}) { return new Promise((resolve, reject) => { const data = body === undefined ? undefined : JSON.stringify(body); const req = http.request({ hostname: '127.0.0.1', port, path, method, headers: { Authorization: `Bearer ${token}`, ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}), ...headers } }, res => { let raw = ''; res.on('data', c => raw += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(raw) })); }); req.on('error', reject); if (data) req.write(data); req.end(); }); }
async function run() {
resetState(); server = createServer().listen(port, '127.0.0.1');
const a1 = 'tenant-a-student-1'; const a2 = 'tenant-a-student-2'; const b1 = 'tenant-b-student-1';
let r = await request('GET', '/app-api/education/context', a1); assert.equal(r.body.data.tenantId, 'tenant-a');
r = await request('GET', '/app-api/education/questions/page?collectionId=col-a-core', a1); assert.equal(r.body.data.list[0].answer, undefined); assert.equal(r.body.data.list.length, 3);
r = await request('POST', '/app-api/education/practice-session/create', a1, { clientSessionId: 'client-1', collectionId: 'col-a-core', questionCount: 3 }); const s = r.body.data;
r = await request('POST', '/app-api/education/practice-session/create', a1, { clientSessionId: 'client-1', collectionId: 'col-a-core', questionCount: 3 }); assert.equal(r.body.data.id, s.id);
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 1, selectedAnswer: 'A', idempotencyKey: 'ans-1', clientSequence: 1, expectedSessionVersion: 0 }, { 'X-Harness-Fault': 'answer-timeout-after-commit' }); assert.equal(r.status, 504);
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 1, selectedAnswer: 'A', idempotencyKey: 'ans-1', clientSequence: 1, expectedSessionVersion: 0 }); assert.equal(r.status, 200); assert.equal(r.body.data.selectedAnswer, 'A');
r = await request('GET', `/app-api/education/practice-session/get?id=${s.id}`, a1); assert.equal(r.body.data.questions[0].selectedAnswer, 'A');
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 2, selectedAnswer: 'B', idempotencyKey: 'ans-2', clientSequence: 2, expectedSessionVersion: 0 }); assert.equal(r.status, 409);
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 2, selectedAnswer: 'B', idempotencyKey: 'ans-2', clientSequence: 2, expectedSessionVersion: 1 }); assert.equal(r.status, 200);
r = await request('GET', `/app-api/education/practice-session/get?id=${s.id}`, a2); assert.equal(r.status, 404);
r = await request('POST', '/app-api/education/favorite/create', a1, { targetType: 'QUESTION', targetId: 'q-a-1' }); assert.equal(r.status, 200);
r = await request('GET', '/app-api/education/favorite/page', b1); assert.equal(r.body.data.total, 0);
r = await request('POST', '/app-api/education/practice-session/submit', a1, { sessionId: s.id, idempotencyKey: 'submit-1', expectedSessionVersion: 2 }); assert.equal(r.status, 200); assert.equal(r.body.data.details[0].answer, 'A');
r = await request('POST', '/app-api/education/practice-session/submit', a1, { sessionId: s.id, idempotencyKey: 'submit-1', expectedSessionVersion: 2 }); assert.equal(r.status, 200);
r = await request('PUT', '/app-api/education/practice-session/answer', a1, { sessionId: s.id, questionSequence: 1, selectedAnswer: 'C', idempotencyKey: 'ans-3', clientSequence: 3, expectedSessionVersion: 3 }); assert.equal(r.status, 409);
r = await request('GET', '/app-api/education/wrong-question/page', a1); assert.equal(r.body.data.total, 2);
server.close(); process.stdout.write('education student harness contract tests passed\n');
}
run().catch(err => { if (server) server.close(); console.error(err); process.exitCode = 1; });

View File

@@ -1,5 +1,7 @@
package cn.iocoder.yudao.framework.common.biz.system.tenant; package cn.iocoder.yudao.framework.common.biz.system.tenant;
import cn.iocoder.yudao.framework.common.biz.system.tenant.dto.TenantRespDTO;
import java.util.List; import java.util.List;
/** /**
@@ -23,4 +25,34 @@ public interface TenantCommonApi {
*/ */
void validateTenant(Long id); void validateTenant(Long id);
/**
* 根据租户编号获得租户信息
*
* @param id 租户编号
* @return 租户信息,不存在时返回 null
*/
default TenantRespDTO getTenant(Long id) {
throw new UnsupportedOperationException("getTenant is not implemented");
}
/**
* 根据租户名获得租户信息
*
* @param name 租户名
* @return 租户信息,不存在时返回 null
*/
default TenantRespDTO getTenantByName(String name) {
throw new UnsupportedOperationException("getTenantByName is not implemented");
}
/**
* 根据域名获得租户信息
*
* @param website 域名
* @return 租户信息,不存在时返回 null
*/
default TenantRespDTO getTenantByWebsite(String website) {
throw new UnsupportedOperationException("getTenantByWebsite is not implemented");
}
} }

View File

@@ -0,0 +1,44 @@
package cn.iocoder.yudao.framework.common.biz.system.tenant.dto;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 租户信息 Response DTO
*
* @author 恭学教育
*/
@Data
public class TenantRespDTO implements Serializable {
/**
* 租户编号
*/
private Long id;
/**
* 租户名
*/
private String name;
/**
* 租户状态
*
* 0 - 开启1 - 禁用
*/
private Integer status;
/**
* 绑定域名列表
*/
private List<String> websites;
/**
* 过期时间
*/
private LocalDateTime expireTime;
}

View File

@@ -33,6 +33,7 @@ public class ServiceErrorCodeRange {
// 模块 system 错误码区间 [1-002-000-000 ~ 1-003-000-000) // 模块 system 错误码区间 [1-002-000-000 ~ 1-003-000-000)
// 模块 report 错误码区间 [1-003-000-000 ~ 1-004-000-000) // 模块 report 错误码区间 [1-003-000-000 ~ 1-004-000-000)
// 模块 member 错误码区间 [1-004-000-000 ~ 1-005-000-000) // 模块 member 错误码区间 [1-004-000-000 ~ 1-005-000-000)
// 模块 education 错误码区间 [1-005-000-000 ~ 1-006-000-000)
// 模块 mp 错误码区间 [1-006-000-000 ~ 1-007-000-000) // 模块 mp 错误码区间 [1-006-000-000 ~ 1-007-000-000)
// 模块 pay 错误码区间 [1-007-000-000 ~ 1-008-000-000) // 模块 pay 错误码区间 [1-007-000-000 ~ 1-008-000-000)
// 模块 bpm 错误码区间 [1-009-000-000 ~ 1-010-000-000) // 模块 bpm 错误码区间 [1-009-000-000 ~ 1-010-000-000)

View File

@@ -0,0 +1,482 @@
# yudao-module-education
教育业务模块,提供课程、练习、题库、考试等教育业务功能。
## 当前状态
此模块提供教育业务功能骨架、题库目录浏览 tracer bullet以及题目预览与练习配置预览。
**已实现**
- 模块骨架与包结构
- 能力探测端点 (`/education/capability`)
- 租户识别端点 (`/education/tenant/resolve`) — 学生端登录前使用
- 教育上下文端点 (`/education/context`) — 学生端已认证状态
- 题库目录端点 (见下方 Catalog API) — 学生端已认证
- 题目浏览与筛选端点 (见下方 Questions API) — 学生端已认证
- 练习配置预览端点 (见下方 Practice API) — 学生端已认证
- 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试
- 题目安全过滤(答案/解析绝不暴露到前端)
- 独立的功能开关配置 + Scalar 数据源配置
- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习)
- 权限与菜单种子数据
## 功能配置
`application.yaml` 或对应 profile 中配置:
```yaml
yudao:
education:
enabled: true
# 题库目录与题目读取开关;关闭不会删除已有练习、报告、错题或收藏
catalog-read-enabled: true
# 练习创建、答案保存、交卷写入开关;关闭后历史会话与报告仍可读取
practice-write-enabled: true
# Pilot 灰度租户;空列表表示不限制,生产 Pilot 应显式配置目标租户 ID
pilot-tenant-ids: [1024]
catalog-mode: SCALAR_READ
```
灰度与回滚约束:
- `enabled=false`:移除 Education HTTP 能力,不执行任何数据删除。
- `catalog-read-enabled=false`:停止 Scalar 题库读取;已有会话、报告、错题和收藏仍保存在 MySQL。
- `practice-write-enabled=false`:拒绝新建练习、保存答案和交卷;会话恢复、报告与历史查询保持可用。
- `pilot-tenant-ids`:非空时仅允许列表内租户使用题库和练习写入能力。
- 应用回滚只回滚应用版本或开关;不得执行 `*-rollback.sql`。SQL 回滚脚本仅用于明确的数据销毁场景。
## API
### 管理后台 - 能力信息
```
GET /admin-api/education/capability
```
- 权限:`education:capability`
- 响应示例:
```json
{
"code": 0,
"msg": "成功",
"data": {
"module": "education",
"enabled": true,
"version": "1.0.0",
"capabilities": ["shell", "catalog", "questions", "practice-preview"]
}
}
```
### 用户 APP - 教育租户识别
```
GET /app-api/education/tenant/resolve?hostname=school.example.com
GET /app-api/education/tenant/resolve?tenantName=demo-school
GET /app-api/education/tenant/resolve?hostname=school.example.com&tenantName=demo-school
```
- 权限:无需认证(`@PermitAll`
- 说明:通过主机名或租户名解析租户,返回学生端登录引导所需的基础字段。
hostname 和 tenantName 至少提供一个。解析规则:
1. 如果同时提供两者,它们必须解析到同一个租户,否则拒绝请求
2. hostname 经过标准化(保留端口并转为小写),先查 `EducationProperties.hostnameTenantMap` 配置映射,再按 `system_tenant.websites` 的精确 authority 值查询
- 响应示例(成功):
```json
{
"code": 0,
"msg": "成功",
"data": {
"tenantId": 1024,
"tenantName": "demo-school",
"displayName": "demo-school",
"status": "ACTIVE",
"loginMethods": ["PASSWORD", "SMS"]
}
}
```
- 错误响应:
| 错误码 | 说明 |
|--------|------|
| 1_005_001_001 | 租户不存在 |
| 1_005_001_002 | 租户已被禁用 |
| 1_005_001_003 | 租户识别失败hostname 格式不合法等) |
| 1_005_001_004 | 当前租户不可用(过期等) |
### 用户 APP - 教育当前上下文
```
GET /app-api/education/context
```
- 权限:需要认证(登录态)
- 说明:根据当前认证用户和租户上下文返回教育业务信息。不信任请求体中的 userId一切从安全上下文和 TenantContext 派生。TenantSecurityWebFilter 前置完成租户校验,此处二次验证确保租户处于活跃状态。
- 响应示例:
```json
{
"code": 0,
"msg": "成功",
"data": {
"userId": 1024,
"tenantId": 2048,
"tenantName": "demo-school",
"displayName": "demo-school"
}
}
```
### 错误码
| 错误码 | 说明 |
|--------|------|
| 1_005_001_000 | 教育模块未启用 |
| 1_005_001_001 | 租户不存在 |
| 1_005_001_002 | 租户已被禁用 |
| 1_005_001_003 | 租户识别失败:{原因} |
| 1_005_001_004 | 当前租户不可用,请联系管理员 |
## 构建与运行
### 单独编译测试
```bash
# 编译 education 模块
mvn compile -pl yudao-module-education -am
# 运行 education 模块单元测试
mvn test -pl yudao-module-education -am
```
### 整体编译(含 server
```bash
# 编译全量member + education + system + infra + server
mvn compile -pl yudao-server -am
# 打包(跳过测试加速)
mvn package -pl yudao-server -am -DskipTests
```
### 启动验证
1. 确保 `yudao.education.enabled=true`
2. 启动 `yudao-server`
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
# 应用租户识别种子数据
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
```
## SQL 交付约定
- 所有 Education SQL 文件存放在 `sql/mysql/education/` 目录下
- 文件命名:`NNN-描述.sql`NNN 为三位递增序号)
- 每个正向脚本应有对应的回滚脚本
- schema 文件仅包含 DDLseed 文件仅包含 DML
- **不修改**项目根目录的 `ruoyi-vue-pro.sql` 巨量全量转储
- Ticket #5 为只读/预览操作,无新增数据库 schema 或 DML
## 前端状态
**当前工作区未检出完整的前端源码。** `yudao-ui/yudao-ui-admin-vue3/` 仅包含部分 MES 相关文件(`src/api/mes/``src/views/mes/`),缺少 `package.json``router/``store/``config/` 等核心框架文件。
因此:
- **管理后台教育菜单项**:基础 SQL 仅注册了 `system_menu` 记录ID 6800-6801。租户解析与当前上下文属于学生端接口不创建虚假的后台权限菜单。前端无路由/页面组件可渲染,菜单在管理后台不会显示。
- **Student Web/H5 应用外壳**:前端源码不存在,无法建立。
### Student 端前端集成契约
前端就位后必须实现以下流程(不能伪造静态页面):
1. **租户识别**(登录前)
- URL: `GET /app-api/education/tenant/resolve`
- 从浏览器 `window.location.host` 获取 authority包含非默认端口传入 `hostname` 参数
- 备用:支持手动输入 `tenantName`
- 根据返回的 `loginMethods` 决定展示哪种登录方式PASSWORD/SMS
- 获得 `tenantId` 后,在后续请求中通过 `tenant-id` header 传递
2. **用户认证**(复用 Member 模块)
- 密码登录: `POST /app-api/member/auth/login`
- 短信登录: `POST /app-api/member/auth/sms-login`
- 刷新令牌: `POST /app-api/member/auth/refresh-token`
- 登出: `POST /app-api/member/auth/logout`
- 所有请求携带 `tenant-id: {tenantId}` header
3. **获取上下文**(登录后)
- URL: `GET /app-api/education/context`
- 携带有效 Bearer Token + `tenant-id` header
- 从响应获取 `userId``tenantId``tenantName` 用于页面展示
4. **跨租户防护**
- 前端不应允许用户手动切换 `tenant-id` header
- 后端通过 `TenantSecurityWebFilter` 拒绝认证用户的跨租户 header 操作
**阻塞项**:完整前端源码(含 router、store、package.json是上述前端集成的必要前提。一旦前端源码就位
1. 在 Vue3 admin 的路由中添加 `/education` 路由项,绑定 Education 菜单组件
2. 添加 `src/api/education/` API 封装层(调用上述教育端点)
3. Student Web/H5 端如需要独立入口,需新建对应前端项目
### 用户 APP - 题库目录Catalog
所有端点需要学生登录态Bearer Token`userId``tenantId` 由安全上下文派生,不接受客户端传参。
Scalar 代理层自动注入 `x-tenant-id` header来自 `TenantContextHolder`),前端不发起任何直达 Scalar 的请求。
#### 架构边界
```
Browser → Controller(/education/catalog/*) → CatalogService → CatalogProvider → [Scalar]
↑ 内部 DTO/VO ↑ Scalar DTO 仅此层
```
- **业务层**Controller/Service仅操作内部 Catalog VO`CatalogRegionRespVO` 等)
- **集成层**Scalar DTO + ScalarCatalogProvider封装 Scalar 协议差异DTO 不泄露到上层
#### 端点列表
| 端点 | 说明 | 参数 |
|------|------|------|
| `GET /app-api/education/catalog/regions` | 查询可用地区 | 无 |
| `GET /app-api/education/catalog/categories` | 查询题目分类 | `subjectId` (可选), `nodeId` (可选) |
| `GET /app-api/education/catalog/subjects` | 查询科目目录 | `regionId`, `schoolId`, `majorId`, `moduleId`, `type` (均可选) |
| `GET /app-api/education/catalog/module-nodes` | 查询模块导航节点 | `regionId`, `moduleId`, `parentId` (均可选) |
| `GET /app-api/education/catalog/content-entries` | 查询内容入口 | `regionId`, `entryType`, `includeHidden` (均可选) |
| `GET /app-api/education/catalog/content-nodes` | 查询内容导航节点 | `entryId` (必填), `parentId`, `mode`, `includeInactive`, `markerType` (可选) |
| `GET /app-api/education/catalog/question-collections` | 查询可用题集 | `regionId`, `entryId`, `nodeId`, `collectionType`, `limit` (均可选) |
#### 响应格式
所有成功响应返回 `CommonResult<List<T>>`
```json
{
"code": 0,
"msg": "成功",
"data": [
{"id": "uuid", "name": "全国", "order": 1, "active": true}
]
}
```
#### 错误响应
| HTTP 状态 | 错误码 | 说明 |
|-----------|--------|------|
| 401 | 1_016_000_002 | 未登录或会话过期 |
| 400 | 自定义 | 请求参数不合法 |
| 500 | 1_005_002_000 | 题库数据源未启用 |
| 500 | 1_005_002_001 | 上游题库服务异常 |
| 500 | 1_005_002_002 | 上游认证失败(配置问题) |
| 403 | 1_005_002_003 | 无权限访问上游资源 |
| 404 | 1_005_002_004 | 请求的题库资源不存在 |
| 409 | 1_005_002_005 | 资源状态冲突 |
| 429 | 1_005_002_006 | 请求过于频繁 |
| 500 | 1_005_002_007 | 上游超时 |
| 500 | 1_005_002_008 | 上游返回异常:{状态码} |
| 500 | 1_005_002_009 | 不支持的题库数据源模式 |
- **上游错误不会被转换为空列表或成功响应** — 每个上游非 2xx 均映射为明确的 `ServiceException`
- 日志记录脱敏后的端点名、tenant、上游 requestId、耗时和结果
#### 前端集成提示
前端就位后,学生端学习首页应:
1. 获取上下文(`/education/context`)确认登录态
2. 调用 `/education/catalog/regions` 获取地区筛选器
3. 根据地区调用 `subjects` / `categories` 获取科目分类
4. 调用 `content-entries``content-nodes` 构建目录树
5. 叶子节点调用 `question-collections` 获取题集摘要
**当前阻塞**:完整前端源码不存在,后端目录接口已就绪可通过 Swagger/curl 验证。
### 用户 APP - 题目与练习预览Questions & Practice
所有端点需要学生登录态Bearer Token`userId``tenantId` 由安全上下文派生,不接受客户端传参。
返回的题目数据经过白名单过滤,绝不包含 `correctAnswer``answer``explanation``analysis` 或选项的 `isCorrect` 字段。
#### 架构边界
```
Browser -> Controller(/education/questions/*) -> QuestionCatalogService -> QuestionCatalogProvider -> [Scalar]
↑ SafeQuestionRespVO ↑ CatalogQuestionDTO
```
- **业务层**Controller/Service仅操作安全 VO`SafeQuestionRespVO` 等),答案字段在 DTO→VO 转换时被剥离
- **集成层**Scalar DTO + ScalarCatalogProvider封装 Scalar 协议差异,答案字段在此层被映射但绝不透传到上层
#### 端点列表
| 端点 | 说明 | 参数 |
|------|------|------|
| `GET /app-api/education/questions/page` | 分页查询安全题目 | `collectionId`, `nodeId`, `type`, `difficulty` (可选), `pageNo` (默认1), `pageSize` (默认20) |
| `GET /app-api/education/questions/get` | 获取单个安全题目 | `id` (必填) |
| `GET /app-api/education/questions/collection-questions` | 查询题集中的安全题目 | `collectionId` (必填), `type`, `difficulty` (可选), `pageNo`, `pageSize` |
| `GET /app-api/education/practice-config/preview` | 预览练习配置(不创建会话) | `collectionId` (必填), `nodeId`, `type`, `difficulty` (可选), `questionCount` (默认10, 1-1000) |
#### 分页响应格式
```json
{
"code": 0,
"msg": "成功",
"data": {
"list": [
{
"id": "q-001",
"contentVersion": "v2",
"stem": "1+1等于几",
"type": "choice",
"difficulty": "easy",
"options": [
{"label": "A", "content": "2", "order": 1.0}
]
}
],
"total": 50
}
}
```
#### 练习预览响应格式
```json
{
"code": 0,
"msg": "成功",
"data": {
"eligibleCount": 50,
"totalCount": 100,
"availableTypes": ["choice", "fill"],
"availableDifficulties": ["easy", "medium"],
"minQuestions": 1,
"maxQuestions": 50,
"suggestedCount": 20,
"normalizedCount": 10,
"countWithinRange": true
}
}
```
#### 错误响应
| HTTP 状态 | 错误码 | 说明 |
|-----------|--------|------|
| 401 | 1_016_000_002 | 未登录或会话过期 |
| 404 | 1_005_003_001 | 题目不存在或不可见 |
| 400 | 1_005_003_002 | 无效的练习配置 |
| 400 | 1_005_003_003 | 符合条件的题目数量不足 |
| 500 | 1_005_003_004 | 题库数据源返回不安全内容 |
#### 安全字段白名单
`SafeQuestionRespVO` 仅包含以下字段,前端可安全展示:
- `id`, `contentVersion`, `stem`, `type`, `difficulty`
- `options[]` 中仅包含 `label`, `content`, `order`
以下字段**绝不**出现在响应中:
- `correctAnswer`, `answer`, `explanation`, `analysis`
- 选项的 `isCorrect`
- 任何管理元数据
#### 前端集成提示
前端就位后,学生端练习入口应:
1. 浏览题库目录Catalog API选择题集
2. 调用 `/education/questions/page``/education/questions/collection-questions` 预览题目概要
3. 调用 `/education/practice-config/preview` 获取可用题量范围和建议配置
4. 展示预览结果后用户在可用范围内选择题量开始练习Ticket #6 创建持久会话)
**当前阻塞**:完整前端源码不存在,后端接口已就绪可通过 Swagger/curl 验证。
### 更新后的错误码
| 错误码 | 说明 |
|--------|------|
| 1_005_001_000 | 教育模块未启用 |
| 1_005_001_001 | 租户不存在 |
| 1_005_001_002 | 租户已被禁用 |
| 1_005_001_003 | 租户识别失败:{原因} |
| 1_005_001_004 | 当前租户不可用 |
| 1_005_002_000 | 题库数据源未启用 |
| 1_005_002_001 | 上游题库服务异常 |
| 1_005_002_002 | 上游认证失败 |
| 1_005_002_003 | 无权限访问上游资源 |
| 1_005_002_004 | 题库资源不存在 |
| 1_005_002_005 | 资源状态冲突 |
| 1_005_002_006 | 请求过于频繁 |
| 1_005_002_007 | 上游超时 |
| 1_005_002_008 | 上游返回异常:{状态码} |
| 1_005_002_009 | 不支持的题库数据源模式 |
| 1_005_002_010 | Scalar 数据源未配置 |
| 1_005_002_011 | 上游题库返回数据格式异常 |
| 1_005_002_012 | 上游题库服务不可达 |
| 1_005_003_001 | 题目不存在或不可见 |
| 1_005_003_002 | 无效的练习配置 |
| 1_005_003_003 | 符合条件的题目数量不足 |
| 1_005_003_004 | 题库数据源返回不安全内容 |
### 用户 APP - 答案保存Answer
需要学生登录态。`userId`/`tenantId` 由安全上下文派生。
答案保存具有幂等性:同一 `idempotencyKey` + 相同载荷返回首次结果,相同 key + 不同载荷返回冲突。
服务端乐观锁防止旧版本/旧序号覆盖更新答案。
```
PUT /app-api/education/practice-session/answer
```
**请求体:**
```json
{"sessionId":1001,"questionSequence":3,"selectedAnswer":"A","idempotencyKey":"uuid","clientSequence":5,"expectedSessionVersion":1}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| sessionId | Long | 是 | 练习会话 ID |
| questionSequence | Integer | 是 | 题目序号1-based |
| selectedAnswer | String | 否 | 学生选择的答案null 表示清除 |
| idempotencyKey | String | 是 | 客户端幂等键UUID |
| clientSequence | Integer | 是 | 客户端命令序号(单调递增) |
| expectedSessionVersion | Integer | 是 | 客户端期望的会话版本号 |
**成功响应:** `{"sessionId":1001,"questionSequence":3,"selectedAnswer":"A","serverVersion":2,"acceptedSequence":5}`
**前端保存状态契约**(客户端根据 API 响应派生,后端不提供状态枚举):
| 状态 | 条件 | 说明 |
|------|------|------|
| SAVING | 请求发送中 | 显示保存中指示器 |
| SAVED | code=0 | 更新本地版本号和序号 |
| RETRYING | 网络超时/5xx | 相同 idempotencyKey 安全重试 |
| FAILED | 1\_005\_003\_014/015/016 | 刷新页面获取最新状态后重试 |
刷新页面通过 `GET /practice-session/current` 恢复服务端最后确认的答案。
### 答案保存错误码
| 错误码 | 说明 |
|--------|------|
| 1\_005\_003\_006 | 练习会话不存在 |
| 1\_005\_003\_007 | 无权访问该练习会话 |
| 1\_005\_003\_008 | 练习会话已过期 |
| 1\_005\_003\_009 | 练习会话已提交 |
| 1\_005\_003\_010 | 练习会话已取消 |
| 1\_005\_003\_014 | 幂等键相同但请求内容不一致 |
| 1\_005\_003\_015 | 会话版本已更新,请刷新后重试 |
| 1\_005\_003\_016 | 客户端命令序号已过期 |
| 1\_005\_003\_017 | 无效的选项 |
| 1\_005\_003\_019 | 题目不属于当前会话 |

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao</artifactId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>yudao-module-education</artifactId>
<packaging>jar</packaging>
<name>${project.artifactId}</name>
<description>
education 模块,我们放教育业务。
例如说:课程、练习、题库、考试等等
</description>
<dependencies>
<!-- Web 与权限相关 -->
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-module-system</artifactId>
<version>${revision}</version>
</dependency>
<!-- Test 测试相关 -->
<dependency>
<groupId>cn.iocoder.boot</groupId>
<artifactId>yudao-spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,68 @@
package cn.iocoder.yudao.module.education.config;
import cn.iocoder.yudao.module.education.enums.CatalogProviderMode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 教育模块配置属性
*
* @author 恭学教育
*/
@ConfigurationProperties(prefix = "yudao.education")
@Validated
@Data
public class EducationProperties {
/**
* 是否启用教育模块
*/
private boolean enabled = false;
/**
* 教育模块版本号
*/
private String version = "1.0.0";
/**
* 题库目录数据源模式。
* 默认 SCALAR_READJAVA_READ 为预留模式(当前不支持)。
*/
private CatalogProviderMode catalogMode = CatalogProviderMode.SCALAR_READ;
/**
* 是否允许读取题库目录和题目。关闭后不影响已持久化的练习、报告、错题和收藏数据。
*/
private boolean catalogReadEnabled = true;
/**
* 是否允许创建练习、保存答案和交卷。关闭后仍允许读取已有会话和历史报告。
*/
private boolean practiceWriteEnabled = true;
/**
* Education Pilot 租户 ID 列表。为空表示不限制租户;配置后仅列表内租户可使用学生端能力。
*/
private List<Long> pilotTenantIds = List.of();
/**
* 精确主机名到租户名的映射,用于 DNS 与 system_tenant.websites 不一致的场景。
* key = 标准化后的主机名小写、无端口value = 租户名。
* 示例:{ "staging.school.com": "demo-school" }
*
* 映射优先级高于 system_tenant.websites 字段匹配。
*/
private Map<String, String> hostnameTenantMap = new HashMap<>();
/**
* 学生端全局开放的登录方式。它描述当前部署启用的 Member 登录入口,
* 不是租户级 OAuth 提供方探测结果。
*/
private List<String> loginMethods = List.of("PASSWORD", "SMS");
}

View File

@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.education.controller.admin;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.admin.vo.EducationCapabilityRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - 教育模块")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class EducationCapabilityController {
@Resource
private EducationProperties educationProperties;
@GetMapping("/capability")
@Operation(summary = "获得教育模块能力信息")
@PreAuthorize("@ss.hasPermission('education:capability')")
public CommonResult<EducationCapabilityRespVO> getCapability() {
EducationCapabilityRespVO resp = EducationCapabilityRespVO.builder()
.module("education")
.enabled(educationProperties.isEnabled())
.version(educationProperties.getVersion())
.capabilities(List.of("shell", "catalog", "questions", "practice-preview",
"answer-save", "session-submit", "practice-report"))
.catalogReadEnabled(educationProperties.isCatalogReadEnabled())
.practiceWriteEnabled(educationProperties.isPracticeWriteEnabled())
.pilotTenantCount(educationProperties.getPilotTenantIds().size())
.build();
return success(resp);
}
}

View File

@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.education.controller.admin.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "管理后台 - 教育模块能力信息 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EducationCapabilityRespVO {
@Schema(description = "模块名称", example = "education")
private String module;
@Schema(description = "是否启用", example = "true")
private boolean enabled;
@Schema(description = "模块版本", example = "1.0.0")
private String version;
@Schema(description = "支持的能力列表")
private List<String> capabilities;
@Schema(description = "题库读取是否开放", example = "true")
private boolean catalogReadEnabled;
@Schema(description = "练习写入是否开放", example = "true")
private boolean practiceWriteEnabled;
@Schema(description = "Pilot 租户数量0 表示不限制租户", example = "1")
private int pilotTenantCount;
}

View File

@@ -0,0 +1,71 @@
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.pojo.CommonResult;
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;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
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.EDUCATION_TENANT_NOT_ACTIVE;
/**
* 教育当前上下文 Controller — 学生端
*
* <p>根据当前认证用户和安全上下文返回教育业务所需的租户与用户信息。
* 该端点需要认证,不信任请求体中的 userId。</p>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 教育上下文")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class EducationContextController {
@Resource
private TenantCommonApi tenantCommonApi;
@GetMapping("/context")
@Operation(summary = "获取当前教育上下文",
description = "根据当前认证用户和租户上下文返回教育业务信息。需要登录态。")
public CommonResult<EducationContextRespVO> getContext() {
// 1. 从安全上下文获取用户编号(不信任请求参数)
Long userId = SecurityFrameworkUtils.getLoginUserId();
if (userId == null) {
throw exception(UNAUTHORIZED);
}
// 2. 获取当前租户并验证状态(由 TenantSecurityWebFilter 前置完成,此处二次验证)
Long tenantId = TenantContextHolder.getRequiredTenantId();
tenantCommonApi.validateTenant(tenantId);
// 3. 获取租户信息用于展示
TenantRespDTO tenant = tenantCommonApi.getTenant(tenantId);
if (tenant == null) {
throw exception(EDUCATION_TENANT_NOT_ACTIVE);
}
// 构建响应
EducationContextRespVO resp = EducationContextRespVO.builder()
.userId(userId)
.tenantId(tenantId)
.tenantName(tenant.getName())
.displayName(tenant.getName())
.build();
return success(resp);
}
}

View File

@@ -0,0 +1,126 @@
package cn.iocoder.yudao.module.education.controller.app.catalog;
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.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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 题库目录 Controller — 学生端已认证接口。
*
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 题库目录")
@RestController
@RequestMapping("/education/catalog")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class CatalogController {
@Resource
private CatalogService catalogService;
@Resource
private EducationAccessService educationAccessService;
@GetMapping("/regions")
@Operation(summary = "查询可用地区列表")
public CommonResult<List<CatalogRegionRespVO>> listRegions() {
assertAuthenticated();
return success(catalogService.listRegions());
}
@GetMapping("/categories")
@Operation(summary = "查询题目分类列表")
public CommonResult<List<CatalogCategoryRespVO>> listCategories(
@Parameter(description = "科目 ID") @RequestParam(required = false) String subjectId,
@Parameter(description = "旧导航节点 ID") @RequestParam(required = false) String nodeId) {
assertAuthenticated();
return success(catalogService.listCategories(subjectId, nodeId));
}
@GetMapping("/subjects")
@Operation(summary = "查询科目目录")
public CommonResult<List<CatalogSubjectRespVO>> listSubjects(
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
@Parameter(description = "院校 ID") @RequestParam(required = false) String schoolId,
@Parameter(description = "专业 ID") @RequestParam(required = false) String majorId,
@Parameter(description = "模块 ID") @RequestParam(required = false) String moduleId,
@Parameter(description = "科目类型") @RequestParam(required = false) String type) {
assertAuthenticated();
return success(catalogService.listSubjects(regionId, schoolId, majorId, moduleId, type));
}
@GetMapping("/module-nodes")
@Operation(summary = "查询模块导航节点")
public CommonResult<List<CatalogModuleNodeRespVO>> listModuleNodes(
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
@Parameter(description = "模块 ID") @RequestParam(required = false) String moduleId,
@Parameter(description = "父节点 ID传 root 表示根节点)") @RequestParam(required = false) String parentId) {
assertAuthenticated();
return success(catalogService.listModuleNodes(regionId, moduleId, parentId));
}
@GetMapping("/content-entries")
@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) {
assertAuthenticated();
return success(catalogService.listContentEntries(regionId, entryType, includeHidden));
}
@GetMapping("/content-nodes")
@Operation(summary = "查询内容导航节点")
public CommonResult<List<CatalogContentNodeRespVO>> listContentNodes(
@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));
}
@GetMapping("/question-collections")
@Operation(summary = "查询可用题集")
public CommonResult<List<CatalogQuestionCollectionRespVO>> listQuestionCollections(
@Parameter(description = "地区 ID") @RequestParam(required = false) String regionId,
@Parameter(description = "内容入口 ID") @RequestParam(required = false) String entryId,
@Parameter(description = "内容节点 ID") @RequestParam(required = false) String nodeId,
@Parameter(description = "题集类型") @RequestParam(required = false) String collectionType,
@Parameter(description = "返回条数上限") @RequestParam(required = false) Integer limit) {
assertAuthenticated();
return success(catalogService.listQuestionCollections(regionId, entryId, nodeId, collectionType, limit));
}
/**
* 校验当前学生的租户上下文和题库读取灰度开关。
*/
private void assertAuthenticated() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null || loginUser.getId() == null || loginUser.getTenantId() == null) {
throw exception(UNAUTHORIZED);
}
educationAccessService.assertCatalogReadAllowed(loginUser.getTenantId());
}
}

View File

@@ -0,0 +1,31 @@
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;
@Schema(description = "用户 APP - 题库目录 Category Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogCategoryRespVO {
@Schema(description = "分类 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440001")
private String id;
@Schema(description = "分类名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "高考")
private String name;
@Schema(description = "分类类型", example = "exam_type")
private String type;
@Schema(description = "显示排序", example = "2")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,37 @@
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;
@Schema(description = "用户 APP - 题库目录 ContentEntry Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogContentEntryRespVO {
@Schema(description = "入口 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440005")
private String id;
@Schema(description = "入口名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "高考数学题库")
private String name;
@Schema(description = "入口唯一键", requiredMode = Schema.RequiredMode.REQUIRED, example = "gaokao-math")
private String entryKey;
@Schema(description = "入口类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "question_bank")
private String entryType;
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String regionId;
@Schema(description = "显示排序", example = "1")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,46 @@
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;
@Schema(description = "用户 APP - 题库目录 ContentNode Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogContentNodeRespVO {
@Schema(description = "节点 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440004")
private String id;
@Schema(description = "节点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "1.1 集合")
private String name;
@Schema(description = "节点类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "node")
private String nodeType;
@Schema(description = "内容入口 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440005")
private String entryId;
@Schema(description = "父节点 ID", example = "root")
private String parentId;
@Schema(description = "树深度", example = "1")
private Double depth;
@Schema(description = "是否叶子节点", example = "false")
private Boolean leaf;
@Schema(description = "是否可被选择", example = "true")
private Boolean selectable;
@Schema(description = "显示排序", example = "1")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,37 @@
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;
@Schema(description = "用户 APP - 题库目录 ModuleNode Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogModuleNodeRespVO {
@Schema(description = "节点 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440003")
private String id;
@Schema(description = "节点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "第一章")
private String name;
@Schema(description = "节点类型", example = "chapter")
private String type;
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String regionId;
@Schema(description = "父节点 ID", example = "root")
private String parentId;
@Schema(description = "显示排序", example = "4")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,34 @@
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;
@Schema(description = "用户 APP - 题库目录 QuestionCollection Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogQuestionCollectionRespVO {
@Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440006")
private String id;
@Schema(description = "题集名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "2024 高考数学真题")
private String name;
@Schema(description = "题集类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "exam_paper")
private String collectionType;
@Schema(description = "题目数量", example = "24")
private Long questionCount;
@Schema(description = "显示排序", example = "1")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,28 @@
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;
@Schema(description = "用户 APP - 题库目录 Region Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogRegionRespVO {
@Schema(description = "地区 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
private String id;
@Schema(description = "地区名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "全国")
private String name;
@Schema(description = "显示排序", example = "1")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,34 @@
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;
@Schema(description = "用户 APP - 题库目录 Subject Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CatalogSubjectRespVO {
@Schema(description = "科目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440002")
private String id;
@Schema(description = "科目名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "数学")
private String name;
@Schema(description = "科目类型", example = "academic")
private String type;
@Schema(description = "地区 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String regionId;
@Schema(description = "显示排序", example = "3")
private Double order;
@Schema(description = "是否启用", example = "true")
private Boolean active;
}

View File

@@ -0,0 +1,108 @@
package cn.iocoder.yudao.module.education.controller.app.favorite;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
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.module.education.controller.app.favorite.vo.*;
import cn.iocoder.yudao.module.education.service.favorite.FavoriteService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 收藏夹 Controller — 学生端已认证接口。
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 收藏夹")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class FavoriteController {
@Resource
private FavoriteService favoriteService;
// ========== 收藏操作 ==========
@PostMapping("/favorite/create")
@Operation(summary = "收藏题目(幂等)",
description = "收藏指定题目并保存安全快照。重复收藏返回已有记录不报错。"
+ "重新收藏已取消的记录将恢复。验证题目存在且可见。")
public CommonResult<FavoritePageItemRespVO> create(@Valid @RequestBody FavoriteCreateReqVO reqVO) {
return success(favoriteService.create(reqVO, getUserId(), getTenantId()));
}
@DeleteMapping("/favorite/delete")
@Operation(summary = "取消收藏(幂等)",
description = "取消收藏,通过 id 或 (targetType + targetId) 删除。重复取消返回成功。逻辑删除。")
public CommonResult<Boolean> delete(@Valid @RequestBody FavoriteDeleteReqVO reqVO) {
favoriteService.delete(reqVO, getUserId(), getTenantId());
return success(true);
}
// ========== 查询操作 ==========
@GetMapping("/favorite/page")
@Operation(summary = "分页查询收藏列表",
description = "按当前用户分页查询收藏记录,支持按目标类型筛选,按收藏时间降序。")
public CommonResult<PageResult<FavoritePageItemRespVO>> page(@Valid FavoritePageReqVO reqVO) {
return success(favoriteService.page(reqVO, getUserId(), getTenantId()));
}
@PostMapping("/favorite/status")
@Operation(summary = "批量查询题目收藏状态",
description = "传入题目 ID 列表(最多 100 个),返回已收藏的题目 ID 列表。用于前端图标同步。")
public CommonResult<FavoriteStatusRespVO> status(@Valid @RequestBody FavoriteStatusReqVO reqVO) {
List<String> ids = reqVO.getQuestionIds().stream()
.map(String::trim)
.filter(s -> !s.isEmpty())
.distinct()
.collect(Collectors.toList());
// Validate individual ID length
for (String id : ids) {
if (id.length() > 128) {
throw new ServiceException(BAD_REQUEST.getCode(), "题目 ID 长度不能超过 128 个字符");
}
}
return success(favoriteService.status(ids, getUserId(), getTenantId()));
}
// ========== security helpers ==========
private Long getUserId() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
return loginUser.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;
}
}

View File

@@ -0,0 +1,27 @@
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
import cn.iocoder.yudao.module.education.enums.FavoriteTargetType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 收藏创建请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 收藏创建请求")
@Data
public class FavoriteCreateReqVO {
@Schema(description = "目标类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "QUESTION")
@NotBlank(message = "目标类型不能为空")
private String targetType;
@Schema(description = "目标 ID题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001")
@NotBlank(message = "目标 ID 不能为空")
@Size(max = 64, message = "目标 ID 长度不能超过 64 个字符")
private String targetId;
}

View File

@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 收藏删除请求 VO — 支持按 (targetType + targetId) 或按 id 删除。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 收藏删除请求")
@Data
public class FavoriteDeleteReqVO {
@Schema(description = "收藏记录 ID与 targetType+targetId 二选一)", example = "1")
private Long id;
@Schema(description = "目标类型", example = "QUESTION")
private String targetType;
@Schema(description = "目标 ID题目 ID", example = "q-001")
private String targetId;
}

View File

@@ -0,0 +1,73 @@
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 收藏列表项响应 VO。
*
* @author 恭学教育
*/
@Schema(description = "收藏列表项")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FavoritePageItemRespVO {
@Schema(description = "收藏记录 ID", example = "1")
private Long id;
@Schema(description = "目标类型", example = "QUESTION")
private String targetType;
@Schema(description = "目标 ID题目 ID", example = "q-001")
private String targetId;
@Schema(description = "题干快照", example = "1+1=?")
private String stem;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "选项列表(不含 isCorrect")
private List<OptionVO> options;
@Schema(description = "内容版本", example = "v1")
private String contentVersion;
@Schema(description = "源资源是否可用", example = "true")
private Boolean available;
@Schema(description = "收藏时间", example = "2026-07-27T10:00:00")
private LocalDateTime createTime;
/**
* 安全选项 VO — 不含 isCorrect。
*/
@Schema(description = "安全选项")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class OptionVO {
@Schema(description = "选项标签", example = "A")
private String label;
@Schema(description = "选项内容", example = "2")
private String content;
@Schema(description = "排序", example = "1.0")
private Double order;
}
}

View File

@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 收藏分页查询请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 收藏分页查询请求")
@Data
public class FavoritePageReqVO {
@Schema(description = "页码", example = "1")
@Min(value = 1, message = "页码最小为 1")
@Max(value = 10000, message = "页码最大为 10000")
@NotNull(message = "页码不能为空")
private Integer pageNo = 1;
@Schema(description = "每页条数", example = "10")
@Min(value = 1, message = "每页条数最小为 1")
@Max(value = 100, message = "每页条数最大为 100")
@NotNull(message = "每页条数不能为空")
private Integer pageSize = 10;
@Schema(description = "目标类型筛选(留空=全部)", example = "QUESTION")
private String targetType;
}

View File

@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 收藏状态批量查询请求 VO — 替换原有逗号分隔字符串参数。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 收藏状态批量查询请求")
@Data
public class FavoriteStatusReqVO {
@Schema(description = "题目 ID 列表,最大 100 个", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"q-001\",\"q-002\"]")
@NotEmpty(message = "题目 ID 列表不能为空")
@Size(max = 100, message = "单次最多查询 100 个题目")
private List<String> questionIds;
}

View File

@@ -0,0 +1,26 @@
package cn.iocoder.yudao.module.education.controller.app.favorite.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 收藏状态响应 VO — 批量查询指定题目的收藏状态。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 收藏状态响应")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class FavoriteStatusRespVO {
@Schema(description = "已收藏的目标 ID 列表", example = "[\"q-001\", \"q-003\"]")
private List<String> favoritedIds;
}

View File

@@ -0,0 +1,145 @@
package cn.iocoder.yudao.module.education.controller.app.practice;
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.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;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.practice.PracticeSessionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 练习会话 Controller — 学生端已认证接口。
*
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
* <p>会话和答题数据写入 MySQL从不经过 Scalar。</p>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 练习会话")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class PracticeSessionController {
@Resource
private PracticeSessionService practiceSessionService;
@Resource
private EducationAccessService educationAccessService;
// ========== 会话管理 ==========
@PostMapping("/practice-session/create")
@Operation(summary = "创建练习会话(幂等)",
description = "根据练习配置创建一次持久化练习。同一 clientSessionId 重复调用返回已有会话。"
+ "题目顺序由服务端固定,选项不含答案标记。")
public CommonResult<PracticeSessionRespVO> createSession(@Valid @RequestBody PracticeSessionCreateReqVO reqVO) {
Long tenantId = getTenantId();
educationAccessService.assertPracticeWriteAllowed(tenantId);
return success(practiceSessionService.createPracticeSession(reqVO, getUserId(), tenantId));
}
@GetMapping("/practice-session/current")
@Operation(summary = "获取当前进行中的练习会话",
description = "返回当前用户最近一条 ACTIVE 状态的练习会话,用于刷新恢复。无进行中会话时返回 data=null。")
public CommonResult<PracticeSessionRespVO> currentSession() {
return success(practiceSessionService.getCurrentSession(getUserId(), getTenantId()));
}
@GetMapping("/practice-session/get")
@Operation(summary = "获取指定练习会话",
description = "按会话 ID 获取会话详情,必须验证租户和用户所有权。")
public CommonResult<PracticeSessionRespVO> getSession(
@Parameter(description = "会话 ID", required = true) @RequestParam Long id) {
return success(practiceSessionService.getSession(id, getUserId(), getTenantId()));
}
// ========== 答案保存 ==========
@PutMapping("/practice-session/answer")
@Operation(summary = "保存答案(幂等,支持安全重试)",
description = "保存学生选择题目的答案。同一 idempotencyKey + 相同载荷返回首次成功结果。"
+ "旧 clientSequence 或旧 expectedSessionVersion 拒绝覆盖。"
+ "客户端根据响应中的 serverVersion 和 acceptedSequence 更新本地状态。")
public CommonResult<PracticeAnswerRespVO> submitAnswer(@Valid @RequestBody PracticeAnswerReqVO reqVO) {
Long tenantId = getTenantId();
educationAccessService.assertPracticeWriteAllowed(tenantId);
return success(practiceSessionService.submitAnswer(reqVO, getUserId(), tenantId));
}
// ========== 交卷提交 ==========
@PostMapping("/practice-session/submit")
@Operation(summary = "交卷提交(幂等,原子单向状态转换)",
description = "提交练习会话完成评分。ACTIVE→SUBMITTED 原子单向转换。"
+ "同一 idempotencyKey + 相同载荷返回首次评分报告(超时重试安全)。"
+ "交卷后不可再修改答案。")
public CommonResult<PracticeSubmitRespVO> submitSession(@Valid @RequestBody PracticeSubmitReqVO reqVO) {
Long tenantId = getTenantId();
educationAccessService.assertPracticeWriteAllowed(tenantId);
return success(practiceSessionService.submitSession(reqVO, getUserId(), tenantId));
}
// ========== 报告查看 ==========
@GetMapping("/practice-session/report")
@Operation(summary = "获取会话评分报告",
description = "按会话 ID 获取评分报告,含逐题结果、正确答案和解析。仅已提交会话可用。")
public CommonResult<PracticeSubmitRespVO> getReport(
@Parameter(description = "会话 ID", required = true) @RequestParam Long sessionId) {
return success(practiceSessionService.getReport(sessionId, getUserId(), getTenantId()));
}
@GetMapping("/practice-session/reports")
@Operation(summary = "分页查询练习历史报告",
description = "分页查询当前用户的历史练习报告列表,按创建时间降序。仅返回本人记录。")
public CommonResult<PageResult<PracticeSubmitRespVO>> getReportHistory(
@Parameter(description = "页码", example = "1") @RequestParam(defaultValue = "1") @Min(1) int pageNo,
@Parameter(description = "每页条数", example = "10") @RequestParam(defaultValue = "10") @Min(1) @Max(100) int pageSize) {
return success(practiceSessionService.getReportHistory(getUserId(), getTenantId(), pageNo, pageSize));
}
// ========== security helpers ==========
private Long getUserId() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
return loginUser.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;
}
}

View File

@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 答案保存请求 VO。
*
* <p>所有字段由客户端提供。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 答案保存请求")
@Data
public class PracticeAnswerReqVO {
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@NotNull(message = "会话 ID 不能为空")
private Long sessionId;
@Schema(description = "题目序号1-based", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
@NotNull(message = "题目序号不能为空")
@Min(value = 1, message = "题目序号必须为正整数")
private Integer questionSequence;
@Schema(description = "学生选择的答案(选项标签)", example = "A")
@Size(max = 64, message = "答案长度不能超过 64 字符")
private String selectedAnswer;
@Schema(description = "客户端幂等键UUID", requiredMode = Schema.RequiredMode.REQUIRED,
example = "550e8400-e29b-41d4-a716-446655440000")
@NotBlank(message = "幂等键不能为空")
@Size(max = 64, message = "幂等键长度不能超过 64 字符")
private String idempotencyKey;
@Schema(description = "客户端命令序号(单调递增)", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
@NotNull(message = "客户端命令序号不能为空")
@Min(value = 0, message = "客户端命令序号不能为负")
private Integer clientSequence;
@Schema(description = "客户端期望的会话版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "期望会话版本号不能为空")
@Min(value = 0, message = "期望会话版本号不能为负")
private Integer expectedSessionVersion;
}

View File

@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 答案保存响应 VO。
*
* <p>返回服务端确认的版本号和已接受的序号。客户端据此更新本地状态机SAVED/SAVING/RETRYING/FAILED。</p>
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 答案保存响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PracticeAnswerRespVO {
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
private Long sessionId;
@Schema(description = "题目序号1-based", requiredMode = Schema.RequiredMode.REQUIRED, example = "3")
private Integer questionSequence;
@Schema(description = "服务端已确认的学生答案", example = "A")
private String selectedAnswer;
@Schema(description = "服务端最新会话版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer serverVersion;
@Schema(description = "服务端已接受的客户端命令序号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
private Integer acceptedSequence;
}

View File

@@ -0,0 +1,69 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 练习会话题目响应 VO — 安全视图,不含答案/解析。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 练习会话题目响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PracticeQuestionRespVO {
@Schema(description = "题目序号1-based", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer sequence;
@Schema(description = "原始题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001")
private String questionId;
@Schema(description = "题干", requiredMode = Schema.RequiredMode.REQUIRED, example = "1+1等于几")
private String stem;
@Schema(description = "题型", requiredMode = Schema.RequiredMode.REQUIRED, example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "选项列表(不含正确性标记)")
private List<OptionVO> options;
@Schema(description = "学生已选答案", example = "A")
private String selectedAnswer;
@Schema(description = "是否已作答", example = "true")
private Boolean isAnswered;
@Schema(description = "快照时的内容版本", example = "v2")
private String contentVersion;
/**
* 选项 VO — 仅 label、content、order不含 isCorrect。
*/
@Schema(description = "选项")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class OptionVO {
@Schema(description = "选项标签", example = "A")
private String label;
@Schema(description = "选项内容", example = "2")
private String content;
@Schema(description = "排序", example = "1.0")
private Double order;
}
}

View File

@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 报告逐题明细响应 VO — 含正确答案和解析。
*
* <p>仅在报告查看接口中暴露,创建/获取会话接口中绝不出现在响应中。</p>
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 报告逐题明细响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PracticeReportDetailRespVO {
@Schema(description = "题目序号1-based", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer sequence;
@Schema(description = "原始题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001")
private String questionId;
@Schema(description = "题干", requiredMode = Schema.RequiredMode.REQUIRED, example = "1+1等于几")
private String stem;
@Schema(description = "题型", requiredMode = Schema.RequiredMode.REQUIRED, example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "学生已选答案", example = "A")
private String selectedAnswer;
@Schema(description = "正确答案", example = "B")
private String correctAnswer;
@Schema(description = "是否正确", requiredMode = Schema.RequiredMode.REQUIRED, example = "false")
private Boolean isCorrect;
@Schema(description = "解析", example = "1+1=2因此正确答案为 B")
private String explanation;
}

View File

@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 创建练习会话请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 创建练习会话请求")
@Data
public class PracticeSessionCreateReqVO {
@Schema(description = "客户端生成的会话标识UUID", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
@NotBlank(message = "客户端会话标识不能为空")
private String clientSessionId;
@Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "col-001")
@NotBlank(message = "题集 ID 不能为空")
private String collectionId;
@Schema(description = "目录节点 ID", example = "node-001")
private String nodeId;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "请求题量(正整数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
@NotNull(message = "题量不能为空")
@Min(value = 1, message = "题量至少为 1")
@Max(value = 1000, message = "题量最多为 1000")
private Integer questionCount = 10;
}

View File

@@ -0,0 +1,43 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 练习会话响应 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 练习会话响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PracticeSessionRespVO {
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
private Long sessionId;
@Schema(description = "会话状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "ACTIVE")
private String status;
@Schema(description = "题目总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
private Integer questionCount;
@Schema(description = "服务端版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer version;
@Schema(description = "客户端会话标识", example = "550e8400-e29b-41d4-a716-446655440000")
private String clientSessionId;
@Schema(description = "会话级最后接受的客户端命令序号", example = "5")
private Integer lastClientSequence;
@Schema(description = "会话题目列表(安全视图)")
private List<PracticeQuestionRespVO> questions;
}

View File

@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 交卷提交请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 交卷提交请求")
@Data
public class PracticeSubmitReqVO {
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
@NotNull(message = "会话 ID 不能为空")
private Long sessionId;
@Schema(description = "客户端幂等键UUID", requiredMode = Schema.RequiredMode.REQUIRED,
example = "550e8400-e29b-41d4-a716-446655440000")
@NotBlank(message = "幂等键不能为空")
@Size(max = 64, message = "幂等键长度不能超过 64 字符")
private String idempotencyKey;
@Schema(description = "客户端期望的会话版本号", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
@NotNull(message = "期望会话版本号不能为空")
@Min(value = 1, message = "期望会话版本号必须为正整数")
private Integer expectedSessionVersion;
}

View File

@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.education.controller.app.practice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 交卷提交响应 VO — 评分总览 + 逐题结果。
*
* <p>包含报告 ID、得分统计和逐题明细。明细中才暴露正确答案和解析。</p>
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 交卷提交响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PracticeSubmitRespVO {
@Schema(description = "报告 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "5001")
private Long reportId;
@Schema(description = "会话 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001")
private Long sessionId;
@Schema(description = "题目总数", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
private Integer questionCount;
@Schema(description = "已答题数", requiredMode = Schema.RequiredMode.REQUIRED, example = "8")
private Integer answeredCount;
@Schema(description = "未答题数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer unansweredCount;
@Schema(description = "正确题数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6")
private Integer correctCount;
@Schema(description = "错误题数", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer incorrectCount;
@Schema(description = "得分(整数,满分 100 为基准)", requiredMode = Schema.RequiredMode.REQUIRED, example = "75")
private Integer score;
@Schema(description = "逐题明细(含正确答案和解析)")
private List<PracticeReportDetailRespVO> details;
}

View File

@@ -0,0 +1,116 @@
package cn.iocoder.yudao.module.education.controller.app.question;
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.module.education.controller.app.question.vo.*;
import cn.iocoder.yudao.module.education.service.access.EducationAccessService;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 题目 Controller — 学生端已认证接口。
*
* <p>所有端点需要学生登录态。userId/tenantId 由安全上下文派生,不接受请求参数。</p>
* <p>返回的题目数据经过白名单过滤,绝不包含答案、解析或正确性标记。</p>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 题目与练习")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class QuestionController {
@Resource
private QuestionCatalogService questionCatalogService;
@Resource
private EducationAccessService educationAccessService;
// ========== 题目浏览 ==========
@GetMapping("/questions/page")
@Operation(summary = "分页查询安全题目列表",
description = "支持按题集、节点、题型、难度筛选。仅返回已发布且非隐藏的题目,不含答案字段。")
public CommonResult<PageResult<SafeQuestionRespVO>> pageQuestions(
@Parameter(description = "题集 ID") @RequestParam(required = false) String collectionId,
@Parameter(description = "目录节点 ID") @RequestParam(required = false) String nodeId,
@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();
QuestionPageReqVO reqVO = new QuestionPageReqVO();
reqVO.setCollectionId(collectionId);
reqVO.setNodeId(nodeId);
reqVO.setType(type);
reqVO.setDifficulty(difficulty);
reqVO.setPageNo(pageNo);
reqVO.setPageSize(pageSize);
return success(questionCatalogService.pageQuestions(reqVO));
}
@GetMapping("/questions/get")
@Operation(summary = "获取单个安全题目",
description = "根据 ID 获取题目详情。仅返回学生可见字段,不含答案/解析。未发布或不可见时返回错误。")
public CommonResult<SafeQuestionRespVO> getQuestion(
@Parameter(description = "题目 ID", required = true) @RequestParam String id) {
assertAuthenticated();
return success(questionCatalogService.getQuestion(id));
}
@GetMapping("/questions/collection-questions")
@Operation(summary = "查询题集中的安全题目",
description = "获取指定题集中的题目列表,支持题型和难度筛选。")
public CommonResult<PageResult<SafeQuestionRespVO>> listCollectionQuestions(
@Parameter(description = "题集 ID", required = true) @RequestParam String collectionId,
@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(
collectionId, type, difficulty, pageNo, pageSize));
}
// ========== 练习配置预览 ==========
@GetMapping("/practice-config/preview")
@Operation(summary = "预览练习配置",
description = "验证筛选条件和题量,返回标准化配置摘要。不创建持久化会话。")
public CommonResult<PracticeConfigPreviewRespVO> previewPracticeConfig(
@Valid PracticeConfigPreviewReqVO reqVO) {
assertAuthenticated();
return success(questionCatalogService.previewPracticeConfig(reqVO));
}
/**
* 校验当前学生的租户上下文和题库读取灰度开关。
*/
private void assertAuthenticated() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null || loginUser.getId() == null || loginUser.getTenantId() == null) {
throw exception(UNAUTHORIZED);
}
educationAccessService.assertCatalogReadAllowed(loginUser.getTenantId());
}
}

View File

@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.education.controller.app.question.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 练习配置预览请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 练习配置预览请求")
@Data
public class PracticeConfigPreviewReqVO {
@Schema(description = "题集 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "col-001")
@NotBlank(message = "题集 ID 不能为空")
private String collectionId;
@Schema(description = "目录节点 ID", example = "node-001")
private String nodeId;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "请求题量(正整数)", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
@Min(value = 1, message = "题量至少为 1")
@Max(value = 1000, message = "题量最多为 1000")
private Integer questionCount = 10;
}

View File

@@ -0,0 +1,50 @@
package cn.iocoder.yudao.module.education.controller.app.question.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 练习配置预览响应 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 练习配置预览响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PracticeConfigPreviewRespVO {
@Schema(description = "符合条件的题目数", example = "50")
private Integer eligibleCount;
@Schema(description = "总题目数", example = "100")
private Integer totalCount;
@Schema(description = "可用题型列表", example = "[\"choice\", \"fill\"]")
private List<String> availableTypes;
@Schema(description = "可用难度列表", example = "[\"easy\", \"medium\"]")
private List<String> availableDifficulties;
@Schema(description = "最少题量", example = "1")
private Integer minQuestions;
@Schema(description = "最多题量", example = "50")
private Integer maxQuestions;
@Schema(description = "建议题量", example = "20")
private Integer suggestedCount;
@Schema(description = "服务器标准化后的请求题量", example = "10")
private Integer normalizedCount;
@Schema(description = "请求题量是否在可用范围内", example = "true")
private Boolean countWithinRange;
}

View File

@@ -0,0 +1,33 @@
package cn.iocoder.yudao.module.education.controller.app.question.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 题目分页查询请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 题目分页查询请求")
@Data
public class QuestionPageReqVO {
@Schema(description = "题集 ID", example = "col-001")
private String collectionId;
@Schema(description = "目录节点 ID", example = "node-001")
private String nodeId;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "页码,从 1 开始", example = "1")
private Integer pageNo = 1;
@Schema(description = "每页条数", example = "20")
private Integer pageSize = 20;
}

View File

@@ -0,0 +1,61 @@
package cn.iocoder.yudao.module.education.controller.app.question.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 安全题目响应 VO — 仅包含学生可见字段的 allow-list。
* 绝不包含 correctAnswer、answer、explanation、analysis 或选项的 isCorrect。
*
* @author 恭学教育
*/
@Schema(description = "用户 APP - 安全题目响应")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SafeQuestionRespVO {
@Schema(description = "题目 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "q-001")
private String id;
@Schema(description = "内容版本", example = "v2")
private String contentVersion;
@Schema(description = "题干", requiredMode = Schema.RequiredMode.REQUIRED, example = "1+1等于几")
private String stem;
@Schema(description = "题型", requiredMode = Schema.RequiredMode.REQUIRED, example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "选项列表(不含正确性标记)")
private List<SafeOptionVO> options;
/**
* 安全选项 VO — 仅包含 label 和 content不含 isCorrect。
*/
@Schema(description = "安全选项")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class SafeOptionVO {
@Schema(description = "选项标签", example = "A")
private String label;
@Schema(description = "选项内容", example = "2")
private String content;
@Schema(description = "排序", example = "1.0")
private Double order;
}
}

View File

@@ -0,0 +1,181 @@
package cn.iocoder.yudao.module.education.controller.app.tenant;
import cn.hutool.core.util.StrUtil;
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.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.date.DateUtils;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.education.config.EducationProperties;
import cn.iocoder.yudao.module.education.controller.app.tenant.vo.EducationTenantRespVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
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.*;
/**
* 教育租户识别 Controller — 学生端入口
*
* <p>用于在登录前通过主机名或租户名解析当前租户,返回引导信息。
* 该端点无需认证(@PermitAll忽略租户上下文@TenantIgnore。</p>
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 教育租户识别")
@RestController
@RequestMapping("/education/tenant")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class EducationTenantController {
@Resource
private TenantCommonApi tenantCommonApi;
@Resource
private EducationProperties educationProperties;
@GetMapping("/resolve")
@PermitAll
@TenantIgnore
@Operation(summary = "解析租户",
description = "通过主机名或租户名解析租户信息,返回登录引导所需的基础字段。" +
"hostname 和 tenantName 至少提供一个。")
@Parameters({
@Parameter(name = "hostname", description = "标准化主机名(小写、无端口、无协议)", example = "school.example.com"),
@Parameter(name = "tenantName", description = "租户名", example = "demo-school")
})
public CommonResult<EducationTenantRespVO> resolve(
@RequestParam(value = "hostname", required = false) String hostname,
@RequestParam(value = "tenantName", required = false) String tenantName) {
// 1. 校验输入:至少提供一个
if (StrUtil.isBlank(hostname) && StrUtil.isBlank(tenantName)) {
throw exception(EDUCATION_TENANT_RESOLVE_FAILED, "hostname 和 tenantName 不能同时为空");
}
// 2. 主机名标准化
String normalizedHostname = normalizeHostname(hostname);
// 3. 解析租户并验证一致性
TenantRespDTO tenant = resolveTenantAndEnsureConsistency(normalizedHostname, tenantName);
// 4. 验证租户状态
if (tenant == null) {
throw exception(EDUCATION_TENANT_NOT_FOUND);
}
if (CommonStatusEnum.isDisable(tenant.getStatus())) {
throw exception(EDUCATION_TENANT_DISABLED);
}
if (DateUtils.isExpired(tenant.getExpireTime())) {
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);
}
/**
* 按优先级解析租户:
* 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) {
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);
}
return tenantCommonApi.getTenantByWebsite(hostname);
}
/**
* 主机名标准化:转为小写并拒绝协议、路径和非法端口。
* 端口会被保留,因为 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)) {
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();
}
}

View File

@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.education.controller.app.tenant.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Schema(description = "用户 APP - 教育租户识别 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
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")
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;
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.education.controller.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Schema(description = "用户 APP - 教育当前上下文 Response VO")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class EducationContextRespVO {
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long userId;
@Schema(description = "租户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
private Long tenantId;
@Schema(description = "租户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "demo-school")
private String tenantName;
@Schema(description = "租户显示名称", example = "Demo School")
private String displayName;
}

View File

@@ -0,0 +1,116 @@
package cn.iocoder.yudao.module.education.controller.app.wrong;
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.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionDetailRespVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageItemRespVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageReqVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionReviewReqVO;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 错题本 Controller — 学生端已认证接口。
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 错题本")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class WrongQuestionController {
@Resource
private WrongQuestionService wrongQuestionService;
// ========== 错题列表 ==========
@GetMapping("/wrong-question/page")
@Operation(summary = "分页查询错题列表",
description = "按当前用户分页查询错题,支持按掌握状态筛选,按最近错误时间降序。")
public CommonResult<PageResult<WrongQuestionPageItemRespVO>> page(@Valid WrongQuestionPageReqVO reqVO) {
return success(wrongQuestionService.getWrongQuestionPage(
getUserId(), getTenantId(),
reqVO.getPageNo(), reqVO.getPageSize(),
reqVO.getMasterStatus()));
}
// ========== 错题详情 ==========
@GetMapping("/wrong-question/get")
@Operation(summary = "获取错题详情",
description = "获取指定错题的完整信息,含正确答案和解析。仅限本人错题。")
public CommonResult<WrongQuestionDetailRespVO> get(
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
return success(wrongQuestionService.getWrongQuestionDetail(id, getUserId(), getTenantId()));
}
// ========== 掌握操作 ==========
@PutMapping("/wrong-question/master")
@Operation(summary = "标记错题已掌握(幂等)",
description = "标记指定错题为已掌握。已掌握的错题再次调用无操作。不删除历史记录。")
public CommonResult<Boolean> master(
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
wrongQuestionService.markMastered(id, getUserId(), getTenantId());
return success(true);
}
@PutMapping("/wrong-question/unmaster")
@Operation(summary = "取消掌握标记(幂等)",
description = "取消指定错题的掌握标记。未掌握的错题再次调用无操作。")
public CommonResult<Boolean> unmaster(
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
wrongQuestionService.unmarkMastered(id, getUserId(), getTenantId());
return success(true);
}
// ========== 复习入口 ==========
@PostMapping("/wrong-question/review-session")
@Operation(summary = "从错题创建复习练习(幂等)",
description = "选择错题 ID 列表创建一次复习练习会话。服务端验证所有权。"
+ "同一 clientSessionId 重复调用返回已有会话。"
+ "使用错题快照创建题目,不暴露正确答案。")
public CommonResult<PracticeSessionRespVO> createReviewSession(
@Valid @RequestBody WrongQuestionReviewReqVO reqVO) {
return success(wrongQuestionService.createReviewSession(reqVO, getUserId(), getTenantId()));
}
// ========== security helpers ==========
private Long getUserId() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
return loginUser.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;
}
}

View File

@@ -0,0 +1,65 @@
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 错题详情响应 VO — 含正确答案和解析(仅已提交后可用)。
*
* @author 恭学教育
*/
@Schema(description = "错题详情")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WrongQuestionDetailRespVO {
@Schema(description = "错题记录 ID", example = "1")
private Long id;
@Schema(description = "原始题目 ID", example = "q-001")
private String questionId;
@Schema(description = "题干快照", example = "1+1=?")
private String stem;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "选项快照 JSON", example = "[{\"label\":\"A\",\"content\":\"1\",\"order\":1.0}]")
private String options;
@Schema(description = "题目内容版本", example = "v1")
private String contentVersion;
@Schema(description = "正确答案", example = "B")
private String correctAnswer;
@Schema(description = "解析", example = "因为...所以选B")
private String explanation;
@Schema(description = "首次错误时间", example = "2026-07-20T10:00:00")
private LocalDateTime firstWrongTime;
@Schema(description = "最近错误时间", example = "2026-07-27T10:00:00")
private LocalDateTime lastWrongTime;
@Schema(description = "累计错误次数", example = "3")
private Integer wrongCount;
@Schema(description = "掌握状态", example = "PENDING")
private String masterStatus;
@Schema(description = "标记掌握时间", example = "2026-07-28T10:00:00")
private LocalDateTime masteredTime;
}

View File

@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 错题列表项响应 VO。
*
* @author 恭学教育
*/
@Schema(description = "错题列表项")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WrongQuestionPageItemRespVO {
@Schema(description = "错题记录 ID", example = "1")
private Long id;
@Schema(description = "原始题目 ID", example = "q-001")
private String questionId;
@Schema(description = "题干快照", example = "1+1=?")
private String stem;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "累计错误次数", example = "3")
private Integer wrongCount;
@Schema(description = "最近错误时间", example = "2026-07-27T10:00:00")
private LocalDateTime lastWrongTime;
@Schema(description = "掌握状态", example = "PENDING")
private String masterStatus;
}

View File

@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 错题分页查询请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "错题分页查询请求")
@Data
public class WrongQuestionPageReqVO {
@Schema(description = "页码", example = "1")
@Min(value = 1, message = "页码最小为 1")
@NotNull(message = "页码不能为空")
private Integer pageNo = 1;
@Schema(description = "每页条数", example = "10")
@Min(value = 1, message = "每页条数最小为 1")
@Max(value = 100, message = "每页条数最大为 100")
@NotNull(message = "每页条数不能为空")
private Integer pageSize = 10;
@Schema(description = "掌握状态筛选(留空=全部)", example = "PENDING")
private String masterStatus;
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 错题复习练习创建请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "错题复习练习创建请求")
@Data
public class WrongQuestionReviewReqVO {
@Schema(description = "客户端生成的会话标识UUID用于幂等创建", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
@NotEmpty(message = "clientSessionId 不能为空")
private String clientSessionId;
@Schema(description = "错题记录 ID 列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]")
@NotEmpty(message = "错题 ID 列表不能为空")
@Size(max = 100, message = "单次复习题目数不能超过 100")
private List<Long> wrongQuestionIds;
}

View File

@@ -0,0 +1,55 @@
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;
}

View File

@@ -0,0 +1,59 @@
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, target_type, target_id) 唯一一条。
* 逻辑删除:取消收藏设置 deleted=1重新收藏通过 ON DUPLICATE KEY UPDATE 恢复。</p>
*
* <p>快照字段stem/type/difficulty/options/contentVersion来自收藏时
* 题目的安全视图保留题目当时状态。available 标记源资源当前是否可用。</p>
*
* <p>target_type 当前仅支持 QUESTION使用枚举类型预留扩展。</p>
*
* @author 恭学教育
*/
@TableName("education_favorite")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class EducationFavoriteDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 学生用户编号 */
private Long userId;
/** 目标类型QUESTION */
private String targetType;
/** 目标 ID题目 ID */
private String targetId;
/** 题干快照 */
private String stem;
/** 题型快照 */
private String type;
/** 难度快照 */
private String difficulty;
/** 选项快照 JSON不含 isCorrect */
private String options;
/** 题目内容版本 */
private String contentVersion;
/** 源资源是否可见 */
private Boolean available;
}

View File

@@ -0,0 +1,66 @@
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>会话创建时从题目源获取当前题目内容并快照固化。后续改题不影响本次练习。
* 选项以 JSON 存储,不含 isCorrect 字段以确保前端安全。</p>
*
* @author 恭学教育
*/
@TableName("education_practice_question")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class PracticeQuestionDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 会话 ID */
private Long sessionId;
/** 题目序号1-based服务端固定顺序 */
private Integer sequence;
/** 原始题目 ID */
private String questionId;
/** 快照时的题目内容版本 */
private String contentVersion;
/** 题干快照 */
private String stem;
/** 题型快照 */
private String type;
/** 难度快照 */
private String difficulty;
/** 选项快照 JSON — 仅含 label、content、order不含 isCorrect */
private String options;
/** 正确答案快照 — 不可在交卷前暴露JSON 格式 */
private String correctAnswer;
/** 解析快照 — 不可在交卷前暴露 */
private String explanation;
/** 学生已选答案 */
private String selectedAnswer;
/** 是否已作答 */
private Boolean isAnswered;
/** 最后接受的客户端命令序号(单调递增),用于拒绝乱序/过期请求 */
private Integer clientSequence;
}

View File

@@ -0,0 +1,54 @@
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>一个会话最多一份报告uk_report_session。创建后不可变。
* 题目级结果存储在 PracticeReportDetailDO 中。</p>
*
* @author 恭学教育
*/
@TableName("education_practice_report")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class PracticeReportDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 用户编号 */
private Long userId;
/** 会话 ID */
private Long sessionId;
/** 题目总数 */
private Integer questionCount;
/** 已答题数 */
private Integer answeredCount;
/** 未答题数 */
private Integer unansweredCount;
/** 正确题数 */
private Integer correctCount;
/** 错误题数 */
private Integer incorrectCount;
/** 得分(整数,满分 100 为基准) */
private Integer score;
/** 报告状态 */
private String status;
}

View File

@@ -0,0 +1,69 @@
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>交卷时从会话题目快照计算,含题干、学生答案、正确答案和解析的快照。
* 创建后不可变,确保历史报告不受后续题目修改影响。</p>
*
* @author 恭学教育
*/
@TableName("education_practice_report_detail")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class PracticeReportDetailDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 用户编号 */
private Long userId;
/** 报告 ID */
private Long reportId;
/** 会话 ID */
private Long sessionId;
/** 原始题目 ID */
private String questionId;
/** 题目序号1-based */
private Integer sequence;
/** 题干快照 */
private String stem;
/** 题型快照 */
private String type;
/** 难度快照 */
private String difficulty;
/** 学生已选答案 */
private String selectedAnswer;
/** 正确答案快照 */
private String correctAnswer;
/** 是否正确 */
private Boolean isCorrect;
/** 解析快照 */
private String explanation;
/** 题目内容版本快照 */
private String contentVersion;
/** 选项快照 JSON不含 isCorrect */
private String options;
}

View File

@@ -0,0 +1,62 @@
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>归属于当前租户和 Member 用户。clientSessionId 由客户端生成,服务端保证 per-tenant 唯一。
* version 字段用于乐观锁并发控制。</p>
*
* @author 恭学教育
*/
@TableName("education_practice_session")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class PracticeSessionDO extends TenantBaseDO {
/** 会话主键 */
@TableId
private Long id;
/** Member 用户编号(由安全上下文派生,非请求参数) */
private Long userId;
/** 客户端生成的会话标识UUID用于幂等创建 */
private String clientSessionId;
/**
* 会话状态ACTIVE | SUBMITTED | EXPIRED | CANCELLED
*/
private String status;
/** 题目总数 */
private Integer questionCount;
/** 源题集 ID */
private String collectionId;
/** 源目录节点 ID */
private String nodeId;
/** 筛选题型 */
private String type;
/** 筛选难度 */
private String difficulty;
/** 乐观锁版本号 */
private Integer version;
/** 会话级最后接受的客户端命令序号(跨题目单调递增),用于拒绝乱序请求 */
private Integer lastClientSequence;
/** 复习会话题目指纹SHA-256 of sorted unique wrongQuestionIds用于幂等重放校验 */
private String reviewFingerprint;
}

View File

@@ -0,0 +1,52 @@
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;
}

View File

@@ -0,0 +1,80 @@
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.*;
import java.time.LocalDateTime;
/**
* 错题本 DO — 学生错题持久记录。
*
* <p>同一 (tenant, user, question) 唯一一条。重复答错只更新计数和快照。
* 标记掌握不删除记录masterStatus 字段标识当前状态。</p>
*
* <p>快照字段stem/type/difficulty/options/contentVersion来自最近一次
* 错误报告明细,用于列表和详情展示,无需回查 report_detail 表。</p>
*
* @author 恭学教育
*/
@TableName("education_wrong_question")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class WrongQuestionDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 学生用户编号 */
private Long userId;
/** 原始题目 ID */
private String questionId;
/** 题干快照(最新) */
private String stem;
/** 题型快照 */
private String type;
/** 难度快照 */
private String difficulty;
/** 选项快照 JSON不含 isCorrect */
private String options;
/** 题目内容版本 */
private String contentVersion;
/** 正确答案快照(最新,供详情展示) */
private String latestCorrectAnswer;
/** 解析快照(最新,供详情展示) */
private String latestExplanation;
/** 首次错误时间 */
private LocalDateTime firstWrongTime;
/** 最近错误时间 */
private LocalDateTime lastWrongTime;
/** 累计错误次数 */
private Integer wrongCount;
/** 掌握状态PENDING / MASTERED */
private String masterStatus;
/** 标记掌握时间 */
private LocalDateTime masteredTime;
/** 最近关联的报告 ID */
private Long lastReportId;
/** 最近关联的会话 ID */
private Long lastSessionId;
}

View File

@@ -0,0 +1,39 @@
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>同一 (wrong_question_id, report_id) 唯一,确保每个报告明细
* 对错题本最多贡献一次。用于防止重放交卷和并发提交重复计数。</p>
*
* @author 恭学教育
*/
@TableName("education_wrong_question_idempotency")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class WrongQuestionIdempotencyDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 学生用户编号 */
private Long userId;
/** 错题记录 ID */
private Long wrongQuestionId;
/** 报告 ID */
private Long reportId;
/** 题目 ID */
private String questionId;
}

View File

@@ -0,0 +1,43 @@
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);
}

View File

@@ -0,0 +1,163 @@
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.EducationFavoriteDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import java.util.Collection;
import java.util.List;
/**
* 收藏夹 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface EducationFavoriteMapper extends BaseMapperX<EducationFavoriteDO> {
/**
* INSERT ... ON DUPLICATE KEY 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
* updating snapshots. The unique key covers all states (deleted or not),
* so soft-deleted rows are reactivated on re-favorite.</p>
*/
@Insert("INSERT INTO education_favorite " +
"(tenant_id, user_id, target_type, target_id, " +
"stem, type, difficulty, options, content_version, available, " +
"creator, create_time, updater, update_time, deleted) " +
"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), " +
"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)")
@Options(useGeneratedKeys = true, keyProperty = "id")
int upsert(EducationFavoriteDO record);
/**
* 按租户、用户、目标类型和目标 ID 查找收藏记录(含已删除,用于 reactivation 检查)。
*/
default EducationFavoriteDO selectByTenantUserTarget(Long tenantId, Long userId,
String targetType, String targetId) {
return selectOne(new LambdaQueryWrapperX<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getTenantId, tenantId)
.eq(EducationFavoriteDO::getUserId, userId)
.eq(EducationFavoriteDO::getTargetType, targetType)
.eq(EducationFavoriteDO::getTargetId, targetId));
}
/**
* 按租户、用户、目标类型和目标 ID 查找未删除的收藏记录。
*/
default EducationFavoriteDO selectActiveByTenantUserTarget(Long tenantId, Long userId,
String targetType, String targetId) {
return selectOne(new LambdaQueryWrapperX<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getTenantId, tenantId)
.eq(EducationFavoriteDO::getUserId, userId)
.eq(EducationFavoriteDO::getTargetType, targetType)
.eq(EducationFavoriteDO::getTargetId, targetId)
.eq(EducationFavoriteDO::getDeleted, false));
}
/**
* 按租户和用户分页查询收藏列表(仅未删除),按更新时间降序。
*
* @param targetType 可选目标类型筛选null = 全部)
*/
default IPage<EducationFavoriteDO> selectPageByTenantAndUser(IPage<EducationFavoriteDO> page,
Long tenantId, Long userId,
String targetType) {
LambdaQueryWrapperX<EducationFavoriteDO> wrapper = new LambdaQueryWrapperX<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getTenantId, tenantId)
.eq(EducationFavoriteDO::getUserId, userId)
.eq(EducationFavoriteDO::getDeleted, false)
.orderByDesc(EducationFavoriteDO::getUpdateTime);
if (targetType != null && !targetType.isEmpty()) {
wrapper.eq(EducationFavoriteDO::getTargetType, targetType);
}
return selectPage(page, wrapper);
}
/**
* 按租户、用户和目标 ID 列表查询未删除的收藏记录(用于状态批量查询)。
*/
default List<EducationFavoriteDO> selectActiveByTenantUserAndTargetIds(Long tenantId, Long userId,
String targetType,
Collection<String> targetIds) {
return selectList(new LambdaQueryWrapperX<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getTenantId, tenantId)
.eq(EducationFavoriteDO::getUserId, userId)
.eq(EducationFavoriteDO::getTargetType, targetType)
.in(EducationFavoriteDO::getTargetId, targetIds)
.eq(EducationFavoriteDO::getDeleted, false));
}
/**
* 逻辑删除收藏记录 — 设置 deleted=1。
*
* @return 受影响行数
*/
default int softDeleteByTenantUserTarget(Long tenantId, Long userId,
String targetType, String targetId) {
return update(null,
new LambdaUpdateWrapper<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getTenantId, tenantId)
.eq(EducationFavoriteDO::getUserId, userId)
.eq(EducationFavoriteDO::getTargetType, targetType)
.eq(EducationFavoriteDO::getTargetId, targetId)
.eq(EducationFavoriteDO::getDeleted, false)
.set(EducationFavoriteDO::getDeleted, true));
}
/**
* 按 ID 逻辑删除收藏记录(带所有权校验)。
*
* @return 受影响行数
*/
default int softDeleteByIdAndTenantAndUser(Long id, Long tenantId, Long userId) {
return update(null,
new LambdaUpdateWrapper<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getId, id)
.eq(EducationFavoriteDO::getTenantId, tenantId)
.eq(EducationFavoriteDO::getUserId, userId)
.eq(EducationFavoriteDO::getDeleted, false)
.set(EducationFavoriteDO::getDeleted, true));
}
/**
* 按 ID、租户和用户更新可用标记避免调用方遗漏所有权边界。
*/
default int updateAvailableByIdAndTenantAndUser(Long id, Long tenantId, Long userId, Boolean available) {
return update(null,
new LambdaUpdateWrapper<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getId, id)
.eq(EducationFavoriteDO::getTenantId, tenantId)
.eq(EducationFavoriteDO::getUserId, userId)
.eq(EducationFavoriteDO::getDeleted, false)
.set(EducationFavoriteDO::getAvailable, available));
}
/**
* 兼容旧调用方;业务服务应使用带租户和用户的重载。
*/
default int updateAvailable(Long id, Boolean available) {
return update(null,
new LambdaUpdateWrapper<EducationFavoriteDO>()
.eq(EducationFavoriteDO::getId, id)
.set(EducationFavoriteDO::getAvailable, available));
}
}

View File

@@ -0,0 +1,89 @@
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.PracticeQuestionDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 练习会话题目快照 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface PracticeQuestionMapper extends BaseMapperX<PracticeQuestionDO> {
default List<PracticeQuestionDO> selectBySessionIdAndTenantIdOrderBySequence(Long sessionId, Long tenantId) {
return selectList(new LambdaQueryWrapperX<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getSessionId, sessionId)
.eq(PracticeQuestionDO::getTenantId, tenantId)
.orderByAsc(PracticeQuestionDO::getSequence));
}
/**
* 按 tenant、会话 ID 和序号查询题目快照。
*/
default PracticeQuestionDO selectBySessionIdAndTenantIdAndSequence(Long sessionId, Long tenantId,
Integer sequence) {
return selectOne(new LambdaQueryWrapperX<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getSessionId, sessionId)
.eq(PracticeQuestionDO::getTenantId, tenantId)
.eq(PracticeQuestionDO::getSequence, sequence));
}
/**
* 兼容内部测试和迁移查询;业务服务优先使用带 tenantId 的方法。
*/
default List<PracticeQuestionDO> selectBySessionIdOrderBySequence(Long sessionId) {
return selectList(new LambdaQueryWrapperX<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getSessionId, sessionId)
.orderByAsc(PracticeQuestionDO::getSequence));
}
/**
* 兼容内部测试和迁移查询;业务服务优先使用带 tenantId 的方法。
*/
default PracticeQuestionDO selectBySessionIdAndSequence(Long sessionId, Integer sequence) {
return selectOne(new LambdaQueryWrapperX<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getSessionId, sessionId)
.eq(PracticeQuestionDO::getSequence, sequence));
}
/**
* CAS 条件更新题目答案。仅当指定租户、会话和题目匹配,且当前 clientSequence
* 小于传入值(或为 NULL时才执行更新。
*/
default int updateAnswerIfNewer(Long questionId, Long sessionId, Long tenantId, String selectedAnswer,
Boolean isAnswered, Integer clientSequence) {
return update(null,
new LambdaUpdateWrapper<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getId, questionId)
.eq(PracticeQuestionDO::getSessionId, sessionId)
.eq(PracticeQuestionDO::getTenantId, tenantId)
.and(w -> w.isNull(PracticeQuestionDO::getClientSequence)
.or().lt(PracticeQuestionDO::getClientSequence, clientSequence))
.set(PracticeQuestionDO::getSelectedAnswer, selectedAnswer)
.set(PracticeQuestionDO::getIsAnswered, isAnswered)
.set(PracticeQuestionDO::getClientSequence, clientSequence));
}
/**
* 兼容旧调用方;业务服务应使用包含 sessionId 的重载。
*/
default int updateAnswerIfNewer(Long questionId, Long tenantId, String selectedAnswer,
Boolean isAnswered, Integer clientSequence) {
return update(null,
new LambdaUpdateWrapper<PracticeQuestionDO>()
.eq(PracticeQuestionDO::getId, questionId)
.eq(PracticeQuestionDO::getTenantId, tenantId)
.and(w -> w.isNull(PracticeQuestionDO::getClientSequence)
.or().lt(PracticeQuestionDO::getClientSequence, clientSequence))
.set(PracticeQuestionDO::getSelectedAnswer, selectedAnswer)
.set(PracticeQuestionDO::getIsAnswered, isAnswered)
.set(PracticeQuestionDO::getClientSequence, clientSequence));
}
}

View File

@@ -0,0 +1,38 @@
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.PracticeReportDetailDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 练习报告明细 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface PracticeReportDetailMapper extends BaseMapperX<PracticeReportDetailDO> {
/**
* 按报告 ID、租户、用户查询所有明细按 sequence 升序。
* Ownership check: tenant + user must match the report owner.
*/
default List<PracticeReportDetailDO> selectByReportIdAndTenantAndUser(Long reportId, Long tenantId, Long userId) {
return selectList(new LambdaQueryWrapperX<PracticeReportDetailDO>()
.eq(PracticeReportDetailDO::getReportId, reportId)
.eq(PracticeReportDetailDO::getTenantId, tenantId)
.eq(PracticeReportDetailDO::getUserId, userId)
.orderByAsc(PracticeReportDetailDO::getSequence));
}
/**
* 按报告 ID 查询所有明细,按 sequence 升序。
* Prefer {@link #selectByReportIdAndTenantAndUser} when tenant/user context is available.
*/
default List<PracticeReportDetailDO> selectByReportIdOrderBySequence(Long reportId) {
return selectList(new LambdaQueryWrapperX<PracticeReportDetailDO>()
.eq(PracticeReportDetailDO::getReportId, reportId)
.orderByAsc(PracticeReportDetailDO::getSequence));
}
}

View File

@@ -0,0 +1,52 @@
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.PracticeReportDO;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
/**
* 练习报告 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface PracticeReportMapper extends BaseMapperX<PracticeReportDO> {
/**
* 按会话 ID 和租户查找报告。
*/
default PracticeReportDO selectBySessionIdAndTenant(Long sessionId, Long tenantId) {
return selectOne(new LambdaQueryWrapperX<PracticeReportDO>()
.eq(PracticeReportDO::getSessionId, sessionId)
.eq(PracticeReportDO::getTenantId, tenantId));
}
/**
* 按租户和用户分页查询报告,按创建时间降序。
*/
default IPage<PracticeReportDO> selectPageByTenantAndUser(IPage<PracticeReportDO> page, Long tenantId, Long userId) {
return selectPage(page, new LambdaQueryWrapperX<PracticeReportDO>()
.eq(PracticeReportDO::getTenantId, tenantId)
.eq(PracticeReportDO::getUserId, userId)
.orderByDesc(PracticeReportDO::getCreateTime));
}
/**
* 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("INSERT IGNORE 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)")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(PracticeReportDO record);
}

Some files were not shown because too many files have changed in this diff Show More