Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 55 additions & 33 deletions apps/backend/src/routes/chapter.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,45 @@ import { OocAgent } from "../services/ai/agents";

const router: Router = Router();

const MAX_SNAPSHOTS_PER_CHAPTER = 30;

async function createChapterSnapshot(
chapterId: string,
novelId: string,
content: string,
title: string,
wordCount: number,
label: string = "自动快照",
) {
const existing = await db.query.chapterSnapshots.findMany({
where: eq(schema.chapterSnapshots.chapterId, chapterId),
orderBy: [desc(schema.chapterSnapshots.createdAt)],
});

if (existing.length >= MAX_SNAPSHOTS_PER_CHAPTER) {
const toDelete = existing.slice(MAX_SNAPSHOTS_PER_CHAPTER - 1);
for (const snap of toDelete) {
await db
.delete(schema.chapterSnapshots)
.where(eq(schema.chapterSnapshots.id, snap.id));
}
}

const [snapshot] = await db
.insert(schema.chapterSnapshots)
.values({
chapterId,
novelId,
content,
title,
wordCount: wordCount ?? 0,
label,
})
.returning();

return snapshot;
}

// ========= Analysis & Checking Routes =========

// POST /chapters/:id/ooc-check - Check text for out-of-character behavior
Expand Down Expand Up @@ -172,31 +211,14 @@ router.post("/:id/snapshots", async (req: AuthRequest, res, next) => {
return;
}

// Limit to 30 snapshots per chapter
const existing = await db.query.chapterSnapshots.findMany({
where: eq(schema.chapterSnapshots.chapterId, id),
orderBy: [desc(schema.chapterSnapshots.createdAt)],
});
if (existing.length >= 30) {
const toDelete = existing.slice(29);
for (const snap of toDelete) {
await db
.delete(schema.chapterSnapshots)
.where(eq(schema.chapterSnapshots.id, snap.id));
}
}

const [snapshot] = await db
.insert(schema.chapterSnapshots)
.values({
chapterId: id,
novelId: chapter.novelId,
content: chapter.content,
title: chapter.title,
wordCount: chapter.wordCount ?? 0,
label: label ?? "手动快照",
})
.returning();
const snapshot = await createChapterSnapshot(
id,
chapter.novelId,
chapter.content,
chapter.title,
chapter.wordCount ?? 0,
label ?? "手动快照",
);

res.status(201).json(snapshot);
} catch (error) {
Expand Down Expand Up @@ -244,14 +266,14 @@ router.post(
where: eq(schema.chapters.id, id),
});
if (currentChapter?.content) {
await db.insert(schema.chapterSnapshots).values({
chapterId: id,
novelId: currentChapter.novelId,
content: currentChapter.content,
title: currentChapter.title,
wordCount: currentChapter.wordCount ?? 0,
label: "还原前自动保存",
});
await createChapterSnapshot(
id,
currentChapter.novelId,
currentChapter.content,
currentChapter.title,
currentChapter.wordCount ?? 0,
"还原前自动保存",
);
}

// Restore snapshot content
Expand Down
101 changes: 95 additions & 6 deletions apps/backend/src/routes/novel.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Router } from "express";
import { db, schema } from "../database";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import { AuthRequest } from "../middleware/auth";
import { AppError } from "../middleware/errorHandler";
import { z } from "zod";
Expand Down Expand Up @@ -29,6 +29,24 @@ import epub from "epub-gen-memory";

const router: Router = Router();

async function validateCharacterRelationships(
novelId: string,
relationships: { characterId: string; relation: string }[] | undefined,
): Promise<{ characterId: string; relation: string }[]> {
if (!relationships?.length) return [];

const existingCharacterIds = (
await db.query.characters.findMany({
where: eq(schema.characters.novelId, novelId),
columns: { id: true },
})
).map((c) => c.id);

return relationships.filter((r) =>
existingCharacterIds.includes(r.characterId),
);
}

// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
Expand Down Expand Up @@ -215,6 +233,30 @@ router.get("/", async (req: AuthRequest, res, next) => {
}
});

// Get all novels with volumes/chapters for stats (bulk endpoint)
router.get("/stats/all", async (req: AuthRequest, res, next) => {
try {
const novels = await db.query.novels.findMany({
where: eq(schema.novels.userId, req.userId!),
orderBy: (novels, { desc }) => [desc(novels.createdAt)],
with: {
volumes: {
with: {
chapters: {
orderBy: (chapters, { asc }) => [asc(chapters.order)],
},
},
orderBy: (volumes, { asc }) => [asc(volumes.order)],
},
},
});

res.json(novels);
} catch (error) {
next(error);
}
});

// Get single novel
router.get("/:id", async (req: AuthRequest, res, next) => {
try {
Expand Down Expand Up @@ -429,11 +471,19 @@ router.get("/:id/characters", async (req: AuthRequest, res, next) => {
// Create character
router.post("/:id/characters", async (req: AuthRequest, res, next) => {
try {
const novelId = req.params.id;
const characterData = req.body;

characterData.relationships = await validateCharacterRelationships(
novelId,
characterData.relationships,
);

const [character] = await db
.insert(schema.characters)
.values({
novelId: req.params.id,
...req.body,
novelId,
...characterData,
})
.returning();

Expand All @@ -448,10 +498,21 @@ router.patch(
"/:novelId/characters/:characterId",
async (req: AuthRequest, res, next) => {
try {
const novelId = req.params.novelId;
const characterId = req.params.characterId;
const characterData = req.body;

if (characterData.relationships !== undefined) {
characterData.relationships = await validateCharacterRelationships(
novelId,
characterData.relationships,
);
}

const [character] = await db
.update(schema.characters)
.set({ ...req.body, updatedAt: new Date() })
.where(eq(schema.characters.id, req.params.characterId))
.set({ ...characterData, updatedAt: new Date() })
.where(eq(schema.characters.id, characterId))
.returning();

res.json(character);
Expand All @@ -466,9 +527,37 @@ router.delete(
"/:novelId/characters/:characterId",
async (req: AuthRequest, res, next) => {
try {
const novelId = req.params.novelId;
const deletedCharacterId = req.params.characterId;

const otherCharacters = await db.query.characters.findMany({
where: and(
eq(schema.characters.novelId, novelId),
ne(schema.characters.id, deletedCharacterId),
),
});

for (const char of otherCharacters) {
if (char.relationships?.length) {
const filteredRelationships = char.relationships.filter(
(r) => r.characterId !== deletedCharacterId,
);

if (filteredRelationships.length !== char.relationships.length) {
await db
.update(schema.characters)
.set({
relationships: filteredRelationships,
updatedAt: new Date(),
})
.where(eq(schema.characters.id, char.id));
}
}
}

await db
.delete(schema.characters)
.where(eq(schema.characters.id, req.params.characterId));
.where(eq(schema.characters.id, deletedCharacterId));

res.status(204).send();
} catch (error) {
Expand Down
10 changes: 2 additions & 8 deletions apps/frontend/app/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,8 @@ export default function StatsPage() {
}

try {
const res = await novelsAPI.list();
// Also fetch full novel for each to get volumes/chapters
const detailedNovels = await Promise.all(
(res.data || []).map((n: any) =>
novelsAPI.get(n.id).then((r) => r.data),
),
);
setNovels(detailedNovels);
const res = await novelsAPI.getStats();
setNovels(res.data || []);

if (showToast) {
toast.success("数据已刷新");
Expand Down
Loading