diff --git a/drizzle/orm.ts b/drizzle/orm.ts index 8bc7d6040..00af5cb57 100644 --- a/drizzle/orm.ts +++ b/drizzle/orm.ts @@ -30,6 +30,7 @@ export type IPersonCollect = typeof schema.chiiPersonCollects.$inferSelect; export type IIndex = typeof schema.chiiIndexes.$inferSelect; export type IIndexCollect = typeof schema.chiiIndexCollects.$inferSelect; +export type IIndexRelated = typeof schema.chiiIndexRelated.$inferSelect; export type IBlogEntry = typeof schema.chiiBlogEntries.$inferSelect; export type IBlogPhoto = typeof schema.chiiBlogPhotos.$inferSelect; diff --git a/drizzle/schema.ts b/drizzle/schema.ts index d05a9ed0c..61134d530 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -230,8 +230,8 @@ export const chiiGroupPosts = mysqlTable('chii_group_posts', { export const chiiIndexes = mysqlTable('chii_index', { id: mediumint('idx_id').autoincrement().notNull(), type: tinyint('idx_type').default(0).notNull(), - title: varchar('idx_title', { length: 80 }).notNull(), - desc: mediumtext('idx_desc').notNull(), + title: htmlEscapedString('varchar')('idx_title', { length: 80 }).notNull(), + desc: htmlEscapedString('mediumtext')('idx_desc').notNull(), replies: mediumint('idx_replies').notNull(), total: mediumint('idx_subject_total').notNull(), collects: mediumint('idx_collects').notNull(), @@ -259,15 +259,15 @@ export const chiiIndexComments = mysqlTable('chii_index_comments', { }); export const chiiIndexRelated = mysqlTable('chii_index_related', { - idxRltId: mediumint('idx_rlt_id').autoincrement().notNull(), - idxRltCat: tinyint('idx_rlt_cat').notNull(), - idxRltRid: mediumint('idx_rlt_rid').notNull(), - idxRltType: smallint('idx_rlt_type').notNull(), - idxRltSid: mediumint('idx_rlt_sid').notNull(), - idxRltOrder: mediumint('idx_rlt_order').notNull(), - idxRltComment: mediumtext('idx_rlt_comment').notNull(), - idxRltDateline: int('idx_rlt_dateline').notNull(), - idxRltBan: tinyint('idx_rlt_ban').default(0).notNull(), + id: mediumint('idx_rlt_id').autoincrement().notNull(), + cat: tinyint('idx_rlt_cat').notNull(), + rid: mediumint('idx_rlt_rid').notNull(), + type: smallint('idx_rlt_type').notNull(), + sid: mediumint('idx_rlt_sid').notNull(), + order: mediumint('idx_rlt_order').notNull(), + comment: htmlEscapedString('mediumtext')('idx_rlt_comment').notNull(), + createdAt: int('idx_rlt_dateline').notNull(), + ban: tinyint('idx_rlt_ban').default(0).notNull(), }); export const chiiLikes = mysqlTable('chii_likes', { diff --git a/lib/index/types.ts b/lib/index/types.ts new file mode 100644 index 000000000..14e1cbaab --- /dev/null +++ b/lib/index/types.ts @@ -0,0 +1,6 @@ +export enum IndexRelatedCategory { + Subject = 0, + Character = 1, + Person = 2, + Ep = 3, +} diff --git a/lib/openapi/index.ts b/lib/openapi/index.ts index 8f5d1e0c1..15ed2637c 100644 --- a/lib/openapi/index.ts +++ b/lib/openapi/index.ts @@ -8,6 +8,7 @@ export const Tag = { Collection: 'collection', Episode: 'episode', Group: 'group', + Index: 'index', Person: 'person', Subject: 'subject', Timeline: 'timeline', diff --git a/lib/types/common.ts b/lib/types/common.ts index dcfa8961b..53f964f72 100644 --- a/lib/types/common.ts +++ b/lib/types/common.ts @@ -56,3 +56,18 @@ export const CollectionType = t.Integer({ - 4 = 搁置 - 5 = 抛弃`, }); + +export const IndexRelatedCategory = t.Integer({ + $id: 'IndexRelatedCategory', + enum: [0, 1, 2, 3], + 'x-ms-enum': { + name: 'IndexRelatedCategory', + modelAsString: false, + }, + 'x-enum-varnames': ['Subject', 'Character', 'Person', 'Episode'], + description: `目录关联类型 + - 0 = 条目 + - 1 = 角色 + - 2 = 人物 + - 3 = 剧集`, +}); diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 1bf6cbfb3..502fb63f2 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -603,6 +603,19 @@ export function toIndex(index: orm.IIndex, user: orm.IUser): res.IIndex { }; } +export function toIndexRelated(related: orm.IIndexRelated): res.IIndexRelated { + return { + id: related.id, + cat: related.cat, + rid: related.rid, + type: related.type, + sid: related.sid, + order: related.order, + comment: related.comment, + createdAt: related.createdAt, + }; +} + export function toCharacterSubjectRelation( subject: orm.ISubject, fields: orm.ISubjectFields, diff --git a/lib/types/fetcher.ts b/lib/types/fetcher.ts index daba9231e..5e115de62 100644 --- a/lib/types/fetcher.ts +++ b/lib/types/fetcher.ts @@ -486,6 +486,37 @@ async function fetchEpisodeItemByID(episodeID: number): Promise> { + const cached = await redis.mget(episodeIDs.map((id) => getSubjectEpCacheKey(id))); + const result: Record = {}; + const missing = []; + for (const [idx, id] of episodeIDs.entries()) { + if (cached[idx]) { + const item = JSON.parse(cached[idx]) as res.IEpisode; + item.desc = undefined; + result[id] = item; + } else { + missing.push(id); + } + } + if (missing.length > 0) { + const data = await db + .select() + .from(schema.chiiEpisodes) + .where(op.inArray(schema.chiiEpisodes.id, missing)); + for (const d of data) { + const item = convert.toEpisode(d); + await redis.setex(getSubjectEpCacheKey(d.id), ONE_MONTH, JSON.stringify(item)); + item.desc = undefined; + result[d.id] = item; + } + } + return result; +} + /** Cached */ export async function fetchSlimCharacterByID( id: number, diff --git a/lib/types/res.ts b/lib/types/res.ts index 1c1189118..5dee79dc5 100644 --- a/lib/types/res.ts +++ b/lib/types/res.ts @@ -758,6 +758,26 @@ export const SlimIndex = t.Object( { $id: 'SlimIndex', title: 'SlimIndex' }, ); +export type IIndexRelated = Static; +export const IndexRelated = t.Object( + { + id: t.Integer(), + cat: t.Integer(), + rid: t.Integer(), + type: t.Integer(), + sid: t.Integer(), + order: t.Integer(), + comment: t.String(), + createdAt: t.Integer(), + + subject: t.Optional(Ref(SlimSubject)), + character: t.Optional(Ref(SlimCharacter)), + person: t.Optional(Ref(SlimPerson)), + episode: t.Optional(Ref(Episode)), + }, + { $id: 'IndexRelated', title: 'IndexRelated' }, +); + export type IGroup = Static; export const Group = t.Object( { diff --git a/routes/__snapshots__/index.test.ts.snap b/routes/__snapshots__/index.test.ts.snap index 23835d763..8b3134639 100644 --- a/routes/__snapshots__/index.test.ts.snap +++ b/routes/__snapshots__/index.test.ts.snap @@ -758,6 +758,64 @@ exports[`should build private api spec 1`] = ` - creator title: Index type: object + IndexRelated: + properties: + cat: + type: integer + character: + $ref: '#/components/schemas/SlimCharacter' + comment: + type: string + createdAt: + type: integer + episode: + $ref: '#/components/schemas/Episode' + id: + type: integer + order: + type: integer + person: + $ref: '#/components/schemas/SlimPerson' + rid: + type: integer + sid: + type: integer + subject: + $ref: '#/components/schemas/SlimSubject' + type: + type: integer + required: + - id + - cat + - rid + - type + - sid + - order + - comment + - createdAt + title: IndexRelated + type: object + IndexRelatedCategory: + description: |- + 目录关联类型 + - 0 = 条目 + - 1 = 角色 + - 2 = 人物 + - 3 = 剧集 + enum: + - 0 + - 1 + - 2 + - 3 + type: integer + x-enum-varnames: + - Subject + - Character + - Person + - Episode + x-ms-enum: + modelAsString: false + name: IndexRelatedCategory IndexStats: additionalProperties: type: integer @@ -3573,6 +3631,102 @@ paths: - CookiesSession: [] tags: - group + /p1/indexes/{indexID}: + get: + operationId: getIndex + parameters: + - in: path + name: indexID + required: true + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Index' + description: Default Response + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 意料之外的服务器错误 + description: 意料之外的服务器错误 + security: + - CookiesSession: [] + HTTPBearer: [] + summary: 获取目录详情 + tags: + - index + /p1/indexes/{indexID}/related: + get: + operationId: getIndexRelated + parameters: + - in: query + name: cat + required: false + schema: + $ref: '#/components/schemas/IndexRelatedCategory' + - in: query + name: type + required: false + schema: + $ref: '#/components/schemas/SubjectType' + - description: max 100 + in: query + name: limit + required: false + schema: + default: 20 + maximum: 100 + minimum: 1 + type: integer + - description: min 0 + in: query + name: offset + required: false + schema: + default: 0 + minimum: 0 + type: integer + - in: path + name: indexID + required: true + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + properties: + data: + items: + $ref: '#/components/schemas/IndexRelated' + type: array + total: + description: limit+offset 为参数的请求表示总条数,page 为参数的请求表示总页数 + type: integer + required: + - data + - total + type: object + description: Default Response + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 意料之外的服务器错误 + description: 意料之外的服务器错误 + security: + - CookiesSession: [] + HTTPBearer: [] + summary: 获取目录的关联内容 + tags: + - index /p1/login: post: description: >- diff --git a/routes/private/index.ts b/routes/private/index.ts index 84cb0428d..46e3eea0c 100644 --- a/routes/private/index.ts +++ b/routes/private/index.ts @@ -13,6 +13,7 @@ import * as blog from './routes/blog.ts'; import * as calendar from './routes/calendar.ts'; import * as character from './routes/character.ts'; import * as episode from './routes/episode.ts'; +import * as index from './routes/index.ts'; import * as misc from './routes/misc.ts'; import * as person from './routes/person.ts'; import * as post from './routes/post.ts'; @@ -74,6 +75,7 @@ async function API(app: App) { await app.register(character.setup); await app.register(episode.setup); await app.register(group.setup); + await app.register(index.setup); await app.register(misc.setup); await app.register(person.setup); await app.register(post.setup); diff --git a/routes/private/routes/__snapshots__/index.test.ts.snap b/routes/private/routes/__snapshots__/index.test.ts.snap new file mode 100644 index 000000000..73cd850e1 --- /dev/null +++ b/routes/private/routes/__snapshots__/index.test.ts.snap @@ -0,0 +1,240 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`get index > should get index 1`] = ` +Object { + "collects": 96, + "createdAt": 1352366596, + "creator": Object { + "avatar": Object { + "large": "https://lain.bgm.tv/pic/user/l/icon.jpg", + "medium": "https://lain.bgm.tv/pic/user/m/icon.jpg", + "small": "https://lain.bgm.tv/pic/user/s/icon.jpg", + }, + "id": 14127, + "joinedAt": 0, + "nickname": "nickname 14127", + "sign": "sing 14127", + "username": "14127", + }, + "desc": "[url]http://www.tudou.com/programs/view/W6eIoxnHs6g/[/url] +有美国动画混入,所以准确的说是在日本播放的动画最高收视率(而且是关东地区的 +基本大部分是70年代的,那个年代娱乐贫乏优势真大", + "id": 15045, + "replies": 8, + "stats": Object { + "2": 101, + }, + "title": "日本动画最高收视率TOP100", + "total": 101, + "type": 0, + "updatedAt": 1356922367, +} +`; + +exports[`get index > should get index related 1`] = ` +Object { + "data": Array [ + Object { + "cat": 0, + "comment": "1位 40.3", + "createdAt": 1352382566, + "id": 29277, + "order": 1, + "rid": 15045, + "sid": 806, + "type": 2, + }, + Object { + "cat": 0, + "comment": "2位 39.9", + "createdAt": 1352382511, + "id": 29276, + "order": 2, + "rid": 15045, + "sid": 28165, + "type": 2, + }, + Object { + "cat": 0, + "comment": "3位 39.4", + "createdAt": 1352382444, + "id": 29275, + "order": 3, + "rid": 15045, + "sid": 32585, + "type": 2, + }, + Object { + "cat": 0, + "comment": "4位 36.9", + "createdAt": 1352382378, + "id": 29274, + "order": 4, + "rid": 15045, + "sid": 47243, + "type": 2, + }, + Object { + "cat": 0, + "comment": "5位 36.7", + "createdAt": 1352382135, + "id": 29272, + "order": 5, + "rid": 15045, + "sid": 41983, + "type": 2, + }, + Object { + "cat": 0, + "comment": "5位 36.7", + "createdAt": 1352382314, + "id": 29273, + "order": 5, + "rid": 15045, + "sid": 53805, + "type": 2, + }, + Object { + "cat": 0, + "comment": "7位 36", + "createdAt": 1352382072, + "id": 29271, + "order": 7, + "rid": 15045, + "sid": 37258, + "type": 2, + }, + Object { + "cat": 0, + "comment": "8位 35.6", + "createdAt": 1352382022, + "id": 29270, + "order": 8, + "rid": 15045, + "sid": 53804, + "type": 2, + }, + Object { + "cat": 0, + "comment": "9位 34.5", + "createdAt": 1352381764, + "id": 29266, + "order": 9, + "rid": 15045, + "sid": 53739, + "type": 2, + }, + Object { + "cat": 0, + "comment": "10位 33.7 (有奇怪的美国动画混进去了!", + "createdAt": 1352381582, + "id": 29264, + "order": 10, + "rid": 15045, + "sid": 25713, + "type": 2, + }, + Object { + "cat": 0, + "comment": "11位 33.6", + "createdAt": 1352380988, + "id": 29258, + "order": 11, + "rid": 15045, + "sid": 53802, + "type": 2, + }, + Object { + "cat": 0, + "comment": "12位 32.5", + "createdAt": 1352380796, + "id": 29257, + "order": 12, + "rid": 15045, + "sid": 36359, + "type": 2, + }, + Object { + "cat": 0, + "comment": "13位 31.9", + "createdAt": 1352380648, + "id": 29252, + "order": 13, + "rid": 15045, + "sid": 53801, + "type": 2, + }, + Object { + "cat": 0, + "comment": "13位 31.9", + "createdAt": 1352380716, + "id": 29254, + "order": 13, + "rid": 15045, + "sid": 2741, + "type": 2, + }, + Object { + "cat": 0, + "comment": "15位 31.8", + "createdAt": 1352380464, + "id": 29246, + "order": 15, + "rid": 15045, + "sid": 53800, + "type": 2, + }, + Object { + "cat": 0, + "comment": "16位 31.6", + "createdAt": 1352380102, + "id": 29245, + "order": 16, + "rid": 15045, + "sid": 10404, + "type": 2, + }, + Object { + "cat": 0, + "comment": "17位 31.2", + "createdAt": 1352379920, + "id": 29241, + "order": 17, + "rid": 15045, + "sid": 37460, + "type": 2, + }, + Object { + "cat": 0, + "comment": "18位 30.7", + "createdAt": 1352379781, + "id": 29239, + "order": 18, + "rid": 15045, + "sid": 53794, + "type": 2, + }, + Object { + "cat": 0, + "comment": "19位 30.4", + "createdAt": 1352379633, + "id": 29235, + "order": 19, + "rid": 15045, + "sid": 10390, + "type": 2, + }, + Object { + "cat": 0, + "comment": "20位 30.1", + "createdAt": 1352379532, + "id": 29234, + "order": 20, + "rid": 15045, + "sid": 10374, + "type": 2, + }, + ], + "total": 101, +} +`; diff --git a/routes/private/routes/index.test.ts b/routes/private/routes/index.test.ts new file mode 100644 index 000000000..ae6865035 --- /dev/null +++ b/routes/private/routes/index.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'vitest'; + +import { createTestServer } from '@app/tests/utils.ts'; + +import { setup } from './index.ts'; + +describe('get index', () => { + test('should get index', async () => { + const app = createTestServer(); + await app.register(setup); + const res = await app.inject({ + method: 'get', + url: '/indexes/15045', + }); + expect(res.json()).toMatchSnapshot(); + }); + + test('should get index related', async () => { + const app = createTestServer(); + await app.register(setup); + const res = await app.inject({ + method: 'get', + url: '/indexes/15045/related', + }); + expect(res.json()).toMatchSnapshot(); + }); +}); diff --git a/routes/private/routes/index.ts b/routes/private/routes/index.ts new file mode 100644 index 000000000..93b4efcd6 --- /dev/null +++ b/routes/private/routes/index.ts @@ -0,0 +1,150 @@ +import { Type as t } from '@sinclair/typebox'; + +import { db, op } from '@app/drizzle/db.ts'; +import * as schema from '@app/drizzle/schema'; +import { NotFoundError } from '@app/lib/error.ts'; +import { IndexRelatedCategory } from '@app/lib/index/types.ts'; +import { Security, Tag } from '@app/lib/openapi/index.ts'; +import * as convert from '@app/lib/types/convert.ts'; +import * as fetcher from '@app/lib/types/fetcher.ts'; +import * as req from '@app/lib/types/req.ts'; +import * as res from '@app/lib/types/res.ts'; +import type { App } from '@app/routes/type.ts'; + +// eslint-disable-next-line @typescript-eslint/require-await +export async function setup(app: App) { + app.get( + '/indexes/:indexID', + { + schema: { + summary: '获取目录详情', + operationId: 'getIndex', + tags: [Tag.Index], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + indexID: t.Integer(), + }), + response: { + 200: res.Ref(res.Index), + }, + }, + }, + async ({ params: { indexID } }) => { + const [data] = await db + .select() + .from(schema.chiiIndexes) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiIndexes.uid, schema.chiiUsers.id)) + .where(op.eq(schema.chiiIndexes.id, indexID)); + if (!data) { + throw new NotFoundError('index'); + } + const index = convert.toIndex(data.chii_index, data.chii_members); + return index; + }, + ); + + app.get( + '/indexes/:indexID/related', + { + schema: { + summary: '获取目录的关联内容', + operationId: 'getIndexRelated', + tags: [Tag.Index], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + indexID: t.Integer(), + }), + querystring: t.Object({ + cat: t.Optional(req.Ref(req.IndexRelatedCategory)), + type: t.Optional(req.Ref(req.SubjectType)), + limit: t.Optional( + t.Integer({ default: 20, minimum: 1, maximum: 100, description: 'max 100' }), + ), + offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), + }), + response: { + 200: res.Paged(res.Ref(res.IndexRelated)), + }, + }, + }, + async ({ params: { indexID }, query: { cat, type, limit = 20, offset = 0 } }) => { + const index = await fetcher.fetchSlimIndexByID(indexID); + if (!index) { + throw new NotFoundError('index'); + } + + const conditions = [ + op.eq(schema.chiiIndexRelated.rid, indexID), + op.eq(schema.chiiIndexRelated.cat, IndexRelatedCategory.Subject), + op.eq(schema.chiiIndexRelated.ban, 0), + ]; + if (cat) { + conditions.push(op.eq(schema.chiiIndexRelated.cat, cat)); + } + if (type) { + conditions.push(op.eq(schema.chiiIndexRelated.type, type)); + } + + const [{ count = 0 } = {}] = await db + .select({ count: op.count() }) + .from(schema.chiiIndexRelated) + .where(op.and(...conditions)); + + const data = await db + .select() + .from(schema.chiiIndexRelated) + .where(op.and(...conditions)) + .orderBy(op.asc(schema.chiiIndexRelated.order), op.asc(schema.chiiIndexRelated.id)) + .limit(limit) + .offset(offset); + const items = data.map((item) => convert.toIndexRelated(item)); + + const subjectIDs = items + .filter((item) => item.cat === IndexRelatedCategory.Subject) + .map((item) => item.sid); + const subjects = await fetcher.fetchSlimSubjectsByIDs(subjectIDs); + + const characterIDs = items + .filter((item) => item.cat === IndexRelatedCategory.Character) + .map((item) => item.sid); + const characters = await fetcher.fetchSlimCharactersByIDs(characterIDs); + + const personIDs = items + .filter((item) => item.cat === IndexRelatedCategory.Person) + .map((item) => item.sid); + const persons = await fetcher.fetchSlimPersonsByIDs(personIDs); + + const episodeIDs = items + .filter((item) => item.cat === IndexRelatedCategory.Ep) + .map((item) => item.sid); + const episodes = await fetcher.fetchSlimEpisodesByIDs(episodeIDs); + + const result = []; + for (const item of items) { + switch (item.cat) { + case IndexRelatedCategory.Subject: { + item.subject = subjects[item.sid]; + break; + } + case IndexRelatedCategory.Character: { + item.character = characters[item.sid]; + break; + } + case IndexRelatedCategory.Person: { + item.person = persons[item.sid]; + break; + } + case IndexRelatedCategory.Ep: { + item.episode = episodes[item.sid]; + break; + } + } + result.push(item); + } + return { + data: result, + total: count, + }; + }, + ); +} diff --git a/routes/schemas.ts b/routes/schemas.ts index 7f5079024..6bb144825 100644 --- a/routes/schemas.ts +++ b/routes/schemas.ts @@ -31,6 +31,8 @@ export function addSchemas(app: App) { app.addSchema(res.GroupMember); app.addSchema(res.Index); app.addSchema(res.IndexStats); + app.addSchema(res.IndexRelated); + app.addSchema(res.IndexRelatedCategory); app.addSchema(res.Infobox); app.addSchema(res.Person); app.addSchema(res.PersonCharacter);