Compare commits
4 Commits
3dec3ea720
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f27a6eb7 | |||
| d8805bd4ed | |||
| fe99f8dc72 | |||
| be434efd75 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Скопируйте в `.env` в корне репозитория для `docker compose up`
|
||||
APP_PORT=3000
|
||||
APP_PORT=3107
|
||||
POSTGRES_USER=edu
|
||||
POSTGRES_PASSWORD=edu
|
||||
POSTGRES_DB=edu_helper
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Скрипты для Linux-контейнеров — только LF (иначе на Windows: exec /docker-entrypoint.sh: no such file or directory)
|
||||
*.sh text eol=lf
|
||||
docker-entrypoint.sh text eol=lf
|
||||
@@ -4,3 +4,6 @@ dist/
|
||||
*.db
|
||||
generated/
|
||||
.DS_Store
|
||||
|
||||
# Локальные дампы PostgreSQL (не коммитить)
|
||||
dumps/
|
||||
|
||||
+5
-2
@@ -23,7 +23,10 @@ COPY --from=backend-build /app/backend/prisma ./prisma
|
||||
COPY --from=backend-build /app/backend/package.json ./package.json
|
||||
COPY --from=frontend-build /app/frontend/dist ./public
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
EXPOSE 3000
|
||||
# Убрать CRLF после checkout на Windows (иначе: exec /docker-entrypoint.sh: no such file or directory)
|
||||
RUN tr -d '\r' < /docker-entrypoint.sh > /tmp/docker-entrypoint.sh && \
|
||||
mv /tmp/docker-entrypoint.sh /docker-entrypoint.sh && \
|
||||
chmod +x /docker-entrypoint.sh
|
||||
EXPOSE 3107
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
|
||||
@@ -37,16 +37,28 @@
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
5. Откройте в браузере: **http://localhost:3000** (или порт из `APP_PORT` в `.env`).
|
||||
5. Откройте в браузере: **http://localhost:3107** (или порт из `APP_PORT` в `.env`).
|
||||
|
||||
6. Проверка API:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
curl http://localhost:3107/api/health
|
||||
```
|
||||
|
||||
При первом старте контейнер приложения выполняет `prisma migrate deploy` и сид пользователей (если пользователей ещё нет).
|
||||
|
||||
### Windows: `exec /docker-entrypoint.sh: no such file or directory`
|
||||
|
||||
Обычно это **CRLF** в `docker-entrypoint.sh` после клонирования на Windows. В репозитории задан **`.gitattributes`** (для `*.sh` — только LF). Обновите репозиторий и пересоберите образ без кэша:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose build --no-cache app
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Если проблема осталась: `git config core.autocrlf false`, затем `git add --renormalize .` и зафиксируйте изменения или переклонируйте репозиторий; либо в редакторе сохраните `docker-entrypoint.sh` с окончаниями строк **LF (Unix)**. При сборке образа символы `\r` в скрипте также удаляются автоматически.
|
||||
|
||||
### Тома данных
|
||||
|
||||
- **`pgdata`** — данные PostgreSQL. Пароль пользователя БД задаётся в **`POSTGRES_PASSWORD`** только при **первом** создании тома. Если сменить пароль в `.env` позже, сам PostgreSQL **не** пересоздаст пароль автоматически — приложение не подключится (ошибка Prisma `P1000`). Варианты: вернуть в `.env` старый пароль, либо сменить пароль вручную в Postgres, либо **один раз** пересоздать том (данные БД пропадут):
|
||||
@@ -109,10 +121,6 @@
|
||||
| `SEED_*_USERNAME` / `SEED_*_PASSWORD` | Логины и пароли для первичного сида |
|
||||
| `DEEPSEEK_API_KEY` | Опционально: ключ API по умолчанию |
|
||||
| `COOKIE_SECURE` | `true` только при HTTPS |
|
||||
| `APP_PORT` | Проброс порта хоста на контейнер приложения (по умолчанию 3000) |
|
||||
| `APP_PORT` | Проброс порта хоста на контейнер приложения (по умолчанию 3107) |
|
||||
|
||||
Полный список и комментарии — в **`.env.docker.example`**.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Укажите лицензию при необходимости (файл `LICENSE`).
|
||||
|
||||
@@ -21,7 +21,7 @@ cp .env.docker.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Приложение: http://localhost:3000 (логины по умолчанию `alexey` / `konstantin`, см. `SEED_*_USERNAME`).
|
||||
Приложение: http://localhost:3107 (логины по умолчанию `alexey` / `konstantin`, см. `SEED_*_USERNAME`).
|
||||
|
||||
Файл-пример переменных: [.env.docker.example](.env.docker.example).
|
||||
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- Общие тесты в контексте пары: studentId = id назначенного ученика; authorId = кто создал.
|
||||
-- Результаты (TestResult) привязаны к ученику (studentId).
|
||||
|
||||
ALTER TABLE "Test" ADD COLUMN "authorId" INTEGER;
|
||||
|
||||
UPDATE "Test" SET "authorId" = "studentId";
|
||||
|
||||
UPDATE "Test" AS t
|
||||
SET "studentId" = ta."studentId"
|
||||
FROM "TutorAssignment" AS ta
|
||||
WHERE t."authorId" = ta."tutorId";
|
||||
|
||||
ALTER TABLE "Test" ALTER COLUMN "authorId" SET NOT NULL;
|
||||
|
||||
ALTER TABLE "Test" ADD CONSTRAINT "Test_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "TestResult" ADD COLUMN "studentId" INTEGER;
|
||||
|
||||
UPDATE "TestResult" AS tr
|
||||
SET "studentId" = t."studentId"
|
||||
FROM "Test" AS t
|
||||
WHERE tr."testId" = t."id";
|
||||
|
||||
ALTER TABLE "TestResult" ALTER COLUMN "studentId" SET NOT NULL;
|
||||
|
||||
ALTER TABLE "TestResult" ADD CONSTRAINT "TestResult_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -26,7 +26,9 @@ model User {
|
||||
questions Question[]
|
||||
chatMessages ChatMessage[]
|
||||
textbooks Textbook[]
|
||||
tests Test[]
|
||||
testsInScope Test[] @relation("TestStudentScope")
|
||||
testsAuthored Test[] @relation("TestAuthor")
|
||||
testResults TestResult[]
|
||||
reports Report[]
|
||||
hallPhotos HallPhoto[]
|
||||
}
|
||||
@@ -75,8 +77,11 @@ model Textbook {
|
||||
|
||||
model Test {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Контекст пары наставник–ученик (id назначенного ученика); общие тесты для обоих
|
||||
studentId Int
|
||||
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
student User @relation("TestStudentScope", fields: [studentId], references: [id], onDelete: Cascade)
|
||||
authorId Int
|
||||
author User @relation("TestAuthor", fields: [authorId], references: [id], onDelete: Cascade)
|
||||
topic String
|
||||
questions String
|
||||
createdAt DateTime @default(now())
|
||||
@@ -86,11 +91,13 @@ model Test {
|
||||
model TestResult {
|
||||
id Int @id @default(autoincrement())
|
||||
testId Int
|
||||
test Test @relation(fields: [testId], references: [id], onDelete: Cascade)
|
||||
studentId Int
|
||||
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
answers String
|
||||
score Int
|
||||
total Int
|
||||
createdAt DateTime @default(now())
|
||||
test Test @relation(fields: [testId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Report {
|
||||
|
||||
+33
-14
@@ -17,27 +17,41 @@ const DEFAULT_TEST_PROMPT = `Ты — составитель тестов. Сг
|
||||
ВАЖНО: Верни ТОЛЬКО JSON-массив, без markdown-обёрток (\`\`\`json), без пояснений, ЧИСТЫЙ JSON:
|
||||
[{"question":"текст вопроса","options":{"a":"вариант А","b":"вариант Б","c":"вариант В","d":"вариант Г"},"correct":"a"}]`;
|
||||
|
||||
/** Тесты привязаны к текущему пользователю (ученик — свои; наставник — свои черновые, не к ученику). */
|
||||
function testOwnerId(req: AuthRequest): number {
|
||||
return req.user!.id;
|
||||
/** Контекст пары: id назначенного ученика (общие тесты для наставника и ученика). */
|
||||
function pairStudentScopeId(req: AuthRequest): number {
|
||||
return req.studentId!;
|
||||
}
|
||||
|
||||
const authorSelect = { username: true, displayName: true } as const;
|
||||
|
||||
router.get("/", async (req: AuthRequest, res: Response) => {
|
||||
const ownerId = testOwnerId(req);
|
||||
const scopeId = pairStudentScopeId(req);
|
||||
const tests = await prisma.test.findMany({
|
||||
where: { studentId: ownerId },
|
||||
where: { studentId: scopeId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { results: true },
|
||||
include: {
|
||||
author: { select: authorSelect },
|
||||
results: {
|
||||
where: { studentId: scopeId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
res.json(tests);
|
||||
});
|
||||
|
||||
router.get("/:id", async (req: AuthRequest, res: Response) => {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const ownerId = testOwnerId(req);
|
||||
const scopeId = pairStudentScopeId(req);
|
||||
const test = await prisma.test.findFirst({
|
||||
where: { id, studentId: ownerId },
|
||||
include: { results: true },
|
||||
where: { id, studentId: scopeId },
|
||||
include: {
|
||||
author: { select: authorSelect },
|
||||
results: {
|
||||
where: { studentId: scopeId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!test) {
|
||||
@@ -50,8 +64,7 @@ router.get("/:id", async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.post("/generate", async (req: AuthRequest, res: Response) => {
|
||||
const { topic, fromQuestions } = req.body;
|
||||
const ownerId = testOwnerId(req);
|
||||
/** Вопросы «по моим вопросам» берутся из базы назначенного ученика (для наставника — его ученик). */
|
||||
const scopeId = pairStudentScopeId(req);
|
||||
const questionBankStudentId = req.studentId!;
|
||||
|
||||
const client = await getDeepSeekClient();
|
||||
@@ -92,7 +105,12 @@ router.post("/generate", async (req: AuthRequest, res: Response) => {
|
||||
data: {
|
||||
topic: topic || "По прошлым вопросам",
|
||||
questions: questionsJson,
|
||||
studentId: ownerId,
|
||||
studentId: scopeId,
|
||||
authorId: req.user!.id,
|
||||
},
|
||||
include: {
|
||||
author: { select: authorSelect },
|
||||
results: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,10 +122,10 @@ router.post("/generate", async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.post("/:id/submit", async (req: AuthRequest, res: Response) => {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const ownerId = testOwnerId(req);
|
||||
const scopeId = pairStudentScopeId(req);
|
||||
const { answers } = req.body as { answers: Record<string, string> };
|
||||
|
||||
const test = await prisma.test.findFirst({ where: { id, studentId: ownerId } });
|
||||
const test = await prisma.test.findFirst({ where: { id, studentId: scopeId } });
|
||||
if (!test) {
|
||||
res.status(404).json({ error: "Test not found" });
|
||||
return;
|
||||
@@ -131,6 +149,7 @@ router.post("/:id/submit", async (req: AuthRequest, res: Response) => {
|
||||
const result = await prisma.testResult.create({
|
||||
data: {
|
||||
testId: id,
|
||||
studentId: req.user!.id,
|
||||
answers: JSON.stringify(answers),
|
||||
score,
|
||||
total,
|
||||
|
||||
+2
-2
@@ -16,10 +16,10 @@ services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
- "${APP_PORT:-3107}:3107"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
PORT: "3107"
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-edu}:${POSTGRES_PASSWORD:-edu}@db:5432/${POSTGRES_DB:-edu_helper}
|
||||
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
|
||||
SEED_TUTOR_USERNAME: ${SEED_TUTOR_USERNAME:-alexey}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface Test {
|
||||
questions: string;
|
||||
createdAt: string;
|
||||
results: TestResult[];
|
||||
author?: { username: string; displayName: string | null };
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
@@ -248,7 +249,9 @@ export default function TestPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Тестирование</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Проверьте знания — 10 вопросов с вариантами ответов</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Проверьте знания — 10 вопросов с вариантами ответов. Тесты общие для пары; в истории — только попытки ученика (видны всем).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -298,8 +301,13 @@ export default function TestPage() {
|
||||
</h2>
|
||||
{tests.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between p-3.5 rounded-xl border text-sm">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{t.topic}</span>
|
||||
{t.author && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
· автор: {t.author.displayName?.trim() || t.author.username}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{new Date(t.createdAt).toLocaleDateString("ru")}
|
||||
</span>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
exec npm run app
|
||||
Reference in New Issue
Block a user