feat(core): 实现文章的发布/取消发布功能 (#2443)

* feat(migration): add isPublished field to posts and notes collections

* feat(note, post): add publish status management for notes and posts

* build(tsup): 添加 import.meta.url 支持

- 在 tsup 配置中添加 platform: 'node' 设置
- 注入 import.meta.url 兼容层,以支持不同模块格式

* build(core): 修改 mx-server 启动脚本路径

* feat(migration): 添加 v8.4.0 版本迁移脚本至history

* feat(core): 修复未认证用户可查看未发布内容的安全问题

- 在 Note 和 Post 模块中添加了对未认证用户的访问限制

* fix(core): 回滚 mx-server 启动脚本路径

* feat(migration): 添加 v8.4.0 数据库脚本以更新 notes 集合字段

* feat(migration): 更新 v8.4.0 修复脚本以重命名 hide 字段并互换其值

* feat(note): 替换 hide 字段为 isPublished,更新相关查询和条件

* feat(note): 将 hide 字段替换为 isPublished,更新相关数据模型

* feat(migration): 添加 v8.4.0 fix2修复脚本以更新 posts 集合中的 isPublished 字段为true
This commit is contained in:
Teror Fox
2025-07-03 16:06:06 +08:00
committed by GitHub
parent 97cd8d4a68
commit 00b66bef7e
26 changed files with 285 additions and 67 deletions

View File

@@ -9,7 +9,7 @@ module.exports = {
apps: [
{
name: 'mx-server',
script: 'index.js',
script: './index.js',
autorestart: true,
exec_mode: 'cluster',
watch: false,

View File

@@ -9,6 +9,9 @@ import v5_0_0__1 from './version/v5.0.0-1'
import v5_1_1 from './version/v5.1.1'
import v5_6_0 from './version/v5.6.0'
import v7_2_1 from './version/v7.2.1'
import v8_4_0 from './version/v8.4.0'
import v8_4_0__1 from './version/v8.4.0.fix1'
import v8_4_0__2 from './version/v8.4.0.fix2'
export default [
v200Alpha1,
@@ -22,4 +25,7 @@ export default [
v5_1_1,
v5_6_0,
v7_2_1,
v8_4_0,
v8_4_0__1,
v8_4_0__2,
]

View File

@@ -0,0 +1,39 @@
//patch for version 8.4.0 v1
//移除Note中的isPublished字段并将hide字段重命名为isPublished
import type { Db } from 'mongodb'
export default (async function v0840Fix1(db: Db) {
try {
const notesCollection = db.collection('notes')
// 移除 isPublished 字段
await notesCollection.updateMany(
{},
{ $unset: { isPublished: '' } },
{ upsert: false },
)
// 将 hide 字段重命名为 isPublished, 同时将true与false互换
await notesCollection.updateMany(
{},
[
{
$set: {
isPublished: {
$cond: {
if: { $eq: ['$hide', true] },
then: false,
else: true,
},
},
},
},
{ $unset: 'hide' },
],
{ upsert: false },
)
} catch (error) {
console.error('Migration v8.4.0 Fix1 failed:', error)
throw error
}
})

View File

@@ -0,0 +1,19 @@
// patch for version 8.4.0 v2
// 将Posts中的isPublished字段全部设置为true
import type { Db } from 'mongodb'
export default (async function v0840Fix2(db: Db) {
try {
const postsCollection = db.collection('posts')
// 将 isPublished 字段全部设置为 true
await postsCollection.updateMany(
{},
{ $set: { isPublished: true } },
{ upsert: false },
)
} catch (error) {
console.error('Migration v8.4.0 Fix2 failed:', error)
throw error
}
})

View File

@@ -0,0 +1,26 @@
// patch for version lower than v8.4.0
// 本次migration会向posts和notes表中添加一个isPublished字段默认值为true
import type { Db } from 'mongodb'
export default (async function v0840(db: Db) {
try {
const postsCollection = db.collection('posts')
const notesCollection = db.collection('notes')
// 添加 isPublished 字段到 posts 集合
await postsCollection.updateMany(
{},
{ $set: { isPublished: true } },
{ upsert: false },
)
// 添加 isPublished 字段到 notes 集合
await notesCollection.updateMany(
{},
{ $set: { isPublished: true } },
{ upsert: false },
)
} catch (error) {
console.error('Migration to v8.4.0 failed:', error)
}
})

View File

@@ -670,7 +670,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy {
this.databaseService.db
.collection(NOTE_COLLECTION_NAME)
.find({
hide: false,
isPublished: true,
})
.sort({
created: -1,
@@ -722,7 +722,7 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy {
mood: 1,
bookmark: 1,
password: 1,
hide: 1,
isPublished: 1,
},
)
.lean(),
@@ -730,11 +730,11 @@ export class ActivityService implements OnModuleInit, OnModuleDestroy {
return {
posts,
notes: notes.map((note) => {
if (note.password || note.hide) {
if (note.password || !note.isPublished) {
note.title = '未公开的日记'
}
return omit(note, 'password', 'hide')
return omit(note, 'password', 'isPublished')
}),
}
}

View File

@@ -102,7 +102,7 @@ export class AggregateService {
this.noteService.model,
!isAuthenticated
? {
hide: false,
isPublished: true,
password: undefined,
}
: {},
@@ -111,7 +111,7 @@ export class AggregateService {
this.findTop(
this.postService.model,
!isAuthenticated ? { hide: false } : {},
!isAuthenticated ? { isPublished: true } : {},
size,
)
.populate('categoryId')
@@ -162,7 +162,7 @@ export class AggregateService {
this.noteService.model
.find(
{
hide: false,
isPublished: true,
...addYearCondition(year),
},
'_id nid title weather mood created modified bookmark',
@@ -209,7 +209,7 @@ export class AggregateService {
this.noteService.model
.find({
hide: false,
isPublished: true,
$or: [
{
@@ -258,7 +258,7 @@ export class AggregateService {
])
return combineTasks
.flat(1)
.flat()
.sort((a, b) => -(a.published_at.getTime() - b.published_at.getTime()))
}
@@ -291,7 +291,7 @@ export class AggregateService {
this.noteService.model
.find({
hide: false,
isPublished: true,
$and: [
{
$or: [

View File

@@ -46,7 +46,7 @@ export class CategoryController {
async getCategories(@Query() query: MultiCategoriesQueryDto) {
const { ids, joint, type = CategoryType.Category } = query // categories is category's mongo id
if (ids) {
const ignoreKeys = '-text -summary -hide -images -commentsIndex'
const ignoreKeys = '-text -summary -isPublished -images -commentsIndex'
if (joint) {
const map = new Object()

View File

@@ -94,13 +94,15 @@ export class McpService {
*/
async getNotes(page = 1, size = 10) {
const query = this.noteService.model
.find({ hide: false })
.find({ isPublished: true })
.sort({ created: -1 })
.skip((page - 1) * size)
.limit(size)
const notes = await query.exec()
const total = await this.noteService.model.countDocuments({ hide: false })
const total = await this.noteService.model.countDocuments({
isPublished: true,
})
return {
data: notes,

View File

@@ -29,6 +29,7 @@ import {
NidType,
NotePasswordQueryDto,
NoteQueryDto,
SetNotePublishStatusDto,
} from './note.dto'
import { NoteModel, PartialNoteModel } from './note.model'
import { NoteService } from './note.service'
@@ -68,8 +69,10 @@ export class NoteController {
}
@Get(':id')
@Auth()
async getOneNote(@Param() params: MongoIdDto) {
async getOneNote(
@Param() params: MongoIdDto,
@IsAuthenticated() isAuthenticated: boolean,
) {
const { id } = params
const current = await this.noteService.model
@@ -82,6 +85,11 @@ export class NoteController {
throw new CannotFindException()
}
// 非认证用户只能查看已发布的手记
if (!isAuthenticated && !current.isPublished) {
throw new CannotFindException()
}
return current
}
@@ -95,9 +103,9 @@ export class NoteController {
const half = size >> 1
const { id } = params
const select = isAuthenticated
? 'nid _id title created hide'
? 'nid _id title created isPublished'
: 'nid _id title created'
const condition = isAuthenticated ? {} : { hide: false }
const condition = isAuthenticated ? {} : { isPublished: true }
// 当前文档直接找,不用加条件,反正里面的东西是看不到的
const currentDocument = await this.noteService.model
@@ -197,7 +205,7 @@ export class NoteController {
) {
const { nid } = params
const { password, single: isSingle } = query
const condition = isAuthenticated ? {} : { hide: false }
const condition = isAuthenticated ? {} : { isPublished: true }
const current: NoteModel | null = await this.noteService.model
.findOne({
nid,
@@ -278,8 +286,8 @@ export class NoteController {
sortOrder,
} = query
const condition: FilterQuery<NoteModel> = isAuthenticated
? { $or: [{ hide: false }, { hide: true }] }
: { hide: false }
? { $or: [{ isPublished: false }, { isPublished: true }] }
: { isPublished: true }
return await this.noteService.getNotePaginationByTopicId(
id,
@@ -292,4 +300,16 @@ export class NoteController {
{ ...condition },
)
}
@Patch('/:id/publish')
@Auth()
async setPublishStatus(
@Param() params: MongoIdDto,
@Body() body: SetNotePublishStatusDto,
) {
await this.noteService.updateById(params.id, {
isPublished: body.isPublished,
})
return { success: true }
}
}

View File

@@ -57,3 +57,8 @@ export class NidType {
@Transform(({ value: val }) => Number.parseInt(val))
nid: number
}
export class SetNotePublishStatusDto {
@IsBoolean()
isPublished: boolean
}

View File

@@ -45,10 +45,10 @@ export class NoteModel extends WriteBaseModel {
@prop({ required: false, unique: true })
public nid: number
@prop({ default: false })
@prop({ default: true })
@IsBoolean()
@IsOptional()
hide: boolean
isPublished?: boolean
@prop({
select: false,

View File

@@ -40,7 +40,7 @@ export class NoteService {
}
public readonly publicNoteQueryCondition = {
hide: false,
isPublished: true,
$and: [
{
$or: [
@@ -170,7 +170,9 @@ export class NoteService {
return note.save()
},
),
note.hide || note.password || this.checkNoteIsSecret(note)
note.isPublished === false ||
note.password ||
this.checkNoteIsSecret(note)
? null
: this.eventManager.broadcast(
BusinessEvents.NOTE_CREATE,
@@ -266,7 +268,11 @@ export class NoteService {
scope: EventScope.TO_SYSTEM,
})
if (updated.password || updated.hide || updated.publicAt) {
if (
updated.password ||
updated.isPublished === false ||
updated.publicAt
) {
return
}
this.eventManager.broadcast(

View File

@@ -16,12 +16,17 @@ import { ApiController } from '~/common/decorators/api-controller.decorator'
import { Auth } from '~/common/decorators/auth.decorator'
import { HTTPDecorators, Paginator } from '~/common/decorators/http.decorator'
import { IpLocation, IpRecord } from '~/common/decorators/ip.decorator'
import { IsAuthenticated } from '~/common/decorators/role.decorator'
import { CannotFindException } from '~/common/exceptions/cant-find.exception'
import { CountingService } from '~/processors/helper/helper.counting.service'
import { MongoIdDto } from '~/shared/dto/id.dto'
import { addYearCondition } from '~/transformers/db-query.transformer'
import { CategoryAndSlugDto, PostPagerDto } from './post.dto'
import {
CategoryAndSlugDto,
PostPagerDto,
SetPostPublishStatusDto,
} from './post.dto'
import { PartialPostModel, PostModel } from './post.model'
import { PostService } from './post.service'
@@ -34,7 +39,10 @@ export class PostController {
@Get('/')
@Paginator
async getPaginate(@Query() query: PostPagerDto) {
async getPaginate(
@Query() query: PostPagerDto,
@IsAuthenticated() isAuthenticated: boolean,
) {
const { size, select, page, year, sortBy, sortOrder, truncate } = query
return this.postService.model
@@ -44,6 +52,8 @@ export class PostController {
{
$match: {
...addYearCondition(year),
// 非认证用户只能看到已发布的文章
...(isAuthenticated ? {} : { isPublished: true }),
},
},
// @see https://stackoverflow.com/questions/54810712/mongodb-sort-by-field-a-if-field-b-null-otherwise-sort-by-field-c
@@ -137,8 +147,10 @@ export class PostController {
}
@Get('/:id')
@Auth()
async getById(@Param() params: MongoIdDto) {
async getById(
@Param() params: MongoIdDto,
@IsAuthenticated() isAuthenticated: boolean,
) {
const { id } = params
const doc = await this.postService.model
.findById(id)
@@ -151,13 +163,28 @@ export class PostController {
throw new CannotFindException()
}
// 非认证用户只能查看已发布的文章
if (!isAuthenticated && !doc.isPublished) {
throw new CannotFindException()
}
return doc
}
@Get('/latest')
async getLatest(@IpLocation() ip: IpRecord) {
async getLatest(
@IpLocation() ip: IpRecord,
@IsAuthenticated() isAuthenticated: boolean,
) {
const query: any = {}
// 非认证用户只能看到已发布的文章
if (!isAuthenticated) {
query.isPublished = true
}
const last = await this.postService.model
.findOne({})
.findOne(query)
.sort({ created: -1 })
.lean({ getters: true, autopopulate: true })
if (!last) {
@@ -169,6 +196,7 @@ export class PostController {
slug: last.slug,
},
ip,
isAuthenticated,
)
}
@@ -176,12 +204,23 @@ export class PostController {
async getByCateAndSlug(
@Param() params: CategoryAndSlugDto,
@IpLocation() { ip }: IpRecord,
@IsAuthenticated() isAuthenticated?: boolean,
) {
const { category, slug } = params
const postDocument = await this.postService.getPostBySlug(category, slug)
const postDocument = await this.postService.getPostBySlug(
category,
slug,
isAuthenticated,
)
if (!postDocument) {
throw new CannotFindException()
}
// 非认证用户只能查看已发布的文章
if (!isAuthenticated && !postDocument.isPublished) {
throw new CannotFindException()
}
const liked = await this.countingService.getThisRecordIsLiked(
postDocument.id,
ip,
@@ -223,4 +262,16 @@ export class PostController {
return
}
@Patch('/:id/publish')
@Auth()
async setPublishStatus(
@Param() params: MongoIdDto,
@Body() body: SetPostPublishStatusDto,
) {
await this.postService.updateById(params.id, {
isPublished: body.isPublished,
})
return { success: true }
}
}

View File

@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer'
import { IsInt, IsOptional, IsString } from 'class-validator'
import { IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'
import { PagerDto } from '~/shared/dto/pager.dto'
@@ -18,3 +18,8 @@ export class PostPagerDto extends PagerDto {
@Type(() => Number)
truncate?: number
}
export class SetPostPublishStatusDto {
@IsBoolean()
isPublished: boolean
}

View File

@@ -72,6 +72,11 @@ export class PostModel extends WriteBaseModel {
@IsOptional()
copyright?: boolean
@prop({ default: true })
@IsBoolean()
@IsOptional()
isPublished?: boolean
@prop({
type: String,
})

View File

@@ -166,7 +166,11 @@ export class PostService {
}
}
async getPostBySlug(categorySlug: string, slug: string) {
async getPostBySlug(
categorySlug: string,
slug: string,
isAuthenticated?: boolean,
) {
const slugTrackerService = this.slugTrackerService
const postModel = this.postModel
@@ -176,14 +180,27 @@ export class PostService {
if (!trackedPost) {
throw new NotFoundException('该分类未找到 (。•́︿•̀。)')
}
// 检查发布状态
if (!isAuthenticated && !trackedPost.isPublished) {
throw new NotFoundException('该文章未找到')
}
return trackedPost
}
const queryConditions: any = {
slug,
categoryId: categoryDocument._id,
}
// 非认证用户只能查看已发布的文章
if (!isAuthenticated) {
queryConditions.isPublished = true
}
const postDocument = await this.model
.findOne({
slug,
categoryId: categoryDocument._id,
})
.findOne(queryConditions)
.populate('category')
.populate({
path: 'related',
@@ -192,7 +209,15 @@ export class PostService {
if (postDocument) return postDocument
return findTrackedPost()
const trackedPost = await findTrackedPost()
// 检查追踪文章的发布状态
if (trackedPost && !isAuthenticated && !trackedPost.isPublished) {
throw new NotFoundException('该文章未找到')
}
return trackedPost
async function findTrackedPost() {
const tracked = await slugTrackerService.findTrackerBySlug(
`/${categorySlug}/${slug}`,

View File

@@ -62,7 +62,7 @@ export class RenderEjsController {
])
const isPrivateOrEncrypt =
('hide' in document && document.hide) ||
('isPublished' in document && !document.isPublished) ||
('password' in document && !isNil(document.password))
if (!isAuthenticated && isPrivateOrEncrypt) {

View File

@@ -61,7 +61,7 @@ export class SearchService {
$or: [{ title: { $in: keywordArr } }, { text: { $in: keywordArr } }],
$and: [
{ password: { $not: null } },
{ hide: { $in: showHidden ? [false, true] : [false] } },
{ isPublished: { $in: showHidden ? [false, true] : [true] } },
{
$or: [
{ publicAt: { $not: null } },
@@ -260,7 +260,7 @@ export class SearchService {
this.noteService.model
.find(
{
hide: false,
isPublished: true,
$or: [
{ password: undefined },
{ password: null },

View File

@@ -154,7 +154,8 @@ export class SubscribeService implements OnModuleInit, OnModuleDestroy {
const precheck: CoAction<any> = async function (
noteOrPost: NoteModel | PostModel,
) {
if ('hide' in noteOrPost && noteOrPost.hide) return this.abort()
if ('isPublished' in noteOrPost && !noteOrPost.isPublished)
return this.abort()
if ('password' in noteOrPost && !!noteOrPost.password) return this.abort()
if (
'publicAt' in noteOrPost &&

View File

@@ -70,8 +70,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-20T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 20,
@@ -88,8 +88,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-19T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 19,
@@ -106,8 +106,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-18T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 18,
@@ -124,8 +124,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-17T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 17,
@@ -142,8 +142,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-16T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 16,
@@ -160,8 +160,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-15T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 15,
@@ -178,8 +178,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-14T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 14,
@@ -196,8 +196,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-13T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 13,
@@ -214,8 +214,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-12T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 12,
@@ -232,8 +232,8 @@ exports[`NoteController (e2e) > GET /notes 1`] = `
"read": 0,
},
"created": "2021-03-11T00:00:00.000Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 11,
@@ -264,8 +264,8 @@ exports[`NoteController (e2e) > GET /notes/nid/:nid 1`] = `
"read": 0,
},
"created": "2023-01-17T11:01:57.851Z",
"hide": false,
"images": [],
"is_published": true,
"liked": true,
"modified": null,
"mood": "happy",
@@ -295,8 +295,8 @@ exports[`NoteController (e2e) > Get patched note 1`] = `
"read": 0,
},
"created": "2023-01-17T11:01:57.851Z",
"hide": false,
"images": [],
"is_published": true,
"modified": null,
"mood": "happy",
"nid": 21,
@@ -316,8 +316,8 @@ exports[`NoteController (e2e) > POST /notes 1`] = `
"read": 0,
},
"created": "2023-01-17T11:01:57.851Z",
"hide": false,
"images": [],
"is_published": true,
"meta": null,
"modified": null,
"nid": 21,

View File

@@ -9,7 +9,7 @@ export default Array.from({ length: 20 }).map((_, _i) => {
modified: null,
allowComment: true,
hide: false,
isPublished: true,
commentsIndex: 0,
}
}) as NoteModel[]

View File

@@ -103,7 +103,10 @@ export class NoteController<ResponseWrapper> implements IController {
*/
getMiddleList(id: string, size = 5) {
return this.proxy.list(id).get<{
data: Pick<NoteModel, 'id' | 'title' | 'nid' | 'created' | 'hide'>[]
data: Pick<
NoteModel,
'id' | 'title' | 'nid' | 'created' | 'isPublished'
>[]
size: number
}>({
params: { size },

View File

@@ -2,7 +2,7 @@ import type { ModelWithLiked, TextBaseModel } from './base'
import type { TopicModel } from './topic'
export interface NoteModel extends TextBaseModel {
hide: boolean
isPublished: boolean
count: {
read: number
like: number

View File

@@ -7,4 +7,14 @@ export default defineConfig({
dts: true,
external: ['mongodb'],
format: ['cjs'],
platform: 'node',
banner: {
js: `const __injected_import_meta_url = require("url").pathToFileURL(__filename).href;`,
},
esbuildOptions(options) {
options.define = {
...options.define,
'import.meta.url': '__injected_import_meta_url',
}
},
})

9
pnpm-lock.yaml generated
View File

@@ -13,11 +13,6 @@ overrides:
whatwg-url: 14.1.1
zod: 3.25.63
patchedDependencies:
tinyexec@1.0.1:
hash: b5c07938d1255875a3a5bceac67c4c8d729351c52498ec9f39dcec351340929c
path: patches/tinyexec@1.0.1.patch
importers:
.:
@@ -7152,7 +7147,7 @@ snapshots:
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.3.0
tinyexec: 1.0.1(patch_hash=b5c07938d1255875a3a5bceac67c4c8d729351c52498ec9f39dcec351340929c)
tinyexec: 1.0.1
'@antfu/utils@8.1.1':
optional: true
@@ -13238,7 +13233,7 @@ snapshots:
tinyexec@0.3.2: {}
tinyexec@1.0.1(patch_hash=b5c07938d1255875a3a5bceac67c4c8d729351c52498ec9f39dcec351340929c): {}
tinyexec@1.0.1: {}
tinyglobby@0.2.13:
dependencies: