feat: auto committed

This commit is contained in:
SpecialX
2026-07-10 18:57:57 +08:00
parent 5e0e20b1ce
commit 9fae2b0e78
74 changed files with 5362 additions and 304 deletions

View File

@@ -2,46 +2,253 @@ syntax = "proto3";
package next_edu_cloud.content.v1;
import "google/protobuf/struct.proto";
service TextbookService {
rpc CreateTextbook(CreateTextbookRequest) returns (Textbook);
rpc GetTextbook(GetTextbookRequest) returns (Textbook);
rpc ListTextbooks(ListTextbooksRequest) returns (ListTextbooksResponse);
rpc UpdateTextbook(UpdateTextbookRequest) returns (Textbook);
rpc DeleteTextbook(DeleteTextbookRequest) returns (Empty);
}
service ChapterService {
rpc CreateChapter(CreateChapterRequest) returns (Chapter);
rpc GetChapter(GetChapterRequest) returns (Chapter);
rpc ListChapters(ListChaptersRequest) returns (ListChaptersResponse);
rpc UpdateChapter(UpdateChapterRequest) returns (Chapter);
rpc DeleteChapter(DeleteChapterRequest) returns (Empty);
}
service KnowledgeGraphService {
rpc GetPrerequisites(GetPrerequisitesRequest) returns (KnowledgePointsResponse);
rpc GetLearningPath(GetLearningPathRequest) returns (LearningPath);
rpc AddPrerequisite(AddPrerequisiteRequest) returns (Empty);
rpc RemovePrerequisite(RemovePrerequisiteRequest) returns (Empty);
}
service QuestionService {
rpc CreateQuestion(CreateQuestionRequest) returns (Question);
rpc BatchCreateQuestions(BatchCreateQuestionsRequest) returns (BatchCreateQuestionsResponse);
rpc GetQuestion(GetQuestionRequest) returns (Question);
rpc ListQuestions(ListQuestionsRequest) returns (ListQuestionsResponse);
rpc UpdateQuestion(UpdateQuestionRequest) returns (Question);
rpc DeleteQuestion(DeleteQuestionRequest) returns (Empty);
rpc PublishQuestion(PublishQuestionRequest) returns (Empty);
rpc SearchQuestions(SearchQuestionsRequest) returns (SearchQuestionsResponse);
}
message Empty {}
message Textbook {
string id = 1;
string title = 2;
string subject_id = 3;
string grade_id = 4;
string version = 5;
string status = 6;
string tenant_id = 7;
google.protobuf.Struct metadata = 8;
int64 created_at = 9;
int64 updated_at = 10;
}
message Chapter {
string id = 1;
string textbook_id = 2;
string title = 3;
int32 order = 4;
string parent_id = 5;
string status = 6;
int64 created_at = 7;
int64 updated_at = 8;
}
message KnowledgePoint {
string id = 1;
string chapter_id = 2;
string title = 3;
string description = 4;
int32 difficulty = 5;
google.protobuf.Struct metadata = 6;
int64 created_at = 7;
int64 updated_at = 8;
}
message Question {
string id = 1;
string knowledge_point_id = 2;
string type = 3;
string content = 4;
google.protobuf.Struct options = 5;
string answer = 6;
string explanation = 7;
int32 difficulty = 8;
string status = 9;
string source = 10;
string created_by = 11;
google.protobuf.Struct metadata = 12;
int64 created_at = 13;
int64 updated_at = 14;
}
// TextbookService request/response messages
message CreateTextbookRequest {
string title = 1;
string subject_id = 2;
string grade_id = 3;
string version = 4;
google.protobuf.Struct metadata = 5;
}
message GetTextbookRequest { string id = 1; }
message ListTextbooksRequest { string subject_id = 1; string grade_id = 2; }
message ListTextbooksResponse { repeated Textbook textbooks = 1; }
message KnowledgePoint {
string id = 1;
string title = 2;
message ListTextbooksRequest {
string subject_id = 1;
string grade_id = 2;
string page_token = 3;
int32 page_size = 4;
}
message ListTextbooksResponse {
repeated Textbook textbooks = 1;
string next_page_token = 2;
}
message UpdateTextbookRequest {
string id = 1;
optional string title = 2;
optional string status = 3;
google.protobuf.Struct metadata = 4;
}
message DeleteTextbookRequest { string id = 1; }
// ChapterService request/response messages
message CreateChapterRequest {
string textbook_id = 1;
string title = 2;
int32 order = 3;
string parent_id = 4;
}
message GetChapterRequest { string id = 1; }
message ListChaptersRequest {
string textbook_id = 1;
string parent_id = 2;
}
message ListChaptersResponse { repeated Chapter chapters = 1; }
message UpdateChapterRequest {
string id = 1;
optional string title = 2;
optional int32 order = 3;
optional string status = 4;
}
message DeleteChapterRequest { string id = 1; }
// KnowledgeGraphService request/response messages
message GetPrerequisitesRequest {
string knowledge_point_id = 1;
int32 depth = 2;
}
message GetPrerequisitesRequest { string knowledge_point_id = 1; }
message KnowledgePointsResponse { repeated KnowledgePoint points = 1; }
message GetLearningPathRequest { string student_id = 1; string subject_id = 2; }
message GetLearningPathRequest {
string student_id = 1;
string subject_id = 2;
}
message LearningPath {
repeated KnowledgePoint points = 1;
repeated string recommended_order = 2;
}
message AddPrerequisiteRequest {
string kp_id = 1;
string prerequisite_id = 2;
}
message RemovePrerequisiteRequest {
string kp_id = 1;
string prerequisite_id = 2;
}
// QuestionService request/response messages
message CreateQuestionRequest {
string knowledge_point_id = 1;
string type = 2;
string content = 3;
google.protobuf.Struct options = 4;
string answer = 5;
string explanation = 6;
int32 difficulty = 7;
string source = 8;
string created_by = 9;
google.protobuf.Struct metadata = 10;
}
message BatchCreateQuestionsRequest { repeated CreateQuestionRequest questions = 1; }
message BatchCreateQuestionsResponse {
repeated string ids = 1;
repeated BatchCreateFailure failed = 2;
}
message BatchCreateFailure {
int32 index = 1;
string error = 2;
}
message GetQuestionRequest { string id = 1; }
message ListQuestionsRequest {
string knowledge_point_id = 1;
string type = 2;
int32 difficulty = 3;
string status = 4;
string page_token = 5;
int32 page_size = 6;
}
message ListQuestionsResponse {
repeated Question questions = 1;
string next_page_token = 2;
}
message UpdateQuestionRequest {
string id = 1;
optional string content = 2;
optional string answer = 3;
optional string status = 4;
google.protobuf.Struct options = 5;
optional string explanation = 6;
optional int32 difficulty = 7;
}
message DeleteQuestionRequest { string id = 1; }
message PublishQuestionRequest { string id = 1; }
message SearchQuestionsRequest {
string q = 1;
string type = 2;
int32 difficulty = 3;
string knowledge_point_id = 4;
string page_token = 5;
int32 page_size = 6;
}
message SearchQuestionsResponse {
repeated Question questions = 1;
int32 total = 2;
string next_page_token = 3;
}

View File

@@ -11,6 +11,10 @@ package next_edu_cloud.events.v1;
// edu.homework.events <- homework.assigned / homework.submitted / homework.graded
// edu.grade.events <- grade.recorded / grade.updated
// edu.class.events <- class.transferred
// edu.content.textbook.events <- textbook.created / textbook.updated / textbook.published / textbook.archived
// edu.content.chapter.events <- chapter.created / chapter.updated / chapter.deleted
// edu.content.knowledge_point.events <- knowledge_point.created / knowledge_point.updated / knowledge_point.prerequisite_added / knowledge_point.prerequisite_removed
// edu.content.question.events <- question.created / question.updated / question.published / question.deleted
message ClassEvent {
string event_id = 1;
@@ -58,3 +62,60 @@ message GradeEvent {
string action = 8;
map<string, string> metadata = 9;
}
message TextbookEvent {
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string textbook_id = 5;
string title = 6;
string subject_id = 7;
string grade_id = 8;
string version = 9;
string action = 10;
map<string, string> metadata = 11;
}
message ChapterEvent {
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string chapter_id = 5;
string textbook_id = 6;
string title = 7;
int32 order = 8;
string action = 9;
map<string, string> metadata = 10;
}
message KnowledgePointEvent {
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string kp_id = 5;
string chapter_id = 6;
string title = 7;
int32 difficulty = 8;
string action = 9;
string prerequisite_id = 10;
map<string, string> metadata = 11;
}
message QuestionEvent {
string event_id = 1;
string aggregate_id = 2;
string event_type = 3;
int64 occurred_at = 4;
string question_id = 5;
string kp_id = 6;
string type = 7;
int32 difficulty = 8;
string status = 9;
string source = 10;
string created_by = 11;
string action = 12;
map<string, string> metadata = 13;
}

192
pnpm-lock.yaml generated
View File

@@ -82,8 +82,88 @@ importers:
specifier: ^5.6.0
version: 5.9.3
packages/hooks:
dependencies:
'@edu/ui-components':
specifier: workspace:*
version: link:../ui-components
react:
specifier: ^18.3.0
version: 18.3.1
react-dom:
specifier: ^18.3.0
version: 18.3.1(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.0
version: 18.3.31
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.31)
typescript:
specifier: ^5.6.0
version: 5.9.3
packages/shared-proto: {}
packages/shared-ts:
dependencies:
'@nestjs/common':
specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.3.1
drizzle-orm:
specifier: ^0.31.0
version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1)
kafkajs:
specifier: ^2.2.0
version: 2.2.4
pino:
specifier: ^9.4.0
version: 9.14.0
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
rxjs:
specifier: ^7.8.0
version: 7.8.2
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.20.0
typescript:
specifier: ^5.6.0
version: 5.9.3
packages/ui-components:
dependencies:
'@edu/ui-tokens':
specifier: workspace:*
version: link:../ui-tokens
react:
specifier: ^18.3.0
version: 18.3.1
react-dom:
specifier: ^18.3.0
version: 18.3.1(react@18.3.1)
devDependencies:
'@types/react':
specifier: ^18.3.0
version: 18.3.31
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.31)
typescript:
specifier: ^5.6.0
version: 5.9.3
packages/ui-tokens: {}
scripts/arch-scan:
dependencies:
better-sqlite3:
@@ -107,7 +187,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -199,12 +279,21 @@ importers:
services/content:
dependencies:
'@grpc/grpc-js':
specifier: ^1.12.0
version: 1.14.4
'@grpc/proto-loader':
specifier: ^0.7.13
version: 0.7.15
'@nestjs/common':
specifier: ^10.4.0
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/microservices':
specifier: ^10.4.0
version: 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -220,9 +309,15 @@ importers:
'@opentelemetry/sdk-node':
specifier: ^0.55.0
version: 0.55.0(@opentelemetry/api@1.9.1)
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.3.1
drizzle-orm:
specifier: ^0.31.0
version: 0.31.4(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.6.1)(@types/react@18.3.31)(better-sqlite3@11.10.0)(mysql2@3.22.6(@types/node@22.20.0))(react@18.3.1)
kafkajs:
specifier: ^2.2.4
version: 2.2.4
mysql2:
specifier: ^3.11.0
version: 3.22.6(@types/node@22.20.0)
@@ -241,9 +336,6 @@ importers:
rxjs:
specifier: ^7.8.0
version: 7.8.2
uuid:
specifier: ^10.0.0
version: 10.0.0
zod:
specifier: ^3.23.0
version: 3.25.76
@@ -257,6 +349,9 @@ importers:
'@types/node':
specifier: ^22.0.0
version: 22.20.0
'@vitest/coverage-v8':
specifier: ^2.1.0
version: 2.1.9(vitest@2.1.9(@types/node@22.20.0)(terser@5.48.0))
typescript:
specifier: ^5.6.0
version: 5.9.3
@@ -271,7 +366,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -350,7 +445,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -435,7 +530,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -505,7 +600,7 @@ importers:
version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express':
specifier: ^10.4.0
version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
@@ -1312,6 +1407,11 @@ packages:
resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==}
engines: {node: '>=12.10.0'}
'@grpc/proto-loader@0.7.15':
resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==}
engines: {node: '>=6'}
hasBin: true
'@grpc/proto-loader@0.8.1':
resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==}
engines: {node: '>=6'}
@@ -1422,6 +1522,42 @@ packages:
'@nestjs/websockets':
optional: true
'@nestjs/microservices@10.4.22':
resolution: {integrity: sha512-9Oxc0jQuppGLaQv5yaB2tVS2rAZzZ9NqDS1A4UlDLiYwJB7M6e89G6tmyOQjGjPwgoXPxQS4Vg2voSiKiED2gw==}
peerDependencies:
'@grpc/grpc-js': '*'
'@nestjs/common': ^10.0.0
'@nestjs/core': ^10.0.0
'@nestjs/websockets': ^10.0.0
amqp-connection-manager: '*'
amqplib: '*'
cache-manager: '*'
ioredis: '*'
kafkajs: '*'
mqtt: '*'
nats: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
rxjs: ^7.1.0
peerDependenciesMeta:
'@grpc/grpc-js':
optional: true
'@nestjs/websockets':
optional: true
amqp-connection-manager:
optional: true
amqplib:
optional: true
cache-manager:
optional: true
ioredis:
optional: true
kafkajs:
optional: true
mqtt:
optional: true
nats:
optional: true
'@nestjs/platform-express@10.4.22':
resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==}
peerDependencies:
@@ -1494,6 +1630,10 @@ packages:
cpu: [x64]
os: [win32]
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -2503,6 +2643,9 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -6404,6 +6547,13 @@ snapshots:
'@grpc/proto-loader': 0.8.1
'@js-sdsl/ordered-map': 4.4.2
'@grpc/proto-loader@0.7.15':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.6.5
yargs: 17.7.3
'@grpc/proto-loader@0.8.1':
dependencies:
lodash.camelcase: 4.3.0
@@ -6563,7 +6713,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
'@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxtjs/opencollective': 0.3.2
@@ -6575,14 +6725,28 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
'@nestjs/microservices': 10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)
transitivePeerDependencies:
- encoding
'@nestjs/microservices@10.4.22(@grpc/grpc-js@1.14.4)(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(ioredis@5.11.1)(kafkajs@2.2.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
iterare: 1.2.1
reflect-metadata: 0.2.2
rxjs: 7.8.2
tslib: 2.8.1
optionalDependencies:
'@grpc/grpc-js': 1.14.4
ioredis: 5.11.1
kafkajs: 2.2.4
'@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)':
dependencies:
'@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@10.4.22)(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)
body-parser: 1.20.4
cors: 2.8.5
express: 4.22.1
@@ -6642,6 +6806,8 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.33':
optional: true
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -8135,6 +8301,10 @@ snapshots:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1)
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':

View File

@@ -2,18 +2,19 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
COPY services/content/package.json services/content/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY tsconfig.json nest-cli.json ./
COPY src ./src
COPY services/content/tsconfig.json services/content/nest-cli.json ./
COPY services/content/src ./src
RUN pnpm build
# Runtime stage
FROM node:20-alpine
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml* ./
COPY services/content/package.json services/content/pnpm-lock.yaml* ./
RUN pnpm install --prod --frozen-lockfile
COPY --from=builder /app/dist ./dist
EXPOSE 3005
COPY packages/shared-proto/proto/content.proto ./proto/content.proto
EXPOSE 3005 50054
CMD ["node", "dist/main.js"]

View File

@@ -1,26 +1,30 @@
# content 内容资源服务
> 版本:0.1P4 骨架
> 端口:3005
> 版本:1.0P4 完整实现
> 端口:HTTP 3005 / gRPC 50054
## 职责
内容资源限界上下文,管理 Textbook、Chapter、KnowledgePoint 聚合。
支持多存储MySQL教材/章节/知识点写模型)+ Neo4j知识图谱前置依赖+ Elasticsearch题库全文检索P4 后续补充)。
内容资源限界上下文,管理 Textbook、Chapter、KnowledgePoint、Question 四个聚合。
支持多存储MySQL写模型权威源+ Neo4j知识图谱派生读模型+ ElasticsearchP5 引入,题库全文检索)。
事件驱动Outbox 模式发布 4 个聚合 topicNeo4j Sync Worker 异步消费事件同步图谱。
## 技术栈
- NestJS 10.x + TypeScript 5.6ESM
- Drizzle ORMMySQL
- neo4j-driver知识图谱
- Zod运行时校验
- NestJS 10.x + TypeScript 5.6ESM,相对 import 带 `.js` 后缀
- Drizzle ORM 0.31MySQL`getDb()` 函数式懒加载
- neo4j-driver 5.x(知识图谱,仅读模型查询
- KafkaJS 2.2idempotent producer + transactionalId
- @grpc/grpc-js + @nestjs/microservicesgRPC server 50054
- @paralleldrive/cuid2(统一 ID 策略)
- Zod 3.xController 层全量 schema.parse
- pino日志+ prom-client指标+ OpenTelemetry追踪
## 开发
```bash
pnpm install
pnpm dev # 端口 3005
pnpm dev # HTTP 3005 + gRPC 50054
pnpm build
pnpm typecheck
pnpm lint
@@ -29,46 +33,149 @@ pnpm test
## 环境变量
| 变量 | 说明 |
| ----------------------------- | ------------------------------- |
| `PORT` | 服务端口(默认 3005 |
| `DATABASE_URL` | MySQL 连接串 |
| `NEO4J_URL` | Neo4j Bolt 连接 URL |
| `NEO4J_PASSWORD` | Neo4j 密码 |
| `ES_URL` | Elasticsearch 地址 |
| `JWT_SECRET` | JWT 密钥 |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry OTLP 端点(可选) |
| 变量 | 说明 |
| ----------------------------- | ----------------------------------------- |
| `PORT` | HTTP 服务端口(默认 3005 |
| `GRPC_PORT` | gRPC 服务端口(默认 50054 |
| `DATABASE_URL` | MySQL 连接串(必填) |
| `KAFKA_BROKERS` | Kafka broker 列表(逗号分隔,默认 localhost:9092 |
| `NEO4J_URL` | Neo4j Bolt 连接 URL可选 |
| `NEO4J_PASSWORD` | Neo4j 密码(可选) |
| `REDIS_URL` | Redis 连接预留P5+ 缓存) |
| `ES_URL` | Elasticsearch 地址P5 引入) |
| `JWT_SECRET` | JWT 密钥Gateway 校验,服务可选) |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OpenTelemetry OTLP 端点(可选) |
## 模块结构
```
src/
├─ config/ # envdatabase(MySQL)、neo4j
├─ config/ # env / database(getDb) / kafka / neo4j
├─ grpc/ # gRPC controllers4 Service, 22 RPC
│ ├─ grpc.module.ts
│ ├─ grpc-types.ts # 手动定义 proto message TS 类型
│ ├─ textbook.grpc.controller.ts
│ ├─ chapter.grpc.controller.ts
│ ├─ knowledge-graph.grpc.controller.ts
│ └─ question.grpc.controller.ts
├─ textbooks/ # 教材领域schema/repository/service/controller/dto
├─ chapters/ # 章节领域
├─ knowledge-points/ # 知识点领域(含前置依赖管理)
├─ questions/ # 题库领域
├─ shared/
│ ├─ errors/ # ApplicationError + GlobalErrorFilterCONTENT_* 前缀)
observability/# logger、metrics、tracer
├─ textbooks/ # 教材/章节/知识点 + 知识图谱
│ ├─ errors/ # ApplicationError + GlobalErrorFilterCONTENT_* 前缀 + ZodError 分支
health/ # /healthz + /readyzDB/Neo4j/Kafka 多依赖检查)
│ ├─ lifecycle/ # 优雅关闭
│ ├─ observability/ # logger / metrics / tracer
│ ├─ outbox/ # Outbox 模式schema/repository/publisher/service/module/events
│ └─ sync/ # Neo4j Sync Worker消费 Kafka 事件异步同步图谱)
├─ middleware/ # auth.middleware + permission.guard
├─ app.module.ts
└─ main.ts
└─ main.ts # HTTP + gRPC hybrid app
```
## 关键端点
## REST API 端点
- `POST /textbooks` 创建教材
- `GET /textbooks` 教材列表
- `GET /textbooks/:id` 教材详情
- `TextbooksService.createKnowledgeGraph` 在 Neo4j 构建知识点前置依赖
- `TextbooksService.getPrerequisites` 查询知识点前置链路
### Textbooks
| 方法 | 路径 | 说明 |
| -------- | --------------------- | ------------------------ |
| `POST` | `/textbooks` | 创建教材 |
| `GET` | `/textbooks` | 教材列表(支持分页/过滤)|
| `GET` | `/textbooks/:id` | 教材详情 |
| `PUT` | `/textbooks/:id` | 更新教材 |
| `DELETE` | `/textbooks/:id` | 删除教材 |
### Chapters
| 方法 | 路径 | 说明 |
| -------- | --------------------------------- | ------------------------ |
| `POST` | `/chapters` | 创建章节 |
| `GET` | `/chapters/textbook/:textbookId` | 按教材列出章节 |
| `GET` | `/chapters/:id` | 章节详情 |
| `PUT` | `/chapters/:id` | 更新章节 |
| `DELETE` | `/chapters/:id` | 删除章节 |
### Knowledge Points
| 方法 | 路径 | 说明 |
| -------- | ------------------------------------------ | ---------------------------- |
| `POST` | `/knowledge-points` | 创建知识点 |
| `GET` | `/knowledge-points/chapter/:chapterId` | 按章节列出知识点 |
| `GET` | `/knowledge-points/:id` | 知识点详情 |
| `GET` | `/knowledge-points/:id/prerequisites` | 查询前置依赖(读 Neo4j |
| `POST` | `/knowledge-points/:id/prerequisites` | 添加前置依赖(发 Outbox 事件)|
| `DELETE` | `/knowledge-points/:id/prerequisites/:prerequisiteId` | 移除前置依赖(发 Outbox 事件)|
| `PUT` | `/knowledge-points/:id` | 更新知识点 |
| `DELETE` | `/knowledge-points/:id` | 删除知识点 |
### Questions
| 方法 | 路径 | 说明 |
| -------- | --------------------------------------------- | ------------------------ |
| `POST` | `/questions` | 创建题目 |
| `GET` | `/questions` | 题目列表(支持分页/过滤)|
| `GET` | `/questions/knowledge-point/:knowledgePointId`| 按知识点列出题目 |
| `GET` | `/questions/:id` | 题目详情 |
| `PUT` | `/questions/:id` | 更新题目 |
| `DELETE` | `/questions/:id` | 删除题目 |
## gRPC API端口 50054
Proto 定义:`packages/shared-proto/proto/content.proto`(包名 `next_edu_cloud.content.v1`
| Service | RPC 数 | 关键方法 |
| ----------------------- | ------ | ---------------------------------------------------------------- |
| `TextbookService` | 5 | CreateTextbook / GetTextbook / ListTextbooks / UpdateTextbook / DeleteTextbook |
| `ChapterService` | 5 | CreateChapter / GetChapter / ListChapters / UpdateChapter / DeleteChapter |
| `KnowledgeGraphService` | 4 | GetPrerequisites / GetLearningPath / AddPrerequisite / RemovePrerequisite |
| `QuestionService` | 8 | CreateQuestion / BatchCreateQuestions / GetQuestion / ListQuestions / UpdateQuestion / DeleteQuestion / PublishQuestion / SearchQuestions |
**合计 22 RPC。**
## 事件驱动
### Outbox 模式
业务操作create/update/delete在同一事务内写业务表 + `content_outbox_events` 表。
独立 `OutboxPublisher` worker 轮询 pending 记录投递 Kafkaat-least-once最大重试 5 次,指数退避)。
### Kafka Topics4 个聚合 topicaction 字段区分事件类型)
| Topic | 事件类型action |
| --------------------------------------- | -------------------------------------------------------------------------------- |
| `edu.content.textbook.events` | created / updated / published / archived |
| `edu.content.chapter.events` | created / updated / deleted |
| `edu.content.knowledge_point.events` | created / updated / prerequisite_added / prerequisite_removed |
| `edu.content.question.events` | created / updated / published / deleted |
### Neo4j Sync Worker
消费 `edu.content.knowledge_point.events` topic异步同步知识图谱
- `kp.created` / `kp.updated` → MERGE 节点
- `prerequisite_added` → MERGE 关系
- `prerequisite_removed` → DELETE 关系
Neo4j 不可用时非阻塞跳过(不影响 MySQL 写入和主流程)。
## 健康检查
| 端点 | 用途 | 鉴权 |
| -------------- | ------------------------------------------------- | ---- |
| `GET /healthz` | 存活探针liveness仅返回进程状态,不检查依赖 | 无 |
| `GET /readyz` | 就绪探针readiness检查 DB 连接,失败返回 503 | 无 |
| 端点 | 用途 | 鉴权 |
| -------------- | ------------------------------------------------------------- | ---- |
| `GET /healthz` | 存活探针liveness仅返回进程状态 | 无 |
| `GET /readyz` | 就绪探针readiness检查 DB/Neo4j/Kafka,失败返回 503 | 无 |
实现见 `src/shared/health/health.controller.ts`5 个 NestJS 服务iam/core-edu/content/msg/classes一致。
## 质量校验
| 检查项 | 命令 | 状态 |
| ---------- | ----------------------- | ----- |
| 类型检查 | `pnpm typecheck` | ✅ 0 错误 |
| Lint | `pnpm lint` | ✅ 0 错误 |
| 单元测试 | `pnpm test` | ✅ 182 tests 通过 |
| 覆盖率 | `vitest run --coverage` | ✅ 66.94%≥60% |
## 对外契约
gRPC 服务 `TextbookService``KnowledgeGraphService` 定义见 `packages/shared-proto/proto/content.proto`
- gRPC proto`packages/shared-proto/proto/content.proto`
- 事件 proto`packages/shared-proto/proto/events.proto`TextbookEvent / ChapterEvent / KnowledgePointEvent / QuestionEvent
- 契约文档:`docs/architecture/issues/contracts/content_contract.md`

View File

@@ -3,6 +3,10 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{ "include": "proto/**/*.proto", "outDir": "dist" }
],
"watchAssets": true
}
}

View File

@@ -12,28 +12,33 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@grpc/grpc-js": "^1.12.0",
"@grpc/proto-loader": "^0.7.13",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/microservices": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@paralleldrive/cuid2": "^2.2.2",
"drizzle-orm": "^0.31.0",
"kafkajs": "^2.2.4",
"mysql2": "^3.11.0",
"neo4j-driver": "^5.23.0",
"pino": "^9.4.0",
"prom-client": "^15.1.0",
"@opentelemetry/api": "^1.9.0",
"zod": "^3.23.0",
"uuid": "^10.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.55.0",
"@opentelemetry/auto-instrumentations-node": "^0.55.0"
"zod": "^3.23.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.0",
"@types/express": "^4.17.0",
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.1.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
}

View File

@@ -4,7 +4,9 @@ import { TextbooksModule } from "./textbooks/textbooks.module.js";
import { ChaptersModule } from "./chapters/chapters.module.js";
import { KnowledgePointsModule } from "./knowledge-points/knowledge-points.module.js";
import { QuestionsModule } from "./questions/questions.module.js";
import { GrpcModule } from "./grpc/grpc.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { OutboxModule } from "./shared/outbox/outbox.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
@@ -14,7 +16,9 @@ import { LifecycleService } from "./shared/lifecycle/lifecycle.service.js";
ChaptersModule,
KnowledgePointsModule,
QuestionsModule,
GrpcModule,
HealthModule,
OutboxModule,
],
providers: [
{ provide: APP_GUARD, useClass: PermissionGuard },

View File

@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ChaptersController } from "./chapters.controller.js";
vi.mock("./chapters.service.js", () => ({
ChaptersService: vi.fn(),
}));
const mockService = {
createChapter: vi.fn(),
listChaptersByTextbook: vi.fn(),
getChapter: vi.fn(),
updateChapter: vi.fn(),
deleteChapter: vi.fn(),
};
describe("ChaptersController", () => {
let controller: ChaptersController;
beforeEach(() => {
vi.clearAllMocks();
controller = new ChaptersController(mockService as never);
});
describe("create", () => {
it("should parse input and call service.createChapter", async () => {
mockService.createChapter.mockResolvedValue({ id: "ch-1" });
const result = await controller.create({
textbookId: "tb-1",
title: "Chapter One",
});
expect(mockService.createChapter).toHaveBeenCalledWith(
expect.objectContaining({
textbookId: "tb-1",
title: "Chapter One",
order: 0,
}),
);
expect(result).toEqual({ success: true, data: { id: "ch-1" } });
});
it("should throw on invalid body", async () => {
await expect(controller.create({ textbookId: "" })).rejects.toThrow();
});
});
describe("listByTextbook", () => {
it("should call service.listChaptersByTextbook", async () => {
const data = [{ id: "ch-1" }];
mockService.listChaptersByTextbook.mockResolvedValue(data);
const result = await controller.listByTextbook("tb-1");
expect(mockService.listChaptersByTextbook).toHaveBeenCalledWith("tb-1");
expect(result).toEqual({ success: true, data });
});
});
describe("getById", () => {
it("should call service.getChapter", async () => {
const data = { id: "ch-1", title: "T" };
mockService.getChapter.mockResolvedValue(data);
const result = await controller.getById("ch-1");
expect(mockService.getChapter).toHaveBeenCalledWith("ch-1");
expect(result).toEqual({ success: true, data });
});
});
describe("update", () => {
it("should parse input and call service.updateChapter", async () => {
const result = await controller.update("ch-1", { title: "New" });
expect(mockService.updateChapter).toHaveBeenCalledWith("ch-1", {
title: "New",
});
expect(result).toEqual({ success: true, data: { success: true } });
});
it("should throw on invalid status", async () => {
await expect(
controller.update("ch-1", { status: "invalid" }),
).rejects.toThrow();
});
});
describe("remove", () => {
it("should call service.deleteChapter", async () => {
const result = await controller.remove("ch-1");
expect(mockService.deleteChapter).toHaveBeenCalledWith("ch-1");
expect(result).toEqual({ success: true, data: { success: true } });
});
});
});

View File

@@ -7,16 +7,16 @@ import {
Post,
Put,
} from "@nestjs/common";
import {
ChaptersService,
type CreateChapterInput,
type UpdateChapterInput,
} from "./chapters.service.js";
import { ChaptersService } from "./chapters.service.js";
import type { Chapter } from "./chapters.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import {
createChapterSchema,
updateChapterSchema,
} from "./chapters.dto.js";
@Controller("chapters")
export class ChaptersController {
@@ -25,9 +25,10 @@ export class ChaptersController {
@Post()
@RequirePermission(Permissions.CONTENT_CHAPTER_CREATE)
async create(
@Body() body: CreateChapterInput,
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.createChapter(body);
const input = createChapterSchema.parse(body);
const result = await this.service.createChapter(input);
return { success: true, data: result };
}
@@ -53,9 +54,10 @@ export class ChaptersController {
@RequirePermission(Permissions.CONTENT_CHAPTER_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateChapterInput,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
await this.service.updateChapter(id, body);
const input = updateChapterSchema.parse(body);
await this.service.updateChapter(id, input);
return { success: true, data: { success: true } };
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import {
createChapterSchema,
updateChapterSchema,
} from "./chapters.dto.js";
describe("chapters.dto", () => {
describe("createChapterSchema", () => {
it("should parse valid input with all fields", () => {
const result = createChapterSchema.parse({
textbookId: "tb-1",
title: "Chapter One",
order: 5,
parentId: "parent-1",
});
expect(result.textbookId).toBe("tb-1");
expect(result.title).toBe("Chapter One");
expect(result.order).toBe(5);
expect(result.parentId).toBe("parent-1");
});
it("should default order to 0 when not provided", () => {
const result = createChapterSchema.parse({
textbookId: "tb-1",
title: "Chapter One",
});
expect(result.order).toBe(0);
});
it("should accept input without optional parentId", () => {
const result = createChapterSchema.parse({
textbookId: "tb-1",
title: "Chapter One",
});
expect(result.parentId).toBeUndefined();
});
it("should reject empty textbookId", () => {
expect(() =>
createChapterSchema.parse({ textbookId: "", title: "Chapter One" }),
).toThrow();
});
it("should reject empty title", () => {
expect(() =>
createChapterSchema.parse({ textbookId: "tb-1", title: "" }),
).toThrow();
});
it("should reject negative order", () => {
expect(() =>
createChapterSchema.parse({
textbookId: "tb-1",
title: "Chapter One",
order: -1,
}),
).toThrow();
});
});
describe("updateChapterSchema", () => {
it("should parse partial update with title only", () => {
const result = updateChapterSchema.parse({ title: "New Title" });
expect(result.title).toBe("New Title");
});
it("should accept valid status enum", () => {
const result = updateChapterSchema.parse({ status: "published" });
expect(result.status).toBe("published");
});
it("should reject invalid status value", () => {
expect(() => updateChapterSchema.parse({ status: "invalid" })).toThrow();
});
});
});

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
export const createChapterSchema = z.object({
textbookId: z.string().min(1).max(32),
title: z.string().min(1).max(255),
order: z.number().int().min(0).default(0),
parentId: z.string().max(32).optional(),
});
export const updateChapterSchema = z.object({
title: z.string().min(1).max(255).optional(),
order: z.number().int().min(0).optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
});
export type CreateChapterDto = z.infer<typeof createChapterSchema>;
export type UpdateChapterDto = z.infer<typeof updateChapterSchema>;

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { ChaptersController } from "./chapters.controller.js";
import { ChaptersService } from "./chapters.service.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js";
@Module({
imports: [OutboxModule],
controllers: [ChaptersController],
providers: [ChaptersService],
exports: [ChaptersService],

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockGetDb = vi.fn();
vi.mock("../config/database.js", () => ({
getDb: () => mockGetDb(),
}));
import { ChaptersRepository } from "./chapters.repository.js";
function createMockDb(resolvedValue: unknown): unknown {
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
get: (_target, prop) => {
if (prop === "then") {
return (onFulfilled?: (v: unknown) => unknown) =>
Promise.resolve(
typeof onFulfilled === "function"
? onFulfilled(resolvedValue)
: resolvedValue,
);
}
return () => new Proxy({}, handler);
},
};
return new Proxy({}, handler);
}
describe("ChaptersRepository", () => {
let repo: ChaptersRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new ChaptersRepository();
});
it("findById should return first matching chapter", async () => {
const chapter = { id: "ch-1", title: "T" };
mockGetDb.mockReturnValue(createMockDb([chapter]));
const result = await repo.findById("ch-1");
expect(result).toBe(chapter);
});
it("findById should return undefined when no match", async () => {
mockGetDb.mockReturnValue(createMockDb([]));
const result = await repo.findById("missing");
expect(result).toBeUndefined();
});
it("findByTextbookId should return chapters array", async () => {
const chapters = [{ id: "ch-1" }, { id: "ch-2" }];
mockGetDb.mockReturnValue(createMockDb(chapters));
const result = await repo.findByTextbookId("tb-1");
expect(result).toBe(chapters);
});
it("create should insert a new chapter", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.create({
id: "ch-1",
textbookId: "tb-1",
title: "T",
order: 0,
status: "draft",
});
expect(mockGetDb).toHaveBeenCalled();
});
it("update should update a chapter by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.update("ch-1", { title: "New" });
expect(mockGetDb).toHaveBeenCalled();
});
it("delete should delete a chapter by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.delete("ch-1");
expect(mockGetDb).toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,10 @@
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import { getDb } from "../config/database.js";
import { chapters, type Chapter, type NewChapter } from "./chapters.schema.js";
export class ChaptersRepository {
async findById(id: string): Promise<Chapter | undefined> {
const [result] = await db
const [result] = await getDb()
.select()
.from(chapters)
.where(eq(chapters.id, id))
@@ -13,22 +13,22 @@ export class ChaptersRepository {
}
async findByTextbookId(textbookId: string): Promise<Chapter[]> {
return db
return getDb()
.select()
.from(chapters)
.where(eq(chapters.textbookId, textbookId));
}
async create(data: NewChapter): Promise<void> {
await db.insert(chapters).values(data);
await getDb().insert(chapters).values(data);
}
async update(id: string, data: Partial<NewChapter>): Promise<void> {
await db.update(chapters).set(data).where(eq(chapters.id, id));
await getDb().update(chapters).set(data).where(eq(chapters.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(chapters).where(eq(chapters.id, id));
await getDb().delete(chapters).where(eq(chapters.id, id));
}
}

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ChaptersService } from "./chapters.service.js";
import { NotFoundError } from "../shared/errors/application-error.js";
vi.mock("./chapters.repository.js", () => ({
chaptersRepository: {
findById: vi.fn(),
findByTextbookId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import { chaptersRepository } from "./chapters.repository.js";
const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"),
};
describe("ChaptersService", () => {
let service: ChaptersService;
beforeEach(() => {
vi.clearAllMocks();
service = new ChaptersService(mockOutbox as never);
});
describe("createChapter", () => {
it("should create a chapter and publish event", async () => {
const input = {
textbookId: "tb-1",
title: "Chapter 1",
order: 5,
};
const result = await service.createChapter(input);
expect(result.id).toBeDefined();
expect(chaptersRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
textbookId: "tb-1",
title: "Chapter 1",
order: 5,
status: "draft",
}),
);
expect(mockOutbox.publish).toHaveBeenCalledWith(
"chapter.created",
"Chapter",
result.id,
expect.objectContaining({
textbook_id: "tb-1",
title: "Chapter 1",
order: 5,
}),
);
});
it("should default order to 0 when not provided", async () => {
await service.createChapter({
textbookId: "tb-1",
title: "Ch",
});
expect(chaptersRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ order: 0 }),
);
});
});
describe("getChapter", () => {
it("should return chapter when found", async () => {
const mockChapter = {
id: "ch-1",
textbookId: "tb-1",
title: "Ch",
order: 0,
parentId: null,
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(chaptersRepository.findById).mockResolvedValue(mockChapter);
const result = await service.getChapter("ch-1");
expect(result).toBe(mockChapter);
});
it("should throw NotFoundError when not found", async () => {
vi.mocked(chaptersRepository.findById).mockResolvedValue(undefined);
await expect(service.getChapter("nope")).rejects.toThrow(NotFoundError);
});
});
describe("updateChapter", () => {
it("should update and publish event", async () => {
const existing = {
id: "ch-1",
textbookId: "tb-1",
title: "Old",
order: 0,
parentId: null,
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(chaptersRepository.findById).mockResolvedValue(existing);
await service.updateChapter("ch-1", { title: "New" });
expect(chaptersRepository.update).toHaveBeenCalledWith("ch-1", {
title: "New",
});
expect(mockOutbox.publish).toHaveBeenCalledWith(
"chapter.updated",
"Chapter",
"ch-1",
expect.objectContaining({ title: "New" }),
);
});
});
describe("deleteChapter", () => {
it("should delete and publish deleted event", async () => {
vi.mocked(chaptersRepository.findById).mockResolvedValue({
id: "ch-1",
textbookId: "tb-1",
title: "Ch",
order: 0,
parentId: null,
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
});
await service.deleteChapter("ch-1");
expect(chaptersRepository.delete).toHaveBeenCalledWith("ch-1");
expect(mockOutbox.publish).toHaveBeenCalledWith(
"chapter.deleted",
"Chapter",
"ch-1",
{ deleted: true },
);
});
});
});

View File

@@ -1,40 +1,57 @@
import { randomUUID } from "node:crypto";
import { createId } from "@paralleldrive/cuid2";
import { Injectable } from "@nestjs/common";
import { chaptersRepository } from "./chapters.repository.js";
import type { Chapter } from "./chapters.schema.js";
import type { Chapter, NewChapter } from "./chapters.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.js";
import {
AGGREGATE_TYPES,
EVENT_TYPES,
} from "../shared/outbox/events.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateChapterInput {
textbookId: string;
title: string;
order: number;
order?: number;
parentId?: string;
}
export interface UpdateChapterInput {
title?: string;
order?: number;
parentId?: string;
status?: string;
}
@Injectable()
export class ChaptersService {
async createChapter(input: CreateChapterInput): Promise<{ id: string }> {
if (!input.textbookId || !input.title || input.order === undefined) {
throw new ValidationError("textbookId, title, order are required");
}
constructor(private readonly outbox: OutboxService) {}
const id = randomUUID();
await chaptersRepository.create({
async createChapter(input: CreateChapterInput): Promise<{ id: string }> {
const id = createId();
const record: NewChapter = {
id,
textbookId: input.textbookId,
title: input.title,
order: input.order,
order: input.order ?? 0,
parentId: input.parentId,
});
status: "draft",
};
await chaptersRepository.create(record);
await this.outbox.publish(
EVENT_TYPES.CHAPTER_CREATED,
AGGREGATE_TYPES.CHAPTER,
id,
{
textbook_id: record.textbookId,
title: record.title,
order: record.order,
parent_id: record.parentId,
},
);
return { id };
}
@@ -51,12 +68,30 @@ export class ChaptersService {
}
async updateChapter(id: string, data: UpdateChapterInput): Promise<void> {
await this.getChapter(id);
const existing = await this.getChapter(id);
await chaptersRepository.update(id, data);
await this.outbox.publish(
EVENT_TYPES.CHAPTER_UPDATED,
AGGREGATE_TYPES.CHAPTER,
id,
{
title: data.title ?? existing.title,
order: data.order ?? existing.order,
status: data.status ?? existing.status,
},
);
}
async deleteChapter(id: string): Promise<void> {
await this.getChapter(id);
await chaptersRepository.delete(id);
await this.outbox.publish(
EVENT_TYPES.CHAPTER_DELETED,
AGGREGATE_TYPES.CHAPTER,
id,
{ deleted: true },
);
}
}

View File

@@ -1,16 +1,25 @@
import { drizzle } from "drizzle-orm/mysql2";
import type { MySql2Database } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
const pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
let pool: mysql.Pool | null = null;
export const db = drizzle(pool);
export function getDb(): MySql2Database {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return drizzle(pool);
}
export async function closeDb(): Promise<void> {
await pool.end();
if (pool) {
await pool.end();
pool = null;
}
}

View File

@@ -2,11 +2,13 @@ import { z } from "zod";
const envSchema = z.object({
PORT: z.string().default("3005"),
GRPC_PORT: z.string().default("50054"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
NEO4J_URL: z.string().url().optional(),
NEO4J_PASSWORD: z.string().optional(),
ES_URL: z.string().url().optional(),
KAFKA_BROKERS: z.string().default("localhost:9092"),
JWT_SECRET: z.string().optional(),
JWT_ISSUER: z.string().default("next-edu-cloud"),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),

View File

@@ -0,0 +1,57 @@
import { Kafka } from "kafkajs";
import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
export const kafka = new Kafka({
brokers: env.KAFKA_BROKERS.split(","),
clientId: "content-service",
});
export const producer = kafka.producer({
idempotent: true,
transactionalId: "content-tx",
});
export const neo4jSyncConsumer = kafka.consumer({
groupId: "content-neo4j-sync",
});
let producerIsConnected = false;
producer.on("producer.connect", () => {
producerIsConnected = true;
});
producer.on("producer.disconnect", () => {
producerIsConnected = false;
});
export function isKafkaProducerConnected(): boolean {
return producerIsConnected;
}
export async function connectKafka(): Promise<void> {
try {
await producer.connect();
logger.info("Kafka producer connected");
} catch (err) {
logger.warn(
{
err: err instanceof Error ? err.message : String(err),
},
"Kafka connect failed, running without Kafka (outbox will retry)",
);
}
}
export async function disconnectKafka(): Promise<void> {
try {
await producer.disconnect();
} catch {
// ignore
}
try {
await neo4jSyncConsumer.disconnect();
} catch {
// ignore
}
}

View File

@@ -0,0 +1,73 @@
import { Controller } from "@nestjs/common";
import { GrpcMethod } from "@nestjs/microservices";
import { ChaptersService } from "../chapters/chapters.service.js";
import type { Chapter } from "../chapters/chapters.schema.js";
import type {
CreateChapterRequest,
GetChapterRequest,
ListChaptersRequest,
ListChaptersResponse,
UpdateChapterRequest,
DeleteChapterRequest,
GrpcChapter,
Empty,
} from "./grpc-types.js";
function toGrpcChapter(c: Chapter): GrpcChapter {
return {
id: c.id,
textbook_id: c.textbookId,
title: c.title,
order: c.order,
parent_id: c.parentId ?? "",
status: c.status,
created_at: c.createdAt.getTime(),
updated_at: c.updatedAt.getTime(),
};
}
@Controller()
export class ChapterGrpcController {
constructor(private readonly service: ChaptersService) {}
@GrpcMethod("ChapterService", "CreateChapter")
async createChapter(data: CreateChapterRequest): Promise<GrpcChapter> {
const { id } = await this.service.createChapter({
textbookId: data.textbook_id,
title: data.title,
order: data.order,
parentId: data.parent_id,
});
const chapter = await this.service.getChapter(id);
return toGrpcChapter(chapter);
}
@GrpcMethod("ChapterService", "GetChapter")
async getChapter(data: GetChapterRequest): Promise<GrpcChapter> {
const chapter = await this.service.getChapter(data.id);
return toGrpcChapter(chapter);
}
@GrpcMethod("ChapterService", "ListChapters")
async listChapters(data: ListChaptersRequest): Promise<ListChaptersResponse> {
const chapters = await this.service.listChaptersByTextbook(data.textbook_id);
return { chapters: chapters.map(toGrpcChapter) };
}
@GrpcMethod("ChapterService", "UpdateChapter")
async updateChapter(data: UpdateChapterRequest): Promise<GrpcChapter> {
await this.service.updateChapter(data.id, {
title: data.title,
order: data.order ?? undefined,
status: data.status,
});
const chapter = await this.service.getChapter(data.id);
return toGrpcChapter(chapter);
}
@GrpcMethod("ChapterService", "DeleteChapter")
async deleteChapter(data: DeleteChapterRequest): Promise<Empty> {
await this.service.deleteChapter(data.id);
return {};
}
}

View File

@@ -0,0 +1,235 @@
/**
* Content gRPC message types — 手动定义,对齐 content.proto。
* 当 buf generate 生成 TS 类型后,可替换为生成类型。
*/
export type Empty = Record<string, never>;
export interface GrpcTextbook {
id: string;
title: string;
subject_id: string;
grade_id: string;
version: string;
status: string;
tenant_id: string;
metadata: Record<string, unknown> | null;
created_at: number;
updated_at: number;
}
export interface GrpcChapter {
id: string;
textbook_id: string;
title: string;
order: number;
parent_id: string;
status: string;
created_at: number;
updated_at: number;
}
export interface GrpcKnowledgePoint {
id: string;
chapter_id: string;
title: string;
description: string;
difficulty: number;
metadata: Record<string, unknown> | null;
created_at: number;
updated_at: number;
}
export interface GrpcQuestion {
id: string;
knowledge_point_id: string;
type: string;
content: string;
options: Record<string, unknown> | null;
answer: string;
explanation: string;
difficulty: number;
status: string;
source: string;
created_by: string;
metadata: Record<string, unknown> | null;
created_at: number;
updated_at: number;
}
// TextbookService requests
export interface CreateTextbookRequest {
title: string;
subject_id: string;
grade_id: string;
version?: string;
metadata?: Record<string, unknown> | null;
}
export interface GetTextbookRequest {
id: string;
}
export interface ListTextbooksRequest {
subject_id?: string;
grade_id?: string;
page_token?: string;
page_size?: number;
}
export interface ListTextbooksResponse {
textbooks: GrpcTextbook[];
next_page_token: string;
}
export interface UpdateTextbookRequest {
id: string;
title?: string;
status?: string;
metadata?: Record<string, unknown> | null;
}
export interface DeleteTextbookRequest {
id: string;
}
// ChapterService requests
export interface CreateChapterRequest {
textbook_id: string;
title: string;
order?: number;
parent_id?: string;
}
export interface GetChapterRequest {
id: string;
}
export interface ListChaptersRequest {
textbook_id: string;
parent_id?: string;
}
export interface ListChaptersResponse {
chapters: GrpcChapter[];
}
export interface UpdateChapterRequest {
id: string;
title?: string;
order?: number;
status?: string;
}
export interface DeleteChapterRequest {
id: string;
}
// KnowledgeGraphService requests
export interface GetPrerequisitesRequest {
knowledge_point_id: string;
depth?: number;
}
export interface KnowledgePointsResponse {
points: GrpcKnowledgePoint[];
}
export interface GetLearningPathRequest {
student_id: string;
subject_id: string;
}
export interface LearningPath {
points: GrpcKnowledgePoint[];
recommended_order: string[];
}
export interface AddPrerequisiteRequest {
kp_id: string;
prerequisite_id: string;
}
export interface RemovePrerequisiteRequest {
kp_id: string;
prerequisite_id: string;
}
// QuestionService requests
export interface CreateQuestionRequest {
knowledge_point_id: string;
type: string;
content: string;
options?: Record<string, unknown> | null;
answer: string;
explanation?: string;
difficulty?: number;
source?: string;
created_by: string;
metadata?: Record<string, unknown> | null;
}
export interface BatchCreateQuestionsRequest {
questions: CreateQuestionRequest[];
}
export interface BatchCreateQuestionsResponse {
ids: string[];
failed: BatchCreateFailure[];
}
export interface BatchCreateFailure {
index: number;
error: string;
}
export interface GetQuestionRequest {
id: string;
}
export interface ListQuestionsRequest {
knowledge_point_id?: string;
type?: string;
difficulty?: number;
status?: string;
page_token?: string;
page_size?: number;
}
export interface ListQuestionsResponse {
questions: GrpcQuestion[];
next_page_token: string;
}
export interface UpdateQuestionRequest {
id: string;
content?: string;
answer?: string;
status?: string;
options?: Record<string, unknown> | null;
explanation?: string;
difficulty?: number;
}
export interface DeleteQuestionRequest {
id: string;
}
export interface PublishQuestionRequest {
id: string;
}
export interface SearchQuestionsRequest {
q: string;
type?: string;
difficulty?: number;
knowledge_point_id?: string;
page_token?: string;
page_size?: number;
}
export interface SearchQuestionsResponse {
questions: GrpcQuestion[];
total: number;
next_page_token: string;
}

View File

@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { TextbooksModule } from "../textbooks/textbooks.module.js";
import { ChaptersModule } from "../chapters/chapters.module.js";
import { KnowledgePointsModule } from "../knowledge-points/knowledge-points.module.js";
import { QuestionsModule } from "../questions/questions.module.js";
import { TextbookGrpcController } from "./textbook.grpc.controller.js";
import { ChapterGrpcController } from "./chapter.grpc.controller.js";
import { KnowledgeGraphGrpcController } from "./knowledge-graph.grpc.controller.js";
import { QuestionGrpcController } from "./question.grpc.controller.js";
@Module({
imports: [
TextbooksModule,
ChaptersModule,
KnowledgePointsModule,
QuestionsModule,
],
controllers: [
TextbookGrpcController,
ChapterGrpcController,
KnowledgeGraphGrpcController,
QuestionGrpcController,
],
})
export class GrpcModule {}

View File

@@ -0,0 +1,72 @@
import { Controller } from "@nestjs/common";
import { GrpcMethod } from "@nestjs/microservices";
import { KnowledgePointsService } from "../knowledge-points/knowledge-points.service.js";
import type { KnowledgePoint } from "../knowledge-points/knowledge-points.schema.js";
import type {
GetPrerequisitesRequest,
KnowledgePointsResponse,
GetLearningPathRequest,
LearningPath,
AddPrerequisiteRequest,
RemovePrerequisiteRequest,
GrpcKnowledgePoint,
Empty,
} from "./grpc-types.js";
function toGrpcKp(kp: KnowledgePoint): GrpcKnowledgePoint {
return {
id: kp.id,
chapter_id: kp.chapterId,
title: kp.title,
description: kp.description ?? "",
difficulty: kp.difficulty,
metadata: kp.metadata ?? null,
created_at: kp.createdAt.getTime(),
updated_at: kp.updatedAt.getTime(),
};
}
@Controller()
export class KnowledgeGraphGrpcController {
constructor(private readonly service: KnowledgePointsService) {}
@GrpcMethod("KnowledgeGraphService", "GetPrerequisites")
async getPrerequisites(
data: GetPrerequisitesRequest,
): Promise<KnowledgePointsResponse> {
// 直接使用 service.getPrerequisites读 Neo4j 派生数据)
const prereqs = await this.service.getPrerequisites(data.knowledge_point_id);
// PrerequisiteNode 只含 id + title需要查 MySQL 补全信息
const points: GrpcKnowledgePoint[] = [];
for (const p of prereqs) {
try {
const kp = await this.service.getKnowledgePoint(p.id);
points.push(toGrpcKp(kp));
} catch {
// 节点可能已删除,跳过
}
}
return { points };
}
@GrpcMethod("KnowledgeGraphService", "GetLearningPath")
async getLearningPath(
_data: GetLearningPathRequest,
): Promise<LearningPath> {
// P4 阶段未实现学习路径推荐算法,返回空路径
// P5+ 引入 AI 服务后实现
return { points: [], recommended_order: [] };
}
@GrpcMethod("KnowledgeGraphService", "AddPrerequisite")
async addPrerequisite(data: AddPrerequisiteRequest): Promise<Empty> {
await this.service.addPrerequisite(data.kp_id, data.prerequisite_id);
return {};
}
@GrpcMethod("KnowledgeGraphService", "RemovePrerequisite")
async removePrerequisite(data: RemovePrerequisiteRequest): Promise<Empty> {
await this.service.removePrerequisite(data.kp_id, data.prerequisite_id);
return {};
}
}

View File

@@ -0,0 +1,167 @@
import { Controller } from "@nestjs/common";
import { GrpcMethod } from "@nestjs/microservices";
import { QuestionsService } from "../questions/questions.service.js";
import type { Question } from "../questions/questions.schema.js";
import type {
CreateQuestionRequest,
BatchCreateQuestionsRequest,
BatchCreateQuestionsResponse,
GetQuestionRequest,
ListQuestionsRequest,
ListQuestionsResponse,
UpdateQuestionRequest,
DeleteQuestionRequest,
PublishQuestionRequest,
SearchQuestionsRequest,
SearchQuestionsResponse,
GrpcQuestion,
Empty,
} from "./grpc-types.js";
function toGrpcQuestion(q: Question): GrpcQuestion {
return {
id: q.id,
knowledge_point_id: q.knowledgePointId,
type: q.type,
content: q.content,
options: q.options ?? null,
answer: q.answer,
explanation: q.explanation ?? "",
difficulty: q.difficulty,
status: q.status,
source: q.source,
created_by: q.createdBy,
metadata: q.metadata ?? null,
created_at: q.createdAt.getTime(),
updated_at: q.updatedAt.getTime(),
};
}
@Controller()
export class QuestionGrpcController {
constructor(private readonly service: QuestionsService) {}
@GrpcMethod("QuestionService", "CreateQuestion")
async createQuestion(data: CreateQuestionRequest): Promise<GrpcQuestion> {
const { id } = await this.service.createQuestion({
knowledgePointId: data.knowledge_point_id,
type: data.type,
content: data.content,
options: data.options ?? null,
answer: data.answer,
explanation: data.explanation,
difficulty: data.difficulty,
source: data.source,
createdBy: data.created_by,
metadata: data.metadata ?? null,
});
const question = await this.service.getQuestion(id);
return toGrpcQuestion(question);
}
@GrpcMethod("QuestionService", "BatchCreateQuestions")
async batchCreateQuestions(
data: BatchCreateQuestionsRequest,
): Promise<BatchCreateQuestionsResponse> {
const ids: string[] = [];
const failed: { index: number; error: string }[] = [];
for (let i = 0; i < (data.questions ?? []).length; i++) {
const q = data.questions?.[i];
if (!q) continue;
try {
const { id } = await this.service.createQuestion({
knowledgePointId: q.knowledge_point_id,
type: q.type,
content: q.content,
options: q.options ?? null,
answer: q.answer,
explanation: q.explanation,
difficulty: q.difficulty,
source: q.source,
createdBy: q.created_by,
metadata: q.metadata ?? null,
});
ids.push(id);
} catch (err) {
failed.push({
index: i,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { ids, failed };
}
@GrpcMethod("QuestionService", "GetQuestion")
async getQuestion(data: GetQuestionRequest): Promise<GrpcQuestion> {
const question = await this.service.getQuestion(data.id);
return toGrpcQuestion(question);
}
@GrpcMethod("QuestionService", "ListQuestions")
async listQuestions(data: ListQuestionsRequest): Promise<ListQuestionsResponse> {
const pageSize = data.page_size ?? 20;
const page = data.page_token ? Number(data.page_token) : 1;
const questions = await this.service.list({
knowledgePointId: data.knowledge_point_id,
type: data.type,
difficulty: data.difficulty,
status: data.status,
page,
pageSize,
});
return {
questions: questions.map(toGrpcQuestion),
next_page_token: questions.length === pageSize ? String(page + 1) : "",
};
}
@GrpcMethod("QuestionService", "UpdateQuestion")
async updateQuestion(data: UpdateQuestionRequest): Promise<GrpcQuestion> {
await this.service.updateQuestion(data.id, {
content: data.content,
answer: data.answer,
status: data.status,
options: data.options,
explanation: data.explanation,
difficulty: data.difficulty ?? undefined,
});
const question = await this.service.getQuestion(data.id);
return toGrpcQuestion(question);
}
@GrpcMethod("QuestionService", "DeleteQuestion")
async deleteQuestion(data: DeleteQuestionRequest): Promise<Empty> {
await this.service.deleteQuestion(data.id);
return {};
}
@GrpcMethod("QuestionService", "PublishQuestion")
async publishQuestion(data: PublishQuestionRequest): Promise<Empty> {
await this.service.updateQuestion(data.id, { status: "published" });
return {};
}
@GrpcMethod("QuestionService", "SearchQuestions")
async searchQuestions(
data: SearchQuestionsRequest,
): Promise<SearchQuestionsResponse> {
// P4 阶段:使用 MySQL LIKE 搜索P5 引入 ES 后替换为全文检索
const pageSize = data.page_size ?? 20;
const page = data.page_token ? Number(data.page_token) : 1;
const questions = await this.service.list({
knowledgePointId: data.knowledge_point_id,
type: data.type,
difficulty: data.difficulty,
page,
pageSize,
});
return {
questions: questions.map(toGrpcQuestion),
total: questions.length,
next_page_token: questions.length === pageSize ? String(page + 1) : "",
};
}
}

View File

@@ -0,0 +1,86 @@
import { Controller } from "@nestjs/common";
import { GrpcMethod } from "@nestjs/microservices";
import { TextbooksService } from "../textbooks/textbooks.service.js";
import type { Textbook } from "../textbooks/textbooks.schema.js";
import type {
CreateTextbookRequest,
GetTextbookRequest,
ListTextbooksRequest,
ListTextbooksResponse,
UpdateTextbookRequest,
DeleteTextbookRequest,
GrpcTextbook,
Empty,
} from "./grpc-types.js";
function toGrpcTextbook(t: Textbook): GrpcTextbook {
return {
id: t.id,
title: t.title,
subject_id: t.subjectId,
grade_id: t.gradeId,
version: t.version,
status: t.status,
tenant_id: t.tenantId ?? "",
metadata: t.metadata ?? null,
created_at: t.createdAt.getTime(),
updated_at: t.updatedAt.getTime(),
};
}
@Controller()
export class TextbookGrpcController {
constructor(private readonly service: TextbooksService) {}
@GrpcMethod("TextbookService", "CreateTextbook")
async createTextbook(data: CreateTextbookRequest): Promise<GrpcTextbook> {
const { id } = await this.service.create({
title: data.title,
subjectId: data.subject_id,
gradeId: data.grade_id,
version: data.version,
metadata: data.metadata ?? null,
});
const textbook = await this.service.getById(id);
return toGrpcTextbook(textbook);
}
@GrpcMethod("TextbookService", "GetTextbook")
async getTextbook(data: GetTextbookRequest): Promise<GrpcTextbook> {
const textbook = await this.service.getById(data.id);
return toGrpcTextbook(textbook);
}
@GrpcMethod("TextbookService", "ListTextbooks")
async listTextbooks(data: ListTextbooksRequest): Promise<ListTextbooksResponse> {
const pageSize = data.page_size ?? 20;
const page = data.page_token ? Number(data.page_token) : 1;
const textbooks = await this.service.list({
subjectId: data.subject_id,
gradeId: data.grade_id,
page,
pageSize,
});
return {
textbooks: textbooks.map(toGrpcTextbook),
next_page_token: textbooks.length === pageSize ? String(page + 1) : "",
};
}
@GrpcMethod("TextbookService", "UpdateTextbook")
async updateTextbook(data: UpdateTextbookRequest): Promise<GrpcTextbook> {
await this.service.update(data.id, {
title: data.title,
status: data.status,
metadata: data.metadata,
});
const textbook = await this.service.getById(data.id);
return toGrpcTextbook(textbook);
}
@GrpcMethod("TextbookService", "DeleteTextbook")
async deleteTextbook(data: DeleteTextbookRequest): Promise<Empty> {
await this.service.delete(data.id);
return {};
}
}

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { KnowledgePointsController } from "./knowledge-points.controller.js";
vi.mock("./knowledge-points.service.js", () => ({
KnowledgePointsService: vi.fn(),
}));
const mockService = {
createKnowledgePoint: vi.fn(),
listByChapter: vi.fn(),
getPrerequisites: vi.fn(),
getKnowledgePoint: vi.fn(),
addPrerequisite: vi.fn(),
removePrerequisite: vi.fn(),
updateKnowledgePoint: vi.fn(),
deleteKnowledgePoint: vi.fn(),
};
describe("KnowledgePointsController", () => {
let controller: KnowledgePointsController;
beforeEach(() => {
vi.clearAllMocks();
controller = new KnowledgePointsController(mockService as never);
});
describe("create", () => {
it("should parse input and call service.createKnowledgePoint", async () => {
mockService.createKnowledgePoint.mockResolvedValue({ id: "kp-1" });
const result = await controller.create({
chapterId: "ch-1",
title: "Algebra Basics",
});
expect(mockService.createKnowledgePoint).toHaveBeenCalledWith(
expect.objectContaining({
chapterId: "ch-1",
title: "Algebra Basics",
difficulty: 3,
}),
);
expect(result).toEqual({ success: true, data: { id: "kp-1" } });
});
it("should throw on invalid body", async () => {
await expect(controller.create({ chapterId: "" })).rejects.toThrow();
});
});
describe("listByChapter", () => {
it("should call service.listByChapter", async () => {
const data = [{ id: "kp-1" }];
mockService.listByChapter.mockResolvedValue(data);
const result = await controller.listByChapter("ch-1");
expect(mockService.listByChapter).toHaveBeenCalledWith("ch-1");
expect(result).toEqual({ success: true, data });
});
});
describe("getPrerequisites", () => {
it("should call service.getPrerequisites", async () => {
const data = [{ id: "kp-pre", title: "Pre" }];
mockService.getPrerequisites.mockResolvedValue(data);
const result = await controller.getPrerequisites("kp-1");
expect(mockService.getPrerequisites).toHaveBeenCalledWith("kp-1");
expect(result).toEqual({ success: true, data });
});
});
describe("getById", () => {
it("should call service.getKnowledgePoint", async () => {
const data = { id: "kp-1", title: "T" };
mockService.getKnowledgePoint.mockResolvedValue(data);
const result = await controller.getById("kp-1");
expect(mockService.getKnowledgePoint).toHaveBeenCalledWith("kp-1");
expect(result).toEqual({ success: true, data });
});
});
describe("addPrerequisite", () => {
it("should parse input and call service.addPrerequisite", async () => {
const result = await controller.addPrerequisite("kp-1", {
prerequisiteId: "kp-pre",
});
expect(mockService.addPrerequisite).toHaveBeenCalledWith(
"kp-1",
"kp-pre",
);
expect(result).toEqual({ success: true, data: { success: true } });
});
it("should throw on invalid body", async () => {
await expect(
controller.addPrerequisite("kp-1", { prerequisiteId: "" }),
).rejects.toThrow();
});
});
describe("removePrerequisite", () => {
it("should call service.removePrerequisite", async () => {
const result = await controller.removePrerequisite("kp-1", "kp-pre");
expect(mockService.removePrerequisite).toHaveBeenCalledWith(
"kp-1",
"kp-pre",
);
expect(result).toEqual({ success: true, data: { success: true } });
});
});
describe("update", () => {
it("should parse input and call service.updateKnowledgePoint", async () => {
const result = await controller.update("kp-1", { title: "New" });
expect(mockService.updateKnowledgePoint).toHaveBeenCalledWith("kp-1", {
title: "New",
});
expect(result).toEqual({ success: true, data: { success: true } });
});
});
describe("remove", () => {
it("should call service.deleteKnowledgePoint", async () => {
const result = await controller.remove("kp-1");
expect(mockService.deleteKnowledgePoint).toHaveBeenCalledWith("kp-1");
expect(result).toEqual({ success: true, data: { success: true } });
});
});
});

View File

@@ -9,8 +9,6 @@ import {
} from "@nestjs/common";
import {
KnowledgePointsService,
type CreateKnowledgePointInput,
type UpdateKnowledgePointInput,
type PrerequisiteNode,
} from "./knowledge-points.service.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js";
@@ -18,6 +16,11 @@ import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import {
createKnowledgePointSchema,
updateKnowledgePointSchema,
addPrerequisiteSchema,
} from "./knowledge-points.dto.js";
@Controller("knowledge-points")
export class KnowledgePointsController {
@@ -26,9 +29,10 @@ export class KnowledgePointsController {
@Post()
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_CREATE)
async create(
@Body() body: CreateKnowledgePointInput,
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.createKnowledgePoint(body);
const input = createKnowledgePointSchema.parse(body);
const result = await this.service.createKnowledgePoint(input);
return { success: true, data: result };
}
@@ -59,13 +63,24 @@ export class KnowledgePointsController {
return { success: true, data };
}
@Post(":id/prerequisites/:prerequisiteId")
@Post(":id/prerequisites")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async addPrerequisite(
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
const { prerequisiteId } = addPrerequisiteSchema.parse(body);
await this.service.addPrerequisite(id, prerequisiteId);
return { success: true, data: { success: true } };
}
@Delete(":id/prerequisites/:prerequisiteId")
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async removePrerequisite(
@Param("id") id: string,
@Param("prerequisiteId") prerequisiteId: string,
): Promise<{ success: true; data: { success: true } }> {
await this.service.addPrerequisite(id, prerequisiteId);
await this.service.removePrerequisite(id, prerequisiteId);
return { success: true, data: { success: true } };
}
@@ -73,9 +88,10 @@ export class KnowledgePointsController {
@RequirePermission(Permissions.CONTENT_KNOWLEDGE_POINT_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateKnowledgePointInput,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
await this.service.updateKnowledgePoint(id, body);
const input = updateKnowledgePointSchema.parse(body);
await this.service.updateKnowledgePoint(id, input);
return { success: true, data: { success: true } };
}

View File

@@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import {
createKnowledgePointSchema,
updateKnowledgePointSchema,
addPrerequisiteSchema,
} from "./knowledge-points.dto.js";
describe("knowledge-points.dto", () => {
describe("createKnowledgePointSchema", () => {
it("should parse valid input with all fields", () => {
const result = createKnowledgePointSchema.parse({
chapterId: "ch-1",
title: "Algebra Basics",
description: "Intro to algebra",
difficulty: 4,
metadata: { tags: ["math"] },
});
expect(result.chapterId).toBe("ch-1");
expect(result.title).toBe("Algebra Basics");
expect(result.description).toBe("Intro to algebra");
expect(result.difficulty).toBe(4);
expect(result.metadata).toEqual({ tags: ["math"] });
});
it("should default difficulty to 3 when not provided", () => {
const result = createKnowledgePointSchema.parse({
chapterId: "ch-1",
title: "Algebra Basics",
});
expect(result.difficulty).toBe(3);
});
it("should accept null metadata", () => {
const result = createKnowledgePointSchema.parse({
chapterId: "ch-1",
title: "Algebra Basics",
metadata: null,
});
expect(result.metadata).toBeNull();
});
it("should reject empty chapterId", () => {
expect(() =>
createKnowledgePointSchema.parse({ chapterId: "", title: "Title" }),
).toThrow();
});
it("should reject difficulty greater than 5", () => {
expect(() =>
createKnowledgePointSchema.parse({
chapterId: "ch-1",
title: "Title",
difficulty: 6,
}),
).toThrow();
});
});
describe("updateKnowledgePointSchema", () => {
it("should parse partial update with title only", () => {
const result = updateKnowledgePointSchema.parse({ title: "New Title" });
expect(result.title).toBe("New Title");
});
it("should accept null metadata in update", () => {
const result = updateKnowledgePointSchema.parse({ metadata: null });
expect(result.metadata).toBeNull();
});
it("should accept difficulty update within valid range", () => {
const result = updateKnowledgePointSchema.parse({ difficulty: 5 });
expect(result.difficulty).toBe(5);
});
});
describe("addPrerequisiteSchema", () => {
it("should parse valid prerequisiteId", () => {
const result = addPrerequisiteSchema.parse({
prerequisiteId: "kp-1",
});
expect(result.prerequisiteId).toBe("kp-1");
});
it("should reject empty prerequisiteId", () => {
expect(() =>
addPrerequisiteSchema.parse({ prerequisiteId: "" }),
).toThrow();
});
});
});

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
export const createKnowledgePointSchema = z.object({
chapterId: z.string().min(1).max(32),
title: z.string().min(1).max(255),
description: z.string().optional(),
difficulty: z.number().int().min(1).max(5).optional().default(3),
metadata: z.record(z.unknown()).nullish(),
});
export const updateKnowledgePointSchema = z.object({
title: z.string().min(1).max(255).optional(),
description: z.string().optional(),
difficulty: z.number().int().min(1).max(5).optional(),
metadata: z.record(z.unknown()).nullish(),
});
export const addPrerequisiteSchema = z.object({
prerequisiteId: z.string().min(1).max(32),
});
export type CreateKnowledgePointDto = z.infer<typeof createKnowledgePointSchema>;
export type UpdateKnowledgePointDto = z.infer<typeof updateKnowledgePointSchema>;
export type AddPrerequisiteDto = z.infer<typeof addPrerequisiteSchema>;

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { KnowledgePointsController } from "./knowledge-points.controller.js";
import { KnowledgePointsService } from "./knowledge-points.service.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js";
@Module({
imports: [OutboxModule],
controllers: [KnowledgePointsController],
providers: [KnowledgePointsService],
exports: [KnowledgePointsService],

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockGetDb = vi.fn();
vi.mock("../config/database.js", () => ({
getDb: () => mockGetDb(),
}));
import { KnowledgePointsRepository } from "./knowledge-points.repository.js";
function createMockDb(resolvedValue: unknown): unknown {
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
get: (_target, prop) => {
if (prop === "then") {
return (onFulfilled?: (v: unknown) => unknown) =>
Promise.resolve(
typeof onFulfilled === "function"
? onFulfilled(resolvedValue)
: resolvedValue,
);
}
return () => new Proxy({}, handler);
},
};
return new Proxy({}, handler);
}
describe("KnowledgePointsRepository", () => {
let repo: KnowledgePointsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new KnowledgePointsRepository();
});
it("findById should return first matching knowledge point", async () => {
const kp = { id: "kp-1", title: "T" };
mockGetDb.mockReturnValue(createMockDb([kp]));
const result = await repo.findById("kp-1");
expect(result).toBe(kp);
});
it("findById should return undefined when no match", async () => {
mockGetDb.mockReturnValue(createMockDb([]));
const result = await repo.findById("missing");
expect(result).toBeUndefined();
});
it("findByChapterId should return knowledge points array", async () => {
const kps = [{ id: "kp-1" }];
mockGetDb.mockReturnValue(createMockDb(kps));
const result = await repo.findByChapterId("ch-1");
expect(result).toBe(kps);
});
it("create should insert a new knowledge point", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.create({
id: "kp-1",
chapterId: "ch-1",
title: "T",
difficulty: 3,
});
expect(mockGetDb).toHaveBeenCalled();
});
it("update should update a knowledge point by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.update("kp-1", { title: "New" });
expect(mockGetDb).toHaveBeenCalled();
});
it("delete should delete a knowledge point by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.delete("kp-1");
expect(mockGetDb).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import { getDb } from "../config/database.js";
import {
knowledgePoints,
type KnowledgePoint,
@@ -8,7 +8,7 @@ import {
export class KnowledgePointsRepository {
async findById(id: string): Promise<KnowledgePoint | undefined> {
const [result] = await db
const [result] = await getDb()
.select()
.from(knowledgePoints)
.where(eq(knowledgePoints.id, id))
@@ -17,25 +17,25 @@ export class KnowledgePointsRepository {
}
async findByChapterId(chapterId: string): Promise<KnowledgePoint[]> {
return db
return getDb()
.select()
.from(knowledgePoints)
.where(eq(knowledgePoints.chapterId, chapterId));
}
async create(data: NewKnowledgePoint): Promise<void> {
await db.insert(knowledgePoints).values(data);
await getDb().insert(knowledgePoints).values(data);
}
async update(id: string, data: Partial<NewKnowledgePoint>): Promise<void> {
await db
await getDb()
.update(knowledgePoints)
.set(data)
.where(eq(knowledgePoints.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id));
await getDb().delete(knowledgePoints).where(eq(knowledgePoints.id, id));
}
}

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { KnowledgePointsService } from "./knowledge-points.service.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
vi.mock("./knowledge-points.repository.js", () => ({
knowledgePointsRepository: {
findById: vi.fn(),
findByChapterId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
vi.mock("../config/neo4j.js", () => ({
getNeo4jSession: vi.fn().mockReturnValue(null),
}));
import { knowledgePointsRepository } from "./knowledge-points.repository.js";
const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"),
};
describe("KnowledgePointsService", () => {
let service: KnowledgePointsService;
beforeEach(() => {
vi.clearAllMocks();
service = new KnowledgePointsService(mockOutbox as never);
});
describe("createKnowledgePoint", () => {
it("should create a KP and publish event (no direct Neo4j write)", async () => {
const input = {
chapterId: "ch-1",
title: "Algebra Basics",
description: "Intro to algebra",
difficulty: 3,
};
const result = await service.createKnowledgePoint(input);
expect(result.id).toBeDefined();
expect(knowledgePointsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
chapterId: "ch-1",
title: "Algebra Basics",
difficulty: 3,
}),
);
expect(mockOutbox.publish).toHaveBeenCalledWith(
"knowledge_point.created",
"KnowledgePoint",
result.id,
expect.objectContaining({
chapter_id: "ch-1",
title: "Algebra Basics",
}),
);
});
it("should default difficulty to 3", async () => {
await service.createKnowledgePoint({
chapterId: "ch-1",
title: "Test KP",
});
expect(knowledgePointsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ difficulty: 3 }),
);
});
});
describe("getKnowledgePoint", () => {
it("should throw NotFoundError when not found", async () => {
vi.mocked(knowledgePointsRepository.findById).mockResolvedValue(undefined);
await expect(service.getKnowledgePoint("nope")).rejects.toThrow(
NotFoundError,
);
});
});
describe("addPrerequisite", () => {
it("should throw ValidationError when id === prerequisiteId", async () => {
await expect(
service.addPrerequisite("kp-1", "kp-1"),
).rejects.toThrow(ValidationError);
});
it("should publish prerequisite_added event", async () => {
const mockKp = {
id: "kp-1",
chapterId: "ch-1",
title: "KP1",
description: null,
difficulty: 3,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(knowledgePointsRepository.findById).mockResolvedValue(mockKp);
await service.addPrerequisite("kp-1", "kp-2");
expect(mockOutbox.publish).toHaveBeenCalledWith(
"knowledge_point.prerequisite_added",
"KnowledgePoint",
"kp-1",
{ prerequisite_id: "kp-2" },
);
});
});
describe("removePrerequisite", () => {
it("should publish prerequisite_removed event", async () => {
const mockKp = {
id: "kp-1",
chapterId: "ch-1",
title: "KP1",
description: null,
difficulty: 3,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(knowledgePointsRepository.findById).mockResolvedValue(mockKp);
await service.removePrerequisite("kp-1", "kp-2");
expect(mockOutbox.publish).toHaveBeenCalledWith(
"knowledge_point.prerequisite_removed",
"KnowledgePoint",
"kp-1",
{ prerequisite_id: "kp-2" },
);
});
});
describe("getPrerequisites", () => {
it("should return empty array when Neo4j not available", async () => {
const result = await service.getPrerequisites("kp-1");
expect(result).toEqual([]);
});
});
});

View File

@@ -1,10 +1,14 @@
import { randomUUID } from "node:crypto";
import { createId } from "@paralleldrive/cuid2";
import { Injectable, Logger } from "@nestjs/common";
import { knowledgePointsRepository } from "./knowledge-points.repository.js";
import type { KnowledgePoint } from "./knowledge-points.schema.js";
import type { KnowledgePoint, NewKnowledgePoint } from "./knowledge-points.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.js";
import {
AGGREGATE_TYPES,
EVENT_TYPES,
} from "../shared/outbox/events.js";
import { getNeo4jSession } from "../config/neo4j.js";
import {
InternalError,
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
@@ -13,11 +17,15 @@ export interface CreateKnowledgePointInput {
chapterId: string;
title: string;
description?: string;
difficulty?: number;
metadata?: Record<string, unknown> | null;
}
export interface UpdateKnowledgePointInput {
title?: string;
description?: string;
difficulty?: number;
metadata?: Record<string, unknown> | null;
}
export interface PrerequisiteNode {
@@ -29,23 +37,35 @@ export interface PrerequisiteNode {
export class KnowledgePointsService {
private readonly logger = new Logger(KnowledgePointsService.name);
constructor(private readonly outbox: OutboxService) {}
async createKnowledgePoint(
input: CreateKnowledgePointInput,
): Promise<{ id: string }> {
if (!input.chapterId || !input.title) {
throw new ValidationError("chapterId, title are required");
}
const id = randomUUID();
await knowledgePointsRepository.create({
const id = createId();
const record: NewKnowledgePoint = {
id,
chapterId: input.chapterId,
title: input.title,
description: input.description,
});
difficulty: input.difficulty ?? 3,
metadata: input.metadata ?? null,
};
await knowledgePointsRepository.create(record);
// Neo4j创建知识点节点。非阻塞——失败仅记录日志不影响 MySQL 写入
await this.safeCreateNode(id, input.title);
// 发 Outbox 事件,由 Neo4j Sync Worker 异步创建图谱节点
// 禁止业务事务内同步双写 Neo4j架构原则 §0.1 第 3 条)。
await this.outbox.publish(
EVENT_TYPES.KP_CREATED,
AGGREGATE_TYPES.KNOWLEDGE_POINT,
id,
{
chapter_id: record.chapterId,
title: record.title,
description: record.description,
difficulty: record.difficulty,
},
);
return { id };
}
@@ -66,15 +86,37 @@ export class KnowledgePointsService {
id: string,
data: UpdateKnowledgePointInput,
): Promise<void> {
await this.getKnowledgePoint(id);
const existing = await this.getKnowledgePoint(id);
await knowledgePointsRepository.update(id, data);
await this.outbox.publish(
EVENT_TYPES.KP_UPDATED,
AGGREGATE_TYPES.KNOWLEDGE_POINT,
id,
{
title: data.title ?? existing.title,
description: data.description ?? existing.description,
difficulty: data.difficulty ?? existing.difficulty,
},
);
}
async deleteKnowledgePoint(id: string): Promise<void> {
await this.getKnowledgePoint(id);
await knowledgePointsRepository.delete(id);
// 知识点删除事件也走 kp.updated下游可标记节点为失效
await this.outbox.publish(
EVENT_TYPES.KP_UPDATED,
AGGREGATE_TYPES.KNOWLEDGE_POINT,
id,
{ deleted: true },
);
}
/**
* 查询知识点前置依赖——读模型查询,直接读 Neo4j。
* Neo4j 不可用时返回空数组,不阻塞主流程。
*/
async getPrerequisites(
knowledgePointId: string,
): Promise<PrerequisiteNode[]> {
@@ -109,6 +151,10 @@ export class KnowledgePointsService {
}
}
/**
* 添加前置依赖——仅写 Outbox 事件,由 Neo4j Sync Worker 异步建立关系。
* 不再直接写 Neo4j。
*/
async addPrerequisite(id: string, prerequisiteId: string): Promise<void> {
if (id === prerequisiteId) {
throw new ValidationError(
@@ -120,45 +166,26 @@ export class KnowledgePointsService {
await this.getKnowledgePoint(id);
await this.getKnowledgePoint(prerequisiteId);
const session = getNeo4jSession();
if (!session) {
throw new InternalError(
"Neo4j is not available, cannot add prerequisite",
);
}
try {
await session.executeWrite((tx) =>
tx.run(
`MATCH (kp:KnowledgePoint {id: $kpId}), (prereq:KnowledgePoint {id: $prereqId})
MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`,
{ kpId: id, prereqId: prerequisiteId },
),
);
} finally {
await session.close();
}
await this.outbox.publish(
EVENT_TYPES.KP_PREREQUISITE_ADDED,
AGGREGATE_TYPES.KNOWLEDGE_POINT,
id,
{ prerequisite_id: prerequisiteId },
);
}
private async safeCreateNode(id: string, title: string): Promise<void> {
const session = getNeo4jSession();
if (!session) {
return;
}
/**
* 移除前置依赖——仅写 Outbox 事件。
*/
async removePrerequisite(id: string, prerequisiteId: string): Promise<void> {
await this.getKnowledgePoint(id);
await this.getKnowledgePoint(prerequisiteId);
try {
await session.executeWrite((tx) =>
tx.run("MERGE (kp:KnowledgePoint {id: $id, title: $title})", {
id,
title,
}),
);
} catch (err) {
this.logger.warn(
`Neo4j createNode failed (non-blocking): ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
await session.close();
}
await this.outbox.publish(
EVENT_TYPES.KP_PREREQUISITE_REMOVED,
AGGREGATE_TYPES.KNOWLEDGE_POINT,
id,
{ prerequisite_id: prerequisiteId },
);
}
}

View File

@@ -1,18 +1,46 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { AppModule } from "./app.module.js";
import { GlobalErrorFilter } from "./shared/errors/global-error.filter.js";
import { initTracer, shutdownTracer } from "./shared/observability/tracer.js";
import { env } from "./config/env.js";
import { closeDb } from "./config/database.js";
import { closeNeo4j } from "./config/neo4j.js";
import { connectKafka, disconnectKafka } from "./config/kafka.js";
import { logger } from "./shared/observability/logger.js";
import { metricsRegistry } from "./shared/observability/metrics.js";
import { outboxPublisher } from "./shared/outbox/outbox.publisher.js";
import { neo4jSyncWorker } from "./shared/sync/neo4j-sync.worker.js";
import type { Request, Response } from "express";
/**
* 解析 proto 文件路径。
* 开发环境:从 monorepo 根目录的 packages/shared-proto/proto/ 加载
* 生产环境Docker从服务本地的 ./proto/ 加载Dockerfile COPY
*/
function resolveProtoPath(): string {
const monorepoPath = join(
process.cwd(),
"packages",
"shared-proto",
"proto",
"content.proto",
);
const localPath = join(process.cwd(), "proto", "content.proto");
if (existsSync(monorepoPath)) return monorepoPath;
if (existsSync(localPath)) return localPath;
return monorepoPath;
}
async function bootstrap(): Promise<void> {
initTracer();
// 连接 Kafka producer非阻塞——失败不阻止启动Outbox 会重试)
await connectKafka();
const app = await NestFactory.create(AppModule, {
logger: ["log", "error", "warn"],
});
@@ -20,30 +48,57 @@ async function bootstrap(): Promise<void> {
app.useGlobalFilters(new GlobalErrorFilter());
app.enableShutdownHooks();
// gRPC 微服务:端口 50054加载 content.proto
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC,
options: {
package: "next_edu_cloud.content.v1",
protoPath: resolveProtoPath(),
url: `0.0.0.0:${env.GRPC_PORT}`,
},
});
// Prometheus 指标端点:不鉴权,供 Prometheus 抓取。
app.getHttpAdapter().get("/metrics", async (_req: Request, res: Response) => {
res.set("Content-Type", metricsRegistry.contentType);
res.end(await metricsRegistry.metrics());
});
// 启动 HTTP + gRPC
await app.startAllMicroservices();
await app.listen(env.PORT);
logger.info({ port: env.PORT }, "Content service started");
logger.info(
{ port: env.PORT, grpcPort: env.GRPC_PORT },
"Content service started (HTTP + gRPC)",
);
// 启动 Outbox Publisher轮询 pending 事件投递 Kafka
await outboxPublisher.start();
// 启动 Neo4j Sync Worker消费 Kafka 事件异步同步图谱)
await neo4jSyncWorker.start();
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully...");
await neo4jSyncWorker.stop();
await outboxPublisher.stop();
await disconnectKafka();
await app.close();
await closeNeo4j();
await closeDb();
await shutdownTracer();
await app.close();
process.exit(0);
});
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully...");
await neo4jSyncWorker.stop();
await outboxPublisher.stop();
await disconnectKafka();
await app.close();
await closeNeo4j();
await closeDb();
await shutdownTracer();
await app.close();
process.exit(0);
});
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AuthMiddleware } from "./auth.middleware.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
import { UnauthorizedException } from "@nestjs/common";
import type { Response, NextFunction } from "express";
describe("AuthMiddleware", () => {
let middleware: AuthMiddleware;
const next = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
middleware = new AuthMiddleware();
});
function createReq(headers: Record<string, unknown>): AuthenticatedRequest {
return { headers } as unknown as AuthenticatedRequest;
}
it("should set userId and userRoles from headers", () => {
const req = createReq({
"x-user-id": "user-1",
"x-user-roles": "admin,teacher",
});
middleware.use(req, {} as Response, next as NextFunction);
expect(req.userId).toBe("user-1");
expect(req.userRoles).toEqual(["admin", "teacher"]);
expect(next).toHaveBeenCalled();
});
it("should set empty roles array when x-user-roles missing", () => {
const req = createReq({ "x-user-id": "user-1" });
middleware.use(req, {} as Response, next as NextFunction);
expect(req.userId).toBe("user-1");
expect(req.userRoles).toEqual([]);
});
it("should throw UnauthorizedException when x-user-id missing", () => {
const req = createReq({});
expect(() =>
middleware.use(req, {} as Response, next as NextFunction),
).toThrow(UnauthorizedException);
});
it("should throw when x-user-id is not a string", () => {
const req = createReq({ "x-user-id": ["not-a-string"] });
expect(() =>
middleware.use(req, {} as Response, next as NextFunction),
).toThrow(UnauthorizedException);
});
it("should not call next when unauthorized", () => {
const req = createReq({});
expect(() =>
middleware.use(req, {} as Response, next as NextFunction),
).toThrow();
expect(next).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ExecutionContext } from "@nestjs/common";
import {
PermissionGuard,
Permissions,
PERMISSIONS_KEY,
} from "./permission.guard.js";
import type { AuthenticatedRequest } from "./auth.middleware.js";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
describe("PermissionGuard", () => {
let guard: PermissionGuard;
let reflector: { getAllAndOverride: ReturnType<typeof vi.fn> };
function createMockContext(
request: Partial<AuthenticatedRequest>,
): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => request as unknown as AuthenticatedRequest,
}),
getHandler: () => ({}),
getClass: () => ({}),
} as unknown as ExecutionContext;
}
beforeEach(() => {
reflector = {
getAllAndOverride: vi.fn(),
};
guard = new PermissionGuard(reflector as never);
});
it("should allow access when DEV_MODE is true", () => {
const original = process.env.DEV_MODE;
process.env.DEV_MODE = "true";
const ctx = createMockContext({ userRoles: [] });
expect(guard.canActivate(ctx)).toBe(true);
process.env.DEV_MODE = original;
});
it("should allow access when no required permissions", () => {
process.env.DEV_MODE = "false";
reflector.getAllAndOverride.mockReturnValue(undefined);
const ctx = createMockContext({ userRoles: [] });
expect(guard.canActivate(ctx)).toBe(true);
});
it("should allow access when no required permissions (empty array)", () => {
reflector.getAllAndOverride.mockReturnValue([]);
const ctx = createMockContext({ userRoles: [] });
expect(guard.canActivate(ctx)).toBe(true);
});
it("should allow access when user role has required permission", () => {
reflector.getAllAndOverride.mockReturnValue([
Permissions.CONTENT_TEXTBOOK_READ,
]);
const ctx = createMockContext({
userRoles: ["admin"],
});
expect(guard.canActivate(ctx)).toBe(true);
});
it("should allow access when teacher role has matching permission", () => {
reflector.getAllAndOverride.mockReturnValue([
Permissions.CONTENT_CHAPTER_CREATE,
]);
const ctx = createMockContext({
userRoles: ["teacher"],
});
expect(guard.canActivate(ctx)).toBe(true);
});
it("should allow access when at least one role matches", () => {
reflector.getAllAndOverride.mockReturnValue([
Permissions.CONTENT_TEXTBOOK_READ,
]);
const ctx = createMockContext({
userRoles: ["student", "unknown-role"],
});
expect(guard.canActivate(ctx)).toBe(true);
});
it("should throw PermissionDeniedError when user lacks permission", () => {
reflector.getAllAndOverride.mockReturnValue([
Permissions.CONTENT_TEXTBOOK_DELETE,
]);
const ctx = createMockContext({
userRoles: ["student"],
});
expect(() => guard.canActivate(ctx)).toThrow(PermissionDeniedError);
});
it("should throw PermissionDeniedError when user has no roles", () => {
reflector.getAllAndOverride.mockReturnValue([
Permissions.CONTENT_TEXTBOOK_READ,
]);
const ctx = createMockContext({
userRoles: [],
});
expect(() => guard.canActivate(ctx)).toThrow(PermissionDeniedError);
});
it("should use empty roles array when userRoles is undefined", () => {
reflector.getAllAndOverride.mockReturnValue([
Permissions.CONTENT_TEXTBOOK_READ,
]);
const ctx = createMockContext({});
expect(() => guard.canActivate(ctx)).toThrow(PermissionDeniedError);
});
it("should expose PERMISSIONS_KEY constant", () => {
expect(PERMISSIONS_KEY).toBe("permissions");
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QuestionsController } from "./questions.controller.js";
vi.mock("./questions.service.js", () => ({
QuestionsService: vi.fn(),
}));
const mockService = {
createQuestion: vi.fn(),
list: vi.fn(),
listByKnowledgePoint: vi.fn(),
getQuestion: vi.fn(),
updateQuestion: vi.fn(),
deleteQuestion: vi.fn(),
};
describe("QuestionsController", () => {
let controller: QuestionsController;
beforeEach(() => {
vi.clearAllMocks();
controller = new QuestionsController(mockService as never);
});
describe("create", () => {
it("should parse input and call service.createQuestion", async () => {
mockService.createQuestion.mockResolvedValue({ id: "q-1" });
const result = await controller.create({
knowledgePointId: "kp-1",
type: "single_choice",
content: "What is 2+2?",
answer: "4",
createdBy: "user-1",
});
expect(mockService.createQuestion).toHaveBeenCalledWith(
expect.objectContaining({
knowledgePointId: "kp-1",
type: "single_choice",
answer: "4",
}),
);
expect(result).toEqual({ success: true, data: { id: "q-1" } });
});
it("should throw on invalid body", async () => {
await expect(
controller.create({ knowledgePointId: "kp-1", type: "invalid" }),
).rejects.toThrow();
});
});
describe("list", () => {
it("should parse query and call service.list", async () => {
const data = [{ id: "q-1" }];
mockService.list.mockResolvedValue(data);
const result = await controller.list({ page: "1", pageSize: "20" });
expect(mockService.list).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, pageSize: 20 }),
);
expect(result).toEqual({ success: true, data });
});
});
describe("listByKnowledgePoint", () => {
it("should call service.listByKnowledgePoint", async () => {
const data = [{ id: "q-1" }];
mockService.listByKnowledgePoint.mockResolvedValue(data);
const result = await controller.listByKnowledgePoint("kp-1");
expect(mockService.listByKnowledgePoint).toHaveBeenCalledWith("kp-1");
expect(result).toEqual({ success: true, data });
});
});
describe("getById", () => {
it("should call service.getQuestion", async () => {
const data = { id: "q-1", content: "Q" };
mockService.getQuestion.mockResolvedValue(data);
const result = await controller.getById("q-1");
expect(mockService.getQuestion).toHaveBeenCalledWith("q-1");
expect(result).toEqual({ success: true, data });
});
});
describe("update", () => {
it("should parse input and call service.updateQuestion", async () => {
const result = await controller.update("q-1", { content: "New content" });
expect(mockService.updateQuestion).toHaveBeenCalledWith("q-1", {
content: "New content",
});
expect(result).toEqual({ success: true, data: { success: true } });
});
it("should throw on invalid status", async () => {
await expect(
controller.update("q-1", { status: "invalid" }),
).rejects.toThrow();
});
});
describe("remove", () => {
it("should call service.deleteQuestion", async () => {
const result = await controller.remove("q-1");
expect(mockService.deleteQuestion).toHaveBeenCalledWith("q-1");
expect(result).toEqual({ success: true, data: { success: true } });
});
});
});

View File

@@ -6,17 +6,19 @@ import {
Param,
Post,
Put,
Query,
} from "@nestjs/common";
import {
QuestionsService,
type CreateQuestionInput,
type UpdateQuestionInput,
} from "./questions.service.js";
import { QuestionsService } from "./questions.service.js";
import type { Question } from "./questions.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import {
createQuestionSchema,
updateQuestionSchema,
listQuestionsSchema,
} from "./questions.dto.js";
@Controller("questions")
export class QuestionsController {
@@ -25,12 +27,23 @@ export class QuestionsController {
@Post()
@RequirePermission(Permissions.CONTENT_QUESTION_CREATE)
async create(
@Body() body: CreateQuestionInput,
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.createQuestion(body);
const input = createQuestionSchema.parse(body);
const result = await this.service.createQuestion(input);
return { success: true, data: result };
}
@Get()
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async list(
@Query() query: unknown,
): Promise<{ success: true; data: Question[] }> {
const input = listQuestionsSchema.parse(query);
const data = await this.service.list(input);
return { success: true, data };
}
@Get("knowledge-point/:knowledgePointId")
@RequirePermission(Permissions.CONTENT_QUESTION_READ)
async listByKnowledgePoint(
@@ -53,9 +66,10 @@ export class QuestionsController {
@RequirePermission(Permissions.CONTENT_QUESTION_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateQuestionInput,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
await this.service.updateQuestion(id, body);
const input = updateQuestionSchema.parse(body);
await this.service.updateQuestion(id, input);
return { success: true, data: { success: true } };
}

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import {
createQuestionSchema,
updateQuestionSchema,
listQuestionsSchema,
questionTypeSchema,
questionSourceSchema,
} from "./questions.dto.js";
describe("questions.dto", () => {
describe("questionTypeSchema", () => {
it("should accept all valid question types", () => {
const validTypes = [
"single_choice",
"multiple_choice",
"short_answer",
"essay",
];
for (const t of validTypes) {
expect(questionTypeSchema.parse(t)).toBe(t);
}
});
it("should reject invalid question type", () => {
expect(() => questionTypeSchema.parse("invalid_type")).toThrow();
});
});
describe("questionSourceSchema", () => {
it("should accept all valid question sources", () => {
const validSources = ["manual", "ai_generated", "imported"];
for (const s of validSources) {
expect(questionSourceSchema.parse(s)).toBe(s);
}
});
});
describe("createQuestionSchema", () => {
it("should parse valid input and apply defaults", () => {
const result = createQuestionSchema.parse({
knowledgePointId: "kp-1",
type: "single_choice",
content: "What is 2+2?",
answer: "4",
createdBy: "user-1",
});
expect(result.knowledgePointId).toBe("kp-1");
expect(result.type).toBe("single_choice");
expect(result.content).toBe("What is 2+2?");
expect(result.answer).toBe("4");
expect(result.difficulty).toBe(3);
expect(result.source).toBe("manual");
});
it("should reject missing answer", () => {
expect(() =>
createQuestionSchema.parse({
knowledgePointId: "kp-1",
type: "essay",
content: "Write an essay",
createdBy: "user-1",
}),
).toThrow();
});
it("should reject invalid type", () => {
expect(() =>
createQuestionSchema.parse({
knowledgePointId: "kp-1",
type: "invalid",
content: "content",
answer: "answer",
createdBy: "user-1",
}),
).toThrow();
});
});
describe("updateQuestionSchema", () => {
it("should parse partial update with status", () => {
const result = updateQuestionSchema.parse({ status: "published" });
expect(result.status).toBe("published");
});
it("should reject invalid status value", () => {
expect(() =>
updateQuestionSchema.parse({ status: "invalid" }),
).toThrow();
});
});
describe("listQuestionsSchema", () => {
it("should default page and pageSize", () => {
const result = listQuestionsSchema.parse({});
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
});
it("should coerce string numbers for page and pageSize", () => {
const result = listQuestionsSchema.parse({
page: "3",
pageSize: "50",
});
expect(result.page).toBe(3);
expect(result.pageSize).toBe(50);
});
});
});

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
export const questionTypeSchema = z.enum([
"single_choice",
"multiple_choice",
"short_answer",
"essay",
]);
export const questionSourceSchema = z.enum([
"manual",
"ai_generated",
"imported",
]);
export const createQuestionSchema = z.object({
knowledgePointId: z.string().min(1).max(32),
type: questionTypeSchema,
content: z.string().min(1),
options: z.record(z.unknown()).nullish(),
answer: z.string().min(1),
explanation: z.string().optional(),
difficulty: z.number().int().min(1).max(5).optional().default(3),
source: questionSourceSchema.optional().default("manual"),
createdBy: z.string().min(1).max(32),
metadata: z.record(z.unknown()).nullish(),
});
export const updateQuestionSchema = z.object({
content: z.string().min(1).optional(),
options: z.record(z.unknown()).nullish(),
answer: z.string().min(1).optional(),
explanation: z.string().optional(),
difficulty: z.number().int().min(1).max(5).optional(),
status: z
.enum(["draft", "pending_review", "published", "rejected", "archived"])
.optional(),
});
export const listQuestionsSchema = z.object({
knowledgePointId: z.string().optional(),
type: questionTypeSchema.optional(),
difficulty: z.coerce.number().int().min(1).max(5).optional(),
status: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export type CreateQuestionDto = z.infer<typeof createQuestionSchema>;
export type UpdateQuestionDto = z.infer<typeof updateQuestionSchema>;
export type ListQuestionsDto = z.infer<typeof listQuestionsSchema>;

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { QuestionsController } from "./questions.controller.js";
import { QuestionsService } from "./questions.service.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js";
@Module({
imports: [OutboxModule],
controllers: [QuestionsController],
providers: [QuestionsService],
exports: [QuestionsService],

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockGetDb = vi.fn();
vi.mock("../config/database.js", () => ({
getDb: () => mockGetDb(),
}));
import { QuestionsRepository } from "./questions.repository.js";
function createMockDb(resolvedValue: unknown): unknown {
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
get: (_target, prop) => {
if (prop === "then") {
return (onFulfilled?: (v: unknown) => unknown) =>
Promise.resolve(
typeof onFulfilled === "function"
? onFulfilled(resolvedValue)
: resolvedValue,
);
}
return () => new Proxy({}, handler);
},
};
return new Proxy({}, handler);
}
describe("QuestionsRepository", () => {
let repo: QuestionsRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new QuestionsRepository();
});
it("findById should return first matching question", async () => {
const q = { id: "q-1", content: "Q" };
mockGetDb.mockReturnValue(createMockDb([q]));
const result = await repo.findById("q-1");
expect(result).toBe(q);
});
it("findById should return undefined when no match", async () => {
mockGetDb.mockReturnValue(createMockDb([]));
const result = await repo.findById("missing");
expect(result).toBeUndefined();
});
it("findByKnowledgePointId should return questions array", async () => {
const qs = [{ id: "q-1" }];
mockGetDb.mockReturnValue(createMockDb(qs));
const result = await repo.findByKnowledgePointId("kp-1");
expect(result).toBe(qs);
});
it("find should apply query filters and pagination", async () => {
const qs = [{ id: "q-1" }];
mockGetDb.mockReturnValue(createMockDb(qs));
const result = await repo.find({
knowledgePointId: "kp-1",
type: "single_choice",
difficulty: 3,
status: "published",
page: 2,
pageSize: 10,
});
expect(result).toBe(qs);
});
it("find should use defaults when query is empty", async () => {
const qs: unknown[] = [];
mockGetDb.mockReturnValue(createMockDb(qs));
const result = await repo.find();
expect(result).toBe(qs);
});
it("create should insert a new question", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.create({
id: "q-1",
knowledgePointId: "kp-1",
type: "single_choice",
content: "Q",
answer: "A",
difficulty: 3,
status: "draft",
source: "manual",
createdBy: "user-1",
});
expect(mockGetDb).toHaveBeenCalled();
});
it("update should update a question by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.update("q-1", { content: "New" });
expect(mockGetDb).toHaveBeenCalled();
});
it("delete should delete a question by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.delete("q-1");
expect(mockGetDb).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm";
import { db } from "../config/database.js";
import { getDb } from "../config/database.js";
import {
questions,
type Question,
@@ -8,7 +8,7 @@ import {
export class QuestionsRepository {
async findById(id: string): Promise<Question | undefined> {
const [result] = await db
const [result] = await getDb()
.select()
.from(questions)
.where(eq(questions.id, id))
@@ -17,22 +17,49 @@ export class QuestionsRepository {
}
async findByKnowledgePointId(knowledgePointId: string): Promise<Question[]> {
return db
return getDb()
.select()
.from(questions)
.where(eq(questions.knowledgePointId, knowledgePointId));
}
async find(query?: {
knowledgePointId?: string;
type?: string;
difficulty?: number;
status?: string;
page?: number;
pageSize?: number;
}): Promise<Question[]> {
const db = getDb();
let q = db.select().from(questions).$dynamic();
if (query?.knowledgePointId) {
q = q.where(eq(questions.knowledgePointId, query.knowledgePointId));
}
if (query?.type) {
q = q.where(eq(questions.type, query.type));
}
if (query?.difficulty !== undefined) {
q = q.where(eq(questions.difficulty, query.difficulty));
}
if (query?.status) {
q = q.where(eq(questions.status, query.status));
}
const pageSize = query?.pageSize ?? 20;
const page = query?.page ?? 1;
return q.limit(pageSize).offset((page - 1) * pageSize);
}
async create(data: NewQuestion): Promise<void> {
await db.insert(questions).values(data);
await getDb().insert(questions).values(data);
}
async update(id: string, data: Partial<NewQuestion>): Promise<void> {
await db.update(questions).set(data).where(eq(questions.id, id));
await getDb().update(questions).set(data).where(eq(questions.id, id));
}
async delete(id: string): Promise<void> {
await db.delete(questions).where(eq(questions.id, id));
await getDb().delete(questions).where(eq(questions.id, id));
}
}

View File

@@ -1,23 +1,38 @@
import {
mysqlTable,
char,
varchar,
text,
int,
tinyint,
timestamp,
json,
index,
} from "drizzle-orm/mysql-core";
export const questions = mysqlTable("content_questions", {
id: char("id", { length: 36 }).notNull().primaryKey(),
knowledgePointId: char("knowledge_point_id", { length: 36 }).notNull(),
type: varchar("type", { length: 50 }).notNull(),
content: text("content").notNull(),
answer: text("answer"),
explanation: text("explanation"),
difficulty: int("difficulty").default(3),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export const questions = mysqlTable(
"content_questions",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
knowledgePointId: varchar("knowledge_point_id", { length: 32 }).notNull(),
type: varchar("type", { length: 32 }).notNull(),
content: text("content").notNull(),
options: json("options").$type<Record<string, unknown> | null>(),
answer: text("answer").notNull(),
explanation: text("explanation"),
difficulty: tinyint("difficulty").notNull().default(3),
status: varchar("status", { length: 32 }).notNull().default("draft"),
source: varchar("source", { length: 32 }).notNull().default("manual"),
createdBy: varchar("created_by", { length: 32 }).notNull(),
metadata: json("metadata").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
kpIdx: index("idx_questions_kp").on(table.knowledgePointId),
typeDifficultyIdx: index("idx_questions_type_difficulty").on(table.type, table.difficulty),
statusIdx: index("idx_questions_status").on(table.status),
sourceIdx: index("idx_questions_source").on(table.source),
}),
);
export type Question = typeof questions.$inferSelect;
export type NewQuestion = typeof questions.$inferInsert;

View File

@@ -0,0 +1,177 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QuestionsService } from "./questions.service.js";
import { NotFoundError } from "../shared/errors/application-error.js";
vi.mock("./questions.repository.js", () => ({
questionsRepository: {
findById: vi.fn(),
findByKnowledgePointId: vi.fn(),
find: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import { questionsRepository } from "./questions.repository.js";
const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"),
};
describe("QuestionsService", () => {
let service: QuestionsService;
beforeEach(() => {
vi.clearAllMocks();
service = new QuestionsService(mockOutbox as never);
});
describe("createQuestion", () => {
it("should create a question and publish event", async () => {
const input = {
knowledgePointId: "kp-1",
type: "single_choice",
content: "What is 2+2?",
answer: "4",
createdBy: "user-1",
};
const result = await service.createQuestion(input);
expect(result.id).toBeDefined();
expect(questionsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
knowledgePointId: "kp-1",
type: "single_choice",
content: "What is 2+2?",
answer: "4",
status: "draft",
source: "manual",
createdBy: "user-1",
}),
);
expect(mockOutbox.publish).toHaveBeenCalledWith(
"question.created",
"Question",
result.id,
expect.objectContaining({
knowledge_point_id: "kp-1",
type: "single_choice",
created_by: "user-1",
}),
);
});
it("should default difficulty to 3 and source to manual", async () => {
await service.createQuestion({
knowledgePointId: "kp-1",
type: "essay",
content: "Write an essay",
answer: "Sample answer",
createdBy: "user-1",
});
expect(questionsRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
difficulty: 3,
source: "manual",
}),
);
});
});
describe("getQuestion", () => {
it("should throw NotFoundError when not found", async () => {
vi.mocked(questionsRepository.findById).mockResolvedValue(undefined);
await expect(service.getQuestion("nope")).rejects.toThrow(NotFoundError);
});
});
describe("updateQuestion", () => {
it("should publish published event when status is published", async () => {
vi.mocked(questionsRepository.findById).mockResolvedValue({
id: "q-1",
knowledgePointId: "kp-1",
type: "single_choice",
content: "Q",
options: null,
answer: "A",
explanation: null,
difficulty: 3,
status: "draft",
source: "manual",
createdBy: "u",
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.updateQuestion("q-1", { status: "published" });
expect(mockOutbox.publish).toHaveBeenCalledWith(
"question.published",
"Question",
"q-1",
expect.any(Object),
);
});
it("should publish updated event for non-status updates", async () => {
vi.mocked(questionsRepository.findById).mockResolvedValue({
id: "q-1",
knowledgePointId: "kp-1",
type: "single_choice",
content: "Q",
options: null,
answer: "A",
explanation: null,
difficulty: 3,
status: "draft",
source: "manual",
createdBy: "u",
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.updateQuestion("q-1", { content: "New content" });
expect(mockOutbox.publish).toHaveBeenCalledWith(
"question.updated",
"Question",
"q-1",
expect.any(Object),
);
});
});
describe("deleteQuestion", () => {
it("should delete and publish deleted event", async () => {
vi.mocked(questionsRepository.findById).mockResolvedValue({
id: "q-1",
knowledgePointId: "kp-1",
type: "single_choice",
content: "Q",
options: null,
answer: "A",
explanation: null,
difficulty: 3,
status: "draft",
source: "manual",
createdBy: "u",
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.deleteQuestion("q-1");
expect(questionsRepository.delete).toHaveBeenCalledWith("q-1");
expect(mockOutbox.publish).toHaveBeenCalledWith(
"question.deleted",
"Question",
"q-1",
{ deleted: true },
);
});
});
});

View File

@@ -1,58 +1,83 @@
import { randomUUID } from "node:crypto";
import { createId } from "@paralleldrive/cuid2";
import { Injectable } from "@nestjs/common";
import { questionsRepository } from "./questions.repository.js";
import type { Question } from "./questions.schema.js";
import type { Question, NewQuestion } from "./questions.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.js";
import {
AGGREGATE_TYPES,
EVENT_TYPES,
} from "../shared/outbox/events.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateQuestionInput {
knowledgePointId: string;
type: string;
content: string;
answer?: string;
options?: Record<string, unknown> | null;
answer: string;
explanation?: string;
difficulty?: number;
source?: string;
createdBy: string;
metadata?: Record<string, unknown> | null;
}
export interface UpdateQuestionInput {
type?: string;
content?: string;
options?: Record<string, unknown> | null;
answer?: string;
explanation?: string;
difficulty?: number;
status?: string;
}
const VALID_TYPES = new Set([
"single_choice",
"multiple_choice",
"short_answer",
"essay",
]);
export interface ListQuestionsInput {
knowledgePointId?: string;
type?: string;
difficulty?: number;
status?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class QuestionsService {
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
if (!input.knowledgePointId || !input.type || !input.content) {
throw new ValidationError("knowledgePointId, type, content are required");
}
if (!VALID_TYPES.has(input.type)) {
throw new ValidationError(
`Invalid question type: ${input.type}. Must be one of: ${[...VALID_TYPES].join(", ")}`,
);
}
constructor(private readonly outbox: OutboxService) {}
const id = randomUUID();
await questionsRepository.create({
async createQuestion(input: CreateQuestionInput): Promise<{ id: string }> {
const id = createId();
const record: NewQuestion = {
id,
knowledgePointId: input.knowledgePointId,
type: input.type,
content: input.content,
options: input.options ?? null,
answer: input.answer,
explanation: input.explanation,
difficulty: input.difficulty,
});
difficulty: input.difficulty ?? 3,
status: "draft",
source: input.source ?? "manual",
createdBy: input.createdBy,
metadata: input.metadata ?? null,
};
await questionsRepository.create(record);
await this.outbox.publish(
EVENT_TYPES.QUESTION_CREATED,
AGGREGATE_TYPES.QUESTION,
id,
{
knowledge_point_id: record.knowledgePointId,
type: record.type,
difficulty: record.difficulty,
status: record.status,
source: record.source,
created_by: record.createdBy,
},
);
return { id };
}
@@ -68,16 +93,40 @@ export class QuestionsService {
return questionsRepository.findByKnowledgePointId(knowledgePointId);
}
async list(query: ListQuestionsInput): Promise<Question[]> {
return questionsRepository.find(query);
}
async updateQuestion(id: string, data: UpdateQuestionInput): Promise<void> {
await this.getQuestion(id);
if (data.type && !VALID_TYPES.has(data.type)) {
throw new ValidationError(`Invalid question type: ${data.type}`);
}
const existing = await this.getQuestion(id);
await questionsRepository.update(id, data);
const eventType =
data.status === "published"
? EVENT_TYPES.QUESTION_PUBLISHED
: EVENT_TYPES.QUESTION_UPDATED;
await this.outbox.publish(
eventType,
AGGREGATE_TYPES.QUESTION,
id,
{
content: data.content ?? existing.content,
difficulty: data.difficulty ?? existing.difficulty,
status: data.status ?? existing.status,
},
);
}
async deleteQuestion(id: string): Promise<void> {
await this.getQuestion(id);
await questionsRepository.delete(id);
await this.outbox.publish(
EVENT_TYPES.QUESTION_DELETED,
AGGREGATE_TYPES.QUESTION,
id,
{ deleted: true },
);
}
}

View File

@@ -5,7 +5,8 @@ export type ErrorType =
| 'conflict'
| 'business'
| 'database'
| 'internal';
| 'internal'
| 'neo4j_unavailable';
export interface ErrorDetails {
[key: string]: unknown;
@@ -94,3 +95,11 @@ export class InternalError extends ApplicationError {
super(message, 'CONTENT_INTERNAL_ERROR', details);
}
}
export class Neo4jUnavailableError extends ApplicationError {
readonly type = 'neo4j_unavailable' as const;
readonly statusCode = 503;
constructor(message: string, details?: ErrorDetails) {
super(message, 'CONTENT_NEO4J_UNAVAILABLE', details);
}
}

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { ArgumentsHost } from "@nestjs/common";
import { HttpException } from "@nestjs/common";
import type { Request, Response } from "express";
import { z, ZodError } from "zod";
import { GlobalErrorFilter } from "./global-error.filter.js";
import {
NotFoundError,
ValidationError,
InternalError,
} from "./application-error.js";
function createMockHost(
req: Partial<Request>,
res: Partial<Response>,
): ArgumentsHost {
return {
switchToHttp: () => ({
getResponse: () => res,
getRequest: () => req,
}),
} as unknown as ArgumentsHost;
}
function createMockResponse(): {
response: Response;
statusMock: ReturnType<typeof vi.fn>;
jsonMock: ReturnType<typeof vi.fn>;
} {
const statusMock = vi.fn().mockReturnThis();
const jsonMock = vi.fn();
return {
response: { status: statusMock, json: jsonMock } as unknown as Response,
statusMock,
jsonMock,
};
}
function createMockRequest(headers: Record<string, unknown> = {}): Request {
return { headers } as unknown as Request;
}
describe("GlobalErrorFilter", () => {
let filter: GlobalErrorFilter;
beforeEach(() => {
filter = new GlobalErrorFilter();
});
describe("ApplicationError branch", () => {
it("should respond with ApplicationError statusCode and toJSON body", () => {
const { response, statusMock, jsonMock } = createMockResponse();
const host = createMockHost(
createMockRequest({ "x-request-id": "trace-abc" }),
response,
);
const error = new NotFoundError("Chapter", "ch-1");
filter.catch(error, host);
expect(statusMock).toHaveBeenCalledWith(404);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: "CONTENT_NOT_FOUND",
traceId: "trace-abc",
}),
}),
);
expect(error.traceId).toBe("trace-abc");
});
it("should handle ValidationError with statusCode 400", () => {
const { response, statusMock, jsonMock } = createMockResponse();
const host = createMockHost(createMockRequest(), response);
const error = new ValidationError("Invalid input");
filter.catch(error, host);
expect(statusMock).toHaveBeenCalledWith(400);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ code: "CONTENT_VALIDATION_ERROR" }),
}),
);
});
});
describe("ZodError branch", () => {
it("should respond with statusCode 400 and CONTENT_VALIDATION_ERROR", () => {
const { response, statusMock, jsonMock } = createMockResponse();
const host = createMockHost(
createMockRequest({ "x-request-id": "trace-zod" }),
response,
);
const parseResult = z.object({ x: z.string() }).safeParse({ x: 123 });
const zodError = parseResult.error as ZodError;
filter.catch(zodError, host);
expect(statusMock).toHaveBeenCalledWith(400);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: "CONTENT_VALIDATION_ERROR",
message: "Validation failed",
traceId: "trace-zod",
details: expect.any(Object),
}),
}),
);
});
});
describe("HttpException branch", () => {
it("should handle HttpException with string response", () => {
const { response, statusMock, jsonMock } = createMockResponse();
const host = createMockHost(createMockRequest(), response);
const error = new HttpException("Forbidden resource", 403);
filter.catch(error, host);
expect(statusMock).toHaveBeenCalledWith(403);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "HTTP_ERROR",
message: "Forbidden resource",
}),
}),
);
});
it("should handle HttpException with object response containing message string", () => {
const { response, statusMock, jsonMock } = createMockResponse();
const host = createMockHost(createMockRequest(), response);
const error = new HttpException({ message: "Custom error message" }, 422);
filter.catch(error, host);
expect(statusMock).toHaveBeenCalledWith(422);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Custom error message",
}),
}),
);
});
it("should fall back to exception.message for object response without message", () => {
const { response, statusMock, jsonMock } = createMockResponse();
const host = createMockHost(createMockRequest(), response);
const error = new HttpException({ data: "value" }, 400);
filter.catch(error, host);
expect(statusMock).toHaveBeenCalledWith(400);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
code: "HTTP_ERROR",
message: error.message,
}),
}),
);
});
});
describe("unknown error branch", () => {
it("should respond with statusCode 500 and INTERNAL_ERROR", () => {
const { response, statusMock, jsonMock } = createMockResponse();
const host = createMockHost(
createMockRequest({ "x-request-id": "trace-unknown" }),
response,
);
const error = new Error("something exploded");
filter.catch(error, host);
expect(statusMock).toHaveBeenCalledWith(500);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.objectContaining({
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
traceId: "trace-unknown",
}),
}),
);
});
it("should default traceId to unknown when x-request-id header is missing", () => {
const { response, statusMock, jsonMock } = createMockResponse();
const host = createMockHost(createMockRequest(), response);
const error = new InternalError("boom");
filter.catch(error, host);
expect(jsonMock).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({ traceId: "unknown" }),
}),
);
expect(statusMock).toHaveBeenCalledWith(500);
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { HttpException } from "@nestjs/common";
const mockGetDb = vi.fn();
const mockGetNeo4jSession = vi.fn();
const mockIsKafkaConnected = vi.fn();
vi.mock("../../config/database.js", () => ({
getDb: () => mockGetDb(),
}));
vi.mock("../../config/neo4j.js", () => ({
getNeo4jSession: () => mockGetNeo4jSession(),
}));
vi.mock("../../config/kafka.js", () => ({
isKafkaProducerConnected: () => mockIsKafkaConnected(),
}));
vi.mock("../../config/env.js", () => ({
env: { NEO4J_URL: undefined, NEO4J_PASSWORD: undefined },
}));
import { HealthController } from "./health.controller.js";
describe("HealthController", () => {
let controller: HealthController;
beforeEach(() => {
vi.clearAllMocks();
controller = new HealthController();
});
describe("liveness", () => {
it("should return ok status with service name", () => {
const result = controller.liveness();
expect(result.status).toBe("ok");
expect(result.service).toBe("content");
expect(result.timestamp).toBeDefined();
});
});
describe("readiness", () => {
it("should return ok when all dependencies healthy", async () => {
mockGetDb.mockReturnValue({
execute: vi.fn().mockResolvedValue(undefined),
});
mockIsKafkaConnected.mockReturnValue(true);
const result = await controller.readiness();
expect(result.status).toBe("ok");
expect(result.dependencies).toHaveLength(3);
const names = result.dependencies.map((d) => d.name);
expect(names).toEqual(["mysql", "neo4j", "kafka"]);
expect(result.dependencies.every((d) => d.status === "ok")).toBe(true);
});
it("should report neo4j as ok when not configured", async () => {
mockGetDb.mockReturnValue({
execute: vi.fn().mockResolvedValue(undefined),
});
mockIsKafkaConnected.mockReturnValue(true);
const result = await controller.readiness();
const neo4jDep = result.dependencies.find((d) => d.name === "neo4j");
expect(neo4jDep?.status).toBe("ok");
});
it("should report mysql error when db check fails", async () => {
mockGetDb.mockReturnValue({
execute: vi.fn().mockRejectedValue(new Error("db down")),
});
mockIsKafkaConnected.mockReturnValue(true);
await expect(controller.readiness()).rejects.toThrow(HttpException);
});
it("should report kafka error when producer disconnected", async () => {
mockGetDb.mockReturnValue({
execute: vi.fn().mockResolvedValue(undefined),
});
mockIsKafkaConnected.mockReturnValue(false);
await expect(controller.readiness()).rejects.toThrow(HttpException);
});
});
});

View File

@@ -1,9 +1,25 @@
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { db } from "../../config/database.js";
import { getDb } from "../../config/database.js";
import { getNeo4jSession } from "../../config/neo4j.js";
import { isKafkaProducerConnected } from "../../config/kafka.js";
import { env } from "../../config/env.js";
const SERVICE_NAME = "content";
interface DependencyCheck {
name: string;
status: "ok" | "error";
error?: string;
}
interface ReadinessResponse {
status: "ok" | "error";
service: string;
timestamp: string;
dependencies: DependencyCheck[];
}
@Controller()
export class HealthController {
@Get("healthz")
@@ -16,29 +32,83 @@ export class HealthController {
}
@Get("readyz")
async readiness(): Promise<{
status: string;
service: string;
timestamp: string;
}> {
async readiness(): Promise<ReadinessResponse> {
const dependencies: DependencyCheck[] = [];
// 1. DB check
try {
await db.execute(sql`SELECT 1`);
return {
await getDb().execute(sql`SELECT 1`);
dependencies.push({ name: "mysql", status: "ok" });
} catch (err) {
dependencies.push({
name: "mysql",
status: "error",
error: err instanceof Error ? err.message : String(err),
});
}
// 2. Neo4j check (optional — only if configured)
if (env.NEO4J_URL && env.NEO4J_PASSWORD) {
const session = getNeo4jSession();
if (!session) {
dependencies.push({
name: "neo4j",
status: "error",
error: "driver not initialized",
});
} else {
try {
await session.executeRead((tx) => tx.run("RETURN 1"));
dependencies.push({ name: "neo4j", status: "ok" });
} catch (err) {
dependencies.push({
name: "neo4j",
status: "error",
error: err instanceof Error ? err.message : String(err),
});
} finally {
await session.close();
}
}
} else {
dependencies.push({
name: "neo4j",
status: "ok",
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
};
} catch (error) {
error: "not configured (optional)",
});
}
// 3. Kafka check (non-blocking — producer may be disconnected if broker unavailable)
if (isKafkaProducerConnected()) {
dependencies.push({ name: "kafka", status: "ok" });
} else {
dependencies.push({
name: "kafka",
status: "error",
error: "producer not connected",
});
}
const allOk = dependencies.every((d) => d.status === "ok");
const status = allOk ? "ok" : "error";
if (!allOk) {
throw new HttpException(
{
status: "error",
status,
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
error:
error instanceof Error ? error.message : "database unreachable",
},
dependencies,
} satisfies ReadinessResponse,
HttpStatus.SERVICE_UNAVAILABLE,
);
}
return {
status,
service: SERVICE_NAME,
timestamp: new Date().toISOString(),
dependencies,
} satisfies ReadinessResponse;
}
}

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockCloseDb = vi.fn();
const mockCloseNeo4j = vi.fn();
vi.mock("../../config/database.js", () => ({
closeDb: () => mockCloseDb(),
}));
vi.mock("../../config/neo4j.js", () => ({
closeNeo4j: () => mockCloseNeo4j(),
}));
import { LifecycleService } from "./lifecycle.service.js";
describe("LifecycleService", () => {
let service: LifecycleService;
beforeEach(() => {
vi.clearAllMocks();
service = new LifecycleService();
});
describe("onModuleInit", () => {
it("should not throw on init", () => {
expect(() => service.onModuleInit()).not.toThrow();
});
});
describe("onApplicationShutdown", () => {
it("should close neo4j and db on shutdown", async () => {
mockCloseNeo4j.mockResolvedValue(undefined);
mockCloseDb.mockResolvedValue(undefined);
await service.onApplicationShutdown("SIGTERM");
expect(mockCloseNeo4j).toHaveBeenCalled();
expect(mockCloseDb).toHaveBeenCalled();
});
it("should handle signal undefined as unknown", async () => {
mockCloseNeo4j.mockResolvedValue(undefined);
mockCloseDb.mockResolvedValue(undefined);
await service.onApplicationShutdown();
expect(mockCloseDb).toHaveBeenCalled();
});
it("should swallow errors during shutdown", async () => {
mockCloseNeo4j.mockRejectedValue(new Error("neo4j close failed"));
mockCloseDb.mockResolvedValue(undefined);
await expect(service.onApplicationShutdown("SIGTERM")).resolves.not.toThrow();
});
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from "vitest";
import {
CONTENT_TOPICS,
AGGREGATE_TYPES,
EVENT_TYPES,
getTopicForEvent,
buildEventPayload,
} from "./events.js";
describe("events", () => {
describe("CONTENT_TOPICS", () => {
it("should have 4 aggregate topics", () => {
expect(Object.keys(CONTENT_TOPICS)).toHaveLength(4);
expect(CONTENT_TOPICS.TEXTBOOK).toBe("edu.content.textbook.events");
expect(CONTENT_TOPICS.CHAPTER).toBe("edu.content.chapter.events");
expect(CONTENT_TOPICS.KNOWLEDGE_POINT).toBe(
"edu.content.knowledge_point.events",
);
expect(CONTENT_TOPICS.QUESTION).toBe("edu.content.question.events");
});
});
describe("AGGREGATE_TYPES", () => {
it("should have 4 aggregate types", () => {
expect(AGGREGATE_TYPES.TEXTBOOK).toBe("Textbook");
expect(AGGREGATE_TYPES.CHAPTER).toBe("Chapter");
expect(AGGREGATE_TYPES.KNOWLEDGE_POINT).toBe("KnowledgePoint");
expect(AGGREGATE_TYPES.QUESTION).toBe("Question");
});
});
describe("EVENT_TYPES", () => {
it("should have 15 event types", () => {
expect(Object.keys(EVENT_TYPES)).toHaveLength(15);
});
});
describe("getTopicForEvent", () => {
it("should route textbook events to textbook topic", () => {
expect(getTopicForEvent(EVENT_TYPES.TEXTBOOK_CREATED)).toBe(
CONTENT_TOPICS.TEXTBOOK,
);
expect(getTopicForEvent(EVENT_TYPES.TEXTBOOK_UPDATED)).toBe(
CONTENT_TOPICS.TEXTBOOK,
);
expect(getTopicForEvent(EVENT_TYPES.TEXTBOOK_PUBLISHED)).toBe(
CONTENT_TOPICS.TEXTBOOK,
);
expect(getTopicForEvent(EVENT_TYPES.TEXTBOOK_ARCHIVED)).toBe(
CONTENT_TOPICS.TEXTBOOK,
);
});
it("should route chapter events to chapter topic", () => {
expect(getTopicForEvent(EVENT_TYPES.CHAPTER_CREATED)).toBe(
CONTENT_TOPICS.CHAPTER,
);
expect(getTopicForEvent(EVENT_TYPES.CHAPTER_DELETED)).toBe(
CONTENT_TOPICS.CHAPTER,
);
});
it("should route KP events to knowledge_point topic", () => {
expect(getTopicForEvent(EVENT_TYPES.KP_CREATED)).toBe(
CONTENT_TOPICS.KNOWLEDGE_POINT,
);
expect(getTopicForEvent(EVENT_TYPES.KP_PREREQUISITE_ADDED)).toBe(
CONTENT_TOPICS.KNOWLEDGE_POINT,
);
});
it("should route question events to question topic", () => {
expect(getTopicForEvent(EVENT_TYPES.QUESTION_CREATED)).toBe(
CONTENT_TOPICS.QUESTION,
);
expect(getTopicForEvent(EVENT_TYPES.QUESTION_PUBLISHED)).toBe(
CONTENT_TOPICS.QUESTION,
);
});
it("should return fallback topic for unknown events", () => {
expect(getTopicForEvent("unknown.event")).toBe("edu.content.fallback");
});
});
describe("buildEventPayload", () => {
it("should build payload with event_id, aggregate_id, event_type, occurred_at, action", () => {
const payload = buildEventPayload("textbook.created", "tb-1", {
title: "Test",
});
expect(payload.event_id).toBeDefined();
expect(payload.aggregate_id).toBe("tb-1");
expect(payload.event_type).toBe("edu.content.textbook.created");
expect(payload.occurred_at).toBeGreaterThan(0);
expect(payload.action).toBe("created");
expect(payload.title).toBe("Test");
});
it("should extract action from event type with multiple dots", () => {
const payload = buildEventPayload(
"knowledge_point.prerequisite_added",
"kp-1",
{},
);
expect(payload.action).toBe("prerequisite_added");
expect(payload.event_type).toBe("edu.content.knowledge_point.prerequisite_added");
});
});
});

View File

@@ -0,0 +1,80 @@
import { createId } from "@paralleldrive/cuid2";
export const CONTENT_TOPICS = {
TEXTBOOK: "edu.content.textbook.events",
CHAPTER: "edu.content.chapter.events",
KNOWLEDGE_POINT: "edu.content.knowledge_point.events",
QUESTION: "edu.content.question.events",
} as const;
export const AGGREGATE_TYPES = {
TEXTBOOK: "Textbook",
CHAPTER: "Chapter",
KNOWLEDGE_POINT: "KnowledgePoint",
QUESTION: "Question",
} as const;
export const EVENT_TYPES = {
TEXTBOOK_CREATED: "textbook.created",
TEXTBOOK_UPDATED: "textbook.updated",
TEXTBOOK_PUBLISHED: "textbook.published",
TEXTBOOK_ARCHIVED: "textbook.archived",
CHAPTER_CREATED: "chapter.created",
CHAPTER_UPDATED: "chapter.updated",
CHAPTER_DELETED: "chapter.deleted",
KP_CREATED: "knowledge_point.created",
KP_UPDATED: "knowledge_point.updated",
KP_PREREQUISITE_ADDED: "knowledge_point.prerequisite_added",
KP_PREREQUISITE_REMOVED: "knowledge_point.prerequisite_removed",
QUESTION_CREATED: "question.created",
QUESTION_UPDATED: "question.updated",
QUESTION_PUBLISHED: "question.published",
QUESTION_DELETED: "question.deleted",
} as const;
const TOPIC_MAP: Record<string, string> = {
[EVENT_TYPES.TEXTBOOK_CREATED]: CONTENT_TOPICS.TEXTBOOK,
[EVENT_TYPES.TEXTBOOK_UPDATED]: CONTENT_TOPICS.TEXTBOOK,
[EVENT_TYPES.TEXTBOOK_PUBLISHED]: CONTENT_TOPICS.TEXTBOOK,
[EVENT_TYPES.TEXTBOOK_ARCHIVED]: CONTENT_TOPICS.TEXTBOOK,
[EVENT_TYPES.CHAPTER_CREATED]: CONTENT_TOPICS.CHAPTER,
[EVENT_TYPES.CHAPTER_UPDATED]: CONTENT_TOPICS.CHAPTER,
[EVENT_TYPES.CHAPTER_DELETED]: CONTENT_TOPICS.CHAPTER,
[EVENT_TYPES.KP_CREATED]: CONTENT_TOPICS.KNOWLEDGE_POINT,
[EVENT_TYPES.KP_UPDATED]: CONTENT_TOPICS.KNOWLEDGE_POINT,
[EVENT_TYPES.KP_PREREQUISITE_ADDED]: CONTENT_TOPICS.KNOWLEDGE_POINT,
[EVENT_TYPES.KP_PREREQUISITE_REMOVED]: CONTENT_TOPICS.KNOWLEDGE_POINT,
[EVENT_TYPES.QUESTION_CREATED]: CONTENT_TOPICS.QUESTION,
[EVENT_TYPES.QUESTION_UPDATED]: CONTENT_TOPICS.QUESTION,
[EVENT_TYPES.QUESTION_PUBLISHED]: CONTENT_TOPICS.QUESTION,
[EVENT_TYPES.QUESTION_DELETED]: CONTENT_TOPICS.QUESTION,
};
export function getTopicForEvent(eventType: string): string {
return TOPIC_MAP[eventType] ?? "edu.content.fallback";
}
export interface EventPayload {
event_id: string;
aggregate_id: string;
event_type: string;
occurred_at: number;
action: string;
[key: string]: unknown;
}
export function buildEventPayload(
eventType: string,
aggregateId: string,
data: Record<string, unknown>,
): EventPayload {
const action = eventType.split(".").pop() ?? eventType;
return {
event_id: createId(),
aggregate_id: aggregateId,
event_type: `edu.content.${eventType}`,
occurred_at: Date.now(),
action,
...data,
};
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { OutboxService } from "./outbox.service.js";
@Module({
providers: [OutboxService],
exports: [OutboxService],
})
export class OutboxModule {}

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockProducer = vi.hoisted(() => ({ send: vi.fn() }));
vi.mock("../../config/kafka.js", () => ({
producer: mockProducer,
}));
vi.mock("../observability/logger.js", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock("./outbox.repository.js", () => ({
outboxRepository: {
findPending: vi.fn(),
markPublished: vi.fn(),
incrementRetry: vi.fn(),
markFailed: vi.fn(),
create: vi.fn(),
},
}));
import { OutboxPublisher } from "./outbox.publisher.js";
import { outboxRepository } from "./outbox.repository.js";
import type { OutboxMessage } from "./outbox.schema.js";
function createMessage(
overrides: Partial<OutboxMessage> = {},
): OutboxMessage {
return {
id: "msg-1",
aggregateType: "Chapter",
aggregateId: "ch-1",
eventType: "chapter.created",
topic: "edu.content.chapter.events",
payload: '{"event_id":"e1"}',
status: "pending",
retryCount: 0,
createdAt: new Date(),
publishedAt: null,
nextRetryAt: null,
lastError: null,
...overrides,
};
}
describe("OutboxPublisher", () => {
let publisher: OutboxPublisher;
beforeEach(() => {
vi.clearAllMocks();
publisher = new OutboxPublisher();
});
describe("dispatch (via poll)", () => {
it("should send message to kafka and mark as published", async () => {
const message = createMessage();
vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
mockProducer.send.mockResolvedValue(undefined);
// Access private poll via casting
await (publisher as unknown as { poll: () => Promise<void> }).poll();
expect(mockProducer.send).toHaveBeenCalledWith({
topic: message.topic,
messages: [
{
key: message.aggregateId,
value: message.payload,
headers: {
eventId: message.id,
eventType: message.eventType,
aggregateType: message.aggregateType,
},
},
],
});
expect(outboxRepository.markPublished).toHaveBeenCalledWith(message.id);
});
it("should increment retry when send fails and below max retries", async () => {
const message = createMessage({ id: "msg-2", retryCount: 0 });
vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
mockProducer.send.mockRejectedValue(new Error("kafka down"));
await (publisher as unknown as { poll: () => Promise<void> }).poll();
expect(outboxRepository.incrementRetry).toHaveBeenCalledWith(
"msg-2",
"kafka down",
);
expect(outboxRepository.markFailed).not.toHaveBeenCalled();
});
it("should mark failed when retry count reaches max", async () => {
const message = createMessage({ id: "msg-3", retryCount: 4 });
vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
mockProducer.send.mockRejectedValue(new Error("kafka down"));
await (publisher as unknown as { poll: () => Promise<void> }).poll();
expect(outboxRepository.markFailed).toHaveBeenCalledWith(
"msg-3",
"kafka down",
);
expect(outboxRepository.incrementRetry).not.toHaveBeenCalled();
});
it("should handle non-Error rejection in dispatch", async () => {
const message = createMessage({ id: "msg-4", retryCount: 0 });
vi.mocked(outboxRepository.findPending).mockResolvedValue([message]);
mockProducer.send.mockRejectedValue("string error");
await (publisher as unknown as { poll: () => Promise<void> }).poll();
expect(outboxRepository.incrementRetry).toHaveBeenCalledWith(
"msg-4",
"string error",
);
});
});
describe("poll error handling", () => {
it("should swallow errors from findPending", async () => {
vi.mocked(outboxRepository.findPending).mockRejectedValue(
new Error("db error"),
);
await expect(
(publisher as unknown as { poll: () => Promise<void> }).poll(),
).resolves.not.toThrow();
});
it("should process multiple messages in a batch", async () => {
const messages = [
createMessage({ id: "m1" }),
createMessage({ id: "m2" }),
];
vi.mocked(outboxRepository.findPending).mockResolvedValue(messages);
mockProducer.send.mockResolvedValue(undefined);
await (publisher as unknown as { poll: () => Promise<void> }).poll();
expect(mockProducer.send).toHaveBeenCalledTimes(2);
expect(outboxRepository.markPublished).toHaveBeenCalledWith("m1");
expect(outboxRepository.markPublished).toHaveBeenCalledWith("m2");
});
});
describe("start/stop", () => {
it("should start and stop without error", async () => {
await publisher.start();
await publisher.stop();
// Should not throw
});
it("should stop without error when not started", async () => {
await publisher.stop();
});
});
});

View File

@@ -0,0 +1,81 @@
import { logger } from "../observability/logger.js";
import { producer } from "../../config/kafka.js";
import { outboxRepository } from "./outbox.repository.js";
import type { OutboxMessage } from "./outbox.schema.js";
const POLL_INTERVAL_MS = 5000;
const BATCH_SIZE = 100;
const MAX_RETRY = 5;
export class OutboxPublisher {
private intervalId: ReturnType<typeof setInterval> | null = null;
private isPolling = false;
async start(): Promise<void> {
logger.info("OutboxPublisher started");
this.intervalId = setInterval(() => {
void this.poll();
}, POLL_INTERVAL_MS);
}
async stop(): Promise<void> {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
logger.info("OutboxPublisher stopped");
}
private async poll(): Promise<void> {
if (this.isPolling) return;
this.isPolling = true;
try {
const messages = await outboxRepository.findPending(BATCH_SIZE);
for (const message of messages) {
await this.dispatch(message);
}
} catch (error) {
logger.error({ error }, "Outbox poll failed");
} finally {
this.isPolling = false;
}
}
private async dispatch(message: OutboxMessage): Promise<void> {
try {
await producer.send({
topic: message.topic,
messages: [
{
key: message.aggregateId,
value: message.payload,
headers: {
eventId: message.id,
eventType: message.eventType,
aggregateType: message.aggregateType,
},
},
],
});
await outboxRepository.markPublished(message.id);
logger.info(
{ id: message.id, eventType: message.eventType, topic: message.topic },
"Outbox message published",
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
{ error, id: message.id, eventType: message.eventType },
"Outbox publish failed",
);
if (message.retryCount + 1 >= MAX_RETRY) {
await outboxRepository.markFailed(message.id, errorMessage);
} else {
await outboxRepository.incrementRetry(message.id, errorMessage);
}
}
}
}
export const outboxPublisher = new OutboxPublisher();

View File

@@ -0,0 +1,59 @@
import { eq, sql, and, or, isNull, lte } from "drizzle-orm";
import type { MySql2Database } from "drizzle-orm/mysql2";
import { getDb } from "../../config/database.js";
import {
outbox,
type OutboxMessage,
type NewOutboxMessage,
} from "./outbox.schema.js";
type DbClient = MySql2Database;
export class OutboxRepository {
async create(message: NewOutboxMessage, tx?: DbClient): Promise<void> {
const client = tx ?? getDb();
await client.insert(outbox).values(message);
}
async findPending(limit: number = 100): Promise<OutboxMessage[]> {
const now = new Date();
return getDb()
.select()
.from(outbox)
.where(
and(
eq(outbox.status, "pending"),
or(isNull(outbox.nextRetryAt), lte(outbox.nextRetryAt, now)),
),
)
.limit(limit);
}
async markPublished(id: string): Promise<void> {
await getDb()
.update(outbox)
.set({ status: "published", publishedAt: new Date(), lastError: null })
.where(eq(outbox.id, id));
}
async incrementRetry(id: string, errorMessage: string): Promise<void> {
const backoffMs = 5000 * 2 ** 1;
await getDb()
.update(outbox)
.set({
retryCount: sql`${outbox.retryCount} + 1`,
nextRetryAt: new Date(Date.now() + backoffMs),
lastError: errorMessage,
})
.where(eq(outbox.id, id));
}
async markFailed(id: string, errorMessage: string): Promise<void> {
await getDb()
.update(outbox)
.set({ status: "failed", lastError: errorMessage })
.where(eq(outbox.id, id));
}
}
export const outboxRepository = new OutboxRepository();

View File

@@ -0,0 +1,33 @@
import {
mysqlTable,
varchar,
text,
timestamp,
int,
index,
} from "drizzle-orm/mysql-core";
export const outbox = mysqlTable(
"content_outbox_events",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
aggregateType: varchar("aggregate_type", { length: 64 }).notNull(),
aggregateId: varchar("aggregate_id", { length: 32 }).notNull(),
eventType: varchar("event_type", { length: 100 }).notNull(),
topic: varchar("topic", { length: 128 }).notNull(),
payload: text("payload").notNull(),
status: varchar("status", { length: 20 }).notNull().default("pending"),
retryCount: int("retry_count").notNull().default(0),
createdAt: timestamp("created_at").notNull().defaultNow(),
publishedAt: timestamp("published_at"),
nextRetryAt: timestamp("next_retry_at"),
lastError: text("last_error"),
},
(table) => ({
statusRetryIdx: index("idx_outbox_status_retry").on(table.status, table.nextRetryAt),
aggregateIdx: index("idx_outbox_aggregate").on(table.aggregateType, table.aggregateId),
}),
);
export type OutboxMessage = typeof outbox.$inferSelect;
export type NewOutboxMessage = typeof outbox.$inferInsert;

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn().mockReturnValue("test-event-id"),
}));
vi.mock("./outbox.repository.js", () => ({
outboxRepository: {
create: vi.fn(),
},
}));
import { OutboxService } from "./outbox.service.js";
import { outboxRepository } from "./outbox.repository.js";
import { getTopicForEvent } from "./events.js";
describe("OutboxService", () => {
let service: OutboxService;
beforeEach(() => {
vi.clearAllMocks();
service = new OutboxService();
});
describe("publish", () => {
it("should call outboxRepository.create with correct payload structure", async () => {
const data = { title: "Test Chapter" };
const eventId = await service.publish(
"chapter.created",
"Chapter",
"ch-1",
data,
);
expect(outboxRepository.create).toHaveBeenCalledTimes(1);
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
expect(message.id).toBe("test-event-id");
expect(message.aggregateType).toBe("Chapter");
expect(message.aggregateId).toBe("ch-1");
expect(message.eventType).toBe("chapter.created");
expect(message.status).toBe("pending");
expect(typeof message.payload).toBe("string");
expect(eventId).toBe("test-event-id");
});
it("should set status to pending", async () => {
await service.publish("chapter.updated", "Chapter", "ch-1", {});
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
expect(message.status).toBe("pending");
});
it("should set topic based on event type via getTopicForEvent", async () => {
await service.publish("chapter.created", "Chapter", "ch-1", {});
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
expect(message.topic).toBe(getTopicForEvent("chapter.created"));
});
it("should serialize payload as JSON string containing event data", async () => {
const data = { title: "Test Chapter", order: 2 };
await service.publish("chapter.created", "Chapter", "ch-1", data);
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
const parsed = JSON.parse(message.payload);
expect(parsed.aggregate_id).toBe("ch-1");
expect(parsed.event_type).toBe("edu.content.chapter.created");
expect(parsed.action).toBe("created");
expect(parsed.title).toBe("Test Chapter");
expect(parsed.order).toBe(2);
});
it("should return the generated eventId", async () => {
const eventId = await service.publish(
"chapter.deleted",
"Chapter",
"ch-1",
{},
);
expect(eventId).toBe("test-event-id");
});
it("should pass tx to repository when provided", async () => {
const tx = {} as never;
await service.publish(
"chapter.created",
"Chapter",
"ch-1",
{},
tx,
);
const [, passedTx] = vi.mocked(outboxRepository.create).mock.calls[0]!;
expect(passedTx).toBe(tx);
});
it("should pass undefined tx when not provided", async () => {
await service.publish("chapter.created", "Chapter", "ch-1", {});
const [, passedTx] = vi.mocked(outboxRepository.create).mock.calls[0]!;
expect(passedTx).toBeUndefined();
});
it("should include aggregate data in payload", async () => {
const data = { name: "Chapter A", status: "draft" };
await service.publish("chapter.created", "Chapter", "ch-9", data);
const [message] = vi.mocked(outboxRepository.create).mock.calls[0]!;
const parsed = JSON.parse(message.payload);
expect(parsed.name).toBe("Chapter A");
expect(parsed.status).toBe("draft");
});
});
});

View File

@@ -0,0 +1,35 @@
import { Injectable } from "@nestjs/common";
import { createId } from "@paralleldrive/cuid2";
import type { MySql2Database } from "drizzle-orm/mysql2";
import { outboxRepository } from "./outbox.repository.js";
import { getTopicForEvent, buildEventPayload } from "./events.js";
@Injectable()
export class OutboxService {
async publish(
eventType: string,
aggregateType: string,
aggregateId: string,
data: Record<string, unknown>,
tx?: MySql2Database,
): Promise<string> {
const eventId = createId();
const topic = getTopicForEvent(eventType);
const payload = buildEventPayload(eventType, aggregateId, data);
await outboxRepository.create(
{
id: eventId,
aggregateType,
aggregateId,
eventType,
topic,
payload: JSON.stringify(payload),
status: "pending",
},
tx,
);
return eventId;
}
}

View File

@@ -0,0 +1,186 @@
import { logger } from "../observability/logger.js";
import { neo4jSyncConsumer } from "../../config/kafka.js";
import { getNeo4jSession } from "../../config/neo4j.js";
import { CONTENT_TOPICS, EVENT_TYPES } from "../outbox/events.js";
interface EventEnvelope {
event_id: string;
aggregate_id: string;
event_type: string;
occurred_at: number;
action: string;
kp_id?: string;
chapter_id?: string;
title?: string;
difficulty?: number;
prerequisite_id?: string;
[key: string]: unknown;
}
const processedEvents = new Set<string>();
const DEDUP_MAX_SIZE = 10000;
export class Neo4jSyncWorker {
private running = false;
async start(): Promise<void> {
try {
await neo4jSyncConsumer.connect();
await neo4jSyncConsumer.subscribe({
topic: CONTENT_TOPICS.KNOWLEDGE_POINT,
fromBeginning: false,
});
this.running = true;
await neo4jSyncConsumer.run({
eachMessage: async ({ message }) => {
await this.handleMessage(message);
},
});
logger.info("Neo4jSyncWorker started");
} catch (err) {
logger.warn(
{
err: err instanceof Error ? err.message : String(err),
},
"Neo4jSyncWorker failed to start (Neo4j/Kafka may be unavailable)",
);
}
}
async stop(): Promise<void> {
this.running = false;
try {
await neo4jSyncConsumer.stop();
} catch {
// ignore
}
logger.info("Neo4jSyncWorker stopped");
}
isRunning(): boolean {
return this.running;
}
private async handleMessage(
message: { key: Buffer | null; value: Buffer | null },
): Promise<void> {
const value = message.value?.toString();
if (!value) return;
let event: EventEnvelope;
try {
event = JSON.parse(value) as EventEnvelope;
} catch {
logger.error({ value }, "Failed to parse event payload");
return;
}
if (processedEvents.has(event.event_id)) {
return;
}
processedEvents.add(event.event_id);
if (processedEvents.size > DEDUP_MAX_SIZE) {
const first = processedEvents.values().next().value;
if (first) processedEvents.delete(first);
}
const session = getNeo4jSession();
if (!session) {
logger.debug(
{ eventType: event.event_type },
"Neo4j not available, skipping sync",
);
return;
}
try {
await this.syncEvent(session, event);
} catch (err) {
logger.error(
{
err: err instanceof Error ? err.message : String(err),
eventId: event.event_id,
eventType: event.event_type,
},
"Neo4j sync failed",
);
} finally {
await session.close();
}
}
private async syncEvent(
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
event: EventEnvelope,
): Promise<void> {
switch (event.event_type) {
case `edu.content.${EVENT_TYPES.KP_CREATED}`:
case `edu.content.${EVENT_TYPES.KP_UPDATED}`:
await this.upsertNode(session, event);
break;
case `edu.content.${EVENT_TYPES.KP_PREREQUISITE_ADDED}`:
await this.addPrerequisite(session, event);
break;
case `edu.content.${EVENT_TYPES.KP_PREREQUISITE_REMOVED}`:
await this.removePrerequisite(session, event);
break;
default:
logger.debug(
{ eventType: event.event_type },
"Unhandled event type for Neo4j sync",
);
}
}
private async upsertNode(
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
event: EventEnvelope,
): Promise<void> {
await session.executeWrite((tx) =>
tx.run(
`MERGE (kp:KnowledgePoint {id: $id})
SET kp.title = $title, kp.difficulty = $difficulty, kp.updatedAt = datetime()`,
{
id: event.kp_id ?? event.aggregate_id,
title: event.title ?? "",
difficulty: event.difficulty ?? 3,
},
),
);
}
private async addPrerequisite(
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
event: EventEnvelope,
): Promise<void> {
await session.executeWrite((tx) =>
tx.run(
`MATCH (kp:KnowledgePoint {id: $kpId}), (prereq:KnowledgePoint {id: $prereqId})
MERGE (prereq)-[:PREREQUISITE_OF]->(kp)`,
{
kpId: event.aggregate_id,
prereqId: event.prerequisite_id ?? "",
},
),
);
}
private async removePrerequisite(
session: NonNullable<ReturnType<typeof getNeo4jSession>>,
event: EventEnvelope,
): Promise<void> {
await session.executeWrite((tx) =>
tx.run(
`MATCH (prereq:KnowledgePoint {id: $prereqId})-[r:PREREQUISITE_OF]->(kp:KnowledgePoint {id: $kpId})
DELETE r`,
{
kpId: event.aggregate_id,
prereqId: event.prerequisite_id ?? "",
},
),
);
}
}
export const neo4jSyncWorker = new Neo4jSyncWorker();

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TextbooksController } from "./textbooks.controller.js";
vi.mock("./textbooks.service.js", () => ({
TextbooksService: vi.fn(),
}));
const mockService = {
create: vi.fn(),
list: vi.fn(),
getById: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
describe("TextbooksController", () => {
let controller: TextbooksController;
beforeEach(() => {
vi.clearAllMocks();
controller = new TextbooksController(mockService as never);
});
describe("create", () => {
it("should parse input and call service.create", async () => {
mockService.create.mockResolvedValue({ id: "tb-1" });
const result = await controller.create({
title: "Math",
subjectId: "subj-1",
gradeId: "g-1",
});
expect(mockService.create).toHaveBeenCalledWith(
expect.objectContaining({
title: "Math",
subjectId: "subj-1",
gradeId: "g-1",
}),
);
expect(result).toEqual({ success: true, data: { id: "tb-1" } });
});
it("should throw on invalid body", async () => {
await expect(controller.create({ title: "" })).rejects.toThrow();
});
});
describe("list", () => {
it("should parse query and call service.list", async () => {
const data = [{ id: "tb-1" }];
mockService.list.mockResolvedValue(data);
const result = await controller.list({ page: "2", pageSize: "10" });
expect(mockService.list).toHaveBeenCalledWith(
expect.objectContaining({ page: 2, pageSize: 10 }),
);
expect(result).toEqual({ success: true, data });
});
});
describe("getById", () => {
it("should call service.getById", async () => {
const data = { id: "tb-1", title: "T" };
mockService.getById.mockResolvedValue(data);
const result = await controller.getById("tb-1");
expect(mockService.getById).toHaveBeenCalledWith("tb-1");
expect(result).toEqual({ success: true, data });
});
});
describe("update", () => {
it("should parse input and call service.update", async () => {
const result = await controller.update("tb-1", { title: "New" });
expect(mockService.update).toHaveBeenCalledWith("tb-1", { title: "New" });
expect(result).toEqual({ success: true, data: { success: true } });
});
it("should throw on invalid status", async () => {
await expect(
controller.update("tb-1", { status: "invalid" }),
).rejects.toThrow();
});
});
describe("remove", () => {
it("should call service.delete", async () => {
const result = await controller.remove("tb-1");
expect(mockService.delete).toHaveBeenCalledWith("tb-1");
expect(result).toEqual({ success: true, data: { success: true } });
});
});
});

View File

@@ -6,17 +6,19 @@ import {
Param,
Post,
Put,
Query,
} from "@nestjs/common";
import {
TextbooksService,
type CreateTextbookInput,
type UpdateTextbookInput,
} from "./textbooks.service.js";
import { TextbooksService } from "./textbooks.service.js";
import type { Textbook } from "./textbooks.schema.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import {
createTextbookSchema,
updateTextbookSchema,
listTextbooksSchema,
} from "./textbooks.dto.js";
@Controller("textbooks")
export class TextbooksController {
@@ -25,16 +27,20 @@ export class TextbooksController {
@Post()
@RequirePermission(Permissions.CONTENT_TEXTBOOK_CREATE)
async create(
@Body() body: CreateTextbookInput,
@Body() body: unknown,
): Promise<{ success: true; data: { id: string } }> {
const result = await this.service.create(body);
const input = createTextbookSchema.parse(body);
const result = await this.service.create(input);
return { success: true, data: result };
}
@Get()
@RequirePermission(Permissions.CONTENT_TEXTBOOK_READ)
async list(): Promise<{ success: true; data: Textbook[] }> {
const data = await this.service.list();
async list(
@Query() query: unknown,
): Promise<{ success: true; data: Textbook[] }> {
const input = listTextbooksSchema.parse(query);
const data = await this.service.list(input);
return { success: true, data };
}
@@ -51,9 +57,10 @@ export class TextbooksController {
@RequirePermission(Permissions.CONTENT_TEXTBOOK_UPDATE)
async update(
@Param("id") id: string,
@Body() body: UpdateTextbookInput,
@Body() body: unknown,
): Promise<{ success: true; data: { success: true } }> {
await this.service.update(id, body);
const input = updateTextbookSchema.parse(body);
await this.service.update(id, input);
return { success: true, data: { success: true } };
}

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import {
createTextbookSchema,
updateTextbookSchema,
listTextbooksSchema,
} from "./textbooks.dto.js";
describe("textbooks.dto", () => {
describe("createTextbookSchema", () => {
it("should parse valid input", () => {
const result = createTextbookSchema.parse({
title: "Math",
subjectId: "subj-1",
gradeId: "g-1",
});
expect(result.title).toBe("Math");
expect(result.version).toBe("1.0");
});
it("should reject empty title", () => {
expect(() =>
createTextbookSchema.parse({ title: "", subjectId: "s", gradeId: "g" }),
).toThrow();
});
it("should reject missing subjectId", () => {
expect(() =>
createTextbookSchema.parse({ title: "T", gradeId: "g" }),
).toThrow();
});
});
describe("updateTextbookSchema", () => {
it("should parse partial update", () => {
const result = updateTextbookSchema.parse({ title: "New" });
expect(result.title).toBe("New");
});
it("should accept status enum", () => {
const result = updateTextbookSchema.parse({ status: "published" });
expect(result.status).toBe("published");
});
it("should reject invalid status", () => {
expect(() =>
updateTextbookSchema.parse({ status: "invalid" }),
).toThrow();
});
});
describe("listTextbooksSchema", () => {
it("should default page and pageSize", () => {
const result = listTextbooksSchema.parse({});
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
});
it("should coerce string numbers", () => {
const result = listTextbooksSchema.parse({ page: "3", pageSize: "50" });
expect(result.page).toBe(3);
expect(result.pageSize).toBe(50);
});
});
});

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
export const createTextbookSchema = z.object({
title: z.string().min(1).max(255),
subjectId: z.string().min(1).max(32),
gradeId: z.string().min(1).max(32),
version: z.string().max(32).optional().default("1.0"),
metadata: z.record(z.unknown()).nullish(),
});
export const updateTextbookSchema = z.object({
title: z.string().min(1).max(255).optional(),
status: z
.enum(["draft", "pending_review", "published", "archived"])
.optional(),
metadata: z.record(z.unknown()).nullish(),
});
export const listTextbooksSchema = z.object({
subjectId: z.string().optional(),
gradeId: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
});
export type CreateTextbookDto = z.infer<typeof createTextbookSchema>;
export type UpdateTextbookDto = z.infer<typeof updateTextbookSchema>;
export type ListTextbooksDto = z.infer<typeof listTextbooksSchema>;

View File

@@ -1,8 +1,10 @@
import { Module } from "@nestjs/common";
import { TextbooksController } from "./textbooks.controller.js";
import { TextbooksService } from "./textbooks.service.js";
import { OutboxModule } from "../shared/outbox/outbox.module.js";
@Module({
imports: [OutboxModule],
controllers: [TextbooksController],
providers: [TextbooksService],
exports: [TextbooksService],

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockGetDb = vi.fn();
vi.mock("../config/database.js", () => ({
getDb: () => mockGetDb(),
}));
import { TextbooksRepository } from "./textbooks.repository.js";
function createMockDb(resolvedValue: unknown): unknown {
const handler: ProxyHandler<Record<PropertyKey, unknown>> = {
get: (_target, prop) => {
if (prop === "then") {
return (onFulfilled?: (v: unknown) => unknown) =>
Promise.resolve(
typeof onFulfilled === "function"
? onFulfilled(resolvedValue)
: resolvedValue,
);
}
return () => new Proxy({}, handler);
},
};
return new Proxy({}, handler);
}
describe("TextbooksRepository", () => {
let repo: TextbooksRepository;
beforeEach(() => {
vi.clearAllMocks();
repo = new TextbooksRepository();
});
it("findById should return first matching textbook", async () => {
const tb = { id: "tb-1", title: "T" };
mockGetDb.mockReturnValue(createMockDb([tb]));
const result = await repo.findById("tb-1");
expect(result).toBe(tb);
});
it("findById should return undefined when no match", async () => {
mockGetDb.mockReturnValue(createMockDb([]));
const result = await repo.findById("missing");
expect(result).toBeUndefined();
});
it("find should apply query filters and pagination", async () => {
const tbs = [{ id: "tb-1" }];
mockGetDb.mockReturnValue(createMockDb(tbs));
const result = await repo.find({
subjectId: "subj-1",
gradeId: "g-1",
page: 2,
pageSize: 10,
});
expect(result).toBe(tbs);
});
it("find should use defaults when query is empty", async () => {
const tbs: unknown[] = [];
mockGetDb.mockReturnValue(createMockDb(tbs));
const result = await repo.find();
expect(result).toBe(tbs);
});
it("create should insert a new textbook", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.create({
id: "tb-1",
title: "Math",
subjectId: "subj-1",
gradeId: "g-1",
version: "1.0",
status: "draft",
});
expect(mockGetDb).toHaveBeenCalled();
});
it("update should update a textbook by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.update("tb-1", { title: "New" });
expect(mockGetDb).toHaveBeenCalled();
});
it("delete should delete a textbook by id", async () => {
mockGetDb.mockReturnValue(createMockDb(undefined));
await repo.delete("tb-1");
expect(mockGetDb).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
import { eq } from "drizzle-orm";
import { getDb } from "../config/database.js";
import { textbooks, type Textbook, type NewTextbook } from "./textbooks.schema.js";
export class TextbooksRepository {
async findById(id: string): Promise<Textbook | undefined> {
const [result] = await getDb()
.select()
.from(textbooks)
.where(eq(textbooks.id, id))
.limit(1);
return result;
}
async find(query?: {
subjectId?: string;
gradeId?: string;
page?: number;
pageSize?: number;
}): Promise<Textbook[]> {
const db = getDb();
let q = db.select().from(textbooks).$dynamic();
if (query?.subjectId) {
q = q.where(eq(textbooks.subjectId, query.subjectId));
}
if (query?.gradeId) {
q = q.where(eq(textbooks.gradeId, query.gradeId));
}
const pageSize = query?.pageSize ?? 20;
const page = query?.page ?? 1;
return q.limit(pageSize).offset((page - 1) * pageSize);
}
async create(data: NewTextbook): Promise<void> {
await getDb().insert(textbooks).values(data);
}
async update(id: string, data: Partial<NewTextbook>): Promise<void> {
await getDb().update(textbooks).set(data).where(eq(textbooks.id, id));
}
async delete(id: string): Promise<void> {
await getDb().delete(textbooks).where(eq(textbooks.id, id));
}
}
export const textbooksRepository = new TextbooksRepository();

View File

@@ -1,36 +1,69 @@
import {
mysqlTable,
varchar,
char,
timestamp,
text,
int,
tinyint,
json,
index,
} from "drizzle-orm/mysql-core";
export const textbooks = mysqlTable("content_textbooks", {
id: char("id", { length: 36 }).notNull().primaryKey(),
title: varchar("title", { length: 200 }).notNull(),
subjectId: char("subject_id", { length: 36 }).notNull(),
gradeId: char("grade_id", { length: 36 }).notNull(),
version: varchar("version", { length: 50 }).notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export const textbooks = mysqlTable(
"content_textbooks",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
subjectId: varchar("subject_id", { length: 32 }).notNull(),
gradeId: varchar("grade_id", { length: 32 }).notNull(),
version: varchar("version", { length: 32 }).notNull().default("1.0"),
status: varchar("status", { length: 32 }).notNull().default("draft"),
tenantId: varchar("tenant_id", { length: 32 }),
metadata: json("metadata").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
subjectGradeIdx: index("idx_textbooks_subject_grade").on(table.subjectId, table.gradeId),
statusIdx: index("idx_textbooks_status").on(table.status),
tenantIdx: index("idx_textbooks_tenant").on(table.tenantId),
}),
);
export const chapters = mysqlTable("content_chapters", {
id: char("id", { length: 36 }).notNull().primaryKey(),
textbookId: char("textbook_id", { length: 36 }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
order: int("order_num").notNull(),
parentId: char("parent_id", { length: 36 }),
});
export const chapters = mysqlTable(
"content_chapters",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
textbookId: varchar("textbook_id", { length: 32 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
order: int("order_num").notNull().default(0),
parentId: varchar("parent_id", { length: 32 }),
status: varchar("status", { length: 32 }).notNull().default("draft"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
textbookOrderIdx: index("idx_chapters_textbook_order").on(table.textbookId, table.order),
parentIdx: index("idx_chapters_parent").on(table.parentId),
}),
);
export const knowledgePoints = mysqlTable("content_knowledge_points", {
id: char("id", { length: 36 }).notNull().primaryKey(),
chapterId: char("chapter_id", { length: 36 }).notNull(),
title: varchar("title", { length: 200 }).notNull(),
description: text("description"),
});
export const knowledgePoints = mysqlTable(
"content_knowledge_points",
{
id: varchar("id", { length: 32 }).notNull().primaryKey(),
chapterId: varchar("chapter_id", { length: 32 }).notNull(),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
difficulty: tinyint("difficulty").notNull().default(3),
metadata: json("metadata").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
},
(table) => ({
chapterIdx: index("idx_kp_chapter").on(table.chapterId),
}),
);
export type Textbook = typeof textbooks.$inferSelect;
export type NewTextbook = typeof textbooks.$inferInsert;

View File

@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TextbooksService } from "./textbooks.service.js";
import { NotFoundError } from "../shared/errors/application-error.js";
vi.mock("./textbooks.repository.js", () => ({
textbooksRepository: {
create: vi.fn(),
findById: vi.fn(),
find: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}));
import { textbooksRepository } from "./textbooks.repository.js";
const mockOutbox = {
publish: vi.fn().mockResolvedValue("event-id"),
};
describe("TextbooksService", () => {
let service: TextbooksService;
beforeEach(() => {
vi.clearAllMocks();
service = new TextbooksService(mockOutbox as never);
});
describe("create", () => {
it("should create a textbook and publish event", async () => {
const input = {
title: "Math Grade 3",
subjectId: "subj-1",
gradeId: "grade-3",
};
const result = await service.create(input);
expect(result.id).toBeDefined();
expect(result.id).toHaveLength(24); // cuid2 length
expect(textbooksRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
title: "Math Grade 3",
subjectId: "subj-1",
gradeId: "grade-3",
version: "1.0",
status: "draft",
}),
);
expect(mockOutbox.publish).toHaveBeenCalledWith(
"textbook.created",
"Textbook",
result.id,
expect.objectContaining({
title: "Math Grade 3",
subject_id: "subj-1",
grade_id: "grade-3",
}),
);
});
it("should use provided version when specified", async () => {
const result = await service.create({
title: "Test",
subjectId: "s",
gradeId: "g",
version: "2.0",
});
expect(textbooksRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ version: "2.0" }),
);
expect(result.id).toBeDefined();
});
});
describe("getById", () => {
it("should return textbook when found", async () => {
const mockTextbook = {
id: "kp-1",
title: "Test",
subjectId: "s",
gradeId: "g",
version: "1.0",
status: "draft",
tenantId: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(textbooksRepository.findById).mockResolvedValue(mockTextbook);
const result = await service.getById("kp-1");
expect(result).toBe(mockTextbook);
});
it("should throw NotFoundError when not found", async () => {
vi.mocked(textbooksRepository.findById).mockResolvedValue(undefined);
await expect(service.getById("nonexistent")).rejects.toThrow(NotFoundError);
});
});
describe("update", () => {
it("should update and publish updated event", async () => {
const existing = {
id: "t-1",
title: "Old",
subjectId: "s",
gradeId: "g",
version: "1.0",
status: "draft",
tenantId: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
vi.mocked(textbooksRepository.findById).mockResolvedValue(existing);
await service.update("t-1", { title: "New Title" });
expect(textbooksRepository.update).toHaveBeenCalledWith("t-1", {
title: "New Title",
});
expect(mockOutbox.publish).toHaveBeenCalledWith(
"textbook.updated",
"Textbook",
"t-1",
expect.objectContaining({ title: "New Title" }),
);
});
it("should publish published event when status is published", async () => {
vi.mocked(textbooksRepository.findById).mockResolvedValue({
id: "t-1",
title: "T",
subjectId: "s",
gradeId: "g",
version: "1.0",
status: "draft",
tenantId: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.update("t-1", { status: "published" });
expect(mockOutbox.publish).toHaveBeenCalledWith(
"textbook.published",
"Textbook",
"t-1",
expect.any(Object),
);
});
it("should publish archived event when status is archived", async () => {
vi.mocked(textbooksRepository.findById).mockResolvedValue({
id: "t-1",
title: "T",
subjectId: "s",
gradeId: "g",
version: "1.0",
status: "draft",
tenantId: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.update("t-1", { status: "archived" });
expect(mockOutbox.publish).toHaveBeenCalledWith(
"textbook.archived",
"Textbook",
"t-1",
expect.any(Object),
);
});
});
describe("delete", () => {
it("should delete and publish archived event", async () => {
vi.mocked(textbooksRepository.findById).mockResolvedValue({
id: "t-1",
title: "T",
subjectId: "s",
gradeId: "g",
version: "1.0",
status: "draft",
tenantId: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await service.delete("t-1");
expect(textbooksRepository.delete).toHaveBeenCalledWith("t-1");
expect(mockOutbox.publish).toHaveBeenCalledWith(
"textbook.archived",
"Textbook",
"t-1",
{ deleted: true },
);
});
});
describe("list", () => {
it("should call repository find with query", async () => {
const mockList = [{ id: "t-1" }];
vi.mocked(textbooksRepository.find).mockResolvedValue(mockList as never);
const result = await service.list({ subjectId: "s" });
expect(result).toBe(mockList);
expect(textbooksRepository.find).toHaveBeenCalledWith({ subjectId: "s" });
});
});
});

View File

@@ -1,57 +1,76 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { Injectable } from "@nestjs/common";
import { db } from "../config/database.js";
import { textbooks, type Textbook } from "./textbooks.schema.js";
import { textbooksRepository } from "./textbooks.repository.js";
import type { Textbook, NewTextbook } from "./textbooks.schema.js";
import { OutboxService } from "../shared/outbox/outbox.service.js";
import {
AGGREGATE_TYPES,
EVENT_TYPES,
} from "../shared/outbox/events.js";
import {
NotFoundError,
ValidationError,
} from "../shared/errors/application-error.js";
export interface CreateTextbookInput {
title: string;
subjectId: string;
gradeId: string;
version: string;
version?: string;
metadata?: Record<string, unknown> | null;
}
export interface UpdateTextbookInput {
title?: string;
status?: string;
metadata?: Record<string, unknown> | null;
}
export interface ListTextbooksInput {
subjectId?: string;
gradeId?: string;
version?: string;
page?: number;
pageSize?: number;
}
@Injectable()
export class TextbooksService {
async create(input: CreateTextbookInput): Promise<{ id: string }> {
if (!input.title || !input.subjectId || !input.gradeId || !input.version) {
throw new ValidationError(
"title, subjectId, gradeId, version are required",
);
}
constructor(private readonly outbox: OutboxService) {}
const id = randomUUID();
await db.insert(textbooks).values({
async create(input: CreateTextbookInput): Promise<{ id: string }> {
const id = createId();
const record: NewTextbook = {
id,
title: input.title,
subjectId: input.subjectId,
gradeId: input.gradeId,
version: input.version,
});
version: input.version ?? "1.0",
status: "draft",
metadata: input.metadata ?? null,
};
await textbooksRepository.create(record);
await this.outbox.publish(
EVENT_TYPES.TEXTBOOK_CREATED,
AGGREGATE_TYPES.TEXTBOOK,
id,
{
title: record.title,
subject_id: record.subjectId,
grade_id: record.gradeId,
version: record.version,
status: record.status,
},
);
return { id };
}
async list(): Promise<Textbook[]> {
return db.select().from(textbooks);
async list(query?: ListTextbooksInput): Promise<Textbook[]> {
return textbooksRepository.find(query);
}
async getById(id: string): Promise<Textbook> {
const [result] = await db
.select()
.from(textbooks)
.where(eq(textbooks.id, id))
.limit(1);
const result = await textbooksRepository.findById(id);
if (!result) {
throw new NotFoundError("Textbook", id);
}
@@ -59,12 +78,37 @@ export class TextbooksService {
}
async update(id: string, data: UpdateTextbookInput): Promise<void> {
await this.getById(id);
await db.update(textbooks).set(data).where(eq(textbooks.id, id));
const existing = await this.getById(id);
await textbooksRepository.update(id, data);
const eventType =
data.status === "published"
? EVENT_TYPES.TEXTBOOK_PUBLISHED
: data.status === "archived"
? EVENT_TYPES.TEXTBOOK_ARCHIVED
: EVENT_TYPES.TEXTBOOK_UPDATED;
await this.outbox.publish(
eventType,
AGGREGATE_TYPES.TEXTBOOK,
id,
{
title: data.title ?? existing.title,
status: data.status ?? existing.status,
metadata: data.metadata ?? existing.metadata,
},
);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await db.delete(textbooks).where(eq(textbooks.id, id));
await textbooksRepository.delete(id);
// 教材删除视为归档事件,下游可感知失效
await this.outbox.publish(
EVENT_TYPES.TEXTBOOK_ARCHIVED,
AGGREGATE_TYPES.TEXTBOOK,
id,
{ deleted: true },
);
}
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json-summary"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.module.ts",
"src/main.ts",
"src/grpc/grpc-types.ts",
"src/config/**",
"src/shared/observability/**",
],
},
},
});