From 5813caa2f8670639059624d4788a442d5827abdd Mon Sep 17 00:00:00 2001 From: everpcpc Date: Fri, 22 Nov 2024 14:45:49 +0800 Subject: [PATCH 01/20] z --- drizzle/orm.ts | 7 +- drizzle/schema.ts | 119 +++++++++++--- lib/graphql/schema.ts | 2 +- lib/orm/index.ts | 2 +- lib/subject/type.ts | 13 ++ lib/topic/display.ts | 2 +- lib/topic/index.test.ts | 9 +- lib/topic/index.ts | 74 ++++----- lib/topic/type.ts | 24 +++ lib/types/convert.ts | 83 +++++++++- lib/types/res.ts | 176 +++++++++++++-------- routes/private/routes/post.test.ts | 4 +- routes/private/routes/post.ts | 107 +++---------- routes/private/routes/subject.ts | 192 ++++++++++++++++++++++- routes/private/routes/topic.test.ts | 11 +- routes/private/routes/topic.ts | 82 +++------- routes/private/routes/user.ts | 28 ++-- routes/private/routes/wiki/subject/ep.ts | 3 +- routes/res.ts | 1 + 19 files changed, 629 insertions(+), 310 deletions(-) create mode 100644 lib/topic/type.ts diff --git a/drizzle/orm.ts b/drizzle/orm.ts index c6687a70..e9b618c3 100644 --- a/drizzle/orm.ts +++ b/drizzle/orm.ts @@ -10,6 +10,8 @@ export type ISubject = typeof schema.chiiSubjects.$inferSelect; export type ISubjectFields = typeof schema.chiiSubjectFields.$inferSelect; export type ISubjectInterest = typeof schema.chiiSubjectInterests.$inferSelect; export type ISubjectRelation = typeof schema.chiiSubjectRelations.$inferSelect; +export type ISubjectRelatedBlog = typeof schema.chiiSubjectRelatedBlogs.$inferSelect; +export type ISubjectTopics = typeof schema.chiiSubjectTopics.$inferSelect; export type IEpisode = typeof schema.chiiEpisodes.$inferSelect; @@ -21,5 +23,8 @@ export type IPersonCollect = typeof schema.chiiPersonCollects.$inferSelect; export type IPersonRelation = typeof schema.chiiPersonRelations.$inferSelect; export type IPersonSubject = typeof schema.chiiPersonSubjects.$inferSelect; -export type IIndex = typeof schema.chiiIndex.$inferSelect; +export type IIndex = typeof schema.chiiIndexes.$inferSelect; export type IIndexCollect = typeof schema.chiiIndexCollects.$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 8abc77ac..ef410a47 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -320,7 +320,7 @@ export const chiiGroupTopics = mysqlTable( }, ); -export const chiiIndex = mysqlTable( +export const chiiIndexes = mysqlTable( 'chii_index', { id: mediumint('idx_id').autoincrement().notNull(), @@ -984,6 +984,26 @@ export const chiiSubjectInterests = mysqlTable( }, ); +export const chiiSubjectRelatedBlogs = mysqlTable( + 'chii_subject_related_blog', + { + id: mediumint('srb_id').autoincrement().notNull(), + uid: mediumint('srb_uid').notNull(), + subjectID: mediumint('srb_subject_id').notNull(), + entryID: mediumint('srb_entry_id').notNull(), + spoiler: mediumint('srb_spoiler').notNull(), + like: mediumint('srb_like').notNull(), + dislike: mediumint('srb_dislike').notNull(), + createdAt: int('srb_dateline').notNull(), + }, + (table) => { + return { + srbUid: index('srb_uid').on(table.uid, table.subjectID, table.entryID), + subjectRelated: index('subject_related').on(table.subjectID), + }; + }, +); + export const chiiSubjectPosts = mysqlTable( 'chii_subject_posts', { @@ -1065,26 +1085,22 @@ export const chiiSubjectRev = mysqlTable('chii_subject_revisions', { export const chiiSubjectTopics = mysqlTable( 'chii_subject_topics', { - sbjTpcId: mediumint('sbj_tpc_id').autoincrement().notNull(), - sbjTpcSubjectId: mediumint('sbj_tpc_subject_id').notNull(), - sbjTpcUid: mediumint('sbj_tpc_uid').notNull(), - sbjTpcTitle: varchar('sbj_tpc_title', { length: 80 }).notNull(), - sbjTpcDateline: int('sbj_tpc_dateline').default(0).notNull(), - sbjTpcLastpost: int('sbj_tpc_lastpost').default(0).notNull(), - sbjTpcReplies: mediumint('sbj_tpc_replies').notNull(), - sbjTpcState: tinyint('sbj_tpc_state').notNull(), - sbjTpcDisplay: tinyint('sbj_tpc_display').default(1).notNull(), + id: mediumint('sbj_tpc_id').autoincrement().notNull(), + subjectID: mediumint('sbj_tpc_subject_id').notNull(), + uid: mediumint('sbj_tpc_uid').notNull(), + title: varchar('sbj_tpc_title', { length: 80 }).notNull(), + createdAt: int('sbj_tpc_dateline').default(0).notNull(), + updatedAt: int('sbj_tpc_lastpost').default(0).notNull(), + replies: mediumint('sbj_tpc_replies').notNull(), + state: tinyint('sbj_tpc_state').notNull(), + display: tinyint('sbj_tpc_display').default(1).notNull(), }, (table) => { return { - tpcSubjectId: index('tpc_subject_id').on(table.sbjTpcSubjectId), - tpcDisplay: index('tpc_display').on(table.sbjTpcDisplay), - sbjTpcUid: index('sbj_tpc_uid').on(table.sbjTpcUid), - sbjTpcLastpost: index('sbj_tpc_lastpost').on( - table.sbjTpcLastpost, - table.sbjTpcSubjectId, - table.sbjTpcDisplay, - ), + tpcSubjectId: index('tpc_subject_id').on(table.subjectID), + tpcDisplay: index('tpc_display').on(table.display), + sbjTpcUid: index('sbj_tpc_uid').on(table.uid), + sbjTpcLastpost: index('sbj_tpc_lastpost').on(table.updatedAt, table.subjectID, table.display), }; }, ); @@ -1187,3 +1203,70 @@ export const chiiUsergroup = mysqlTable('chii_usergroup', { perm: mediumtext('usr_grp_perm').notNull(), updatedAt: int('usr_grp_dateline').notNull(), }); + +export const chiiBlogComments = mysqlTable( + 'chii_blog_comments', + { + id: mediumint('blg_pst_id').autoincrement().notNull(), + mid: mediumint('blg_pst_mid').notNull(), + uid: mediumint('blg_pst_uid').notNull(), + related: mediumint('blg_pst_related').notNull(), + updatedAt: int('blg_pst_dateline').notNull(), + content: mediumtext('blg_pst_content').notNull(), + }, + (table) => { + return { + blgCmtEid: index('blg_cmt_eid').on(table.mid), + blgCmtUid: index('blg_cmt_uid').on(table.uid), + blgPstRelated: index('blg_pst_related').on(table.related), + }; + }, +); + +export const chiiBlogEntries = mysqlTable( + 'chii_blog_entry', + { + id: mediumint('entry_id').autoincrement().notNull(), + type: smallint('entry_type').notNull(), + uid: mediumint('entry_uid').notNull(), + title: varchar('entry_title', { length: 80 }).notNull(), + icon: varchar('entry_icon', { length: 255 }).notNull(), + content: mediumtext('entry_content').notNull(), + tags: mediumtext('entry_tags').notNull(), + views: mediumint('entry_views').notNull(), + replies: mediumint('entry_replies').notNull(), + createdAt: int('entry_dateline').notNull(), + updatedAt: int('entry_lastpost').notNull(), + like: int('entry_like').notNull(), + dislike: int('entry_dislike').notNull(), + noreply: smallint('entry_noreply').notNull(), + related: tinyint('entry_related').default(0).notNull(), + public: customBoolean('entry_public').default(true).notNull(), + }, + (table) => { + return { + entryType: index('entry_type').on(table.type, table.uid, table.noreply), + entryRelate: index('entry_relate').on(table.related), + entryPublic: index('entry_public').on(table.public), + entryDateline: index('entry_dateline').on(table.createdAt), + entryUid: index('entry_uid').on(table.uid, table.public), + }; + }, +); + +export const chiiBlogPhotos = mysqlTable( + 'chii_blog_photo', + { + id: mediumint('photo_id').autoincrement().notNull(), + eid: mediumint('photo_eid').notNull(), + uid: mediumint('photo_uid').notNull(), + target: varchar('photo_target', { length: 255 }).notNull(), + vote: mediumint('photo_vote').notNull(), + createdAt: int('photo_dateline').notNull(), + }, + (table) => { + return { + photoEid: index('photo_eid').on(table.eid), + }; + }, +); diff --git a/lib/graphql/schema.ts b/lib/graphql/schema.ts index 386fecba..3d50a22d 100644 --- a/lib/graphql/schema.ts +++ b/lib/graphql/schema.ts @@ -22,7 +22,7 @@ import { SubjectTopicRepo, } from '@app/lib/orm/index.ts'; import { avatar } from '@app/lib/response.ts'; -import { ListTopicDisplays } from '@app/lib/topic/index.ts'; +import { ListTopicDisplays } from '@app/lib/topic/display.ts'; import type * as types from './__generated__/resolvers.ts'; diff --git a/lib/orm/index.ts b/lib/orm/index.ts index c078c60a..955b4ab0 100644 --- a/lib/orm/index.ts +++ b/lib/orm/index.ts @@ -8,7 +8,7 @@ import * as schema from '@app/drizzle/schema.ts'; import config, { production, stage } from '@app/lib/config.ts'; import { UnexpectedNotFoundError } from '@app/lib/error.ts'; import { logger } from '@app/lib/logger.ts'; -import type { CommentState, TopicDisplay } from '@app/lib/topic/index.ts'; +import type { CommentState, TopicDisplay } from '@app/lib/topic/type.ts'; import * as entity from './entity/index.ts'; import { diff --git a/lib/subject/type.ts b/lib/subject/type.ts index 41cd5c8d..6fd99a07 100644 --- a/lib/subject/type.ts +++ b/lib/subject/type.ts @@ -23,3 +23,16 @@ export enum PersonType { Character = 'crt', Person = 'prsn', } + +export enum EpisodeType { + /** 本篇 */ + Normal = 0, + /** 特别篇 */ + Special = 1, + Op = 2, + ED = 3, + /** 预告/宣传/广告 */ + Pre = 4, + MAD = 5, + Other = 6, +} diff --git a/lib/topic/display.ts b/lib/topic/display.ts index 8199d6e5..ec0c9b66 100644 --- a/lib/topic/display.ts +++ b/lib/topic/display.ts @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import type { IAuth } from '@app/lib/auth/index.ts'; import type { IReply } from '@app/lib/topic/index.ts'; -import { CommentState, TopicDisplay } from '@app/lib/topic/index.ts'; +import { CommentState, TopicDisplay } from '@app/lib/topic/type.ts'; export const CanViewStateClosedTopic = 24 * 60 * 60 * 180; export const CanViewStateDeleteTopic = 24 * 60 * 60 * 365; diff --git a/lib/topic/index.test.ts b/lib/topic/index.test.ts index 66f7d01c..9fedaddd 100644 --- a/lib/topic/index.test.ts +++ b/lib/topic/index.test.ts @@ -3,6 +3,7 @@ import { afterAll, afterEach, describe, expect, test, vi } from 'vitest'; import { AppDataSource, GroupPostRepo, GroupTopicRepo } from '@app/lib/orm/index.ts'; import * as Topic from './index.ts'; +import { CommentState, TopicParentType } from './type.ts'; describe('mocked', () => { const transaction = vi.fn().mockResolvedValue({ @@ -26,11 +27,11 @@ describe('mocked', () => { test('create topic reply', async () => { const r = await Topic.createTopicReply({ - topicType: Topic.Type.group, + topicType: TopicParentType.Group, topicID: 10, content: 'c', userID: 1, - state: Topic.CommentState.Normal, + state: CommentState.Normal, parentID: 6, }); @@ -45,11 +46,11 @@ describe('should create topic reply', () => { const topicBefore = await GroupTopicRepo.findOneOrFail({ where: { id: 375793 } }); const r = await Topic.createTopicReply({ - topicType: Topic.Type.group, + topicType: TopicParentType.Group, topicID: 375793, content: 'new content for testing', userID: 1, - state: Topic.CommentState.Normal, + state: CommentState.Normal, parentID: 0, }); diff --git a/lib/topic/index.ts b/lib/topic/index.ts index b03d6d5e..57c8d6f3 100644 --- a/lib/topic/index.ts +++ b/lib/topic/index.ts @@ -31,33 +31,7 @@ import { rateLimit } from '@app/routes/hooks/rate-limit.ts'; import type { IBasicReply } from '@app/routes/private/routes/post.ts'; import { NotAllowedError } from './../auth/index'; - -export { CanViewTopicContent, ListTopicDisplays } from './display.ts'; - -export const enum Type { - group = 'group', - subject = 'subject', -} - -export const enum CommentState { - Normal = 0, // 正常 - // CommentStateAdminCloseTopic 管理员关闭主题 https://bgm.tv/subject/topic/12629#post_108127 - AdminCloseTopic = 1, // 关闭 - AdminReopen = 2, // 重开 - AdminPin = 3, // 置顶 - AdminMerge = 4, // 合并 - // CommentStateAdminSilentTopic 管理员下沉 https://bgm.tv/subject/topic/18784#post_160402 - AdminSilentTopic = 5, // 下沉 - UserDelete = 6, // 自行删除 - AdminDelete = 7, // 管理员删除 - AdminOffTopic = 8, // 折叠 -} - -export const enum TopicDisplay { - Ban = 0, // 软删除 - Normal = 1, - Review = 2, -} +import { CommentState, TopicParentType } from './type.ts'; interface IPost { id: number; @@ -66,7 +40,7 @@ interface IPost { state: CommentState; content: string; topicID: number; - type: Type; + type: TopicParentType; } export type ISubReply = IBaseReply; @@ -92,16 +66,16 @@ export interface ITopicDetails { export async function fetchTopicDetail( auth: IAuth, - type: Type, + type: TopicParentType, id: number, ): Promise { let topic: orm.entity.GroupTopic | orm.entity.SubjectTopic | null; switch (type) { - case Type.group: { + case TopicParentType.Group: { topic = await GroupTopicRepo.findOne({ where: { id: id } }); break; } - case Type.subject: { + case TopicParentType.Subject: { topic = await SubjectTopicRepo.findOne({ where: { id: id } }); break; } @@ -120,11 +94,11 @@ export async function fetchTopicDetail( let replies: orm.entity.GroupPost[] | orm.entity.SubjectPost[]; switch (type) { - case Type.group: { + case TopicParentType.Group: { replies = await GroupPostRepo.find({ where: { topicID: topic.id } }); break; } - case Type.subject: { + case TopicParentType.Subject: { replies = await SubjectPostRepo.find({ where: { topicID: topic.id } }); break; } @@ -196,7 +170,7 @@ export interface ITopic { export async function fetchTopicList( auth: IAuth, - topicType: Type, + topicType: TopicParentType, id: number, { limit = 30, offset = 0 }: Page, ): Promise<[number, ITopic[]]> { @@ -208,7 +182,7 @@ export async function fetchTopicList( let total = 0; let topics: entity.GroupTopic[] | entity.SubjectTopic[]; switch (topicType) { - case Type.group: { + case TopicParentType.Group: { total = await GroupTopicRepo.count({ where }); topics = await GroupTopicRepo.find({ where, @@ -218,7 +192,7 @@ export async function fetchTopicList( }); break; } - case Type.subject: { + case TopicParentType.Subject: { total = await SubjectTopicRepo.count({ where }); topics = await SubjectTopicRepo.find({ where, @@ -263,7 +237,7 @@ export async function createTopicReply({ parentID, state = CommentState.Normal, }: { - topicType: Type; + topicType: TopicParentType; topicID: number; userID: number; content: string; @@ -277,12 +251,12 @@ export async function createTopicReply({ let topicRepo: Repository | Repository; switch (topicType) { - case Type.group: { + case TopicParentType.Group: { postRepo = t.getRepository(entity.GroupPost); topicRepo = t.getRepository(entity.GroupTopic); break; } - case Type.subject: { + case TopicParentType.Subject: { postRepo = t.getRepository(entity.SubjectPost); topicRepo = t.getRepository(entity.SubjectTopic); break; @@ -306,7 +280,7 @@ export async function createTopicReply({ replies: posts, } as QueryDeepPartialEntity; - if (topicType === Type.subject) { + if (topicType === TopicParentType.Subject) { topicUpdate = { replies: posts, } as QueryDeepPartialEntity; @@ -332,8 +306,16 @@ export async function createTopicReply({ }; } -function scoredUpdateTime(timestamp: number, type: Type, main_info: entity.GroupTopic): number { - if (type === Type.group && [364].includes(main_info.parentID) && main_info.replies > 0) { +function scoredUpdateTime( + timestamp: number, + type: TopicParentType, + main_info: entity.GroupTopic, +): number { + if ( + type === TopicParentType.Group && + [364].includes(main_info.parentID) && + main_info.replies > 0 + ) { const $created_at = main_info.createdAt; const $created_hours = (timestamp - $created_at) / 3600; const $gravity = 1.8; @@ -347,7 +329,7 @@ function scoredUpdateTime(timestamp: number, type: Type, main_info: entity.Group export async function handleTopicReply( auth: IAuth, - topicType: Type, + topicType: TopicParentType, topicID: number, content: string, replyTo: number, @@ -398,7 +380,7 @@ export async function handleTopicReply( parentID = replied.repliedTo || replied.id; } - if (topicType === Type.group) { + if (topicType === TopicParentType.Group) { const group = await GroupRepo.findOneOrFail({ where: { id: topic.parentID }, }); @@ -420,11 +402,11 @@ export async function handleTopicReply( let notifyType; switch (topicType) { - case Type.group: { + case TopicParentType.Group: { notifyType = replyTo === 0 ? Notify.Type.GroupTopicReply : Notify.Type.GroupPostReply; break; } - case Type.subject: { + case TopicParentType.Subject: { notifyType = replyTo === 0 ? Notify.Type.SubjectTopicReply : Notify.Type.SubjectPostReply; break; } diff --git a/lib/topic/type.ts b/lib/topic/type.ts new file mode 100644 index 00000000..4462ca34 --- /dev/null +++ b/lib/topic/type.ts @@ -0,0 +1,24 @@ +export const enum TopicParentType { + Group = 'group', + Subject = 'subject', +} + +export const enum CommentState { + Normal = 0, // 正常 + // CommentStateAdminCloseTopic 管理员关闭主题 https://bgm.tv/subject/topic/12629#post_108127 + AdminCloseTopic = 1, // 关闭 + AdminReopen = 2, // 重开 + AdminPin = 3, // 置顶 + AdminMerge = 4, // 合并 + // CommentStateAdminSilentTopic 管理员下沉 https://bgm.tv/subject/topic/18784#post_160402 + AdminSilentTopic = 5, // 下沉 + UserDelete = 6, // 自行删除 + AdminDelete = 7, // 管理员删除 + AdminOffTopic = 8, // 折叠 +} + +export const enum TopicDisplay { + Ban = 0, // 软删除 + Normal = 1, + Review = 2, +} diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 308bdc18..28a3f9bb 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -12,6 +12,13 @@ import { findSubjectStaffPosition, } from '@app/vendor'; +export function splitTags(tags: string): string[] { + return tags + .split(' ') + .map((x) => x.trim()) + .filter((x) => x !== ''); +} + // for backward compatibility export function oldToUser(user: ormold.IUser): res.ISlimUser { return { @@ -176,10 +183,7 @@ export function toSubject(subject: orm.ISubject, fields: orm.ISubjectFields): re id: subject.id, images: subjectCover(subject.image) || undefined, infobox: toInfobox(subject.infobox), - metaTags: subject.metaTags - .split(' ') - .map((x) => x.trim()) - .filter((x) => x !== ''), + metaTags: splitTags(subject.metaTags), locked: subject.ban === 2, name: subject.name, nameCN: subject.nameCN, @@ -222,6 +226,65 @@ export function toSubjectStaffPosition(relation: orm.IPersonSubject): res.ISubje }; } +export function toBlotEntry(entry: orm.IBlogEntry, user: orm.IUser): res.IBlogEntry { + return { + id: entry.id, + type: entry.type, + user: toSlimUser(user), + title: entry.title, + icon: entry.icon, + content: entry.content, + tags: splitTags(entry.tags), + views: entry.views, + replies: entry.replies, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + like: entry.like, + dislike: entry.dislike, + noreply: entry.noreply, + related: entry.related, + public: entry.public, + }; +} + +export function toSlimBlogEntry(entry: orm.IBlogEntry): res.ISlimBlogEntry { + return { + id: entry.id, + type: entry.type, + title: entry.title, + summary: entry.content.slice(0, 120), + replies: entry.replies, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + like: entry.like, + dislike: entry.dislike, + }; +} + +export function toSubjectComment( + interest: orm.ISubjectInterest, + user: orm.IUser, +): res.ISubjectComment { + return { + user: toSlimUser(user), + rate: interest.rate, + comment: interest.comment, + updatedAt: interest.updatedAt, + }; +} + +export function toSubjectReview( + review: orm.ISubjectRelatedBlog, + blog: orm.IBlogEntry, + user: orm.IUser, +): res.ISubjectReview { + return { + id: review.id, + user: toSlimUser(user), + entry: toSlimBlogEntry(blog), + }; +} + export function toEpisode(episode: orm.IEpisode): res.IEpisode { return { id: episode.id, @@ -353,3 +416,15 @@ export function toCharacterSubjectRelation( type: relation.type, }; } + +export function toSubjectTopic(topic: orm.ISubjectTopics, user: orm.IUser): res.ITopic { + return { + id: topic.id, + creator: toSlimUser(user), + title: topic.title, + parentID: topic.subjectID, + createdAt: topic.createdAt, + updatedAt: topic.updatedAt, + repliesCount: topic.replies, + }; +} diff --git a/lib/types/res.ts b/lib/types/res.ts index 4d31d0b4..ac3dec49 100644 --- a/lib/types/res.ts +++ b/lib/types/res.ts @@ -4,22 +4,59 @@ import { Type as t } from '@sinclair/typebox'; import httpCodes from 'http-status-codes'; import * as lo from 'lodash-es'; -import { SubjectType } from '@app/lib/subject/type.ts'; +import { EpisodeType, SubjectType } from '@app/lib/subject/type.ts'; import * as examples from '@app/lib/types/examples.ts'; -export enum EpisodeType { - /** 本篇 */ - Normal = 0, - /** 特别篇 */ - Special = 1, - Op = 2, - ED = 3, - /** 预告/宣传/广告 */ - Pre = 4, - MAD = 5, - Other = 6, +export const Paged = (type: T) => + t.Object({ + data: t.Array(type), + total: t.Integer(), + }); + +export const Error = t.Object( + { + code: t.String(), + error: t.String(), + message: t.String(), + statusCode: t.Integer(), + }, + { $id: 'ErrorResponse', description: 'default error response type' }, +); + +export function formatError(e: FastifyError): Static { + const statusCode = e.statusCode ?? 500; + return { + code: e.code, + error: httpCodes.getStatusText(statusCode), + message: e.message, + statusCode: statusCode, + }; +} + +export function formatErrors( + ...errors: FastifyError[] +): Record }> { + return Object.fromEntries( + errors.map((e) => { + return [e.code, { value: formatError(e) }]; + }), + ); } +export function errorResponses(...errors: FastifyError[]): Record { + const status: Record = lo.groupBy(errors, (x) => x.statusCode ?? 500); + + return lo.mapValues(status, (errs) => { + return t.Ref(Error, { + 'x-examples': formatErrors(...errs), + }); + }); +} + +export type UnknownObject = Record; + +export type EmptyObject = Record; + export type IAvatar = Static; export const Avatar = t.Object( { @@ -321,6 +358,66 @@ export const SlimPerson = t.Object( }, ); +export type IBlogEntry = Static; +export const BlogEntry = t.Object( + { + id: t.Integer(), + type: t.Integer(), + user: t.Ref(SlimUser), + title: t.String(), + icon: t.String(), + content: t.String(), + tags: t.Array(t.String()), + views: t.Integer(), + replies: t.Integer(), + createdAt: t.Integer(), + updatedAt: t.Integer(), + like: t.Integer(), + dislike: t.Integer(), + noreply: t.Integer(), + related: t.Integer(), + public: t.Boolean(), + }, + { $id: 'BlogEntry', title: 'BlogEntry' }, +); + +export type ISlimBlogEntry = Static; +export const SlimBlogEntry = t.Object( + { + id: t.Integer(), + type: t.Integer(), + title: t.String(), + summary: t.String(), + replies: t.Integer(), + createdAt: t.Integer(), + updatedAt: t.Integer(), + like: t.Integer(), + dislike: t.Integer(), + }, + { $id: 'SlimBlogEntry', title: 'SlimBlogEntry' }, +); + +export type ISubjectComment = Static; +export const SubjectComment = t.Object( + { + user: t.Ref(SlimUser), + rate: t.Integer(), + comment: t.String(), + updatedAt: t.Integer(), + }, + { $id: 'SubjectComment', title: 'SubjectComment' }, +); + +export type ISubjectReview = Static; +export const SubjectReview = t.Object( + { + id: t.Integer(), + user: t.Ref(SlimUser), + entry: t.Ref(SlimBlogEntry), + }, + { $id: 'SubjectReview', title: 'SubjectReview' }, +); + export type ISubjectRelation = Static; export const SubjectRelation = t.Object( { @@ -446,10 +543,11 @@ export const SlimIndex = t.Object( { $id: 'SlimIndex', title: 'SlimIndex' }, ); +export type ITopic = Static; export const Topic = t.Object( { - id: t.Integer({ description: 'topic id' }), - creator: SlimUser, + id: t.Integer(), + creator: t.Ref(SlimUser), title: t.String(), parentID: t.Integer({ description: '小组/条目ID' }), createdAt: t.Integer({ description: '发帖时间,unix time stamp in seconds' }), @@ -458,53 +556,3 @@ export const Topic = t.Object( }, { $id: 'Topic', title: 'Topic' }, ); - -export const Paged = (type: T) => - t.Object({ - data: t.Array(type), - total: t.Integer(), - }); - -export const Error = t.Object( - { - code: t.String(), - error: t.String(), - message: t.String(), - statusCode: t.Integer(), - }, - { $id: 'ErrorResponse', description: 'default error response type' }, -); - -export function formatError(e: FastifyError): Static { - const statusCode = e.statusCode ?? 500; - return { - code: e.code, - error: httpCodes.getStatusText(statusCode), - message: e.message, - statusCode: statusCode, - }; -} - -export function formatErrors( - ...errors: FastifyError[] -): Record }> { - return Object.fromEntries( - errors.map((e) => { - return [e.code, { value: formatError(e) }]; - }), - ); -} - -export function errorResponses(...errors: FastifyError[]): Record { - const status: Record = lo.groupBy(errors, (x) => x.statusCode ?? 500); - - return lo.mapValues(status, (errs) => { - return t.Ref(Error, { - 'x-examples': formatErrors(...errs), - }); - }); -} - -export type UnknownObject = Record; - -export type EmptyObject = Record; diff --git a/routes/private/routes/post.test.ts b/routes/private/routes/post.test.ts index fd284b93..44897d2c 100644 --- a/routes/private/routes/post.test.ts +++ b/routes/private/routes/post.test.ts @@ -6,7 +6,7 @@ import { emptyAuth, UserGroup } from '@app/lib/auth/index.ts'; import * as Notify from '@app/lib/notify.ts'; import * as orm from '@app/lib/orm/index.ts'; import type { ITopicDetails } from '@app/lib/topic/index.ts'; -import { CommentState, TopicDisplay } from '@app/lib/topic/index.ts'; +import { CommentState, TopicDisplay, TopicParentType } from '@app/lib/topic/type.ts'; import * as Topic from '@app/lib/topic/index.ts'; import { createTestServer } from '@app/tests/utils.ts'; @@ -284,7 +284,7 @@ describe('create group post reply', () => { content: '', state: CommentState.Normal, createdAt: DateTime.fromISO('2021-10-21').toUnixInteger(), - type: Topic.Type.group, + type: TopicParentType.Group, topicID: 371602, user: { img: '', diff --git a/routes/private/routes/post.ts b/routes/private/routes/post.ts index 580f18c3..43e28e67 100644 --- a/routes/private/routes/post.ts +++ b/routes/private/routes/post.ts @@ -12,13 +12,9 @@ import type { entity } from '@app/lib/orm/index.ts'; import { EpisodeCommentRepo, EpisodeRepo, fetchUserX } from '@app/lib/orm/index.ts'; import * as orm from '@app/lib/orm/index.ts'; import { createTurnstileDriver } from '@app/lib/services/turnstile'; -import { - CommentState, - handleTopicReply, - NotJoinPrivateGroupError, - Type, -} from '@app/lib/topic/index.ts'; +import { handleTopicReply, NotJoinPrivateGroupError } from '@app/lib/topic/index.ts'; import * as Topic from '@app/lib/topic/index.ts'; +import { CommentState, TopicParentType } from '@app/lib/topic/type.ts'; import * as convert from '@app/lib/types/convert.ts'; import { formatErrors } from '@app/lib/types/res.ts'; import * as res from '@app/lib/types/res.ts'; @@ -80,14 +76,14 @@ export async function setup(app: App) { app.addSchema(BasicReply); app.addSchema(Reply); - async function getPost(auth: IAuth, postID: number, type: Type) { + async function getPost(auth: IAuth, postID: number, type: TopicParentType) { let post: entity.GroupPost | entity.SubjectPost | null; switch (type) { - case Type.group: { + case TopicParentType.Group: { post = await orm.GroupPostRepo.findOneBy({ id: postID }); break; } - case Type.subject: { + case TopicParentType.Subject: { post = await orm.SubjectPostRepo.findOneBy({ id: postID }); break; } @@ -100,7 +96,7 @@ export async function setup(app: App) { throw new NotFoundError(`${type} post ${postID}`); } - if ([Topic.CommentState.UserDelete, Topic.CommentState.AdminDelete].includes(post.state)) { + if ([CommentState.UserDelete, CommentState.AdminDelete].includes(post.state)) { throw new NotFoundError(`${type} post ${postID}`); } @@ -113,7 +109,7 @@ export async function setup(app: App) { throw new NotFoundError(`${type} post ${postID}`); } - if ([Topic.CommentState.UserDelete, Topic.CommentState.AdminDelete].includes(topic.state)) { + if ([CommentState.UserDelete, CommentState.AdminDelete].includes(topic.state)) { throw new NotFoundError(`${type} topic ${post.topicID}`); } @@ -410,7 +406,7 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, }, }, async ({ auth, params: { postID } }): Promise> => { - const { topic, post } = await getPost(auth, postID, Type.group); + const { topic, post } = await getPost(auth, postID, TopicParentType.Group); const creator = convert.oldToUser(await fetchUserX(post.uid)); @@ -476,7 +472,7 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, throw new NotAllowedError('edit reply not created by you'); } - const topic = await Topic.fetchTopicDetail(auth, Type.group, post.topicID); + const topic = await Topic.fetchTopicDetail(auth, TopicParentType.Group, post.topicID); if (!topic) { throw new NotFoundError(`topic ${post.topicID}`); } @@ -524,17 +520,17 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, preHandler: [requireLogin('delete a post')], }, async ({ auth, params: { postID } }) => { - const { post } = await getPost(auth, postID, Type.group); + const { post } = await getPost(auth, postID, TopicParentType.Group); if (auth.userID !== post.uid) { throw new NotAllowedError('delete this post'); } - if (post.state !== Topic.CommentState.Normal) { + if (post.state !== CommentState.Normal) { return {}; } - await orm.GroupPostRepo.update({ id: postID }, { state: Topic.CommentState.UserDelete }); + await orm.GroupPostRepo.update({ id: postID }, { state: CommentState.UserDelete }); return {}; }, ); @@ -598,7 +594,7 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, if (!(await turnstile.verify(cfCaptchaResponse))) { throw new CaptchaError(); } - return await handleTopicReply(auth, Topic.Type.group, topicID, content, replyTo); + return await handleTopicReply(auth, TopicParentType.Group, topicID, content, replyTo); }, ); @@ -667,7 +663,7 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, if (!(await turnstile.verify(cfCaptchaResponse))) { throw new CaptchaError(); } - return await handleTopicReply(auth, Topic.Type.subject, topicID, content, replyTo); + return await handleTopicReply(auth, TopicParentType.Subject, topicID, content, replyTo); }, ); @@ -695,17 +691,17 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, preHandler: [requireLogin('delete a post')], }, async ({ auth, params: { postID } }) => { - const { post } = await getPost(auth, postID, Type.subject); + const { post } = await getPost(auth, postID, TopicParentType.Subject); if (auth.userID !== post.uid) { throw new NotAllowedError('delete this post'); } - if (post.state !== Topic.CommentState.Normal) { + if (post.state !== CommentState.Normal) { return {}; } - await orm.SubjectPostRepo.update({ id: postID }, { state: Topic.CommentState.UserDelete }); + await orm.SubjectPostRepo.update({ id: postID }, { state: CommentState.UserDelete }); return {}; }, ); @@ -731,7 +727,7 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, preHandler: [requireLogin('get a posts')], }, async ({ auth, params: { postID } }): Promise> => { - const { topic, post } = await getPost(auth, postID, Type.subject); + const { topic, post } = await getPost(auth, postID, TopicParentType.Subject); const creator = convert.oldToUser(await fetchUserX(post.uid)); @@ -798,7 +794,7 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, throw new NotAllowedError('edit reply not created by you'); } - const topic = await Topic.fetchTopicDetail(auth, Type.subject, post.topicID); + const topic = await Topic.fetchTopicDetail(auth, TopicParentType.Subject, post.topicID); if (!topic) { throw new NotFoundError(`topic ${post.topicID}`); } @@ -822,69 +818,4 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, return {}; }, ); - - type ISubjectInterestComment = Static; - const SubjectInterestComment = t.Object( - { - total: t.Integer(), - list: t.Array( - t.Object({ - user: t.Ref(res.SlimUser), - rate: t.Integer(), - comment: t.String(), - updatedAt: t.Integer(), - }), - ), - }, - { $id: 'SubjectInterestComment' }, - ); - - app.addSchema(SubjectInterestComment); - - app.get( - '/subjects/:subjectID/comments', - { - schema: { - summary: '获取条目的吐槽箱', - tags: [Tag.Subject], - operationId: 'subjectComments', - params: t.Object({ - subjectID: t.Integer({ examples: [8], minimum: 0 }), - }), - querystring: t.Object({ - limit: t.Optional(t.Integer({ default: 20 })), - offset: t.Optional(t.Integer({ default: 0, minimum: 0 })), - }), - response: { - 200: t.Ref(SubjectInterestComment), - }, - }, - }, - async ({ params: { subjectID }, query }): Promise => { - const where = { subjectID: subjectID, private: 0, hasComment: 1 }; - - const count = await orm.SubjectInterestRepo.count({ where }); - const comments = await orm.SubjectInterestRepo.find({ - where: where, - order: { updatedAt: 'desc' }, - skip: query.offset, - take: query.limit, - }); - - const commentPromises = comments.map(async (v) => { - const u = await fetchUserX(v.uid); - return { - user: convert.oldToUser(u), - rate: v.rate, - comment: v.comment, - updatedAt: v.updatedAt, - }; - }); - - return { - total: count, - list: await Promise.all(commentPromises), - }; - }, - ); } diff --git a/routes/private/routes/subject.ts b/routes/private/routes/subject.ts index 98090d53..51189fd7 100644 --- a/routes/private/routes/subject.ts +++ b/routes/private/routes/subject.ts @@ -5,7 +5,8 @@ import type * as orm from '@app/drizzle/orm.ts'; import * as schema from '@app/drizzle/schema'; import { NotFoundError } from '@app/lib/error.ts'; import { Security, Tag } from '@app/lib/openapi/index.ts'; -import { SubjectType } from '@app/lib/subject/type.ts'; +import { CollectionType, EpisodeType, SubjectType } from '@app/lib/subject/type.ts'; +import { ListTopicDisplays } from '@app/lib/topic/display.ts'; import * as convert from '@app/lib/types/convert.ts'; import * as fetcher from '@app/lib/types/fetcher.ts'; import * as res from '@app/lib/types/res.ts'; @@ -99,7 +100,7 @@ export async function setup(app: App) { subjectID: t.Integer(), }), querystring: t.Object({ - type: t.Optional(t.Enum(res.EpisodeType, { description: '剧集类型' })), + type: t.Optional(t.Enum(EpisodeType, { description: '剧集类型' })), limit: t.Optional( t.Integer({ default: 100, minimum: 1, maximum: 1000, description: 'max 1000' }), ), @@ -364,4 +365,191 @@ export async function setup(app: App) { }; }, ); + + app.get( + '/subjects/:subjectID/comments', + { + schema: { + summary: '获取条目的吐槽箱', + operationId: 'getSubjectComments', + tags: [Tag.Subject], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + subjectID: t.Integer(), + }), + querystring: t.Object({ + type: t.Optional(t.Enum(CollectionType, { description: '收藏类型' })), + 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.SubjectComment), + 404: t.Ref(res.Error, { + 'x-examples': formatErrors(new NotFoundError('subject')), + }), + }, + }, + }, + async ({ auth, params: { subjectID }, query: { type, limit = 20, offset = 0 } }) => { + const subject = await fetcher.fetchSlimSubjectByID(subjectID, auth.allowNsfw); + if (!subject) { + throw new NotFoundError(`subject ${subjectID}`); + } + const condition = op.and( + op.eq(schema.chiiSubjectInterests.subjectID, subjectID), + op.eq(schema.chiiSubjectInterests.private, 0), + op.eq(schema.chiiSubjectInterests.hasComment, 1), + type ? op.eq(schema.chiiSubjectInterests.type, type) : undefined, + ); + const [{ count = 0 } = {}] = await db + .select({ count: op.count() }) + .from(schema.chiiSubjectInterests) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiSubjectInterests.uid, schema.chiiUsers.id)) + .where(condition) + .execute(); + const data = await db + .select() + .from(schema.chiiSubjectInterests) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiSubjectInterests.uid, schema.chiiUsers.id)) + .where(condition) + .orderBy(op.desc(schema.chiiSubjectInterests.updatedAt)) + .limit(limit) + .offset(offset) + .execute(); + const comments = data.map((d) => + convert.toSubjectComment(d.chii_subject_interests, d.chii_members), + ); + return { + data: comments, + total: count, + }; + }, + ); + + app.get( + '/subjects/:subjectID/reviews', + { + schema: { + summary: '获取条目的评论', + operationId: 'getSubjectReviews', + tags: [Tag.Subject], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + subjectID: t.Integer(), + }), + querystring: t.Object({ + limit: t.Optional( + t.Integer({ default: 5, minimum: 1, maximum: 20, description: 'max 20' }), + ), + offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), + }), + response: { + 200: res.Paged(res.SubjectReview), + 404: t.Ref(res.Error, { + 'x-examples': formatErrors(new NotFoundError('subject')), + }), + }, + }, + }, + async ({ auth, params: { subjectID }, query: { limit = 5, offset = 0 } }) => { + const subject = await fetcher.fetchSlimSubjectByID(subjectID, auth.allowNsfw); + if (!subject) { + throw new NotFoundError(`subject ${subjectID}`); + } + const condition = op.and( + op.eq(schema.chiiSubjectRelatedBlogs.subjectID, subjectID), + op.eq(schema.chiiBlogEntries.public, true), + ); + const [{ count = 0 } = {}] = await db + .select({ count: op.count() }) + .from(schema.chiiSubjectRelatedBlogs) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiSubjectRelatedBlogs.uid, schema.chiiUsers.id)) + .innerJoin( + schema.chiiBlogEntries, + op.eq(schema.chiiSubjectRelatedBlogs.entryID, schema.chiiBlogEntries.id), + ) + .where(condition) + .execute(); + const data = await db + .select() + .from(schema.chiiSubjectRelatedBlogs) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiSubjectRelatedBlogs.uid, schema.chiiUsers.id)) + .innerJoin( + schema.chiiBlogEntries, + op.eq(schema.chiiSubjectRelatedBlogs.entryID, schema.chiiBlogEntries.id), + ) + .where(condition) + .orderBy(op.desc(schema.chiiBlogEntries.createdAt)) + .limit(limit) + .offset(offset) + .execute(); + const reviews = data.map((d) => + convert.toSubjectReview(d.chii_subject_related_blog, d.chii_blog_entry, d.chii_members), + ); + return { + data: reviews, + total: count, + }; + }, + ); + + app.get( + '/subjects/:subjectID/topics', + { + schema: { + summary: '获取条目讨论版', + operationId: 'getSubjectTopics', + tags: [Tag.Subject], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + subjectID: t.Integer(), + }), + querystring: t.Object({ + 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(t.Ref(res.Topic)), + 404: t.Ref(res.Error, { + 'x-examples': formatErrors(new NotFoundError('subject')), + }), + }, + }, + }, + async ({ auth, params: { subjectID }, query: { limit = 20, offset = 0 } }) => { + const subject = await fetcher.fetchSlimSubjectByID(subjectID, auth.allowNsfw); + if (!subject) { + throw new NotFoundError(`subject ${subjectID}`); + } + const display = ListTopicDisplays(auth); + const condition = op.and( + op.eq(schema.chiiSubjectTopics.subjectID, subjectID), + op.inArray(schema.chiiSubjectTopics.display, display), + ); + const [{ count = 0 } = {}] = await db + .select({ count: op.count() }) + .from(schema.chiiSubjectTopics) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiSubjectTopics.uid, schema.chiiUsers.id)) + .where(condition) + .execute(); + const data = await db + .select() + .from(schema.chiiSubjectTopics) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiSubjectTopics.uid, schema.chiiUsers.id)) + .where(condition) + .orderBy(op.desc(schema.chiiSubjectTopics.createdAt)) + .limit(limit) + .offset(offset) + .execute(); + const topics = data.map((d) => convert.toSubjectTopic(d.chii_subject_topics, d.chii_members)); + return { + data: topics, + total: count, + }; + }, + ); } diff --git a/routes/private/routes/topic.test.ts b/routes/private/routes/topic.test.ts index c0f2d570..3942bd82 100644 --- a/routes/private/routes/topic.test.ts +++ b/routes/private/routes/topic.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { IAuth } from '@app/lib/auth/index.ts'; import { emptyAuth, UserGroup } from '@app/lib/auth/index.ts'; import * as orm from '@app/lib/orm/index.ts'; -import { fetchTopicDetail, Type } from '@app/lib/topic/index.ts'; +import { fetchTopicDetail } from '@app/lib/topic/index.ts'; +import { TopicParentType } from '@app/lib/topic/type.ts'; import { createTestServer } from '@app/tests/utils.ts'; import { setup } from './topic.ts'; @@ -240,7 +241,7 @@ describe('edit group topic', () => { expect(res.statusCode).toBe(200); - const topic = await fetchTopicDetail(emptyAuth(), Type.group, 375793); + const topic = await fetchTopicDetail(emptyAuth(), TopicParentType.Group, 375793); expect(topic?.title).toBe('new topic title'); expect(topic?.text).toBe('new contents'); @@ -259,7 +260,7 @@ describe('edit group topic', () => { expect(res.statusCode).toBe(200); - const topic = await fetchTopicDetail(emptyAuth(), Type.group, 375793); + const topic = await fetchTopicDetail(emptyAuth(), TopicParentType.Group, 375793); expect(topic?.title).toBe('new topic title 2'); expect(topic?.text).toBe('new contents 2'); @@ -324,7 +325,7 @@ describe('edit subjec topic', () => { expect(res.statusCode).toBe(200); - const topic = await fetchTopicDetail(emptyAuth(), Type.subject, 3); + const topic = await fetchTopicDetail(emptyAuth(), TopicParentType.Subject, 3); expect(topic?.title).toBe('new topic title'); expect(topic?.text).toBe('new contents'); @@ -343,7 +344,7 @@ describe('edit subjec topic', () => { expect(res.statusCode).toBe(200); - const topic = await fetchTopicDetail(emptyAuth(), Type.subject, 3); + const topic = await fetchTopicDetail(emptyAuth(), TopicParentType.Subject, 3); expect(topic?.title).toBe('new topic title 2'); expect(topic?.text).toBe('new contents 2'); diff --git a/routes/private/routes/topic.ts b/routes/private/routes/topic.ts index c5bac06e..8295ff90 100644 --- a/routes/private/routes/topic.ts +++ b/routes/private/routes/topic.ts @@ -20,12 +20,8 @@ import { avatar, groupIcon } from '@app/lib/response.ts'; import { createTurnstileDriver } from '@app/lib/services/turnstile'; import type { ITopic } from '@app/lib/topic/index.ts'; import * as Topic from '@app/lib/topic/index.ts'; -import { - CommentState, - NotJoinPrivateGroupError, - TopicDisplay, - Type, -} from '@app/lib/topic/index.ts'; +import { NotJoinPrivateGroupError } from '@app/lib/topic/index.ts'; +import { CommentState, TopicDisplay, TopicParentType } from '@app/lib/topic/type.ts'; import * as convert from '@app/lib/types/convert.ts'; import * as fetcher from '@app/lib/types/fetcher.ts'; import * as res from '@app/lib/types/res.ts'; @@ -181,7 +177,12 @@ export async function setup(app: App) { throw new NotFoundError('group'); } - const [total, topicList] = await Topic.fetchTopicList(auth, Type.group, group.id, query); + const [total, topicList] = await Topic.fetchTopicList( + auth, + TopicParentType.Group, + group.id, + query, + ); const topics = await addCreators(topicList, group.id); @@ -217,7 +218,7 @@ export async function setup(app: App) { }, }, async ({ auth, params: { topicID } }) => { - return await handleTopicDetail(auth, Type.subject, topicID); + return await handleTopicDetail(auth, TopicParentType.Subject, topicID); }, ); @@ -243,7 +244,7 @@ export async function setup(app: App) { }, }, async ({ auth, params: { id } }) => { - return await handleTopicDetail(auth, Type.group, id); + return await handleTopicDetail(auth, TopicParentType.Group, id); }, ); @@ -333,52 +334,17 @@ export async function setup(app: App) { throw new NotJoinPrivateGroupError(group.name); } - const [total, topics] = await Topic.fetchTopicList(auth, Type.group, group.id, query); + const [total, topics] = await Topic.fetchTopicList( + auth, + TopicParentType.Group, + group.id, + query, + ); return { total, data: await addCreators(topics, group.id) }; }, ); - app.get( - '/subjects/:subjectID/topics', - { - schema: { - summary: '获取条目讨论版列表', - operationId: 'getSubjectTopicsBySubjectId', - tags: [Tag.Subject], - params: t.Object({ - subjectID: t.Integer({ exclusiveMinimum: 0 }), - }), - querystring: t.Object({ - limit: t.Optional(t.Integer({ default: 30, maximum: 40 })), - offset: t.Optional(t.Integer({ default: 0 })), - }), - response: { - 200: res.Paged(t.Ref(res.Topic)), - 404: t.Ref(res.Error, { - description: '条目不存在', - 'x-examples': { - NotFoundError: { value: res.formatError(new NotFoundError('topic')) }, - }, - }), - }, - security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], - }, - }, - async ({ params: { subjectID }, query, auth }) => { - const subject = await orm.fetchSubjectByID(subjectID); - if (!subject) { - throw new NotFoundError(`subject ${subjectID}`); - } - if (subject.nsfw && !auth.allowNsfw) { - throw new NotFoundError(`subject ${subjectID}`); - } - - const [total, topics] = await Topic.fetchTopicList(auth, Type.subject, subjectID, query); - return { total, data: await addCreators(topics, subjectID) }; - }, - ); - app.post( '/groups/:groupName/topics', { @@ -424,7 +390,7 @@ export async function setup(app: App) { display, userID: auth.userID, parentID: group.id, - state: Topic.CommentState.Normal, + state: CommentState.Normal, topicType: 'group', }); }, @@ -470,7 +436,7 @@ export async function setup(app: App) { throw new BadRequestError('text contains invalid invisible character'); } - const topic = await Topic.fetchTopicDetail(auth, Type.group, topicID); + const topic = await Topic.fetchTopicDetail(auth, TopicParentType.Group, topicID); if (!topic) { throw new NotFoundError(`topic ${topicID}`); } @@ -571,7 +537,7 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, display, userID: auth.userID, parentID: subject.id, - state: Topic.CommentState.Normal, + state: CommentState.Normal, topicType: 'subject', }); }, @@ -618,7 +584,7 @@ dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, throw new BadRequestError('text contains invalid invisible character'); } - const topic = await Topic.fetchTopicDetail(auth, Type.subject, topicID); + const topic = await Topic.fetchTopicDetail(auth, TopicParentType.Subject, topicID); if (!topic) { throw new NotFoundError(`topic ${topicID}`); } @@ -721,7 +687,7 @@ async function fetchRecentMember(groupID: number): Promise { export async function handleTopicDetail( auth: IAuth, - type: Type, + type: TopicParentType, id: number, ): Promise> { const topic = await Topic.fetchTopicDetail(auth, type, id); @@ -731,11 +697,11 @@ export async function handleTopicDetail( let parent: orm.IGroup | res.ISubject | null; switch (type) { - case Type.group: { + case TopicParentType.Group: { parent = await orm.fetchGroupByID(topic.parentID); break; } - case Type.subject: { + case TopicParentType.Subject: { parent = await fetcher.fetchSubjectByID(topic.parentID); break; } @@ -768,7 +734,7 @@ export async function handleTopicDetail( text: topic.text, parent: { ...parent, - icon: type === Type.group ? groupIcon((parent as orm.IGroup).icon) : '', + icon: type === TopicParentType.Group ? groupIcon((parent as orm.IGroup).icon) : '', }, reactions: reactions[topic.contentPost.id] ?? [], replies: topic.replies.map((x) => { diff --git a/routes/private/routes/user.ts b/routes/private/routes/user.ts index f04cd068..156a06f5 100644 --- a/routes/private/routes/user.ts +++ b/routes/private/routes/user.ts @@ -409,8 +409,8 @@ export async function setup(app: App) { .select({ count: op.count(), }) - .from(schema.chiiIndex) - .where(op.and(op.eq(schema.chiiIndex.uid, userID), op.ne(schema.chiiIndex.ban, 1))) + .from(schema.chiiIndexes) + .where(op.and(op.eq(schema.chiiIndexes.uid, userID), op.ne(schema.chiiIndexes.ban, 1))) .execute(); indexSummary.count = count; } @@ -521,9 +521,9 @@ export async function setup(app: App) { async function appendIndexDetail(userID: number) { const data = await db .select() - .from(schema.chiiIndex) - .where(op.and(op.eq(schema.chiiIndex.uid, userID), op.ne(schema.chiiIndex.ban, 1))) - .orderBy(op.desc(schema.chiiIndex.createdAt)) + .from(schema.chiiIndexes) + .where(op.and(op.eq(schema.chiiIndexes.uid, userID), op.ne(schema.chiiIndexes.ban, 1))) + .orderBy(op.desc(schema.chiiIndexes.createdAt)) .limit(7) .execute(); for (const d of data) { @@ -817,21 +817,21 @@ export async function setup(app: App) { const conditions = op.and( op.eq(schema.chiiIndexCollects.uid, user.id), - op.ne(schema.chiiIndex.ban, 1), + op.ne(schema.chiiIndexes.ban, 1), ); const [{ count = 0 } = {}] = await db .select({ count: op.count() }) .from(schema.chiiIndexCollects) - .innerJoin(schema.chiiIndex, op.eq(schema.chiiIndexCollects.mid, schema.chiiIndex.id)) + .innerJoin(schema.chiiIndexes, op.eq(schema.chiiIndexCollects.mid, schema.chiiIndexes.id)) .where(conditions) .execute(); const data = await db .select() .from(schema.chiiIndexCollects) - .innerJoin(schema.chiiIndex, op.eq(schema.chiiIndexCollects.mid, schema.chiiIndex.id)) - .innerJoin(schema.chiiUsers, op.eq(schema.chiiIndex.uid, schema.chiiUsers.id)) + .innerJoin(schema.chiiIndexes, op.eq(schema.chiiIndexCollects.mid, schema.chiiIndexes.id)) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiIndexes.uid, schema.chiiUsers.id)) .where(conditions) .orderBy(op.desc(schema.chiiIndexCollects.createdAt)) .limit(limit) @@ -880,21 +880,21 @@ export async function setup(app: App) { } const conditions = op.and( - op.eq(schema.chiiIndex.uid, user.id), - op.ne(schema.chiiIndex.ban, 1), + op.eq(schema.chiiIndexes.uid, user.id), + op.ne(schema.chiiIndexes.ban, 1), ); const [{ count = 0 } = {}] = await db .select({ count: op.count() }) - .from(schema.chiiIndex) + .from(schema.chiiIndexes) .where(conditions) .execute(); const data = await db .select() - .from(schema.chiiIndex) + .from(schema.chiiIndexes) .where(conditions) - .orderBy(op.desc(schema.chiiIndex.createdAt)) + .orderBy(op.desc(schema.chiiIndexes.createdAt)) .limit(limit) .offset(offset) .execute(); diff --git a/routes/private/routes/wiki/subject/ep.ts b/routes/private/routes/wiki/subject/ep.ts index 4445dc2d..b7522d65 100644 --- a/routes/private/routes/wiki/subject/ep.ts +++ b/routes/private/routes/wiki/subject/ep.ts @@ -6,8 +6,9 @@ import { BadRequestError, NotFoundError } from '@app/lib/error.ts'; import { Security, Tag } from '@app/lib/openapi/index.ts'; import { AppDataSource, EpisodeRepo } from '@app/lib/orm/index.ts'; import { pushRev } from '@app/lib/rev/ep.ts'; +import { EpisodeType } from '@app/lib/subject/type.ts'; import * as res from '@app/lib/types/res.ts'; -import { EpisodeType, formatErrors } from '@app/lib/types/res.ts'; +import { formatErrors } from '@app/lib/types/res.ts'; import { parseDuration } from '@app/lib/utils/index.ts'; import { requireLogin, requirePermission } from '@app/routes/hooks/pre-handler.ts'; import type { App } from '@app/routes/type.ts'; diff --git a/routes/res.ts b/routes/res.ts index c682cfe3..73c8d6b8 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -13,6 +13,7 @@ export function addSchemas(app: App) { app.addSchema(res.SubjectRating); app.addSchema(res.SubjectRelationType); app.addSchema(res.SubjectStaffPosition); + app.addSchema(res.SubjectComment); app.addSchema(res.PersonImages); app.addSchema(res.Infobox); app.addSchema(res.Subject); From 15dee326df2092cbd92f9086967f73e384d8592a Mon Sep 17 00:00:00 2001 From: everpcpc Date: Fri, 22 Nov 2024 14:47:56 +0800 Subject: [PATCH 02/20] z --- routes/res.ts | 50 ++++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/routes/res.ts b/routes/res.ts index 73c8d6b8..735ffa62 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -2,37 +2,39 @@ import * as res from '@app/lib/types/res.ts'; import type { App } from '@app/routes/type.ts'; export function addSchemas(app: App) { + app.addSchema(res.BlogEntry); + app.addSchema(res.Character); + app.addSchema(res.CharacterRelation); + app.addSchema(res.CharacterSubject); + app.addSchema(res.CharacterSubjectRelation); + app.addSchema(res.Episode); app.addSchema(res.Error); - app.addSchema(res.User); - app.addSchema(res.SlimUser); app.addSchema(res.Friend); + app.addSchema(res.Index); + app.addSchema(res.Infobox); + app.addSchema(res.Person); + app.addSchema(res.PersonCharacter); + app.addSchema(res.PersonCollect); + app.addSchema(res.PersonImages); + app.addSchema(res.PersonRelation); + app.addSchema(res.PersonWork); + app.addSchema(res.SlimBlogEntry); + app.addSchema(res.SlimCharacter); + app.addSchema(res.SlimIndex); + app.addSchema(res.SlimPerson); + app.addSchema(res.SlimSubject); + app.addSchema(res.SlimUser); + app.addSchema(res.Subject); app.addSchema(res.SubjectAirtime); + app.addSchema(res.SubjectCharacter); app.addSchema(res.SubjectCollection); + app.addSchema(res.SubjectComment); app.addSchema(res.SubjectImages); app.addSchema(res.SubjectPlatform); app.addSchema(res.SubjectRating); - app.addSchema(res.SubjectRelationType); - app.addSchema(res.SubjectStaffPosition); - app.addSchema(res.SubjectComment); - app.addSchema(res.PersonImages); - app.addSchema(res.Infobox); - app.addSchema(res.Subject); - app.addSchema(res.SlimSubject); - app.addSchema(res.Episode); - app.addSchema(res.Character); - app.addSchema(res.SlimCharacter); - app.addSchema(res.Person); - app.addSchema(res.SlimPerson); - app.addSchema(res.Index); - app.addSchema(res.SlimIndex); app.addSchema(res.SubjectRelation); - app.addSchema(res.SubjectCharacter); + app.addSchema(res.SubjectRelationType); app.addSchema(res.SubjectStaff); - app.addSchema(res.CharacterRelation); - app.addSchema(res.CharacterSubject); - app.addSchema(res.CharacterSubjectRelation); - app.addSchema(res.PersonRelation); - app.addSchema(res.PersonWork); - app.addSchema(res.PersonCharacter); - app.addSchema(res.PersonCollect); + app.addSchema(res.SubjectStaffPosition); + app.addSchema(res.User); } From f282766359cf0730c244869319c9f79d59b03957 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Fri, 22 Nov 2024 14:48:56 +0800 Subject: [PATCH 03/20] z --- routes/private/routes/topic.ts | 1 - routes/res.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/private/routes/topic.ts b/routes/private/routes/topic.ts index 8295ff90..466cfac6 100644 --- a/routes/private/routes/topic.ts +++ b/routes/private/routes/topic.ts @@ -128,7 +128,6 @@ const TopicBasic = t.Object( // eslint-disable-next-line @typescript-eslint/require-await export async function setup(app: App) { - app.addSchema(res.Topic); app.addSchema(Group); app.addSchema(TopicBasic); diff --git a/routes/res.ts b/routes/res.ts index 735ffa62..eab5fe32 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -37,4 +37,5 @@ export function addSchemas(app: App) { app.addSchema(res.SubjectStaff); app.addSchema(res.SubjectStaffPosition); app.addSchema(res.User); + app.addSchema(res.Topic); } From 6088425c64a8490423343ee770840aa44ec2679e Mon Sep 17 00:00:00 2001 From: everpcpc Date: Fri, 22 Nov 2024 16:09:30 +0800 Subject: [PATCH 04/20] z --- drizzle/orm.ts | 3 +- drizzle/schema.ts | 20 +- lib/services/turnstile.ts | 3 + lib/types/convert.ts | 2 +- lib/types/examples.ts | 6 + lib/types/req.ts | 23 +++ .../routes/__snapshots__/topic.test.ts.snap | 191 ------------------ routes/private/routes/subject.test.ts | 19 ++ routes/private/routes/subject.ts | 88 +++++++- routes/private/routes/topic.test.ts | 34 ---- routes/private/routes/topic.ts | 80 +------- 11 files changed, 152 insertions(+), 317 deletions(-) create mode 100644 lib/types/req.ts diff --git a/drizzle/orm.ts b/drizzle/orm.ts index e9b618c3..d91acfcc 100644 --- a/drizzle/orm.ts +++ b/drizzle/orm.ts @@ -11,7 +11,8 @@ export type ISubjectFields = typeof schema.chiiSubjectFields.$inferSelect; export type ISubjectInterest = typeof schema.chiiSubjectInterests.$inferSelect; export type ISubjectRelation = typeof schema.chiiSubjectRelations.$inferSelect; export type ISubjectRelatedBlog = typeof schema.chiiSubjectRelatedBlogs.$inferSelect; -export type ISubjectTopics = typeof schema.chiiSubjectTopics.$inferSelect; +export type ISubjectTopic = typeof schema.chiiSubjectTopics.$inferSelect; +export type ISubjectPost = typeof schema.chiiSubjectPosts.$inferSelect; export type IEpisode = typeof schema.chiiEpisodes.$inferSelect; diff --git a/drizzle/schema.ts b/drizzle/schema.ts index ef410a47..5b3e90fa 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1007,19 +1007,19 @@ export const chiiSubjectRelatedBlogs = mysqlTable( export const chiiSubjectPosts = mysqlTable( 'chii_subject_posts', { - sbjPstId: mediumint('sbj_pst_id').autoincrement().notNull(), - sbjPstMid: mediumint('sbj_pst_mid').notNull(), - sbjPstUid: mediumint('sbj_pst_uid').notNull(), - sbjPstRelated: mediumint('sbj_pst_related').notNull(), - sbjPstContent: mediumtext('sbj_pst_content').notNull(), - sbjPstState: tinyint('sbj_pst_state').notNull(), - sbjPstDateline: int('sbj_pst_dateline').default(0).notNull(), + id: mediumint('sbj_pst_id').primaryKey().autoincrement().notNull(), + mid: mediumint('sbj_pst_mid').notNull(), + uid: mediumint('sbj_pst_uid').notNull(), + related: mediumint('sbj_pst_related').notNull(), + content: mediumtext('sbj_pst_content').notNull(), + state: tinyint('sbj_pst_state').notNull(), + createdAt: int('sbj_pst_dateline').default(0).notNull(), }, (table) => { return { - pssTopicId: index('pss_topic_id').on(table.sbjPstMid), - sbjPstRelated: index('sbj_pst_related').on(table.sbjPstRelated), - sbjPstUid: index('sbj_pst_uid').on(table.sbjPstUid), + pssTopicId: index('pss_topic_id').on(table.mid), + sbjPstRelated: index('sbj_pst_related').on(table.related), + sbjPstUid: index('sbj_pst_uid').on(table.uid), }; }, ); diff --git a/lib/services/turnstile.ts b/lib/services/turnstile.ts index a60601ff..c64eb095 100644 --- a/lib/services/turnstile.ts +++ b/lib/services/turnstile.ts @@ -1,4 +1,5 @@ import { stage } from '@app/lib/config.ts'; +import config from '@app/lib/config.ts'; import { BaseExternalHttpSrv } from './base.ts'; @@ -46,3 +47,5 @@ export class Turnstile extends BaseExternalHttpSrv { return data.success; } } + +export const turnstile = createTurnstileDriver(config.turnstile.siteKey); diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 28a3f9bb..482fdc2d 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -417,7 +417,7 @@ export function toCharacterSubjectRelation( }; } -export function toSubjectTopic(topic: orm.ISubjectTopics, user: orm.IUser): res.ITopic { +export function toSubjectTopic(topic: orm.ISubjectTopic, user: orm.IUser): res.ITopic { return { id: topic.id, creator: toSlimUser(user), diff --git a/lib/types/examples.ts b/lib/types/examples.ts index b388e25d..95d8ea79 100644 --- a/lib/types/examples.ts +++ b/lib/types/examples.ts @@ -194,3 +194,9 @@ export const subject = { type: 2, volumes: 0, }; + +export const topicCreation = { + title: 'topic title', + content: 'topic content', + 'cf-turnstile-response': '10000000-aaaa-bbbb-cccc-000000000001', +}; diff --git a/lib/types/req.ts b/lib/types/req.ts new file mode 100644 index 00000000..c4954946 --- /dev/null +++ b/lib/types/req.ts @@ -0,0 +1,23 @@ +import type { Static } from '@sinclair/typebox'; +import { Type as t } from '@sinclair/typebox'; + +import * as examples from '@app/lib/types/examples.ts'; + +const turnstileDescription = `需要 [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) + +next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` + +dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``; + +export type ITopicCreation = Static; +export const TopicCreation = t.Object( + { + title: t.String({ minLength: 1 }), + text: t.String({ minLength: 1, description: 'bbcode' }), + 'cf-turnstile-response': t.String({ description: turnstileDescription }), + }, + { + $id: 'TopicCreation', + examples: [examples.topicCreation], + }, +); diff --git a/routes/private/routes/__snapshots__/topic.test.ts.snap b/routes/private/routes/__snapshots__/topic.test.ts.snap index a95c16ff..9f3d6efd 100644 --- a/routes/private/routes/__snapshots__/topic.test.ts.snap +++ b/routes/private/routes/__snapshots__/topic.test.ts.snap @@ -199,194 +199,3 @@ Object { "title": "reaction", } `; - -exports[`subject topics > should failed on not found subject 1`] = ` -Object { - "code": "NOT_FOUND", - "error": "Not Found", - "message": "subject 114514 not found", - "statusCode": 404, -} -`; - -exports[`subject topics > should fetch topic details 1`] = ` -Object { - "createdAt": 1216022809, - "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": 2, - "joinedAt": 0, - "nickname": "nickname 2", - "sign": "sing 2", - "username": "2", - }, - "id": 3, - "parent": Object { - "airtime": Object { - "date": "2008-07-17", - "month": 7, - "weekday": 4, - "year": 2008, - }, - "collection": Object { - "1": 21, - "2": 197, - "3": 6, - "4": 5, - "5": 4, - }, - "eps": 0, - "id": 4, - "images": Object { - "common": "https://lain.bgm.tv/pic/cover/c/a8/7f/4_cMMK5.jpg", - "grid": "https://lain.bgm.tv/pic/cover/g/a8/7f/4_cMMK5.jpg", - "large": "https://lain.bgm.tv/pic/cover/l/a8/7f/4_cMMK5.jpg", - "medium": "https://lain.bgm.tv/pic/cover/m/a8/7f/4_cMMK5.jpg", - "small": "https://lain.bgm.tv/pic/cover/s/a8/7f/4_cMMK5.jpg", - }, - "infobox": Object { - "website": Array [ - Object { - "v": "", - }, - ], - "中文名": Array [ - Object { - "v": "合金弹头7", - }, - ], - "别名": Array [ - Object { - "v": "Metal Slug 7", - }, - ], - "发行": Array [ - Object { - "v": "SNKプレイモア", - }, - ], - "发行日期": Array [ - Object { - "v": "2008-07-17", - }, - ], - "售价": Array [ - Object { - "v": "5040円", - }, - ], - "平台": Array [ - Object { - "v": "NDS", - }, - ], - "开发": Array [ - Object { - "v": "SNKプレイモア", - }, - ], - "游戏引擎": Array [ - Object { - "v": "", - }, - ], - "游戏类型": Array [ - Object { - "v": "ACT", - }, - ], - "游玩人数": Array [ - Object { - "v": "1人", - }, - ], - }, - "locked": false, - "metaTags": Array [], - "name": "メタルスラッグ7", - "nameCN": "合金弹头7", - "nsfw": false, - "platform": Object { - "alias": "", - "id": 5, - "type": "", - "typeCN": "", - }, - "rating": Object { - "count": Array [ - 0, - 0, - 0, - 1, - 8, - 47, - 43, - 39, - 3, - 2, - ], - "score": 6.9, - "total": 143, - }, - "redirect": 0, - "series": false, - "seriesEntry": 0, - "summary": "  以细腻的画风、搞笑的动作和刺激的战斗被人们所熟知的“合金弹头系列”在NDS 平台推出正统续作!虽然本系列的前几部作品最早都是作为街机游戏而推出的,不过这一次的“7”不但先推出NDS版,而且是独占!日前SNK playmore宣布了这款游戏,目前发售日定为2008年3月。据称,本作中将搭载任务模式,在关卡中不断完成教官所下达的任务,提升军衔。 -  游戏依然保持了系列一贯的风格,战斗的场面也没有因为是掌机游戏而进行削减,游戏依然是射击、跳跃和手雷三个按键,虽然是NDS游戏,不过本作却并不对应触摸屏,游戏的画面显示在上屏幕,而下屏幕则用来显示地图。在TGS 2007(Tokyo Game Show)大会上SNK放出了《合金弹头7》的试玩版。这代产品沿袭了前代风格,战斗的对象似乎也没有多大变化,没有分支路线,不过游戏性和关卡设计却不比前几代差,关卡数也有7关,另外还增加了一种武器。", - "type": 4, - "volumes": 0, - }, - "reactions": Array [], - "replies": Array [ - Object { - "createdAt": 1216023735, - "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": 1, - "joinedAt": 0, - "nickname": "nickname 1", - "sign": "sing 1", - "username": "1", - }, - "id": 6, - "isFriend": false, - "reactions": Array [], - "replies": Array [], - "state": 0, - "text": "NDS被别人借走了……", - }, - Object { - "createdAt": 1217552145, - "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": 101, - "joinedAt": 0, - "nickname": "nickname 101", - "sign": "sing 101", - "username": "101", - }, - "id": 38, - "isFriend": false, - "reactions": Array [], - "replies": Array [], - "state": 0, - "text": "里层众占领了这里", - }, - ], - "state": 0, - "text": "new contents 2", - "title": "new topic title 2", -} -`; diff --git a/routes/private/routes/subject.test.ts b/routes/private/routes/subject.test.ts index d2854e8e..206a426a 100644 --- a/routes/private/routes/subject.test.ts +++ b/routes/private/routes/subject.test.ts @@ -58,4 +58,23 @@ describe('subject', () => { }); expect(res.json()).toMatchSnapshot(); }); + + test('should get subject topics', async () => { + const app = createTestServer(); + await app.register(setup); + const res = await app.inject({ + method: 'get', + url: '/subjects/1/topics', + query: { limit: '2', offset: '0' }, + }); + expect(res.json()).toMatchSnapshot(); + }); + + // test('should fetch topic details', async () => { + // const app = createTestServer(); + // await app.register(setup); + // const res = await app.inject({ url: '/subjects/-/topics/3', method: 'get' }); + // expect(res.statusCode).toBe(200); + // expect(res.json()).toMatchSnapshot(); + // }); }); diff --git a/routes/private/routes/subject.ts b/routes/private/routes/subject.ts index 51189fd7..52df17f9 100644 --- a/routes/private/routes/subject.ts +++ b/routes/private/routes/subject.ts @@ -3,14 +3,22 @@ import { Type as t } from '@sinclair/typebox'; import { db, op } from '@app/drizzle/db.ts'; import type * as orm from '@app/drizzle/orm.ts'; import * as schema from '@app/drizzle/schema'; -import { NotFoundError } from '@app/lib/error.ts'; +import { NotAllowedError } from '@app/lib/auth/index.ts'; +import { Dam, dam } from '@app/lib/dam.ts'; +import { BadRequestError, CaptchaError, NotFoundError } from '@app/lib/error.ts'; import { Security, Tag } from '@app/lib/openapi/index.ts'; +import { turnstile } from '@app/lib/services/turnstile.ts'; import { CollectionType, EpisodeType, SubjectType } from '@app/lib/subject/type.ts'; import { ListTopicDisplays } from '@app/lib/topic/display.ts'; +import { CommentState, TopicDisplay } from '@app/lib/topic/type.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 { formatErrors } from '@app/lib/types/res.ts'; +import { LimitAction } from '@app/lib/utils/rate-limit'; +import { requireLogin } from '@app/routes/hooks/pre-handler.ts'; +import { rateLimit } from '@app/routes/hooks/rate-limit'; import type { App } from '@app/routes/type.ts'; function toSubjectRelation( @@ -552,4 +560,82 @@ export async function setup(app: App) { }; }, ); + + app.post( + '/subjects/:subjectID/topics', + { + schema: { + summary: '创建条目讨论', + tags: [Tag.Subject], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + operationId: 'createSubjectTopic', + params: t.Object({ + subjectID: t.Integer({ examples: [114514], minimum: 0 }), + }), + response: { + 200: t.Object({ + id: t.Integer({ description: 'new topic id' }), + }), + }, + body: t.Ref(req.TopicCreation), + }, + preHandler: [requireLogin('creating a topic')], + }, + async ({ + auth, + body: { text, title, 'cf-turnstile-response': cfCaptchaResponse }, + params: { subjectID }, + }) => { + if (!(await turnstile.verify(cfCaptchaResponse))) { + throw new CaptchaError(); + } + if (!Dam.allCharacterPrintable(text)) { + throw new BadRequestError('text contains invalid invisible character'); + } + if (auth.permission.ban_post) { + throw new NotAllowedError('create topic'); + } + + const subject = await fetcher.fetchSlimSubjectByID(subjectID, auth.allowNsfw); + if (!subject) { + throw new NotFoundError(`subject ${subjectID}`); + } + + const state = CommentState.Normal; + let display = TopicDisplay.Normal; + if (dam.needReview(title) || dam.needReview(text)) { + display = TopicDisplay.Review; + } + await rateLimit(LimitAction.Subject, auth.userID); + + const now = Math.round(Date.now() / 1000); + + const topic: typeof schema.chiiSubjectTopics.$inferInsert = { + createdAt: now, + updatedAt: now, + subjectID: subjectID, + uid: auth.userID, + title, + replies: 0, + state, + display, + }; + const post: typeof schema.chiiSubjectPosts.$inferInsert = { + content: text, + uid: auth.userID, + createdAt: now, + state, + mid: 0, + related: 0, + }; + + await db.transaction(async (t) => { + const [result] = await t.insert(schema.chiiSubjectTopics).values(topic).execute(); + post.mid = result.insertId; + await t.insert(schema.chiiSubjectPosts).values(post).execute(); + }); + + return { id: post.mid }; + }, + ); } diff --git a/routes/private/routes/topic.test.ts b/routes/private/routes/topic.test.ts index 3942bd82..c65f8cdf 100644 --- a/routes/private/routes/topic.test.ts +++ b/routes/private/routes/topic.test.ts @@ -113,40 +113,6 @@ describe('group topics', () => { }); }); -describe('subject topics', () => { - test('should failed on not found subject', async () => { - const app = createTestServer(); - await app.register(setup); - const res = await app.inject({ - url: '/subjects/114514/topics', - }); - - expect(res.statusCode).toBe(404); - expect(res.json()).toMatchSnapshot(); - }); - - test('should return data', async () => { - const app = createTestServer(); - await app.register(setup); - - const res = await app.inject({ - url: '/subjects/1/topics', - }); - const data = res.json(); - - expect(res.statusCode).toBe(200); - expect(data.data).toContainEqual(expect.objectContaining(expectedSubjectTopic)); - }); - - test('should fetch topic details', async () => { - const app = createTestServer(); - await app.register(setup); - const res = await app.inject({ url: '/subjects/-/topics/3', method: 'get' }); - expect(res.statusCode).toBe(200); - expect(res.json()).toMatchSnapshot(); - }); -}); - describe('create group post', () => { const createPostInGroup = vi.fn().mockResolvedValue({ id: 1 }); vi.spyOn(orm, 'createPost').mockImplementation(createPostInGroup); diff --git a/routes/private/routes/topic.ts b/routes/private/routes/topic.ts index 466cfac6..3ead09d0 100644 --- a/routes/private/routes/topic.ts +++ b/routes/private/routes/topic.ts @@ -3,21 +3,14 @@ import { Type as t } from '@sinclair/typebox'; import type { IAuth } from '@app/lib/auth/index.ts'; import { NotAllowedError } from '@app/lib/auth/index.ts'; -import config from '@app/lib/config'; import { Dam, dam } from '@app/lib/dam.ts'; -import { - BadRequestError, - CaptchaError, - NotFoundError, - UnexpectedNotFoundError, -} from '@app/lib/error.ts'; +import { BadRequestError, NotFoundError, UnexpectedNotFoundError } from '@app/lib/error.ts'; import * as Like from '@app/lib/like.ts'; import { Security, Tag } from '@app/lib/openapi/index.ts'; import type { Page } from '@app/lib/orm/index.ts'; import * as orm from '@app/lib/orm/index.ts'; import { GroupMemberRepo, isMemberInGroup } from '@app/lib/orm/index.ts'; import { avatar, groupIcon } from '@app/lib/response.ts'; -import { createTurnstileDriver } from '@app/lib/services/turnstile'; import type { ITopic } from '@app/lib/topic/index.ts'; import * as Topic from '@app/lib/topic/index.ts'; import { NotJoinPrivateGroupError } from '@app/lib/topic/index.ts'; @@ -26,9 +19,7 @@ import * as convert from '@app/lib/types/convert.ts'; import * as fetcher from '@app/lib/types/fetcher.ts'; import * as res from '@app/lib/types/res.ts'; import { formatErrors } from '@app/lib/types/res.ts'; -import { LimitAction } from '@app/lib/utils/rate-limit'; import { requireLogin } from '@app/routes/hooks/pre-handler.ts'; -import { rateLimit } from '@app/routes/hooks/rate-limit'; import type { App } from '@app/routes/type.ts'; const Group = t.Object( @@ -473,75 +464,6 @@ export async function setup(app: App) { }, ); - const turnstile = createTurnstileDriver(config.turnstile.secretKey); - - app.post( - '/subjects/:subjectID/topics', - { - schema: { - summary: '创建条目讨论版', - description: `需要 [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) - -next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` - -dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``, - tags: [Tag.Subject], - operationId: 'createNewSubjectTopic', - params: t.Object({ - subjectID: t.Integer({ examples: [114514], minimum: 0 }), - }), - response: { - 200: t.Object({ - id: t.Integer({ description: 'new topic id' }), - }), - }, - security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], - body: t.Ref(TopicBasic), - }, - preHandler: [requireLogin('creating a topic')], - }, - async ({ - auth, - body: { text, title, 'cf-turnstile-response': cfCaptchaResponse }, - params: { subjectID }, - }) => { - if (!(await turnstile.verify(cfCaptchaResponse))) { - throw new CaptchaError(); - } - - if (!Dam.allCharacterPrintable(text)) { - throw new BadRequestError('text contains invalid invisible character'); - } - - if (auth.permission.ban_post) { - throw new NotAllowedError('create topic'); - } - - const subject = await orm.fetchSubjectByID(subjectID); - if (!subject) { - throw new NotFoundError(`subject ${subjectID}`); - } - - let display = TopicDisplay.Normal; - - if (dam.needReview(title) || dam.needReview(text)) { - display = TopicDisplay.Review; - } - - await rateLimit(LimitAction.Subject, auth.userID); - - return await orm.createPost({ - title, - content: text, - display, - userID: auth.userID, - parentID: subject.id, - state: CommentState.Normal, - topicType: 'subject', - }); - }, - ); - app.put( '/subjects/-/topics/:topicID', { From c3e8d0fa19d9962c63fae5b471ab1de1fa6acd05 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Fri, 22 Nov 2024 16:11:01 +0800 Subject: [PATCH 05/20] z --- routes/res.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routes/res.ts b/routes/res.ts index eab5fe32..b3aeb6e8 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -1,3 +1,4 @@ +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'; @@ -38,4 +39,6 @@ export function addSchemas(app: App) { app.addSchema(res.SubjectStaffPosition); app.addSchema(res.User); app.addSchema(res.Topic); + + app.addSchema(req.TopicCreation); } From a61999656a5cbd8770a87da6eac0ae93299b1bdf Mon Sep 17 00:00:00 2001 From: everpcpc Date: Fri, 22 Nov 2024 16:13:41 +0800 Subject: [PATCH 06/20] z --- .../routes/__snapshots__/subject.test.ts.snap | 28 +++++++++++++++++++ routes/private/routes/topic.ts | 26 +++-------------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/routes/private/routes/__snapshots__/subject.test.ts.snap b/routes/private/routes/__snapshots__/subject.test.ts.snap index 53fa1522..a0ac52bb 100644 --- a/routes/private/routes/__snapshots__/subject.test.ts.snap +++ b/routes/private/routes/__snapshots__/subject.test.ts.snap @@ -391,3 +391,31 @@ Object { "total": 101, } `; + +exports[`subject > should get subject topics 1`] = ` +Object { + "data": Array [ + Object { + "createdAt": 1216020847, + "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": 2, + "joinedAt": 0, + "nickname": "nickname 2", + "sign": "sing 2", + "username": "2", + }, + "id": 1, + "parentID": 1, + "repliesCount": 76, + "title": "拿这个来测试", + "updatedAt": 1639999129, + }, + ], + "total": 1, +} +`; diff --git a/routes/private/routes/topic.ts b/routes/private/routes/topic.ts index 3ead09d0..0a8023f8 100644 --- a/routes/private/routes/topic.ts +++ b/routes/private/routes/topic.ts @@ -17,6 +17,7 @@ import { NotJoinPrivateGroupError } from '@app/lib/topic/index.ts'; import { CommentState, TopicDisplay, TopicParentType } from '@app/lib/topic/type.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 { formatErrors } from '@app/lib/types/res.ts'; import { requireLogin } from '@app/routes/hooks/pre-handler.ts'; @@ -99,28 +100,9 @@ const TopicDetail = t.Object( { $id: 'TopicDetail' }, ); -const TopicBasic = t.Object( - { - title: t.String({ minLength: 1 }), - text: t.String({ minLength: 1, description: 'bbcode' }), - 'cf-turnstile-response': t.String({ minLength: 1 }), - }, - { - $id: 'TopicCreation', - examples: [ - { - title: 'topic title', - content: 'topic content', - 'cf-turnstile-response': '10000000-aaaa-bbbb-cccc-000000000001', - }, - ], - }, -); - // eslint-disable-next-line @typescript-eslint/require-await export async function setup(app: App) { app.addSchema(Group); - app.addSchema(TopicBasic); const GroupProfile = t.Object( { @@ -350,7 +332,7 @@ export async function setup(app: App) { }), }, security: [{ [Security.CookiesSession]: [] }], - body: t.Ref(TopicBasic), + body: t.Ref(req.TopicCreation), }, preHandler: [requireLogin('creating a post')], }, @@ -403,7 +385,7 @@ export async function setup(app: App) { }), }, security: [{ [Security.CookiesSession]: [] }], - body: t.Ref(TopicBasic), + body: t.Ref(req.TopicCreation), }, preHandler: [requireLogin('edit a topic')], }, @@ -482,7 +464,7 @@ export async function setup(app: App) { }), }, security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], - body: t.Ref(TopicBasic), + body: t.Ref(req.TopicCreation), }, preHandler: [requireLogin('edit a topic')], }, From babae272cab12d960b9fb098f310fa90bd4e63fc Mon Sep 17 00:00:00 2001 From: everpcpc Date: Fri, 22 Nov 2024 16:20:05 +0800 Subject: [PATCH 07/20] z --- routes/__snapshots__/index.test.ts.snap | 376 ++++++++++++++++++------ 1 file changed, 281 insertions(+), 95 deletions(-) diff --git a/routes/__snapshots__/index.test.ts.snap b/routes/__snapshots__/index.test.ts.snap index 5cf61195..9d1d946f 100644 --- a/routes/__snapshots__/index.test.ts.snap +++ b/routes/__snapshots__/index.test.ts.snap @@ -65,6 +65,61 @@ exports[`should build private api spec 1`] = ` - text - state type: object + BlogEntry: + properties: + content: + type: string + createdAt: + type: integer + dislike: + type: integer + icon: + type: string + id: + type: integer + like: + type: integer + noreply: + type: integer + public: + type: boolean + related: + type: integer + replies: + type: integer + tags: + items: + type: string + type: array + title: + type: string + type: + type: integer + updatedAt: + type: integer + user: + $ref: '#/components/schemas/SlimUser' + views: + type: integer + required: + - id + - type + - user + - title + - icon + - content + - tags + - views + - replies + - createdAt + - updatedAt + - like + - dislike + - noreply + - related + - public + title: BlogEntry + type: object Character: properties: collects: @@ -882,6 +937,38 @@ exports[`should build private api spec 1`] = ` - state - reactions type: object + SlimBlogEntry: + properties: + createdAt: + type: integer + dislike: + type: integer + id: + type: integer + like: + type: integer + replies: + type: integer + summary: + type: string + title: + type: string + type: + type: integer + updatedAt: + type: integer + required: + - id + - type + - title + - summary + - replies + - createdAt + - updatedAt + - like + - dislike + title: SlimBlogEntry + type: object SlimCharacter: properties: id: @@ -1288,6 +1375,23 @@ exports[`should build private api spec 1`] = ` type: integer title: SubjectCollection type: object + SubjectComment: + properties: + comment: + type: string + rate: + type: integer + updatedAt: + type: integer + user: + $ref: '#/components/schemas/SlimUser' + required: + - user + - rate + - comment + - updatedAt + title: SubjectComment + type: object SubjectEdit: example: infobox: |- @@ -1371,32 +1475,6 @@ exports[`should build private api spec 1`] = ` - grid title: SubjectImages type: object - SubjectInterestComment: - properties: - list: - items: - properties: - comment: - type: string - rate: - type: integer - updatedAt: - type: integer - user: - $ref: '#/components/schemas/SlimUser' - required: - - user - - rate - - comment - - updatedAt - type: object - type: array - total: - type: integer - required: - - total - - list - type: object SubjectNew: properties: infobox: @@ -1600,46 +1678,8 @@ exports[`should build private api spec 1`] = ` description: 发帖时间,unix time stamp in seconds type: integer creator: - properties: - avatar: - properties: - large: - type: string - medium: - example: sai - type: string - small: - type: string - required: - - small - - medium - - large - title: Avatar - type: object - id: - example: 1 - type: integer - joinedAt: - type: integer - nickname: - example: Sai🖖 - type: string - sign: - type: string - username: - example: sai - type: string - required: - - id - - username - - nickname - - avatar - - sign - - joinedAt - title: SlimUser - type: object + $ref: '#/components/schemas/SlimUser' id: - description: topic id type: integer parentID: description: 小组/条目ID @@ -1668,7 +1708,15 @@ exports[`should build private api spec 1`] = ` title: topic title properties: cf-turnstile-response: - minLength: 1 + description: >- + 需要 + [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) + + + next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` + + + dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\` type: string text: description: bbcode @@ -3986,35 +4034,96 @@ paths: - subject /p1/subjects/{subjectID}/comments: get: - operationId: subjectComments + operationId: getSubjectComments parameters: - - in: query + - description: 收藏类型 + in: query + name: type + required: false + schema: + anyOf: + - enum: + - 1 + type: number + - enum: + - 2 + type: number + - enum: + - 3 + type: number + - enum: + - 4 + type: number + - enum: + - 5 + type: number + - description: max 100 + in: query name: limit required: false schema: default: 20 + maximum: 100 + minimum: 1 type: integer - - in: query + - description: min 0 + in: query name: offset required: false schema: default: 0 minimum: 0 type: integer - - example: 8 - in: path + - in: path name: subjectID required: true schema: - minimum: 0 type: integer responses: '200': content: application/json: schema: - $ref: '#/components/schemas/SubjectInterestComment' + properties: + data: + items: + properties: + comment: + type: string + rate: + type: integer + updatedAt: + type: integer + user: + $ref: '#/components/schemas/SlimUser' + required: + - user + - rate + - comment + - updatedAt + title: SubjectComment + type: object + type: array + total: + type: integer + required: + - data + - total + type: object description: Default Response + '404': + content: + application/json: + examples: + NOT_FOUND: + value: + code: NOT_FOUND + error: Not Found + message: subject not found + statusCode: 404 + schema: + $ref: '#/components/schemas/ErrorResponse' + description: default error response type '500': content: application/json: @@ -4022,6 +4131,9 @@ paths: $ref: '#/components/schemas/ErrorResponse' description: 意料之外的服务器错误 description: 意料之外的服务器错误 + security: + - CookiesSession: [] + HTTPBearer: [] summary: 获取条目的吐槽箱 tags: - subject @@ -4218,6 +4330,87 @@ paths: summary: 获取条目的关联条目 tags: - subject + /p1/subjects/{subjectID}/reviews: + get: + operationId: getSubjectReviews + parameters: + - description: max 20 + in: query + name: limit + required: false + schema: + default: 5 + maximum: 20 + minimum: 1 + type: integer + - description: min 0 + in: query + name: offset + required: false + schema: + default: 0 + minimum: 0 + type: integer + - in: path + name: subjectID + required: true + schema: + type: integer + responses: + '200': + content: + application/json: + schema: + properties: + data: + items: + properties: + entry: + $ref: '#/components/schemas/SlimBlogEntry' + id: + type: integer + user: + $ref: '#/components/schemas/SlimUser' + required: + - id + - user + - entry + title: SubjectReview + type: object + type: array + total: + type: integer + required: + - data + - total + type: object + description: Default Response + '404': + content: + application/json: + examples: + NOT_FOUND: + value: + code: NOT_FOUND + error: Not Found + message: subject not found + statusCode: 404 + schema: + $ref: '#/components/schemas/ErrorResponse' + description: default error response type + '500': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 意料之外的服务器错误 + description: 意料之外的服务器错误 + security: + - CookiesSession: [] + HTTPBearer: [] + summary: 获取条目的评论 + tags: + - subject /p1/subjects/{subjectID}/staffs: get: operationId: getSubjectStaffs @@ -4295,26 +4488,29 @@ paths: - subject /p1/subjects/{subjectID}/topics: get: - operationId: getSubjectTopicsBySubjectId + operationId: getSubjectTopics parameters: - - in: query + - description: max 100 + in: query name: limit required: false schema: - default: 30 - maximum: 40 + default: 20 + maximum: 100 + minimum: 1 type: integer - - in: query + - description: min 0 + in: query name: offset required: false schema: default: 0 + minimum: 0 type: integer - in: path name: subjectID required: true schema: - exclusiveMinimum: 0 type: integer responses: '200': @@ -4337,16 +4533,15 @@ paths: content: application/json: examples: - NotFoundError: + NOT_FOUND: value: code: NOT_FOUND error: Not Found - message: topic not found + message: subject not found statusCode: 404 schema: $ref: '#/components/schemas/ErrorResponse' - description: 条目不存在 - description: 条目不存在 + description: default error response type '500': content: application/json: @@ -4357,20 +4552,11 @@ paths: security: - CookiesSession: [] HTTPBearer: [] - summary: 获取条目讨论版列表 + summary: 获取条目讨论版 tags: - subject post: - description: >- - 需要 - [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) - - - next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` - - - dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\` - operationId: createNewSubjectTopic + operationId: createSubjectTopic parameters: - example: 114514 in: path @@ -4407,7 +4593,7 @@ paths: security: - CookiesSession: [] HTTPBearer: [] - summary: 创建条目讨论版 + summary: 创建条目讨论 tags: - subject /p1/trending/subjects: From 628099dbd69dac88a61c91f7483ccf2524b1e4e7 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Fri, 22 Nov 2024 16:27:19 +0800 Subject: [PATCH 08/20] z --- .../routes/__snapshots__/subject.test.ts.snap | 36 +++++++++++++------ routes/private/routes/subject.test.ts | 24 ++++++++++++- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/routes/private/routes/__snapshots__/subject.test.ts.snap b/routes/private/routes/__snapshots__/subject.test.ts.snap index a0ac52bb..1926175a 100644 --- a/routes/private/routes/__snapshots__/subject.test.ts.snap +++ b/routes/private/routes/__snapshots__/subject.test.ts.snap @@ -248,6 +248,13 @@ Object { } `; +exports[`subject > should get subject comments 1`] = ` +Object { + "data": Array [], + "total": 0, +} +`; + exports[`subject > should get subject episodes 1`] = ` Object { "data": Array [ @@ -342,6 +349,13 @@ Object { } `; +exports[`subject > should get subject reviews 1`] = ` +Object { + "data": Array [], + "total": 0, +} +`; + exports[`subject > should get subject staffs 1`] = ` Object { "data": Array [ @@ -396,24 +410,24 @@ exports[`subject > should get subject topics 1`] = ` Object { "data": Array [ Object { - "createdAt": 1216020847, + "createdAt": 1462335911, "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": 2, + "id": 142527, "joinedAt": 0, - "nickname": "nickname 2", - "sign": "sing 2", - "username": "2", - }, - "id": 1, - "parentID": 1, - "repliesCount": 76, - "title": "拿这个来测试", - "updatedAt": 1639999129, + "nickname": "nickname 142527", + "sign": "sing 142527", + "username": "142527", + }, + "id": 6873, + "parentID": 12, + "repliesCount": 8, + "title": "这条目简介也太剧透了吧", + "updatedAt": 1481098545, }, ], "total": 1, diff --git a/routes/private/routes/subject.test.ts b/routes/private/routes/subject.test.ts index 206a426a..2663e5ee 100644 --- a/routes/private/routes/subject.test.ts +++ b/routes/private/routes/subject.test.ts @@ -59,12 +59,34 @@ describe('subject', () => { expect(res.json()).toMatchSnapshot(); }); + test('should get subject comments', async () => { + const app = createTestServer(); + await app.register(setup); + const res = await app.inject({ + method: 'get', + url: '/subjects/12/comments', + query: { limit: '2', offset: '0' }, + }); + expect(res.json()).toMatchSnapshot(); + }); + + test('should get subject reviews', async () => { + const app = createTestServer(); + await app.register(setup); + const res = await app.inject({ + method: 'get', + url: '/subjects/12/reviews', + query: { limit: '2', offset: '0' }, + }); + expect(res.json()).toMatchSnapshot(); + }); + test('should get subject topics', async () => { const app = createTestServer(); await app.register(setup); const res = await app.inject({ method: 'get', - url: '/subjects/1/topics', + url: '/subjects/12/topics', query: { limit: '2', offset: '0' }, }); expect(res.json()).toMatchSnapshot(); From c640e2005a6445eff1b99f6a70ff144bc1b4c114 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sat, 23 Nov 2024 12:53:33 +0800 Subject: [PATCH 09/20] z --- lib/services/turnstile.ts | 4 +- lib/topic/display.ts | 44 +- lib/topic/index.ts | 6 +- lib/types/convert.ts | 27 + lib/types/examples.ts | 2 +- lib/types/fetcher.ts | 55 + lib/types/req.ts | 17 +- lib/types/res.ts | 88 +- routes/__snapshots__/index.test.ts.snap | 1160 +++++++++++++---- .../routes/__snapshots__/subject.test.ts.snap | 11 + routes/private/routes/subject.test.ts | 87 +- routes/private/routes/subject.ts | 189 ++- routes/private/routes/topic.test.ts | 111 -- routes/private/routes/topic.ts | 246 +--- routes/res.ts | 10 +- 15 files changed, 1397 insertions(+), 660 deletions(-) diff --git a/lib/services/turnstile.ts b/lib/services/turnstile.ts index c64eb095..bc65c30a 100644 --- a/lib/services/turnstile.ts +++ b/lib/services/turnstile.ts @@ -1,4 +1,4 @@ -import { stage } from '@app/lib/config.ts'; +import { stage, testing } from '@app/lib/config.ts'; import config from '@app/lib/config.ts'; import { BaseExternalHttpSrv } from './base.ts'; @@ -11,7 +11,7 @@ const VerifyURL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; * @see https://developers.cloudflare.com/turnstile/frequently-asked-questions/#are-there-sitekeys-and-secret-keys-that-can-be-used-for-testing */ export function createTurnstileDriver(secretKey: string) { - if (stage) { + if (stage || testing) { return { verify(): Promise { return Promise.resolve(true); diff --git a/lib/topic/display.ts b/lib/topic/display.ts index ec0c9b66..ca2f547b 100644 --- a/lib/topic/display.ts +++ b/lib/topic/display.ts @@ -22,13 +22,15 @@ export function ListTopicDisplays(u: IAuth): TopicDisplay[] { export function CanViewTopicContent( u: IAuth, - topic: { state: number; display: number; creatorID: number }, + state: number, + display: number, + creatorID: number, ): boolean { if (!u.login) { // 未登录用户只能看到正常帖子 return ( - topic.display === TopicDisplay.Normal && - (topic.state === CommentState.Normal || topic.state == CommentState.AdminReopen) + display === TopicDisplay.Normal && + (state === CommentState.Normal || state == CommentState.AdminReopen) ); } @@ -39,34 +41,34 @@ export function CanViewTopicContent( return true; } - if (u.userID == topic.creatorID && topic.display == TopicDisplay.Review) { + if (u.userID == creatorID && display == TopicDisplay.Review) { return true; } // 非管理员看不到删除和review的帖子 - if (topic.display != TopicDisplay.Normal) { + if (display != TopicDisplay.Normal) { return false; } // 注册时间决定 - if (topic.state === CommentState.AdminCloseTopic) { + if (state === CommentState.AdminCloseTopic) { return CanViewClosedTopic(u); } - if (topic.state === CommentState.AdminDelete) { + if (state === CommentState.AdminDelete) { return false; } - if (topic.state === CommentState.UserDelete) { + if (state === CommentState.UserDelete) { return CanViewDeleteTopic(u); } return ( - topic.state === CommentState.Normal || - topic.state === CommentState.AdminReopen || - topic.state === CommentState.AdminMerge || - topic.state === CommentState.AdminPin || - topic.state === CommentState.AdminSilentTopic + state === CommentState.Normal || + state === CommentState.AdminReopen || + state === CommentState.AdminMerge || + state === CommentState.AdminPin || + state === CommentState.AdminSilentTopic ); } @@ -78,6 +80,22 @@ function CanViewClosedTopic(a: IAuth): boolean { return DateTime.now().toUnixInteger() - a.regTime > CanViewStateClosedTopic; } +export function CanViewTopicReply(state: number): boolean { + switch (state) { + case CommentState.AdminDelete: + case CommentState.UserDelete: + case CommentState.AdminReopen: + case CommentState.AdminCloseTopic: + case CommentState.AdminSilentTopic: { + return false; + } + + default: { + return true; + } + } +} + export function filterReply(x: IReply): IReply { return { ...filterSubReply(x), diff --git a/lib/topic/index.ts b/lib/topic/index.ts index 57c8d6f3..303c259e 100644 --- a/lib/topic/index.ts +++ b/lib/topic/index.ts @@ -88,7 +88,7 @@ export async function fetchTopicDetail( return null; } - if (!CanViewTopicContent(auth, topic)) { + if (!CanViewTopicContent(auth, topic.state, topic.display, topic.creatorID)) { return null; } @@ -166,6 +166,8 @@ export interface ITopic { createdAt: number; title: string; repliesCount: number; + state: number; + display: number; } export async function fetchTopicList( @@ -218,6 +220,8 @@ export async function fetchTopicList( createdAt: x.createdAt, updatedAt: x.updatedAt, repliesCount: x.replies, + state: x.state, + display: x.display, }; }), ]; diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 482fdc2d..96406baa 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -426,5 +426,32 @@ export function toSubjectTopic(topic: orm.ISubjectTopic, user: orm.IUser): res.I createdAt: topic.createdAt, updatedAt: topic.updatedAt, repliesCount: topic.replies, + state: topic.state, + display: topic.display, + }; +} + +export function toSubjectTopicReply(reply: orm.ISubjectPost, user: orm.IUser): res.IReply { + return { + id: reply.id, + text: reply.content, + state: reply.state, + createdAt: reply.createdAt, + creator: toSlimUser(user), + replies: [], + reactions: [], + isFriend: false, + }; +} + +export function toSubjectTopicSubReply(reply: orm.ISubjectPost, user: orm.IUser): res.ISubReply { + return { + id: reply.id, + text: reply.content, + state: reply.state, + createdAt: reply.createdAt, + creator: toSlimUser(user), + reactions: [], + isFriend: false, }; } diff --git a/lib/types/examples.ts b/lib/types/examples.ts index 95d8ea79..623b5b44 100644 --- a/lib/types/examples.ts +++ b/lib/types/examples.ts @@ -195,7 +195,7 @@ export const subject = { volumes: 0, }; -export const topicCreation = { +export const createTopic = { title: 'topic title', content: 'topic content', 'cf-turnstile-response': '10000000-aaaa-bbbb-cccc-000000000001', diff --git a/lib/types/fetcher.ts b/lib/types/fetcher.ts index 4b71cde7..e7f4efeb 100644 --- a/lib/types/fetcher.ts +++ b/lib/types/fetcher.ts @@ -16,6 +16,20 @@ export async function fetchSlimUserByUsername(username: string): Promise { + const data = await db + .select() + .from(schema.chiiFriends) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiFriends.fid, schema.chiiUsers.id)) + .where(op.eq(schema.chiiFriends.uid, userID)) + .execute(); + const list: res.IFriend[] = []; + for (const d of data) { + list.push(convert.toFriend(d.chii_members, d.chii_friends)); + } + return list; +} + export async function fetchSlimSubjectByID( id: number, allowNsfw = false, @@ -197,3 +211,44 @@ export async function fetchCastsByPersonAndCharacterIDs( } return map; } + +export async function fetchSubjectTopicByID(topicID: number): Promise { + const data = await db + .select() + .from(schema.chiiSubjectTopics) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiSubjectTopics.uid, schema.chiiUsers.id)) + .where(op.eq(schema.chiiSubjectTopics.id, topicID)) + .execute(); + for (const d of data) { + return convert.toSubjectTopic(d.chii_subject_topics, d.chii_members); + } + return null; +} + +export async function fetchSubjectTopicRepliesByTopicID(topicID: number): Promise { + const data = await db + .select() + .from(schema.chiiSubjectPosts) + .innerJoin(schema.chiiUsers, op.eq(schema.chiiSubjectPosts.uid, schema.chiiUsers.id)) + .where(op.eq(schema.chiiSubjectPosts.mid, topicID)) + .execute(); + + const subReplies: Record = {}; + const topLevelReplies: res.IReply[] = []; + for (const d of data) { + const related = d.chii_subject_posts.related; + if (related == 0) { + const reply = convert.toSubjectTopicReply(d.chii_subject_posts, d.chii_members); + topLevelReplies.push(reply); + } else { + const subReply = convert.toSubjectTopicSubReply(d.chii_subject_posts, d.chii_members); + const list = subReplies[related] ?? []; + list.push(subReply); + subReplies[related] = list; + } + } + for (const reply of topLevelReplies) { + reply.replies = subReplies[reply.id] ?? []; + } + return topLevelReplies; +} diff --git a/lib/types/req.ts b/lib/types/req.ts index c4954946..f8d089b9 100644 --- a/lib/types/req.ts +++ b/lib/types/req.ts @@ -9,15 +9,24 @@ next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``; -export type ITopicCreation = Static; -export const TopicCreation = t.Object( +export type ICreateTopic = Static; +export const CreateTopic = t.Object( { title: t.String({ minLength: 1 }), text: t.String({ minLength: 1, description: 'bbcode' }), 'cf-turnstile-response': t.String({ description: turnstileDescription }), }, { - $id: 'TopicCreation', - examples: [examples.topicCreation], + $id: 'CreateTopic', + examples: [examples.createTopic], }, ); + +export type IUpdateTopic = Static; +export const UpdateTopic = t.Object( + { + title: t.String({ minLength: 1 }), + text: t.String({ minLength: 1, description: 'bbcode' }), + }, + { $id: 'UpdateTopic' }, +); diff --git a/lib/types/res.ts b/lib/types/res.ts index ac3dec49..6cc95076 100644 --- a/lib/types/res.ts +++ b/lib/types/res.ts @@ -73,7 +73,7 @@ export const SlimUser = t.Object( id: t.Integer({ examples: [1] }), username: t.String({ examples: ['sai'] }), nickname: t.String({ examples: ['Sai🖖'] }), - avatar: Avatar, + avatar: t.Ref(Avatar), sign: t.String(), joinedAt: t.Integer(), }, @@ -86,7 +86,7 @@ export const User = t.Object( id: t.Integer({ examples: [1] }), username: t.String({ examples: ['sai'] }), nickname: t.String({ examples: ['Sai🖖'] }), - avatar: Avatar, + avatar: t.Ref(Avatar), group: t.Integer(), user_group: t.Integer({ description: 'deprecated, use group instead' }), joinedAt: t.Integer(), @@ -543,6 +543,72 @@ export const SlimIndex = t.Object( { $id: 'SlimIndex', title: 'SlimIndex' }, ); +export type IGroup = Static; +export const Group = t.Object( + { + id: t.Integer(), + name: t.String(), + nsfw: t.Boolean(), + title: t.String(), + icon: t.String(), + description: t.String(), + totalMembers: t.Integer(), + createdAt: t.Integer(), + }, + { $id: 'Group', title: 'Group' }, +); + +export type IGroupMember = Static; +export const GroupMember = t.Object( + { + id: t.Integer(), + nickname: t.String(), + username: t.String(), + avatar: t.Ref(Avatar), + joinedAt: t.Integer(), + }, + { $id: 'GroupMember', title: 'GroupMember' }, +); + +export type IReaction = Static; +export const Reaction = t.Object( + { + selected: t.Boolean(), + total: t.Integer(), + value: t.Integer(), + }, + { $id: 'Reaction', title: 'Reaction' }, +); + +export type ISubReply = Static; +export const SubReply = t.Object( + { + id: t.Integer(), + creator: t.Ref(SlimUser), + createdAt: t.Integer(), + isFriend: t.Boolean(), + text: t.String(), + state: t.Integer(), + reactions: t.Array(t.Ref(Reaction)), + }, + { $id: 'SubReply', title: 'SubReply' }, +); + +export type IReply = Static; +export const Reply = t.Object( + { + id: t.Integer(), + isFriend: t.Boolean(), + replies: t.Array(t.Ref(SubReply)), + creator: t.Ref(SlimUser), + createdAt: t.Integer(), + text: t.String(), + state: t.Integer(), + reactions: t.Array(t.Ref(Reaction)), + }, + { $id: 'Reply', title: 'Reply' }, +); + export type ITopic = Static; export const Topic = t.Object( { @@ -553,6 +619,24 @@ export const Topic = t.Object( createdAt: t.Integer({ description: '发帖时间,unix time stamp in seconds' }), updatedAt: t.Integer({ description: '最后回复时间,unix time stamp in seconds' }), repliesCount: t.Integer(), + state: t.Integer(), + display: t.Integer(), }, { $id: 'Topic', title: 'Topic' }, ); + +export type ITopicDetail = Static; +export const TopicDetail = t.Object( + { + id: t.Integer(), + parent: t.Union([t.Ref(Group), t.Ref(SlimSubject)]), + creator: t.Ref(SlimUser), + title: t.String(), + text: t.String(), + state: t.Integer(), + createdAt: t.Integer(), + replies: t.Array(t.Ref(Reply)), + reactions: t.Array(t.Ref(Reaction)), + }, + { $id: 'TopicDetail', title: 'TopicDetail' }, +); diff --git a/routes/__snapshots__/index.test.ts.snap b/routes/__snapshots__/index.test.ts.snap index 9d1d946f..447362b8 100644 --- a/routes/__snapshots__/index.test.ts.snap +++ b/routes/__snapshots__/index.test.ts.snap @@ -193,25 +193,40 @@ exports[`should build private api spec 1`] = ` - subject - type type: object + CreateTopic: + example: + cf-turnstile-response: 10000000-aaaa-bbbb-cccc-000000000001 + content: topic content + title: topic title + properties: + cf-turnstile-response: + description: >- + 需要 + [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) + + + next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` + + + dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\` + type: string + text: + description: bbcode + minLength: 1 + type: string + title: + minLength: 1 + type: string + required: + - title + - text + - cf-turnstile-response + type: object CurrentUser: allOf: - properties: avatar: - properties: - large: - type: string - medium: - examples: - - sai - type: string - small: - type: string - required: - - small - - medium - - large - title: Avatar - type: object + $ref: '#/components/schemas/Avatar' id: examples: - 1 @@ -465,24 +480,12 @@ exports[`should build private api spec 1`] = ` - description - totalMembers - createdAt + title: Group type: object GroupMember: properties: avatar: - properties: - large: - type: string - medium: - example: sai - type: string - small: - type: string - required: - - small - - medium - - large - title: Avatar - type: object + $ref: '#/components/schemas/Avatar' id: type: integer joinedAt: @@ -492,26 +495,105 @@ exports[`should build private api spec 1`] = ` username: type: string required: - - avatar - id - nickname - username + - avatar - joinedAt + title: GroupMember type: object GroupProfile: properties: group: - $ref: '#/components/schemas/Group' + properties: + createdAt: + type: integer + description: + type: string + icon: + type: string + id: + type: integer + name: + type: string + nsfw: + type: boolean + title: + type: string + totalMembers: + type: integer + required: + - id + - name + - nsfw + - title + - icon + - description + - totalMembers + - createdAt + title: Group + type: object inGroup: description: 是否已经加入小组 type: boolean recentAddedMembers: items: - $ref: '#/components/schemas/GroupMember' + properties: + avatar: + $ref: '#/components/schemas/Avatar' + id: + type: integer + joinedAt: + type: integer + nickname: + type: string + username: + type: string + required: + - id + - nickname + - username + - avatar + - joinedAt + title: GroupMember + type: object type: array topics: items: - $ref: '#/components/schemas/Topic' + properties: + createdAt: + description: 发帖时间,unix time stamp in seconds + type: integer + creator: + $ref: '#/components/schemas/SlimUser' + display: + type: integer + id: + type: integer + parentID: + description: 小组/条目ID + type: integer + repliesCount: + type: integer + state: + type: integer + title: + type: string + updatedAt: + description: 最后回复时间,unix time stamp in seconds + type: integer + required: + - id + - creator + - title + - parentID + - createdAt + - updatedAt + - repliesCount + - state + - display + title: Topic + type: object type: array totalTopics: type: integer @@ -662,20 +744,7 @@ exports[`should build private api spec 1`] = ` sender: properties: avatar: - properties: - large: - type: string - medium: - example: sai - type: string - small: - type: string - required: - - small - - medium - - large - title: Avatar - type: object + $ref: '#/components/schemas/Avatar' id: example: 1 type: integer @@ -874,6 +943,7 @@ exports[`should build private api spec 1`] = ` - selected - total - value + title: Reaction type: object RecentWikiChange: properties: @@ -936,6 +1006,7 @@ exports[`should build private api spec 1`] = ` - text - state - reactions + title: Reply type: object SlimBlogEntry: properties: @@ -1089,20 +1160,7 @@ exports[`should build private api spec 1`] = ` SlimUser: properties: avatar: - properties: - large: - type: string - medium: - example: sai - type: string - small: - type: string - required: - - small - - medium - - large - title: Avatar - type: object + $ref: '#/components/schemas/Avatar' id: example: 1 type: integer @@ -1151,6 +1209,7 @@ exports[`should build private api spec 1`] = ` - text - state - reactions + title: SubReply type: object Subject: example: @@ -1679,6 +1738,8 @@ exports[`should build private api spec 1`] = ` type: integer creator: $ref: '#/components/schemas/SlimUser' + display: + type: integer id: type: integer parentID: @@ -1686,6 +1747,8 @@ exports[`should build private api spec 1`] = ` type: integer repliesCount: type: integer + state: + type: integer title: type: string updatedAt: @@ -1699,37 +1762,10 @@ exports[`should build private api spec 1`] = ` - createdAt - updatedAt - repliesCount + - state + - display title: Topic type: object - TopicCreation: - example: - cf-turnstile-response: 10000000-aaaa-bbbb-cccc-000000000001 - content: topic content - title: topic title - properties: - cf-turnstile-response: - description: >- - 需要 - [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) - - - next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` - - - dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\` - type: string - text: - description: bbcode - minLength: 1 - type: string - title: - minLength: 1 - type: string - required: - - title - - text - - cf-turnstile-response - type: object TopicDetail: properties: createdAt: @@ -1741,7 +1777,7 @@ exports[`should build private api spec 1`] = ` parent: anyOf: - $ref: '#/components/schemas/Group' - - $ref: '#/components/schemas/Subject' + - $ref: '#/components/schemas/SlimSubject' reactions: items: $ref: '#/components/schemas/Reaction' @@ -1766,6 +1802,7 @@ exports[`should build private api spec 1`] = ` - createdAt - replies - reactions + title: TopicDetail type: object TrendingSubject: properties: @@ -1777,23 +1814,23 @@ exports[`should build private api spec 1`] = ` - subject - count type: object + UpdateTopic: + properties: + text: + description: bbcode + minLength: 1 + type: string + title: + minLength: 1 + type: string + required: + - title + - text + type: object User: properties: avatar: - properties: - large: - type: string - medium: - example: sai - type: string - small: - type: string - required: - - small - - medium - - large - title: Avatar - type: object + $ref: '#/components/schemas/Avatar' bio: type: string group: @@ -2357,22 +2394,65 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TopicDetail' + properties: + createdAt: + type: integer + creator: + $ref: '#/components/schemas/SlimUser' + id: + type: integer + parent: + anyOf: + - $ref: '#/components/schemas/Group' + - $ref: '#/components/schemas/SlimSubject' + reactions: + items: + $ref: '#/components/schemas/Reaction' + type: array + replies: + items: + $ref: '#/components/schemas/Reply' + type: array + state: + type: integer + text: + type: string + title: + type: string + required: + - id + - parent + - creator + - title + - text + - state + - createdAt + - replies + - reactions + title: TopicDetail + type: object description: Default Response '404': content: application/json: - examples: - NotFoundError: - value: - code: NOT_FOUND - error: Not Found - message: topic not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' - description: 小组不存在 - description: 小组不存在 + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object + description: default error response type '500': content: application/json: @@ -2396,7 +2476,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TopicCreation' + $ref: '#/components/schemas/CreateTopic' responses: '200': content: @@ -2556,7 +2636,25 @@ paths: properties: data: items: - $ref: '#/components/schemas/GroupMember' + properties: + avatar: + $ref: '#/components/schemas/Avatar' + id: + type: integer + joinedAt: + type: integer + nickname: + type: string + username: + type: string + required: + - id + - nickname + - username + - avatar + - joinedAt + title: GroupMember + type: object type: array total: type: integer @@ -2568,17 +2666,24 @@ paths: '404': content: application/json: - examples: - NotFoundError: - value: - code: NOT_FOUND - error: Not Found - message: topic not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' - description: 小组不存在 - description: 小组不存在 + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object + description: default error response type '500': content: application/json: @@ -2618,22 +2723,129 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/GroupProfile' + properties: + group: + properties: + createdAt: + type: integer + description: + type: string + icon: + type: string + id: + type: integer + name: + type: string + nsfw: + type: boolean + title: + type: string + totalMembers: + type: integer + required: + - id + - name + - nsfw + - title + - icon + - description + - totalMembers + - createdAt + title: Group + type: object + inGroup: + description: 是否已经加入小组 + type: boolean + recentAddedMembers: + items: + properties: + avatar: + $ref: '#/components/schemas/Avatar' + id: + type: integer + joinedAt: + type: integer + nickname: + type: string + username: + type: string + required: + - id + - nickname + - username + - avatar + - joinedAt + title: GroupMember + type: object + type: array + topics: + items: + properties: + createdAt: + description: 发帖时间,unix time stamp in seconds + type: integer + creator: + $ref: '#/components/schemas/SlimUser' + display: + type: integer + id: + type: integer + parentID: + description: 小组/条目ID + type: integer + repliesCount: + type: integer + state: + type: integer + title: + type: string + updatedAt: + description: 最后回复时间,unix time stamp in seconds + type: integer + required: + - id + - creator + - title + - parentID + - createdAt + - updatedAt + - repliesCount + - state + - display + title: Topic + type: object + type: array + totalTopics: + type: integer + required: + - recentAddedMembers + - topics + - inGroup + - group + - totalTopics + type: object description: Default Response '404': content: application/json: - examples: - NotFoundError: - value: - code: NOT_FOUND - error: Not Found - message: topic not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' - description: 小组不存在 - description: 小组不存在 + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object + description: default error response type '500': content: application/json: @@ -2721,7 +2933,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TopicCreation' + $ref: '#/components/schemas/CreateTopic' responses: '200': content: @@ -3747,9 +3959,9 @@ paths: - subject /p1/subjects/-/topics/{topicID}: get: - operationId: getSubjectTopicDetail + operationId: getSubjectTopic parameters: - - example: 1 + - example: 371602 in: path name: topicID required: true @@ -3761,8 +3973,65 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/TopicDetail' + properties: + createdAt: + type: integer + creator: + $ref: '#/components/schemas/SlimUser' + id: + type: integer + parent: + anyOf: + - $ref: '#/components/schemas/Group' + - $ref: '#/components/schemas/SlimSubject' + reactions: + items: + $ref: '#/components/schemas/Reaction' + type: array + replies: + items: + $ref: '#/components/schemas/Reply' + type: array + state: + type: integer + text: + type: string + title: + type: string + required: + - id + - parent + - creator + - title + - text + - state + - createdAt + - replies + - reactions + title: TopicDetail + type: object description: Default Response + '404': + content: + application/json: + schema: + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object + description: default error response type '500': content: application/json: @@ -3773,50 +4042,37 @@ paths: security: - CookiesSession: [] HTTPBearer: [] - summary: 获取帖子列表 + summary: 获取条目讨论 tags: - subject put: - operationId: editSubjectTopic + operationId: updateSubjectTopic parameters: - example: 371602 in: path name: topicID required: true schema: + minimum: 0 type: integer requestBody: content: application/json: schema: - $ref: '#/components/schemas/TopicCreation' + properties: + text: + description: bbcode + minLength: 1 + type: string + title: + minLength: 1 + type: string + required: + - title + - text + type: object + required: true responses: - '200': - content: - application/json: - schema: - properties: {} - type: object - description: Default Response - '400': - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: default error response type - '401': - content: - application/json: - examples: - NOT_ALLOWED: - value: - code: NOT_ALLOWED - error: Unauthorized - message: you don't have permission to edit a topic - statusCode: 401 - schema: - $ref: '#/components/schemas/ErrorResponse' - description: default error response type '500': content: application/json: @@ -3827,7 +4083,7 @@ paths: security: - CookiesSession: [] HTTPBearer: [] - summary: 编辑自己创建的条目讨论版 + summary: 编辑自己创建的条目讨论 tags: - subject /p1/subjects/-/topics/{topicID}/replies: @@ -3928,21 +4184,210 @@ paths: '200': content: application/json: + example: + airtime: + date: '2008-04-06' + month: 4 + weekday: 7 + year: 2008 + collection: + '1': 622 + '2': 13216 + '3': 147 + '4': 224 + '5': 115 + eps: 25 + id: 8 + images: + common: https://lain.bgm.tv/pic/cover/c/c9/f0/8_wK0z3.jpg + grid: https://lain.bgm.tv/pic/cover/g/c9/f0/8_wK0z3.jpg + large: https://lain.bgm.tv/pic/cover/l/c9/f0/8_wK0z3.jpg + medium: https://lain.bgm.tv/pic/cover/m/c9/f0/8_wK0z3.jpg + small: https://lain.bgm.tv/pic/cover/s/c9/f0/8_wK0z3.jpg + infobox: + Copyright: + - v: (C)2006 SUNRISE inc./MBS + 中文名: + - v: Code Geass 反叛的鲁路修R2 + 人物原案: + - v: CLAMP + 人物设定: + - v: 木村貴宏 + 其他: + - v: '' + 其他电视台: + - v: '' + 别名: + - v: 叛逆的鲁路修R2 + - v: 'Code Geass: Hangyaku no Lelouch R2' + - v: 叛逆的勒鲁什R2 + - v: 叛逆的鲁鲁修R2 + - v: コードギアス 反逆のルルーシュR2 + - v: 'Code Geass: Lelouch of the Rebellion R2' + - v: 叛逆的勒路什R2 + 动画制作: + - v: サンライズ + 官方网站: + - v: http://www.geass.jp/r2/ + 导演: + - v: 谷口悟朗 + 摄影监督: + - v: 大矢創太 + 播放电视台: + - v: 每日放送 + 播放结束: + - v: 2008年9月28日 + 放送开始: + - v: 2008年4月6日 + 放送星期: + - v: '' + 系列构成: + - v: 大河内一楼 + 美术监督: + - v: 菱沼由典 + 色彩设计: + - v: 岩沢れい子 + 话数: + - v: '25' + 音乐: + - v: 中川幸太郎、黒石ひとみ + 音乐制作: + - v: AUDIO PLANNING U + 音响监督: + - v: 浦上靖夫、井澤基 + locked: false + metaTags: [] + name: コードギアス 反逆のルルーシュR2 + nameCN: Code Geass 反叛的鲁路修R2 + nsfw: false + platform: + alias: tv + enableHeader: true + id: 1 + order: 0 + type: TV + typeCN: TV + wikiTpl: TVAnime + rating: + count: + - 44 + - 15 + - 32 + - 66 + - 145 + - 457 + - 1472 + - 3190 + - 2640 + - 1377 + score: 8.19 + total: 9438 + redirect: 0 + series: false + seriesEntry: 0 + summary: >- +   “东京决战”一年后,布里塔尼亚少年鲁路修在11区(原日本国)过着平凡的学生生活。但是,鲁路修与弟弟罗洛的一次出行,遇到了黑色骑士团的余党。在与少女C.C再次结成契约之后,尘封的记忆摆在了鲁路修的面前。 + type: 2 + volumes: 0 schema: - $ref: '#/components/schemas/Subject' + properties: + airtime: + $ref: '#/components/schemas/SubjectAirtime' + collection: + $ref: '#/components/schemas/SubjectCollection' + eps: + type: integer + id: + type: integer + images: + $ref: '#/components/schemas/SubjectImages' + infobox: + $ref: '#/components/schemas/Infobox' + locked: + type: boolean + metaTags: + items: + type: string + type: array + name: + type: string + nameCN: + type: string + nsfw: + type: boolean + platform: + $ref: '#/components/schemas/SubjectPlatform' + rating: + $ref: '#/components/schemas/SubjectRating' + redirect: + type: integer + series: + type: boolean + seriesEntry: + type: integer + summary: + type: string + type: + anyOf: + - enum: + - 1 + type: number + - enum: + - 2 + type: number + - enum: + - 3 + type: number + - enum: + - 4 + type: number + - enum: + - 6 + type: number + volumes: + type: integer + required: + - airtime + - collection + - eps + - id + - infobox + - metaTags + - locked + - name + - nameCN + - nsfw + - platform + - rating + - redirect + - series + - seriesEntry + - summary + - type + - volumes + title: Subject + type: object description: Default Response '404': content: application/json: - examples: - NOT_FOUND: - value: - code: NOT_FOUND - error: Not Found - message: subject not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object description: default error response type '500': content: @@ -3997,7 +4442,23 @@ paths: properties: data: items: - $ref: '#/components/schemas/SubjectCharacter' + properties: + actors: + items: + $ref: '#/components/schemas/SlimPerson' + type: array + character: + $ref: '#/components/schemas/SlimCharacter' + order: + type: integer + type: + type: integer + required: + - character + - actors + - type + - order + type: object type: array total: type: integer @@ -4009,15 +4470,23 @@ paths: '404': content: application/json: - examples: - NOT_FOUND: - value: - code: NOT_FOUND - error: Not Found - message: subject not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object description: default error response type '500': content: @@ -4114,15 +4583,23 @@ paths: '404': content: application/json: - examples: - NOT_FOUND: - value: - code: NOT_FOUND - error: Not Found - message: subject not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object description: default error response type '500': content: @@ -4198,7 +4675,67 @@ paths: properties: data: items: - $ref: '#/components/schemas/Episode' + properties: + airdate: + type: string + comment: + type: integer + desc: + type: string + disc: + type: integer + duration: + type: string + id: + type: integer + lock: + type: boolean + name: + type: string + nameCN: + type: string + sort: + type: number + subjectID: + type: integer + type: + anyOf: + - enum: + - 0 + type: number + - enum: + - 1 + type: number + - enum: + - 2 + type: number + - enum: + - 3 + type: number + - enum: + - 4 + type: number + - enum: + - 5 + type: number + - enum: + - 6 + type: number + required: + - id + - subjectID + - sort + - type + - disc + - name + - nameCN + - duration + - airdate + - comment + - desc + - lock + title: Episode + type: object type: array total: type: integer @@ -4210,15 +4747,23 @@ paths: '404': content: application/json: - examples: - NOT_FOUND: - value: - code: NOT_FOUND - error: Not Found - message: subject not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object description: default error response type '500': content: @@ -4295,7 +4840,18 @@ paths: properties: data: items: - $ref: '#/components/schemas/SubjectRelation' + properties: + order: + type: integer + relation: + $ref: '#/components/schemas/SubjectRelationType' + subject: + $ref: '#/components/schemas/SlimSubject' + required: + - subject + - relation + - order + type: object type: array total: type: integer @@ -4307,15 +4863,23 @@ paths: '404': content: application/json: - examples: - NOT_FOUND: - value: - code: NOT_FOUND - error: Not Found - message: subject not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object description: default error response type '500': content: @@ -4388,15 +4952,23 @@ paths: '404': content: application/json: - examples: - NOT_FOUND: - value: - code: NOT_FOUND - error: Not Found - message: subject not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object description: default error response type '500': content: @@ -4451,7 +5023,15 @@ paths: properties: data: items: - $ref: '#/components/schemas/SubjectStaff' + properties: + person: + $ref: '#/components/schemas/SlimPerson' + position: + $ref: '#/components/schemas/SubjectStaffPosition' + required: + - person + - position + type: object type: array total: type: integer @@ -4463,15 +5043,23 @@ paths: '404': content: application/json: - examples: - NOT_FOUND: - value: - code: NOT_FOUND - error: Not Found - message: subject not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object description: default error response type '500': content: @@ -4520,7 +5108,40 @@ paths: properties: data: items: - $ref: '#/components/schemas/Topic' + properties: + createdAt: + description: 发帖时间,unix time stamp in seconds + type: integer + creator: + $ref: '#/components/schemas/SlimUser' + display: + type: integer + id: + type: integer + parentID: + description: 小组/条目ID + type: integer + repliesCount: + type: integer + state: + type: integer + title: + type: string + updatedAt: + description: 最后回复时间,unix time stamp in seconds + type: integer + required: + - id + - creator + - title + - parentID + - createdAt + - updatedAt + - repliesCount + - state + - display + title: Topic + type: object type: array total: type: integer @@ -4532,15 +5153,23 @@ paths: '404': content: application/json: - examples: - NOT_FOUND: - value: - code: NOT_FOUND - error: Not Found - message: subject not found - statusCode: 404 schema: - $ref: '#/components/schemas/ErrorResponse' + description: default error response type + properties: + code: + type: string + error: + type: string + message: + type: string + statusCode: + type: integer + required: + - code + - error + - message + - statusCode + type: object description: default error response type '500': content: @@ -4568,8 +5197,36 @@ paths: requestBody: content: application/json: + example: + cf-turnstile-response: 10000000-aaaa-bbbb-cccc-000000000001 + content: topic content + title: topic title schema: - $ref: '#/components/schemas/TopicCreation' + properties: + cf-turnstile-response: + description: >- + 需要 + [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) + + + next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` + + + dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\` + type: string + text: + description: bbcode + minLength: 1 + type: string + title: + minLength: 1 + type: string + required: + - title + - text + - cf-turnstile-response + type: object + required: true responses: '200': content: @@ -5948,20 +6605,7 @@ paths: creator: properties: avatar: - properties: - large: - type: string - medium: - example: sai - type: string - small: - type: string - required: - - small - - medium - - large - title: Avatar - type: object + $ref: '#/components/schemas/Avatar' id: example: 1 type: integer diff --git a/routes/private/routes/__snapshots__/subject.test.ts.snap b/routes/private/routes/__snapshots__/subject.test.ts.snap index 1926175a..1ca41893 100644 --- a/routes/private/routes/__snapshots__/subject.test.ts.snap +++ b/routes/private/routes/__snapshots__/subject.test.ts.snap @@ -423,9 +423,11 @@ Object { "sign": "sing 142527", "username": "142527", }, + "display": 1, "id": 6873, "parentID": 12, "repliesCount": 8, + "state": 0, "title": "这条目简介也太剧透了吧", "updatedAt": 1481098545, }, @@ -433,3 +435,12 @@ Object { "total": 1, } `; + +exports[`subject > should not edited topic by non-owner 1`] = ` +Object { + "code": "NOT_ALLOWED", + "error": "Unauthorized", + "message": "you don't have permission to update topic", + "statusCode": 401, +} +`; diff --git a/routes/private/routes/subject.test.ts b/routes/private/routes/subject.test.ts index 2663e5ee..18b01801 100644 --- a/routes/private/routes/subject.test.ts +++ b/routes/private/routes/subject.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'vitest'; +import type { IAuth } from '@app/lib/auth/index.ts'; +import { emptyAuth, UserGroup } from '@app/lib/auth/index.ts'; import { createTestServer } from '@app/tests/utils.ts'; +import * as res from '@app/lib/types/res.ts'; import { setup } from './subject.ts'; @@ -92,11 +95,81 @@ describe('subject', () => { expect(res.json()).toMatchSnapshot(); }); - // test('should fetch topic details', async () => { - // const app = createTestServer(); - // await app.register(setup); - // const res = await app.inject({ url: '/subjects/-/topics/3', method: 'get' }); - // expect(res.statusCode).toBe(200); - // expect(res.json()).toMatchSnapshot(); - // }); + test('should create and edit topic', async () => { + const app = createTestServer({ + auth: { + ...emptyAuth(), + login: true, + userID: 2, + }, + }); + await app.register(setup); + + const title = 'new topic title'; + const text = 'new contents'; + + const res = await app.inject({ + url: '/subjects/497/topics', + method: 'post', + payload: { + title: title, + text: text, + 'cf-turnstile-response': 'fake-response', + }, + }); + expect(res.statusCode).toBe(200); + const result = res.json() as { id: number }; + expect(result.id).toBeDefined(); + const res2 = await app.inject({ + url: `/subjects/-/topics/${result.id}`, + method: 'get', + }); + expect(res2.statusCode).toBe(200); + const result2 = res2.json() as res.ITopicDetail; + expect(result2.title).toBe(title); + expect(result2.text).toBe(text); + + const title2 = 'new topic title 2'; + const text2 = 'new contents 2'; + + const res3 = await app.inject({ + url: `/subjects/-/topics/${result.id}`, + method: 'put', + payload: { + title: title2, + text: text2, + }, + }); + expect(res3.statusCode).toBe(200); + + const res4 = await app.inject({ + url: `/subjects/-/topics/${result.id}`, + method: 'get', + }); + expect(res4.statusCode).toBe(200); + const result4 = res4.json() as res.ITopicDetail; + expect(result4.title).toBe(title2); + expect(result4.text).toBe(text2); + }); + + test('should not edited topic by non-owner', async () => { + const app = createTestServer({ + auth: { + ...emptyAuth(), + login: true, + userID: 1, + }, + }); + app.register(setup); + const res = await app.inject({ + url: '/subjects/-/topics/6873', + method: 'put', + payload: { + title: 'new topic title', + text: 'new contents', + }, + }); + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchSnapshot(); + }); }); diff --git a/routes/private/routes/subject.ts b/routes/private/routes/subject.ts index 52df17f9..623fed53 100644 --- a/routes/private/routes/subject.ts +++ b/routes/private/routes/subject.ts @@ -6,16 +6,20 @@ import * as schema from '@app/drizzle/schema'; import { NotAllowedError } from '@app/lib/auth/index.ts'; import { Dam, dam } from '@app/lib/dam.ts'; import { BadRequestError, CaptchaError, NotFoundError } from '@app/lib/error.ts'; +import { fetchTopicReactions } from '@app/lib/like.ts'; import { Security, Tag } from '@app/lib/openapi/index.ts'; import { turnstile } from '@app/lib/services/turnstile.ts'; import { CollectionType, EpisodeType, SubjectType } from '@app/lib/subject/type.ts'; -import { ListTopicDisplays } from '@app/lib/topic/display.ts'; +import { + CanViewTopicContent, + CanViewTopicReply, + ListTopicDisplays, +} from '@app/lib/topic/display.ts'; import { CommentState, TopicDisplay } from '@app/lib/topic/type.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 { formatErrors } from '@app/lib/types/res.ts'; import { LimitAction } from '@app/lib/utils/rate-limit'; import { requireLogin } from '@app/routes/hooks/pre-handler.ts'; import { rateLimit } from '@app/routes/hooks/rate-limit'; @@ -66,10 +70,8 @@ export async function setup(app: App) { subjectID: t.Integer(), }), response: { - 200: t.Ref(res.Subject), - 404: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotFoundError('subject')), - }), + 200: res.Subject, + 404: res.Error, }, }, }, @@ -115,10 +117,8 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(t.Ref(res.Episode)), - 404: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotFoundError('subject')), - }), + 200: res.Paged(res.Episode), + 404: res.Error, }, }, }, @@ -172,10 +172,8 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(t.Ref(res.SubjectRelation)), - 404: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotFoundError('subject')), - }), + 200: res.Paged(res.SubjectRelation), + 404: res.Error, }, }, }, @@ -247,10 +245,8 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(t.Ref(res.SubjectCharacter)), - 404: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotFoundError('subject')), - }), + 200: res.Paged(res.SubjectCharacter), + 404: res.Error, }, }, }, @@ -328,10 +324,8 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(t.Ref(res.SubjectStaff)), - 404: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotFoundError('subject')), - }), + 200: res.Paged(res.SubjectStaff), + 404: res.Error, }, }, }, @@ -394,9 +388,7 @@ export async function setup(app: App) { }), response: { 200: res.Paged(res.SubjectComment), - 404: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotFoundError('subject')), - }), + 404: res.Error, }, }, }, @@ -455,9 +447,7 @@ export async function setup(app: App) { }), response: { 200: res.Paged(res.SubjectReview), - 404: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotFoundError('subject')), - }), + 404: res.Error, }, }, }, @@ -521,10 +511,8 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(t.Ref(res.Topic)), - 404: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotFoundError('subject')), - }), + 200: res.Paged(res.Topic), + 404: res.Error, }, }, }, @@ -577,7 +565,7 @@ export async function setup(app: App) { id: t.Integer({ description: 'new topic id' }), }), }, - body: t.Ref(req.TopicCreation), + body: req.CreateTopic, }, preHandler: [requireLogin('creating a topic')], }, @@ -586,7 +574,7 @@ export async function setup(app: App) { body: { text, title, 'cf-turnstile-response': cfCaptchaResponse }, params: { subjectID }, }) => { - if (!(await turnstile.verify(cfCaptchaResponse))) { + if (!(await turnstile.verify(cfCaptchaResponse ?? ''))) { throw new CaptchaError(); } if (!Dam.allCharacterPrintable(text)) { @@ -628,7 +616,6 @@ export async function setup(app: App) { mid: 0, related: 0, }; - await db.transaction(async (t) => { const [result] = await t.insert(schema.chiiSubjectTopics).values(topic).execute(); post.mid = result.insertId; @@ -638,4 +625,136 @@ export async function setup(app: App) { return { id: post.mid }; }, ); + + app.get( + '/subjects/-/topics/:topicID', + { + schema: { + summary: '获取条目讨论', + operationId: 'getSubjectTopic', + tags: [Tag.Subject], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + topicID: t.Integer({ examples: [371602], minimum: 0 }), + }), + response: { + 200: res.TopicDetail, + 404: res.Error, + }, + }, + }, + async ({ auth, params: { topicID } }) => { + const topic = await fetcher.fetchSubjectTopicByID(topicID); + if (!topic) { + throw new NotFoundError(`topic ${topicID}`); + } + const subject = await fetcher.fetchSlimSubjectByID(topic.parentID, auth.allowNsfw); + if (!subject) { + throw new NotFoundError(`subject ${topic.parentID}`); + } + if (!CanViewTopicContent(auth, topic.state, topic.display, topic.creator.id)) { + throw new NotAllowedError('view topic'); + } + + const replies = await fetcher.fetchSubjectTopicRepliesByTopicID(topicID); + const top = replies.shift(); + if (!top) { + throw new NotFoundError(`topic ${topicID}`); + } + const friends = await fetcher.fetchFriendsByUserID(auth.userID); + const friendIDs = new Set(friends.map((f) => f.user.id)); + const reactions = await fetchTopicReactions(auth.userID, auth.userID); + + for (const reply of replies) { + if (!CanViewTopicReply(reply.state)) { + reply.text = ''; + } + if (reply.creator.id in friendIDs) { + reply.isFriend = true; + } + reply.reactions = reactions[reply.creator.id] ?? []; + for (const subReply of reply.replies) { + if (!CanViewTopicReply(subReply.state)) { + subReply.text = ''; + } + if (subReply.creator.id in friendIDs) { + subReply.isFriend = true; + } + subReply.reactions = reactions[subReply.creator.id] ?? []; + } + } + return { + ...topic, + parent: subject, + text: top.text, + replies, + reactions: reactions[top.id] ?? [], + }; + }, + ); + + app.put( + '/subjects/-/topics/:topicID', + { + schema: { + summary: '编辑自己创建的条目讨论', + operationId: 'updateSubjectTopic', + tags: [Tag.Subject], + security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], + params: t.Object({ + topicID: t.Integer({ examples: [371602], minimum: 0 }), + }), + body: req.UpdateTopic, + }, + preHandler: [requireLogin('updating a topic')], + }, + async ({ auth, body: { text, title }, params: { topicID } }) => { + if (auth.permission.ban_post) { + throw new NotAllowedError('create reply'); + } + if (!Dam.allCharacterPrintable(text)) { + throw new BadRequestError('text contains invalid invisible character'); + } + + const topic = await fetcher.fetchSubjectTopicByID(topicID); + if (!topic) { + throw new NotFoundError(`topic ${topicID}`); + } + + if ( + ![CommentState.AdminReopen, CommentState.AdminPin, CommentState.Normal].includes( + topic.state, + ) + ) { + throw new NotAllowedError('edit this topic'); + } + if (topic.creator.id !== auth.userID) { + throw new NotAllowedError('update topic'); + } + + let display = topic.display; + if (dam.needReview(title) || dam.needReview(text)) { + if (display === TopicDisplay.Normal) { + display = TopicDisplay.Review; + } else { + return {}; + } + } + + await db.transaction(async (t) => { + await t + .update(schema.chiiSubjectTopics) + .set({ title, display }) + .where(op.eq(schema.chiiSubjectTopics.id, topicID)) + .execute(); + await t + .update(schema.chiiSubjectPosts) + .set({ content: text }) + .where(op.eq(schema.chiiSubjectPosts.mid, topicID)) + .execute(); + }); + + return {}; + }, + ); } diff --git a/routes/private/routes/topic.test.ts b/routes/private/routes/topic.test.ts index c65f8cdf..815561da 100644 --- a/routes/private/routes/topic.test.ts +++ b/routes/private/routes/topic.test.ts @@ -29,33 +29,6 @@ const expectedGroupTopic = { title: 'tes', }; -const expectedSubjectTopic = { - id: 1, - creator: { - id: 2, - joinedAt: 0, - username: '2', - sign: 'sing 2', - nickname: 'nickname 2', - avatar: { - small: 'https://lain.bgm.tv/pic/user/s/icon.jpg', - medium: 'https://lain.bgm.tv/pic/user/m/icon.jpg', - large: 'https://lain.bgm.tv/pic/user/l/icon.jpg', - }, - }, - title: '拿这个来测试', - parentID: 1, - createdAt: 1216020847, -}; - -beforeEach(async () => { - await orm.SubjectTopicRepo.update({ id: 3 }, { title: 'new topic title 2' }); - const topicPost = await orm.SubjectPostRepo.findOneBy({ topicID: 3 }); - if (topicPost) { - await orm.SubjectPostRepo.update({ id: topicPost.id }, { content: 'new contents 2' }); - } -}); - describe('group topics', () => { test('should failed on not found group', async () => { const app = await createTestServer(); @@ -265,87 +238,3 @@ describe('edit group topic', () => { expect(res.statusCode).toBe(401); }); }); - -describe('edit subjec topic', () => { - test('should edit topic', async () => { - const app = createTestServer({ - auth: { - ...emptyAuth(), - login: true, - userID: 2, - }, - }); - - await app.register(setup); - - { - const res = await app.inject({ - url: '/subjects/-/topics/3', - method: 'put', - payload: { - title: 'new topic title', - text: 'new contents', - 'cf-turnstile-response': 'fake-response', - }, - }); - - expect(res.statusCode).toBe(200); - - const topic = await fetchTopicDetail(emptyAuth(), TopicParentType.Subject, 3); - - expect(topic?.title).toBe('new topic title'); - expect(topic?.text).toBe('new contents'); - } - - { - const res = await app.inject({ - url: '/subjects/-/topics/3', - method: 'put', - payload: { - title: 'new topic title 2', - text: 'new contents 2', - 'cf-turnstile-response': 'fake-response', - }, - }); - - expect(res.statusCode).toBe(200); - - const topic = await fetchTopicDetail(emptyAuth(), TopicParentType.Subject, 3); - - expect(topic?.title).toBe('new topic title 2'); - expect(topic?.text).toBe('new contents 2'); - } - }); - - test('should not edited topic by non-owner', async () => { - const app = createTestServer({ - auth: { - ...emptyAuth(), - login: true, - userID: 1, - }, - }); - - await app.register(setup); - - const res = await app.inject({ - url: '/subjects/-/topics/3', - method: 'put', - payload: { - title: 'new topic title', - text: 'new contents', - 'cf-turnstile-response': 'fake-response', - }, - }); - - expect(res.json()).toMatchInlineSnapshot(` - Object { - "code": "NOT_ALLOWED", - "error": "Unauthorized", - "message": "you don't have permission to edit this topic", - "statusCode": 401, - } - `); - expect(res.statusCode).toBe(401); - }); -}); diff --git a/routes/private/routes/topic.ts b/routes/private/routes/topic.ts index 0a8023f8..f9965022 100644 --- a/routes/private/routes/topic.ts +++ b/routes/private/routes/topic.ts @@ -10,7 +10,7 @@ import { Security, Tag } from '@app/lib/openapi/index.ts'; import type { Page } from '@app/lib/orm/index.ts'; import * as orm from '@app/lib/orm/index.ts'; import { GroupMemberRepo, isMemberInGroup } from '@app/lib/orm/index.ts'; -import { avatar, groupIcon } from '@app/lib/response.ts'; +import { groupIcon } from '@app/lib/response.ts'; import type { ITopic } from '@app/lib/topic/index.ts'; import * as Topic from '@app/lib/topic/index.ts'; import { NotJoinPrivateGroupError } from '@app/lib/topic/index.ts'; @@ -23,98 +23,19 @@ import { formatErrors } from '@app/lib/types/res.ts'; import { requireLogin } from '@app/routes/hooks/pre-handler.ts'; import type { App } from '@app/routes/type.ts'; -const Group = t.Object( +const GroupProfile = t.Object( { - id: t.Integer(), - name: t.String(), - nsfw: t.Boolean(), - title: t.String(), - icon: t.String(), - description: t.String(), - totalMembers: t.Integer(), - createdAt: t.Integer(), + recentAddedMembers: t.Array(res.GroupMember), + topics: t.Array(res.Topic), + inGroup: t.Boolean({ description: '是否已经加入小组' }), + group: res.Group, + totalTopics: t.Integer(), }, - { $id: 'Group' }, -); - -type IGroupMember = Static; -const GroupMember = t.Object( - { - avatar: res.Avatar, - id: t.Integer(), - nickname: t.String(), - username: t.String(), - joinedAt: t.Integer(), - }, - { $id: 'GroupMember' }, -); - -const Reaction = t.Object( - { - selected: t.Boolean(), - total: t.Integer(), - value: t.Integer(), - }, - { $id: 'Reaction' }, -); - -const SubReply = t.Object( - { - id: t.Integer(), - creator: t.Ref(res.SlimUser), - createdAt: t.Integer(), - isFriend: t.Boolean(), - text: t.String(), - state: t.Integer(), - reactions: t.Array(t.Ref(Reaction)), - }, - { $id: 'SubReply' }, -); - -const Reply = t.Object( - { - id: t.Integer(), - isFriend: t.Boolean(), - replies: t.Array(t.Ref(SubReply)), - creator: t.Ref(res.SlimUser), - createdAt: t.Integer(), - text: t.String(), - state: t.Integer(), - reactions: t.Array(t.Ref(Reaction)), - }, - { $id: 'Reply' }, -); - -const TopicDetail = t.Object( - { - id: t.Integer(), - parent: t.Union([t.Ref(Group), t.Ref(res.Subject)]), - creator: t.Ref(res.SlimUser), - title: t.String(), - text: t.String(), - state: t.Integer(), - createdAt: t.Integer(), - replies: t.Array(t.Ref(Reply)), - reactions: t.Array(t.Ref(Reaction)), - }, - { $id: 'TopicDetail' }, + { $id: 'GroupProfile' }, ); // eslint-disable-next-line @typescript-eslint/require-await export async function setup(app: App) { - app.addSchema(Group); - - const GroupProfile = t.Object( - { - recentAddedMembers: t.Array(t.Ref(GroupMember)), - topics: t.Array(t.Ref(res.Topic)), - inGroup: t.Boolean({ description: '是否已经加入小组' }), - group: t.Ref(Group), - totalTopics: t.Integer(), - }, - { $id: 'GroupProfile' }, - ); - app.addSchema(GroupProfile); app.get( @@ -132,13 +53,8 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0 })), }), response: { - 200: t.Ref(GroupProfile), - 404: t.Ref(res.Error, { - description: '小组不存在', - 'x-examples': { - NotFoundError: { value: res.formatError(new NotFoundError('topic')) }, - }, - }), + 200: GroupProfile, + 404: res.Error, }, }, }, @@ -168,32 +84,6 @@ export async function setup(app: App) { }, ); - app.addSchema(SubReply); - app.addSchema(Reply); - app.addSchema(Reaction); - app.addSchema(TopicDetail); - - app.get( - '/subjects/-/topics/:topicID', - { - schema: { - tags: [Tag.Subject], - operationId: 'getSubjectTopicDetail', - summary: '获取帖子列表', - params: t.Object({ - topicID: t.Integer({ examples: [1], minimum: 0 }), - }), - security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], - response: { - 200: t.Ref(TopicDetail), - }, - }, - }, - async ({ auth, params: { topicID } }) => { - return await handleTopicDetail(auth, TopicParentType.Subject, topicID); - }, - ); - app.get( '/groups/-/topics/:id', { @@ -205,13 +95,8 @@ export async function setup(app: App) { id: t.Integer({ examples: [371602] }), }), response: { - 200: t.Ref(TopicDetail), - 404: t.Ref(res.Error, { - description: '小组不存在', - 'x-examples': { - NotFoundError: { value: res.formatError(new NotFoundError('topic')) }, - }, - }), + 200: res.TopicDetail, + 404: res.Error, }, }, }, @@ -220,8 +105,6 @@ export async function setup(app: App) { }, ); - app.addSchema(GroupMember); - app.get( '/groups/:groupName/members', { @@ -247,13 +130,8 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0 })), }), response: { - 200: res.Paged(t.Ref(GroupMember)), - 404: t.Ref(res.Error, { - description: '小组不存在', - 'x-examples': { - NotFoundError: { value: res.formatError(new NotFoundError('topic')) }, - }, - }), + 200: res.Paged(res.GroupMember), + 404: res.Error, }, }, }, @@ -332,7 +210,7 @@ export async function setup(app: App) { }), }, security: [{ [Security.CookiesSession]: [] }], - body: t.Ref(req.TopicCreation), + body: t.Ref(req.CreateTopic), }, preHandler: [requireLogin('creating a post')], }, @@ -385,7 +263,7 @@ export async function setup(app: App) { }), }, security: [{ [Security.CookiesSession]: [] }], - body: t.Ref(req.TopicCreation), + body: t.Ref(req.CreateTopic), }, preHandler: [requireLogin('edit a topic')], }, @@ -445,85 +323,6 @@ export async function setup(app: App) { return {}; }, ); - - app.put( - '/subjects/-/topics/:topicID', - { - schema: { - summary: '编辑自己创建的条目讨论版', - operationId: 'editSubjectTopic', - params: t.Object({ - topicID: t.Integer({ examples: [371602] }), - }), - tags: [Tag.Subject], - response: { - 200: t.Object({}), - 400: t.Ref(res.Error), - 401: t.Ref(res.Error, { - 'x-examples': formatErrors(new NotAllowedError('edit a topic')), - }), - }, - security: [{ [Security.CookiesSession]: [], [Security.HTTPBearer]: [] }], - body: t.Ref(req.TopicCreation), - }, - preHandler: [requireLogin('edit a topic')], - }, - /** - * @param auth - - * @param title - 帖子标题 - * @param text - 帖子内容 - * @param topicID - 帖子 ID - */ - async function ({ - auth, - body: { title, text }, - params: { topicID }, - }): Promise> { - if (auth.permission.ban_post) { - throw new NotAllowedError('create reply'); - } - - if (!(Dam.allCharacterPrintable(title) && Dam.allCharacterPrintable(text))) { - throw new BadRequestError('text contains invalid invisible character'); - } - - const topic = await Topic.fetchTopicDetail(auth, TopicParentType.Subject, topicID); - if (!topic) { - throw new NotFoundError(`topic ${topicID}`); - } - - if ( - ![CommentState.AdminReopen, CommentState.AdminPin, CommentState.Normal].includes( - topic.state, - ) - ) { - throw new NotAllowedError('edit this topic'); - } - - if (topic.creatorID !== auth.userID) { - throw new NotAllowedError('edit this topic'); - } - - let display = topic.display; - if (dam.needReview(title) || dam.needReview(text)) { - if (display === TopicDisplay.Normal) { - display = TopicDisplay.Review; - } else { - return {}; - } - } - - await orm.SubjectTopicRepo.update({ id: topicID }, { title, display }); - - const topicPost = await orm.SubjectPostRepo.findOneBy({ topicID }); - - if (topicPost) { - await orm.SubjectPostRepo.update({ id: topicPost.id }, { content: text }); - } - - return {}; - }, - ); } async function addCreators( @@ -545,7 +344,7 @@ async function addCreators( async function fetchGroupMemberList( groupID: number, { limit = 30, offset = 0, type }: Page & { type: 'mod' | 'normal' | 'all' }, -): Promise<[number, IGroupMember[]]> { +): Promise<[number, res.IGroupMember[]]> { const where = { gmbGid: groupID, gmbModerator: type === 'all' ? undefined : type === 'mod', @@ -565,24 +364,21 @@ async function fetchGroupMemberList( return [ total, - members.map(function (x): IGroupMember { + members.map(function (x): res.IGroupMember { const user = users[x.gmbUid]; if (!user) { throw new UnexpectedNotFoundError(`user ${x.gmbUid}`); } return { - avatar: avatar(user.img), - id: user.id, + ...convert.oldToUser(user), joinedAt: x.gmbDateline, - nickname: user.nickname, - username: user.username, }; }), ]; } -async function fetchRecentMember(groupID: number): Promise { +async function fetchRecentMember(groupID: number): Promise { const [_, members] = await fetchGroupMemberList(groupID, { limit: 6, type: 'all' }); return members; @@ -592,7 +388,7 @@ export async function handleTopicDetail( auth: IAuth, type: TopicParentType, id: number, -): Promise> { +): Promise> { const topic = await Topic.fetchTopicDetail(auth, type, id); if (!topic) { throw new NotFoundError(`topic ${id}`); diff --git a/routes/res.ts b/routes/res.ts index b3aeb6e8..9764acb5 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -3,6 +3,7 @@ import * as res from '@app/lib/types/res.ts'; import type { App } from '@app/routes/type.ts'; export function addSchemas(app: App) { + app.addSchema(res.Avatar); app.addSchema(res.BlogEntry); app.addSchema(res.Character); app.addSchema(res.CharacterRelation); @@ -39,6 +40,13 @@ export function addSchemas(app: App) { app.addSchema(res.SubjectStaffPosition); app.addSchema(res.User); app.addSchema(res.Topic); + app.addSchema(res.SubReply); + app.addSchema(res.Group); + app.addSchema(res.GroupMember); + app.addSchema(res.Reply); + app.addSchema(res.Reaction); + app.addSchema(res.TopicDetail); - app.addSchema(req.TopicCreation); + app.addSchema(req.CreateTopic); + app.addSchema(req.UpdateTopic); } From f2f8dfbadabd566992cf7261f2c07345831ee368 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sat, 23 Nov 2024 13:14:00 +0800 Subject: [PATCH 10/20] z --- lib/types/req.ts | 2 - lib/types/res.ts | 2 +- routes/__snapshots__/index.test.ts.snap | 894 +----------------------- routes/private/routes/subject.ts | 27 +- routes/private/routes/topic.ts | 13 +- routes/res.ts | 2 + 6 files changed, 46 insertions(+), 894 deletions(-) diff --git a/lib/types/req.ts b/lib/types/req.ts index f8d089b9..f7588144 100644 --- a/lib/types/req.ts +++ b/lib/types/req.ts @@ -4,9 +4,7 @@ import { Type as t } from '@sinclair/typebox'; import * as examples from '@app/lib/types/examples.ts'; const turnstileDescription = `需要 [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) - next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` - dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\``; export type ICreateTopic = Static; diff --git a/lib/types/res.ts b/lib/types/res.ts index 6cc95076..58eb2efa 100644 --- a/lib/types/res.ts +++ b/lib/types/res.ts @@ -119,7 +119,7 @@ export const InfoboxValue = t.Object( ); export type IInfobox = Static; -export const Infobox = t.Record(t.String(), t.Array(InfoboxValue), { +export const Infobox = t.Record(t.String(), t.Array(t.Ref(InfoboxValue)), { $id: 'Infobox', title: 'Infobox', }); diff --git a/routes/__snapshots__/index.test.ts.snap b/routes/__snapshots__/index.test.ts.snap index 447362b8..27fbb6ec 100644 --- a/routes/__snapshots__/index.test.ts.snap +++ b/routes/__snapshots__/index.test.ts.snap @@ -204,10 +204,8 @@ exports[`should build private api spec 1`] = ` 需要 [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) - next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` - dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\` type: string text: @@ -505,34 +503,7 @@ exports[`should build private api spec 1`] = ` GroupProfile: properties: group: - properties: - createdAt: - type: integer - description: - type: string - icon: - type: string - id: - type: integer - name: - type: string - nsfw: - type: boolean - title: - type: string - totalMembers: - type: integer - required: - - id - - name - - nsfw - - title - - icon - - description - - totalMembers - - createdAt - title: Group - type: object + $ref: '#/components/schemas/Group' inGroup: description: 是否已经加入小组 type: boolean @@ -560,40 +531,7 @@ exports[`should build private api spec 1`] = ` type: array topics: items: - properties: - createdAt: - description: 发帖时间,unix time stamp in seconds - type: integer - creator: - $ref: '#/components/schemas/SlimUser' - display: - type: integer - id: - type: integer - parentID: - description: 小组/条目ID - type: integer - repliesCount: - type: integer - state: - type: integer - title: - type: string - updatedAt: - description: 最后回复时间,unix time stamp in seconds - type: integer - required: - - id - - creator - - title - - parentID - - createdAt - - updatedAt - - repliesCount - - state - - display - title: Topic - type: object + $ref: '#/components/schemas/Topic' type: array totalTopics: type: integer @@ -690,15 +628,7 @@ exports[`should build private api spec 1`] = ` Infobox: additionalProperties: items: - properties: - k: - type: string - v: - type: string - required: - - v - title: InfoboxValue - type: object + $ref: '#/components/schemas/InfoboxValue' type: array title: Infobox type: object @@ -1655,6 +1585,20 @@ exports[`should build private api spec 1`] = ` - jp - desc type: object + SubjectReview: + properties: + entry: + $ref: '#/components/schemas/SlimBlogEntry' + id: + type: integer + user: + $ref: '#/components/schemas/SlimUser' + required: + - id + - user + - entry + title: SubjectReview + type: object SubjectStaff: properties: person: @@ -2394,65 +2338,8 @@ paths: content: application/json: schema: - properties: - createdAt: - type: integer - creator: - $ref: '#/components/schemas/SlimUser' - id: - type: integer - parent: - anyOf: - - $ref: '#/components/schemas/Group' - - $ref: '#/components/schemas/SlimSubject' - reactions: - items: - $ref: '#/components/schemas/Reaction' - type: array - replies: - items: - $ref: '#/components/schemas/Reply' - type: array - state: - type: integer - text: - type: string - title: - type: string - required: - - id - - parent - - creator - - title - - text - - state - - createdAt - - replies - - reactions - title: TopicDetail - type: object + $ref: '#/components/schemas/TopicDetail' description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -2636,25 +2523,7 @@ paths: properties: data: items: - properties: - avatar: - $ref: '#/components/schemas/Avatar' - id: - type: integer - joinedAt: - type: integer - nickname: - type: string - username: - type: string - required: - - id - - nickname - - username - - avatar - - joinedAt - title: GroupMember - type: object + $ref: '#/components/schemas/GroupMember' type: array total: type: integer @@ -2663,27 +2532,6 @@ paths: - total type: object description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -2723,129 +2571,8 @@ paths: content: application/json: schema: - properties: - group: - properties: - createdAt: - type: integer - description: - type: string - icon: - type: string - id: - type: integer - name: - type: string - nsfw: - type: boolean - title: - type: string - totalMembers: - type: integer - required: - - id - - name - - nsfw - - title - - icon - - description - - totalMembers - - createdAt - title: Group - type: object - inGroup: - description: 是否已经加入小组 - type: boolean - recentAddedMembers: - items: - properties: - avatar: - $ref: '#/components/schemas/Avatar' - id: - type: integer - joinedAt: - type: integer - nickname: - type: string - username: - type: string - required: - - id - - nickname - - username - - avatar - - joinedAt - title: GroupMember - type: object - type: array - topics: - items: - properties: - createdAt: - description: 发帖时间,unix time stamp in seconds - type: integer - creator: - $ref: '#/components/schemas/SlimUser' - display: - type: integer - id: - type: integer - parentID: - description: 小组/条目ID - type: integer - repliesCount: - type: integer - state: - type: integer - title: - type: string - updatedAt: - description: 最后回复时间,unix time stamp in seconds - type: integer - required: - - id - - creator - - title - - parentID - - createdAt - - updatedAt - - repliesCount - - state - - display - title: Topic - type: object - type: array - totalTopics: - type: integer - required: - - recentAddedMembers - - topics - - inGroup - - group - - totalTopics - type: object + $ref: '#/components/schemas/GroupProfile' description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -3973,65 +3700,8 @@ paths: content: application/json: schema: - properties: - createdAt: - type: integer - creator: - $ref: '#/components/schemas/SlimUser' - id: - type: integer - parent: - anyOf: - - $ref: '#/components/schemas/Group' - - $ref: '#/components/schemas/SlimSubject' - reactions: - items: - $ref: '#/components/schemas/Reaction' - type: array - replies: - items: - $ref: '#/components/schemas/Reply' - type: array - state: - type: integer - text: - type: string - title: - type: string - required: - - id - - parent - - creator - - title - - text - - state - - createdAt - - replies - - reactions - title: TopicDetail - type: object + $ref: '#/components/schemas/TopicDetail' description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -4184,211 +3854,9 @@ paths: '200': content: application/json: - example: - airtime: - date: '2008-04-06' - month: 4 - weekday: 7 - year: 2008 - collection: - '1': 622 - '2': 13216 - '3': 147 - '4': 224 - '5': 115 - eps: 25 - id: 8 - images: - common: https://lain.bgm.tv/pic/cover/c/c9/f0/8_wK0z3.jpg - grid: https://lain.bgm.tv/pic/cover/g/c9/f0/8_wK0z3.jpg - large: https://lain.bgm.tv/pic/cover/l/c9/f0/8_wK0z3.jpg - medium: https://lain.bgm.tv/pic/cover/m/c9/f0/8_wK0z3.jpg - small: https://lain.bgm.tv/pic/cover/s/c9/f0/8_wK0z3.jpg - infobox: - Copyright: - - v: (C)2006 SUNRISE inc./MBS - 中文名: - - v: Code Geass 反叛的鲁路修R2 - 人物原案: - - v: CLAMP - 人物设定: - - v: 木村貴宏 - 其他: - - v: '' - 其他电视台: - - v: '' - 别名: - - v: 叛逆的鲁路修R2 - - v: 'Code Geass: Hangyaku no Lelouch R2' - - v: 叛逆的勒鲁什R2 - - v: 叛逆的鲁鲁修R2 - - v: コードギアス 反逆のルルーシュR2 - - v: 'Code Geass: Lelouch of the Rebellion R2' - - v: 叛逆的勒路什R2 - 动画制作: - - v: サンライズ - 官方网站: - - v: http://www.geass.jp/r2/ - 导演: - - v: 谷口悟朗 - 摄影监督: - - v: 大矢創太 - 播放电视台: - - v: 每日放送 - 播放结束: - - v: 2008年9月28日 - 放送开始: - - v: 2008年4月6日 - 放送星期: - - v: '' - 系列构成: - - v: 大河内一楼 - 美术监督: - - v: 菱沼由典 - 色彩设计: - - v: 岩沢れい子 - 话数: - - v: '25' - 音乐: - - v: 中川幸太郎、黒石ひとみ - 音乐制作: - - v: AUDIO PLANNING U - 音响监督: - - v: 浦上靖夫、井澤基 - locked: false - metaTags: [] - name: コードギアス 反逆のルルーシュR2 - nameCN: Code Geass 反叛的鲁路修R2 - nsfw: false - platform: - alias: tv - enableHeader: true - id: 1 - order: 0 - type: TV - typeCN: TV - wikiTpl: TVAnime - rating: - count: - - 44 - - 15 - - 32 - - 66 - - 145 - - 457 - - 1472 - - 3190 - - 2640 - - 1377 - score: 8.19 - total: 9438 - redirect: 0 - series: false - seriesEntry: 0 - summary: >- -   “东京决战”一年后,布里塔尼亚少年鲁路修在11区(原日本国)过着平凡的学生生活。但是,鲁路修与弟弟罗洛的一次出行,遇到了黑色骑士团的余党。在与少女C.C再次结成契约之后,尘封的记忆摆在了鲁路修的面前。 - type: 2 - volumes: 0 schema: - properties: - airtime: - $ref: '#/components/schemas/SubjectAirtime' - collection: - $ref: '#/components/schemas/SubjectCollection' - eps: - type: integer - id: - type: integer - images: - $ref: '#/components/schemas/SubjectImages' - infobox: - $ref: '#/components/schemas/Infobox' - locked: - type: boolean - metaTags: - items: - type: string - type: array - name: - type: string - nameCN: - type: string - nsfw: - type: boolean - platform: - $ref: '#/components/schemas/SubjectPlatform' - rating: - $ref: '#/components/schemas/SubjectRating' - redirect: - type: integer - series: - type: boolean - seriesEntry: - type: integer - summary: - type: string - type: - anyOf: - - enum: - - 1 - type: number - - enum: - - 2 - type: number - - enum: - - 3 - type: number - - enum: - - 4 - type: number - - enum: - - 6 - type: number - volumes: - type: integer - required: - - airtime - - collection - - eps - - id - - infobox - - metaTags - - locked - - name - - nameCN - - nsfw - - platform - - rating - - redirect - - series - - seriesEntry - - summary - - type - - volumes - title: Subject - type: object + $ref: '#/components/schemas/Subject' description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -4442,23 +3910,7 @@ paths: properties: data: items: - properties: - actors: - items: - $ref: '#/components/schemas/SlimPerson' - type: array - character: - $ref: '#/components/schemas/SlimCharacter' - order: - type: integer - type: - type: integer - required: - - character - - actors - - type - - order - type: object + $ref: '#/components/schemas/SubjectCharacter' type: array total: type: integer @@ -4467,27 +3919,6 @@ paths: - total type: object description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -4556,22 +3987,7 @@ paths: properties: data: items: - properties: - comment: - type: string - rate: - type: integer - updatedAt: - type: integer - user: - $ref: '#/components/schemas/SlimUser' - required: - - user - - rate - - comment - - updatedAt - title: SubjectComment - type: object + $ref: '#/components/schemas/SubjectComment' type: array total: type: integer @@ -4580,27 +3996,6 @@ paths: - total type: object description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -4675,67 +4070,7 @@ paths: properties: data: items: - properties: - airdate: - type: string - comment: - type: integer - desc: - type: string - disc: - type: integer - duration: - type: string - id: - type: integer - lock: - type: boolean - name: - type: string - nameCN: - type: string - sort: - type: number - subjectID: - type: integer - type: - anyOf: - - enum: - - 0 - type: number - - enum: - - 1 - type: number - - enum: - - 2 - type: number - - enum: - - 3 - type: number - - enum: - - 4 - type: number - - enum: - - 5 - type: number - - enum: - - 6 - type: number - required: - - id - - subjectID - - sort - - type - - disc - - name - - nameCN - - duration - - airdate - - comment - - desc - - lock - title: Episode - type: object + $ref: '#/components/schemas/Episode' type: array total: type: integer @@ -4744,27 +4079,6 @@ paths: - total type: object description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -4840,18 +4154,7 @@ paths: properties: data: items: - properties: - order: - type: integer - relation: - $ref: '#/components/schemas/SubjectRelationType' - subject: - $ref: '#/components/schemas/SlimSubject' - required: - - subject - - relation - - order - type: object + $ref: '#/components/schemas/SubjectRelation' type: array total: type: integer @@ -4860,27 +4163,6 @@ paths: - total type: object description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -4928,19 +4210,7 @@ paths: properties: data: items: - properties: - entry: - $ref: '#/components/schemas/SlimBlogEntry' - id: - type: integer - user: - $ref: '#/components/schemas/SlimUser' - required: - - id - - user - - entry - title: SubjectReview - type: object + $ref: '#/components/schemas/SubjectReview' type: array total: type: integer @@ -4949,27 +4219,6 @@ paths: - total type: object description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -5023,15 +4272,7 @@ paths: properties: data: items: - properties: - person: - $ref: '#/components/schemas/SlimPerson' - position: - $ref: '#/components/schemas/SubjectStaffPosition' - required: - - person - - position - type: object + $ref: '#/components/schemas/SubjectStaff' type: array total: type: integer @@ -5040,27 +4281,6 @@ paths: - total type: object description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -5108,40 +4328,7 @@ paths: properties: data: items: - properties: - createdAt: - description: 发帖时间,unix time stamp in seconds - type: integer - creator: - $ref: '#/components/schemas/SlimUser' - display: - type: integer - id: - type: integer - parentID: - description: 小组/条目ID - type: integer - repliesCount: - type: integer - state: - type: integer - title: - type: string - updatedAt: - description: 最后回复时间,unix time stamp in seconds - type: integer - required: - - id - - creator - - title - - parentID - - createdAt - - updatedAt - - repliesCount - - state - - display - title: Topic - type: object + $ref: '#/components/schemas/Topic' type: array total: type: integer @@ -5150,27 +4337,6 @@ paths: - total type: object description: Default Response - '404': - content: - application/json: - schema: - description: default error response type - properties: - code: - type: string - error: - type: string - message: - type: string - statusCode: - type: integer - required: - - code - - error - - message - - statusCode - type: object - description: default error response type '500': content: application/json: @@ -5208,10 +4374,8 @@ paths: 需要 [turnstile](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) - next.bgm.tv 域名对应的 site-key 为 \`0x4AAAAAAABkMYinukE8nzYS\` - dev.bgm38.com 域名使用测试用的 site-key \`1x00000000000000000000AA\` type: string text: diff --git a/routes/private/routes/subject.ts b/routes/private/routes/subject.ts index 623fed53..f8b9da83 100644 --- a/routes/private/routes/subject.ts +++ b/routes/private/routes/subject.ts @@ -70,8 +70,7 @@ export async function setup(app: App) { subjectID: t.Integer(), }), response: { - 200: res.Subject, - 404: res.Error, + 200: t.Ref(res.Subject), }, }, }, @@ -117,8 +116,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(res.Episode), - 404: res.Error, + 200: res.Paged(t.Ref(res.Episode)), }, }, }, @@ -172,8 +170,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(res.SubjectRelation), - 404: res.Error, + 200: res.Paged(t.Ref(res.SubjectRelation)), }, }, }, @@ -245,8 +242,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(res.SubjectCharacter), - 404: res.Error, + 200: res.Paged(t.Ref(res.SubjectCharacter)), }, }, }, @@ -324,8 +320,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(res.SubjectStaff), - 404: res.Error, + 200: res.Paged(t.Ref(res.SubjectStaff)), }, }, }, @@ -387,8 +382,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(res.SubjectComment), - 404: res.Error, + 200: res.Paged(t.Ref(res.SubjectComment)), }, }, }, @@ -446,8 +440,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(res.SubjectReview), - 404: res.Error, + 200: res.Paged(t.Ref(res.SubjectReview)), }, }, }, @@ -511,8 +504,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0, description: 'min 0' })), }), response: { - 200: res.Paged(res.Topic), - 404: res.Error, + 200: res.Paged(t.Ref(res.Topic)), }, }, }, @@ -638,8 +630,7 @@ export async function setup(app: App) { topicID: t.Integer({ examples: [371602], minimum: 0 }), }), response: { - 200: res.TopicDetail, - 404: res.Error, + 200: t.Ref(res.TopicDetail), }, }, }, diff --git a/routes/private/routes/topic.ts b/routes/private/routes/topic.ts index f9965022..ea434f81 100644 --- a/routes/private/routes/topic.ts +++ b/routes/private/routes/topic.ts @@ -26,9 +26,9 @@ import type { App } from '@app/routes/type.ts'; const GroupProfile = t.Object( { recentAddedMembers: t.Array(res.GroupMember), - topics: t.Array(res.Topic), + topics: t.Array(t.Ref(res.Topic)), inGroup: t.Boolean({ description: '是否已经加入小组' }), - group: res.Group, + group: t.Ref(res.Group), totalTopics: t.Integer(), }, { $id: 'GroupProfile' }, @@ -53,8 +53,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0, minimum: 0 })), }), response: { - 200: GroupProfile, - 404: res.Error, + 200: t.Ref(GroupProfile), }, }, }, @@ -95,8 +94,7 @@ export async function setup(app: App) { id: t.Integer({ examples: [371602] }), }), response: { - 200: res.TopicDetail, - 404: res.Error, + 200: t.Ref(res.TopicDetail), }, }, }, @@ -130,8 +128,7 @@ export async function setup(app: App) { offset: t.Optional(t.Integer({ default: 0 })), }), response: { - 200: res.Paged(res.GroupMember), - 404: res.Error, + 200: res.Paged(t.Ref(res.GroupMember)), }, }, }, diff --git a/routes/res.ts b/routes/res.ts index 9764acb5..4848f3b6 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -14,6 +14,7 @@ export function addSchemas(app: App) { app.addSchema(res.Friend); app.addSchema(res.Index); app.addSchema(res.Infobox); + app.addSchema(res.InfoboxValue); app.addSchema(res.Person); app.addSchema(res.PersonCharacter); app.addSchema(res.PersonCollect); @@ -36,6 +37,7 @@ export function addSchemas(app: App) { app.addSchema(res.SubjectRating); app.addSchema(res.SubjectRelation); app.addSchema(res.SubjectRelationType); + app.addSchema(res.SubjectReview); app.addSchema(res.SubjectStaff); app.addSchema(res.SubjectStaffPosition); app.addSchema(res.User); From c8cd9a4591f61a633fcd6c9cf64db84d3679165f Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sat, 23 Nov 2024 13:29:42 +0800 Subject: [PATCH 11/20] z --- routes/__snapshots__/index.test.ts.snap | 20 +------------------- routes/private/routes/topic.ts | 2 +- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/routes/__snapshots__/index.test.ts.snap b/routes/__snapshots__/index.test.ts.snap index 27fbb6ec..af3f204d 100644 --- a/routes/__snapshots__/index.test.ts.snap +++ b/routes/__snapshots__/index.test.ts.snap @@ -509,25 +509,7 @@ exports[`should build private api spec 1`] = ` type: boolean recentAddedMembers: items: - properties: - avatar: - $ref: '#/components/schemas/Avatar' - id: - type: integer - joinedAt: - type: integer - nickname: - type: string - username: - type: string - required: - - id - - nickname - - username - - avatar - - joinedAt - title: GroupMember - type: object + $ref: '#/components/schemas/GroupMember' type: array topics: items: diff --git a/routes/private/routes/topic.ts b/routes/private/routes/topic.ts index ea434f81..923d90ba 100644 --- a/routes/private/routes/topic.ts +++ b/routes/private/routes/topic.ts @@ -25,7 +25,7 @@ import type { App } from '@app/routes/type.ts'; const GroupProfile = t.Object( { - recentAddedMembers: t.Array(res.GroupMember), + recentAddedMembers: t.Array(t.Ref(res.GroupMember)), topics: t.Array(t.Ref(res.Topic)), inGroup: t.Boolean({ description: '是否已经加入小组' }), group: t.Ref(res.Group), From 745c7fc7f6c58fde87e6e1c2afc2bbbb81a75fff Mon Sep 17 00:00:00 2001 From: everpcpc Date: Wed, 27 Nov 2024 22:26:03 +0800 Subject: [PATCH 12/20] z --- lib/graphql/resolvers/subject.ts | 7 ++----- lib/types/convert.ts | 13 +++++++++++++ lib/types/res.ts | 10 ++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/graphql/resolvers/subject.ts b/lib/graphql/resolvers/subject.ts index fdf4688e..750b28f2 100644 --- a/lib/graphql/resolvers/subject.ts +++ b/lib/graphql/resolvers/subject.ts @@ -1,12 +1,12 @@ import type { Wiki } from '@bgm38/wiki'; import { parse as parseWiki, WikiSyntaxError } from '@bgm38/wiki'; -import * as php from '@trim21/php-serialize'; import type * as types from '@app/lib/graphql/__generated__/resolvers.ts'; import { convertUser } from '@app/lib/graphql/schema.ts'; import * as entity from '@app/lib/orm/entity/index.ts'; import { SubjectRepo } from '@app/lib/orm/index.ts'; import { subjectCover } from '@app/lib/response.ts'; +import * as convert from '@app/lib/types/convert.ts'; import { findSubjectPlatform } from '@app/vendor'; export function convertSubject(subject: entity.Subject) { @@ -92,10 +92,7 @@ export function convertSubject(subject: entity.Subject) { nsfw: subject.subjectNsfw, locked: subject.locked(), redirect: fields.fieldRedirect, - tags: (php.parse(fields.fieldTags) as { tag_name: string; result: string }[]) - .filter((x) => x.tag_name !== undefined) - .map((x) => ({ name: x.tag_name, count: Number.parseInt(x.result) })) - .filter((x) => !Number.isNaN(x.count)), + tags: convert.toSubjectTags(fields.fieldTags), }; } diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 5cdae940..10fd0ec4 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -1,5 +1,6 @@ import type { WikiMap } from '@bgm38/wiki'; import { parseToMap as parseWiki, WikiSyntaxError } from '@bgm38/wiki'; +import * as php from '@trim21/php-serialize'; import type * as orm from '@app/drizzle/orm.ts'; import type * as ormold from '@app/lib/orm/index.ts'; @@ -19,6 +20,17 @@ export function splitTags(tags: string): string[] { .filter((x) => x !== ''); } +export function toSubjectTags(tags: string): res.ISubjectTag[] { + if (!tags) { + return []; + } + const tagList = php.parse(tags) as { tag_name: string; result: string }[]; + return tagList + .filter((x) => x.tag_name !== undefined) + .map((x) => ({ name: x.tag_name, count: Number.parseInt(x.result) })) + .filter((x) => !Number.isNaN(x.count)); +} + // for backward compatibility export function oldToUser(user: ormold.IUser): res.ISlimUser { return { @@ -196,6 +208,7 @@ export function toSubject(subject: orm.ISubject, fields: orm.ISubjectFields): re summary: subject.summary, type: subject.typeID, volumes: subject.volumes, + tags: toSubjectTags(fields.fieldTags), }; } diff --git a/lib/types/res.ts b/lib/types/res.ts index 58eb2efa..b06eceb7 100644 --- a/lib/types/res.ts +++ b/lib/types/res.ts @@ -135,6 +135,15 @@ export const SubjectAirtime = t.Object( { $id: 'SubjectAirtime', title: 'SubjectAirtime' }, ); +export type ISubjectTag = Static; +export const SubjectTag = t.Object( + { + name: t.String(), + count: t.Integer(), + }, + { $id: 'SubjectTag', title: 'SubjectTag' }, +); + export type ISubjectCollection = Static; export const SubjectCollection = t.Record(t.String(), t.Integer(), { $id: 'SubjectCollection', @@ -201,6 +210,7 @@ export const Subject = t.Object( summary: t.String(), type: t.Enum(SubjectType), volumes: t.Integer(), + tags: t.Array(t.Ref(SubjectTag)), }, { $id: 'Subject', From f8309db7626448beaa73e18eb1da814e54fcce63 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Wed, 27 Nov 2024 22:30:17 +0800 Subject: [PATCH 13/20] z --- routes/__snapshots__/index.test.ts.snap | 16 ++ .../routes/__snapshots__/subject.test.ts.snap | 122 +++++++++ .../routes/__snapshots__/user.test.ts.snap | 244 ++++++++++++++++++ routes/res.ts | 1 + 4 files changed, 383 insertions(+) diff --git a/routes/__snapshots__/index.test.ts.snap b/routes/__snapshots__/index.test.ts.snap index 3364d2f8..e0104712 100644 --- a/routes/__snapshots__/index.test.ts.snap +++ b/routes/__snapshots__/index.test.ts.snap @@ -1266,6 +1266,10 @@ exports[`should build private api spec 1`] = ` type: integer summary: type: string + tags: + items: + $ref: '#/components/schemas/SubjectTag' + type: array type: anyOf: - enum: @@ -1304,6 +1308,7 @@ exports[`should build private api spec 1`] = ` - summary - type - volumes + - tags title: Subject type: object SubjectAirtime: @@ -1607,6 +1612,17 @@ exports[`should build private api spec 1`] = ` - cn - jp type: object + SubjectTag: + properties: + count: + type: integer + name: + type: string + required: + - name + - count + title: SubjectTag + type: object SubjectWikiInfo: properties: availablePlatform: diff --git a/routes/private/routes/__snapshots__/subject.test.ts.snap b/routes/private/routes/__snapshots__/subject.test.ts.snap index 1ca41893..e815fe8f 100644 --- a/routes/private/routes/__snapshots__/subject.test.ts.snap +++ b/routes/private/routes/__snapshots__/subject.test.ts.snap @@ -199,6 +199,128 @@ Object { 到达东京的第一天,他很幸运的在垃圾堆捡到一个人型电脑,一直以来秀树都非常渴望拥有个人电脑.当他抱着她带返公寓后,却不知如何开机,在意想不到的地方找到开关并开启后,故事就此展开 本须和秀树捡到了人型计算机〔唧〕。虽然不晓得她到底是不是〔Chobits〕,但她的身上似乎藏有极大的秘密。看到秀树为了钱而烦恼,唧出去找打工,没想到却找到了危险的工作!为了让秀树开心,唧开始到色情小屋打工。但她在遭到过度激烈的强迫要求之后失控。让周遭计算机因此而强制停摆。 另一方面,秀树发现好友新保与补习班的清水老师有着不可告人的关系……", + "tags": Array [ + Object { + "count": 932, + "name": "CLAMP", + }, + Object { + "count": 718, + "name": "人形电脑天使心", + }, + Object { + "count": 397, + "name": "叽", + }, + Object { + "count": 373, + "name": "chobits", + }, + Object { + "count": 333, + "name": "MADHouse", + }, + Object { + "count": 287, + "name": "TV", + }, + Object { + "count": 274, + "name": "田中理惠", + }, + Object { + "count": 261, + "name": "2002", + }, + Object { + "count": 227, + "name": "小叽", + }, + Object { + "count": 186, + "name": "治愈", + }, + Object { + "count": 165, + "name": "Chobits~初次感动~", + }, + Object { + "count": 137, + "name": "启动键", + }, + Object { + "count": 121, + "name": "漫画改", + }, + Object { + "count": 107, + "name": "萝莉", + }, + Object { + "count": 57, + "name": "恋爱", + }, + Object { + "count": 55, + "name": "浅香守生", + }, + Object { + "count": 53, + "name": "2002年4月", + }, + Object { + "count": 51, + "name": "漫改", + }, + Object { + "count": 40, + "name": "ちぃ", + }, + Object { + "count": 35, + "name": "科幻", + }, + Object { + "count": 32, + "name": "杉田智和", + }, + Object { + "count": 19, + "name": "S田", + }, + Object { + "count": 18, + "name": "2002年", + }, + Object { + "count": 13, + "name": "搞笑", + }, + Object { + "count": 13, + "name": "clamp抢钱", + }, + Object { + "count": 12, + "name": "动画", + }, + Object { + "count": 12, + "name": "童年", + }, + Object { + "count": 11, + "name": "半年番", + }, + Object { + "count": 10, + "name": "萌", + }, + Object { + "count": 9, + "name": "哥特", + }, + ], "type": 2, "volumes": 0, } diff --git a/routes/private/routes/__snapshots__/user.test.ts.snap b/routes/private/routes/__snapshots__/user.test.ts.snap index 2cfd73c7..35e56fbd 100644 --- a/routes/private/routes/__snapshots__/user.test.ts.snap +++ b/routes/private/routes/__snapshots__/user.test.ts.snap @@ -455,6 +455,128 @@ Object { "summary": "  “东京决战”一年后,布里塔尼亚少年鲁路修在11区(原日本国)过着平凡的学生生活。但是,鲁路修与弟弟罗洛的一次出行,遇到了黑色骑士团的余党。在与少女C.C再次结成契约之后,尘封的记忆摆在了鲁路修的面前。   身为帝国王子的鲁路修,为了建立人人平等的世界、让妹妹娜娜丽幸福的世界,而使用GEASS,令人绝对服从的力量,带领黑色骑士团向帝国宣战。带上假面化名ZERO的他,却在一年前的“东京决战”中被好友朱雀击败,被帝国皇帝抹去了记忆。   现在,恢复记忆的鲁路修不仅要面对帝国的强大军事力量,更要面对虚假的弟弟罗洛、失踪的妹妹娜娜丽、不知敌友的中华联盟、内部出现分歧的黑色骑士团。面对内忧外患,鲁路修走上了GEASS之力的诅咒——孤独的王之路。 ", + "tags": Array [ + Object { + "count": 1645, + "name": "叛逆的鲁鲁修", + }, + Object { + "count": 1229, + "name": "SUNRISE", + }, + Object { + "count": 936, + "name": "反逆のルルーシュ", + }, + Object { + "count": 721, + "name": "还是死妹控", + }, + Object { + "count": 664, + "name": "TV", + }, + Object { + "count": 603, + "name": "妹控", + }, + Object { + "count": 569, + "name": "codegeass", + }, + Object { + "count": 523, + "name": "谷口悟朗", + }, + Object { + "count": 453, + "name": "鲁路修", + }, + Object { + "count": 427, + "name": "R2", + }, + Object { + "count": 409, + "name": "2008", + }, + Object { + "count": 385, + "name": "原创", + }, + Object { + "count": 357, + "name": "2008年4月", + }, + Object { + "count": 174, + "name": "大河内一楼", + }, + Object { + "count": 151, + "name": "日升", + }, + Object { + "count": 120, + "name": "萝卜", + }, + Object { + "count": 111, + "name": "机战", + }, + Object { + "count": 104, + "name": "狗得鸡鸭死", + }, + Object { + "count": 94, + "name": "福山润", + }, + Object { + "count": 84, + "name": "露露胸", + }, + Object { + "count": 69, + "name": "CLAMP", + }, + Object { + "count": 67, + "name": "科幻", + }, + Object { + "count": 62, + "name": "鲁鲁修", + }, + Object { + "count": 57, + "name": "GEASS", + }, + Object { + "count": 54, + "name": "神作", + }, + Object { + "count": 49, + "name": "战斗", + }, + Object { + "count": 41, + "name": "战争", + }, + Object { + "count": 40, + "name": "裸露修的跌二次KUSO", + }, + Object { + "count": 37, + "name": "中二", + }, + Object { + "count": 34, + "name": "樱井孝宏", + }, + ], "type": 2, "volumes": 0, }, @@ -664,6 +786,128 @@ Object { "summary": "  “东京决战”一年后,布里塔尼亚少年鲁路修在11区(原日本国)过着平凡的学生生活。但是,鲁路修与弟弟罗洛的一次出行,遇到了黑色骑士团的余党。在与少女C.C再次结成契约之后,尘封的记忆摆在了鲁路修的面前。   身为帝国王子的鲁路修,为了建立人人平等的世界、让妹妹娜娜丽幸福的世界,而使用GEASS,令人绝对服从的力量,带领黑色骑士团向帝国宣战。带上假面化名ZERO的他,却在一年前的“东京决战”中被好友朱雀击败,被帝国皇帝抹去了记忆。   现在,恢复记忆的鲁路修不仅要面对帝国的强大军事力量,更要面对虚假的弟弟罗洛、失踪的妹妹娜娜丽、不知敌友的中华联盟、内部出现分歧的黑色骑士团。面对内忧外患,鲁路修走上了GEASS之力的诅咒——孤独的王之路。 ", + "tags": Array [ + Object { + "count": 1645, + "name": "叛逆的鲁鲁修", + }, + Object { + "count": 1229, + "name": "SUNRISE", + }, + Object { + "count": 936, + "name": "反逆のルルーシュ", + }, + Object { + "count": 721, + "name": "还是死妹控", + }, + Object { + "count": 664, + "name": "TV", + }, + Object { + "count": 603, + "name": "妹控", + }, + Object { + "count": 569, + "name": "codegeass", + }, + Object { + "count": 523, + "name": "谷口悟朗", + }, + Object { + "count": 453, + "name": "鲁路修", + }, + Object { + "count": 427, + "name": "R2", + }, + Object { + "count": 409, + "name": "2008", + }, + Object { + "count": 385, + "name": "原创", + }, + Object { + "count": 357, + "name": "2008年4月", + }, + Object { + "count": 174, + "name": "大河内一楼", + }, + Object { + "count": 151, + "name": "日升", + }, + Object { + "count": 120, + "name": "萝卜", + }, + Object { + "count": 111, + "name": "机战", + }, + Object { + "count": 104, + "name": "狗得鸡鸭死", + }, + Object { + "count": 94, + "name": "福山润", + }, + Object { + "count": 84, + "name": "露露胸", + }, + Object { + "count": 69, + "name": "CLAMP", + }, + Object { + "count": 67, + "name": "科幻", + }, + Object { + "count": 62, + "name": "鲁鲁修", + }, + Object { + "count": 57, + "name": "GEASS", + }, + Object { + "count": 54, + "name": "神作", + }, + Object { + "count": 49, + "name": "战斗", + }, + Object { + "count": 41, + "name": "战争", + }, + Object { + "count": 40, + "name": "裸露修的跌二次KUSO", + }, + Object { + "count": 37, + "name": "中二", + }, + Object { + "count": 34, + "name": "樱井孝宏", + }, + ], "type": 2, "volumes": 0, }, diff --git a/routes/res.ts b/routes/res.ts index 4848f3b6..b614e830 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -40,6 +40,7 @@ export function addSchemas(app: App) { app.addSchema(res.SubjectReview); app.addSchema(res.SubjectStaff); app.addSchema(res.SubjectStaffPosition); + app.addSchema(res.SubjectTag); app.addSchema(res.User); app.addSchema(res.Topic); app.addSchema(res.SubReply); From 889c27b728cca3c57cf29b8a833342b7c4c5bbaf Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 28 Nov 2024 07:55:35 +0800 Subject: [PATCH 14/20] z --- routes/res.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/routes/res.ts b/routes/res.ts index 9ff69def..ecf2c364 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -6,21 +6,14 @@ export function addSchemas(app: App) { app.addSchema(res.Avatar); app.addSchema(res.BlogEntry); app.addSchema(res.Character); - app.addSchema(res.SlimCharacter); - app.addSchema(res.Person); - app.addSchema(res.SlimPerson); - app.addSchema(res.Index); - app.addSchema(res.SlimIndex); - app.addSchema(res.SubjectRelation); - app.addSchema(res.SubjectCharacter); - app.addSchema(res.SubjectStaff); - app.addSchema(res.SubjectTag); app.addSchema(res.CharacterRelation); app.addSchema(res.CharacterSubject); app.addSchema(res.CharacterSubjectRelation); app.addSchema(res.Episode); app.addSchema(res.Error); app.addSchema(res.Friend); + app.addSchema(res.Group); + app.addSchema(res.GroupMember); app.addSchema(res.Index); app.addSchema(res.Infobox); app.addSchema(res.InfoboxValue); @@ -30,12 +23,15 @@ export function addSchemas(app: App) { app.addSchema(res.PersonImages); app.addSchema(res.PersonRelation); app.addSchema(res.PersonWork); + app.addSchema(res.Reaction); + app.addSchema(res.Reply); app.addSchema(res.SlimBlogEntry); app.addSchema(res.SlimCharacter); app.addSchema(res.SlimIndex); app.addSchema(res.SlimPerson); app.addSchema(res.SlimSubject); app.addSchema(res.SlimUser); + app.addSchema(res.SubReply); app.addSchema(res.Subject); app.addSchema(res.SubjectAirtime); app.addSchema(res.SubjectCharacter); @@ -50,14 +46,9 @@ export function addSchemas(app: App) { app.addSchema(res.SubjectStaff); app.addSchema(res.SubjectStaffPosition); app.addSchema(res.SubjectTag); - app.addSchema(res.User); app.addSchema(res.Topic); - app.addSchema(res.SubReply); - app.addSchema(res.Group); - app.addSchema(res.GroupMember); - app.addSchema(res.Reply); - app.addSchema(res.Reaction); app.addSchema(res.TopicDetail); + app.addSchema(res.User); app.addSchema(req.CreateTopic); app.addSchema(req.UpdateTopic); From 7707206e0672c809dd9fefe274c3e56df9f74830 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 28 Nov 2024 08:10:00 +0800 Subject: [PATCH 15/20] z --- drizzle/schema.ts | 14 +++++++------- lib/types/convert.ts | 4 ---- lib/types/res.ts | 4 ---- .../routes/__snapshots__/subject.test.ts.snap | 7 ------- routes/private/routes/subject.test.ts | 2 +- 5 files changed, 8 insertions(+), 23 deletions(-) diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 5b3e90fa..63bbf7ab 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -366,7 +366,7 @@ export const chiiIndexComments = mysqlTable( 'chii_index_comments', { id: mediumint('idx_pst_id').autoincrement().notNull(), - mid: mediumint('idx_pst_mid').notNull(), + mid: mediumint('idx_pst_mid').notNull(), // index id uid: mediumint('idx_pst_uid').notNull(), related: mediumint('idx_pst_related').notNull(), createdAt: int('idx_pst_dateline').notNull(), @@ -662,7 +662,7 @@ export const chiiPersonCollects = mysqlTable( { id: mediumint('prsn_clt_id').autoincrement().notNull(), cat: mysqlEnum('prsn_clt_cat', ['prsn', 'crt']).notNull(), - mid: mediumint('prsn_clt_mid').notNull(), + mid: mediumint('prsn_clt_mid').notNull(), // person id or character id uid: mediumint('prsn_clt_uid').notNull(), createdAt: int('prsn_clt_dateline').notNull(), }, @@ -990,7 +990,7 @@ export const chiiSubjectRelatedBlogs = mysqlTable( id: mediumint('srb_id').autoincrement().notNull(), uid: mediumint('srb_uid').notNull(), subjectID: mediumint('srb_subject_id').notNull(), - entryID: mediumint('srb_entry_id').notNull(), + entryID: mediumint('srb_entry_id').notNull(), // blog etry id spoiler: mediumint('srb_spoiler').notNull(), like: mediumint('srb_like').notNull(), dislike: mediumint('srb_dislike').notNull(), @@ -1008,7 +1008,7 @@ export const chiiSubjectPosts = mysqlTable( 'chii_subject_posts', { id: mediumint('sbj_pst_id').primaryKey().autoincrement().notNull(), - mid: mediumint('sbj_pst_mid').notNull(), + mid: mediumint('sbj_pst_mid').notNull(), // subject id uid: mediumint('sbj_pst_uid').notNull(), related: mediumint('sbj_pst_related').notNull(), content: mediumtext('sbj_pst_content').notNull(), @@ -1208,7 +1208,7 @@ export const chiiBlogComments = mysqlTable( 'chii_blog_comments', { id: mediumint('blg_pst_id').autoincrement().notNull(), - mid: mediumint('blg_pst_mid').notNull(), + mid: mediumint('blg_pst_mid').notNull(), // blog entry id uid: mediumint('blg_pst_uid').notNull(), related: mediumint('blg_pst_related').notNull(), updatedAt: int('blg_pst_dateline').notNull(), @@ -1237,8 +1237,8 @@ export const chiiBlogEntries = mysqlTable( replies: mediumint('entry_replies').notNull(), createdAt: int('entry_dateline').notNull(), updatedAt: int('entry_lastpost').notNull(), - like: int('entry_like').notNull(), - dislike: int('entry_dislike').notNull(), + like: int('entry_like').notNull(), // 未使用 + dislike: int('entry_dislike').notNull(), // 未使用 noreply: smallint('entry_noreply').notNull(), related: tinyint('entry_related').default(0).notNull(), public: customBoolean('entry_public').default(true).notNull(), diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 10fd0ec4..6f93bd17 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -252,8 +252,6 @@ export function toBlotEntry(entry: orm.IBlogEntry, user: orm.IUser): res.IBlogEn replies: entry.replies, createdAt: entry.createdAt, updatedAt: entry.updatedAt, - like: entry.like, - dislike: entry.dislike, noreply: entry.noreply, related: entry.related, public: entry.public, @@ -269,8 +267,6 @@ export function toSlimBlogEntry(entry: orm.IBlogEntry): res.ISlimBlogEntry { replies: entry.replies, createdAt: entry.createdAt, updatedAt: entry.updatedAt, - like: entry.like, - dislike: entry.dislike, }; } diff --git a/lib/types/res.ts b/lib/types/res.ts index b06eceb7..c1ad6cbb 100644 --- a/lib/types/res.ts +++ b/lib/types/res.ts @@ -382,8 +382,6 @@ export const BlogEntry = t.Object( replies: t.Integer(), createdAt: t.Integer(), updatedAt: t.Integer(), - like: t.Integer(), - dislike: t.Integer(), noreply: t.Integer(), related: t.Integer(), public: t.Boolean(), @@ -401,8 +399,6 @@ export const SlimBlogEntry = t.Object( replies: t.Integer(), createdAt: t.Integer(), updatedAt: t.Integer(), - like: t.Integer(), - dislike: t.Integer(), }, { $id: 'SlimBlogEntry', title: 'SlimBlogEntry' }, ); diff --git a/routes/private/routes/__snapshots__/subject.test.ts.snap b/routes/private/routes/__snapshots__/subject.test.ts.snap index e815fe8f..7e778cd6 100644 --- a/routes/private/routes/__snapshots__/subject.test.ts.snap +++ b/routes/private/routes/__snapshots__/subject.test.ts.snap @@ -471,13 +471,6 @@ Object { } `; -exports[`subject > should get subject reviews 1`] = ` -Object { - "data": Array [], - "total": 0, -} -`; - exports[`subject > should get subject staffs 1`] = ` Object { "data": Array [ diff --git a/routes/private/routes/subject.test.ts b/routes/private/routes/subject.test.ts index 18b01801..e628c3c1 100644 --- a/routes/private/routes/subject.test.ts +++ b/routes/private/routes/subject.test.ts @@ -78,7 +78,7 @@ describe('subject', () => { await app.register(setup); const res = await app.inject({ method: 'get', - url: '/subjects/12/reviews', + url: '/subjects/184017/reviews', query: { limit: '2', offset: '0' }, }); expect(res.json()).toMatchSnapshot(); From 715753affd2caee760622af34aa4d4c2bf756976 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 28 Nov 2024 08:12:07 +0800 Subject: [PATCH 16/20] z --- routes/__snapshots__/index.test.ts.snap | 12 ------- .../routes/__snapshots__/subject.test.ts.snap | 32 +++++++++++++++++++ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/routes/__snapshots__/index.test.ts.snap b/routes/__snapshots__/index.test.ts.snap index e0104712..4fb2c616 100644 --- a/routes/__snapshots__/index.test.ts.snap +++ b/routes/__snapshots__/index.test.ts.snap @@ -71,14 +71,10 @@ exports[`should build private api spec 1`] = ` type: string createdAt: type: integer - dislike: - type: integer icon: type: string id: type: integer - like: - type: integer noreply: type: integer public: @@ -113,8 +109,6 @@ exports[`should build private api spec 1`] = ` - replies - createdAt - updatedAt - - like - - dislike - noreply - related - public @@ -924,12 +918,8 @@ exports[`should build private api spec 1`] = ` properties: createdAt: type: integer - dislike: - type: integer id: type: integer - like: - type: integer replies: type: integer summary: @@ -948,8 +938,6 @@ exports[`should build private api spec 1`] = ` - replies - createdAt - updatedAt - - like - - dislike title: SlimBlogEntry type: object SlimCharacter: diff --git a/routes/private/routes/__snapshots__/subject.test.ts.snap b/routes/private/routes/__snapshots__/subject.test.ts.snap index 7e778cd6..4defc3a2 100644 --- a/routes/private/routes/__snapshots__/subject.test.ts.snap +++ b/routes/private/routes/__snapshots__/subject.test.ts.snap @@ -471,6 +471,38 @@ Object { } `; +exports[`subject > should get subject reviews 1`] = ` +Object { + "data": Array [ + Object { + "entry": Object { + "createdAt": 1680065731, + "id": 319484, + "replies": 1, + "summary": "试试", + "title": "试试", + "type": 1, + "updatedAt": 1680072373, + }, + "id": 168120, + "user": 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": 287622, + "joinedAt": 0, + "nickname": "nickname 287622", + "sign": "sing 287622", + "username": "287622", + }, + }, + ], + "total": 1, +} +`; + exports[`subject > should get subject staffs 1`] = ` Object { "data": Array [ From a3be8f080c7614f503a5e17f0a3078fd50b06703 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 28 Nov 2024 08:13:43 +0800 Subject: [PATCH 17/20] z --- lib/types/convert.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/types/convert.ts b/lib/types/convert.ts index 6f93bd17..3663699b 100644 --- a/lib/types/convert.ts +++ b/lib/types/convert.ts @@ -239,7 +239,7 @@ export function toSubjectStaffPosition(relation: orm.IPersonSubject): res.ISubje }; } -export function toBlotEntry(entry: orm.IBlogEntry, user: orm.IUser): res.IBlogEntry { +export function toBlogEntry(entry: orm.IBlogEntry, user: orm.IUser): res.IBlogEntry { return { id: entry.id, type: entry.type, From 3befef43baeb0e5ee3cf409742ba857734805a01 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 1 Dec 2024 18:45:31 +0800 Subject: [PATCH 18/20] z --- routes/res.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/routes/res.ts b/routes/res.ts index ecf2c364..d6f2ac70 100644 --- a/routes/res.ts +++ b/routes/res.ts @@ -16,7 +16,6 @@ export function addSchemas(app: App) { app.addSchema(res.GroupMember); app.addSchema(res.Index); app.addSchema(res.Infobox); - app.addSchema(res.InfoboxValue); app.addSchema(res.Person); app.addSchema(res.PersonCharacter); app.addSchema(res.PersonCollect); From 0cadf5aa993c3019a1cc6fa2dc71aae6f3a6389f Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 1 Dec 2024 18:47:56 +0800 Subject: [PATCH 19/20] z --- .../routes/__snapshots__/topic.test.ts.snap | 307 ------------------ 1 file changed, 307 deletions(-) diff --git a/routes/private/routes/__snapshots__/topic.test.ts.snap b/routes/private/routes/__snapshots__/topic.test.ts.snap index b19cf40d..9f3d6efd 100644 --- a/routes/private/routes/__snapshots__/topic.test.ts.snap +++ b/routes/private/routes/__snapshots__/topic.test.ts.snap @@ -199,310 +199,3 @@ Object { "title": "reaction", } `; - -exports[`subject topics > should failed on not found subject 1`] = ` -Object { - "code": "NOT_FOUND", - "error": "Not Found", - "message": "subject 114514 not found", - "statusCode": 404, -} -`; - -exports[`subject topics > should fetch topic details 1`] = ` -Object { - "createdAt": 1216022809, - "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": 2, - "joinedAt": 0, - "nickname": "nickname 2", - "sign": "sing 2", - "username": "2", - }, - "id": 3, - "parent": Object { - "airtime": Object { - "date": "2008-07-17", - "month": 7, - "weekday": 4, - "year": 2008, - }, - "collection": Object { - "1": 21, - "2": 197, - "3": 6, - "4": 5, - "5": 4, - }, - "eps": 0, - "id": 4, - "images": Object { - "common": "https://lain.bgm.tv/pic/cover/c/a8/7f/4_cMMK5.jpg", - "grid": "https://lain.bgm.tv/pic/cover/g/a8/7f/4_cMMK5.jpg", - "large": "https://lain.bgm.tv/pic/cover/l/a8/7f/4_cMMK5.jpg", - "medium": "https://lain.bgm.tv/pic/cover/m/a8/7f/4_cMMK5.jpg", - "small": "https://lain.bgm.tv/pic/cover/s/a8/7f/4_cMMK5.jpg", - }, - "infobox": Array [ - Object { - "key": "中文名", - "values": Array [ - Object { - "v": "合金弹头7", - }, - ], - }, - Object { - "key": "别名", - "values": Array [ - Object { - "v": "Metal Slug 7", - }, - ], - }, - Object { - "key": "平台", - "values": Array [ - Object { - "v": "NDS", - }, - ], - }, - Object { - "key": "游戏类型", - "values": Array [ - Object { - "v": "ACT", - }, - ], - }, - Object { - "key": "游戏引擎", - "values": Array [ - Object { - "v": "", - }, - ], - }, - Object { - "key": "游玩人数", - "values": Array [ - Object { - "v": "1人", - }, - ], - }, - Object { - "key": "发行日期", - "values": Array [ - Object { - "v": "2008-07-17", - }, - ], - }, - Object { - "key": "售价", - "values": Array [ - Object { - "v": "5040円", - }, - ], - }, - Object { - "key": "website", - "values": Array [ - Object { - "v": "", - }, - ], - }, - Object { - "key": "开发", - "values": Array [ - Object { - "v": "SNKプレイモア", - }, - ], - }, - Object { - "key": "发行", - "values": Array [ - Object { - "v": "SNKプレイモア", - }, - ], - }, - ], - "locked": false, - "metaTags": Array [], - "name": "メタルスラッグ7", - "nameCN": "合金弹头7", - "nsfw": false, - "platform": Object { - "alias": "", - "id": 5, - "type": "", - "typeCN": "", - }, - "rating": Object { - "count": Array [ - 0, - 0, - 0, - 1, - 8, - 47, - 43, - 39, - 3, - 2, - ], - "rank": 3560, - "score": 6.9, - "total": 143, - }, - "redirect": 0, - "series": false, - "seriesEntry": 0, - "summary": "  以细腻的画风、搞笑的动作和刺激的战斗被人们所熟知的“合金弹头系列”在NDS 平台推出正统续作!虽然本系列的前几部作品最早都是作为街机游戏而推出的,不过这一次的“7”不但先推出NDS版,而且是独占!日前SNK playmore宣布了这款游戏,目前发售日定为2008年3月。据称,本作中将搭载任务模式,在关卡中不断完成教官所下达的任务,提升军衔。 -  游戏依然保持了系列一贯的风格,战斗的场面也没有因为是掌机游戏而进行削减,游戏依然是射击、跳跃和手雷三个按键,虽然是NDS游戏,不过本作却并不对应触摸屏,游戏的画面显示在上屏幕,而下屏幕则用来显示地图。在TGS 2007(Tokyo Game Show)大会上SNK放出了《合金弹头7》的试玩版。这代产品沿袭了前代风格,战斗的对象似乎也没有多大变化,没有分支路线,不过游戏性和关卡设计却不比前几代差,关卡数也有7关,另外还增加了一种武器。", - "tags": Array [ - Object { - "count": 52, - "name": "NDS", - }, - Object { - "count": 31, - "name": "合金弹头", - }, - Object { - "count": 15, - "name": "ACT", - }, - Object { - "count": 15, - "name": "SNK", - }, - Object { - "count": 6, - "name": "Metal_Slug_7", - }, - Object { - "count": 4, - "name": "STG", - }, - Object { - "count": 2, - "name": "移植", - }, - Object { - "count": 2, - "name": "诚意不足", - }, - Object { - "count": 2, - "name": "RNG", - }, - Object { - "count": 2, - "name": "2008", - }, - Object { - "count": 1, - "name": "普通游戏", - }, - Object { - "count": 1, - "name": "PC", - }, - Object { - "count": 1, - "name": "未评分", - }, - Object { - "count": 1, - "name": "中型", - }, - Object { - "count": 1, - "name": "六星", - }, - Object { - "count": 1, - "name": "动作", - }, - Object { - "count": 1, - "name": "童年", - }, - Object { - "count": 1, - "name": "汉化", - }, - Object { - "count": 1, - "name": "横版射击", - }, - Object { - "count": 1, - "name": "暴力", - }, - ], - "type": 4, - "volumes": 0, - }, - "reactions": Array [], - "replies": Array [ - Object { - "createdAt": 1216023735, - "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": 1, - "joinedAt": 0, - "nickname": "nickname 1", - "sign": "sing 1", - "username": "1", - }, - "id": 6, - "isFriend": false, - "reactions": Array [], - "replies": Array [], - "state": 0, - "text": "NDS被别人借走了……", - }, - Object { - "createdAt": 1217552145, - "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": 101, - "joinedAt": 0, - "nickname": "nickname 101", - "sign": "sing 101", - "username": "101", - }, - "id": 38, - "isFriend": false, - "reactions": Array [], - "replies": Array [], - "state": 0, - "text": "里层众占领了这里", - }, - ], - "state": 0, - "text": "new contents 2", - "title": "new topic title 2", -} -`; From 2e2ac289313383931e8977252482ebc4060c3c92 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Sun, 15 Dec 2024 17:47:26 +0800 Subject: [PATCH 20/20] Update orm.ts --- drizzle/orm.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/drizzle/orm.ts b/drizzle/orm.ts index 53ceee77..b01fa0f6 100644 --- a/drizzle/orm.ts +++ b/drizzle/orm.ts @@ -30,7 +30,6 @@ export type IPersonCollect = typeof schema.chiiPersonCollects.$inferSelect; export type IIndex = typeof schema.chiiIndexes.$inferSelect; export type IIndexCollect = typeof schema.chiiIndexCollects.$inferSelect; - export type IBlogEntry = typeof schema.chiiBlogEntries.$inferSelect; export type IBlogPhoto = typeof schema.chiiBlogPhotos.$inferSelect;