From cf145a3131f0200ad29d3d2df04ab954490e818a Mon Sep 17 00:00:00 2001 From: caixw Date: Thu, 2 Jan 2025 11:41:12 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(contents/article):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=96=87=E7=AB=A0=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/article/article.go | 8 +++ cmfx/contents/article/install.go | 20 ++++++++ cmfx/contents/article/models.go | 74 ++++++++++++++++++++++++++++ cmfx/contents/article/models_test.go | 13 +++++ cmfx/contents/article/module.go | 30 +++++++++++ cmfx/contents/article/routes.go | 62 +++++++++++++++++++++++ cmfx/contents/contents.go | 6 +++ 7 files changed, 213 insertions(+) create mode 100644 cmfx/contents/article/article.go create mode 100644 cmfx/contents/article/install.go create mode 100644 cmfx/contents/article/models.go create mode 100644 cmfx/contents/article/models_test.go create mode 100644 cmfx/contents/article/module.go create mode 100644 cmfx/contents/article/routes.go create mode 100644 cmfx/contents/contents.go diff --git a/cmfx/contents/article/article.go b/cmfx/contents/article/article.go new file mode 100644 index 0000000..a8b6ac3 --- /dev/null +++ b/cmfx/contents/article/article.go @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +// Package article 文章管理 +package article + +const topicsTableName = "topics" diff --git a/cmfx/contents/article/install.go b/cmfx/contents/article/install.go new file mode 100644 index 0000000..9db89fb --- /dev/null +++ b/cmfx/contents/article/install.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package article + +import ( + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/categories/linkage" +) + +func Install(mod *cmfx.Module) { + linkage.Install(mod, topicsTableName, &linkage.LinkageVO{}) + + if err := mod.DB().Create(&articlePO{}); err != nil { + panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) + } +} diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go new file mode 100644 index 0000000..95ac660 --- /dev/null +++ b/cmfx/contents/article/models.go @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package article + +import ( + "database/sql" + "html" + "time" + + "github.com/issue9/cmfx/cmfx/types" +) + +type articlePO struct { + ID int64 `orm:"name(id);ai"` + Slug string `orm:"name(slug);len(100);unique(slug)"` + + Author string `orm:"name(author);len(20)"` // 显示的作者信息 + Views int `orm:"name(views)"` // 查看数量 + Order int `orm:"name(order)"` // 排序,按从小到大排序 + + // 内容 + + Title string `orm:"name(title);len(100)"` // 标题 + Images types.Strings `orm:"name(images);len(1000)"` // 缩略图 + Keywords string `orm:"name(keywords)"` // 关键字 + Summary string `orm:"name(summary);len(2000)"` // 摘要 + Content string `orm:"name(content);len(-1)"` // 文章内容 + + // 分类信息 + + Topics []types.Int64s `orm:"name(topics)"` + Tags []types.Int64s `orm:"name(tags)"` + + // 各类时间属性 + + Created time.Time `orm:"name(created)"` + Creator int64 `orm:"name(creator)"` + Modified time.Time `orm:"name(modified)"` + Modifier int64 `orm:"name(modifier)"` + Deleted sql.NullTime `orm:"name(deleted);nullable;default(NULL)"` + Deleter int64 `orm:"name(deleter)"` +} + +func (*articlePO) TableName() string { return `_articles` } + +func (l *articlePO) BeforeInsert() error { + l.ID = 0 + l.Title = html.EscapeString(l.Title) + l.Slug = html.EscapeString(l.Slug) + l.Author = html.EscapeString(l.Author) + l.Keywords = html.EscapeString(l.Keywords) + l.Summary = html.EscapeString(l.Summary) + l.Content = html.EscapeString(l.Content) + + l.Created = time.Now() + l.Modified = l.Created + + return nil +} + +func (l *articlePO) BeforeUpdate() error { + l.Title = html.EscapeString(l.Title) + l.Slug = html.EscapeString(l.Slug) + l.Author = html.EscapeString(l.Author) + l.Keywords = html.EscapeString(l.Keywords) + l.Summary = html.EscapeString(l.Summary) + l.Content = html.EscapeString(l.Content) + + l.Modified = time.Now() + + return nil +} diff --git a/cmfx/contents/article/models_test.go b/cmfx/contents/article/models_test.go new file mode 100644 index 0000000..7470f8e --- /dev/null +++ b/cmfx/contents/article/models_test.go @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package article + +import "github.com/issue9/orm/v6" + +var ( + _ orm.TableNamer = &articlePO{} + _ orm.BeforeInserter = &articlePO{} + _ orm.BeforeUpdater = &articlePO{} +) diff --git a/cmfx/contents/article/module.go b/cmfx/contents/article/module.go new file mode 100644 index 0000000..61e5cd2 --- /dev/null +++ b/cmfx/contents/article/module.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package article + +import ( + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/categories/linkage" + "github.com/issue9/cmfx/cmfx/user" +) + +type Module struct { + mod *cmfx.Module + topics *linkage.Module +} + +// Load 加载内容管理模块 +func Load(mod *cmfx.Module, u *user.Module) *Module { + topics := linkage.Load(mod, "topics") + + m := &Module{ + mod: mod, + topics: topics, + } + + // TODO + + return m +} diff --git a/cmfx/contents/article/routes.go b/cmfx/contents/article/routes.go new file mode 100644 index 0000000..ac54722 --- /dev/null +++ b/cmfx/contents/article/routes.go @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package article + +import ( + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/query" +) + +type adminArticlesQuery struct { + query.Text + Topic []int64 `json:"topic"` + Creator []int64 `json:"creator"` +} + +func (q *adminArticlesQuery) Filter(ctx *web.FilterContext) { + q.Text.Filter(ctx) + // TODO +} + +func (m *Module) adminGetArticles(ctx *web.Context) web.Responser { + q := &adminArticlesQuery{} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp + } + + // TODO + + return ctx.NotImplemented() +} + +func (m *Module) adminGetArticle(ctx *web.Context) web.Responser { + // TODO + return ctx.NotImplemented() +} + +func (m *Module) getTopics(ctx *web.Context) web.Responser { + // TODO + return ctx.NotImplemented() +} + +func (m *Module) memberGetArticle(ctx *web.Context) web.Responser { + // TODO + return ctx.NotImplemented() +} + +type memberArticlesQuery struct { + // TODO +} + +func (q *memberArticlesQuery) Filter(ctx *web.Context) { + // TODO +} + +func (m *Module) memberGetArticles(ctx *web.Context) web.Responser { + // TODO + return ctx.NotImplemented() +} diff --git a/cmfx/contents/contents.go b/cmfx/contents/contents.go new file mode 100644 index 0000000..672f7f7 --- /dev/null +++ b/cmfx/contents/contents.go @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +// Package contents 提供文章评论等以文字内容为主的对象 +package contents From 4e603ce805ce5eb1250952f9ef495fa8c801efb0 Mon Sep 17 00:00:00 2001 From: caixw Date: Thu, 2 Jan 2025 20:48:06 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(cmfx/contents/comment):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20comment=20=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/comment/comment.go | 6 ++++++ cmfx/contents/comment/models.go | 25 +++++++++++++++++++++++++ cmfx/contents/comment/module.go | 21 +++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 cmfx/contents/comment/comment.go create mode 100644 cmfx/contents/comment/models.go create mode 100644 cmfx/contents/comment/module.go diff --git a/cmfx/contents/comment/comment.go b/cmfx/contents/comment/comment.go new file mode 100644 index 0000000..fec240d --- /dev/null +++ b/cmfx/contents/comment/comment.go @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +// Package comment 评论 +package comment diff --git a/cmfx/contents/comment/models.go b/cmfx/contents/comment/models.go new file mode 100644 index 0000000..3d48ac9 --- /dev/null +++ b/cmfx/contents/comment/models.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package comment + +import ( + "database/sql" + "time" +) + +type commentPO struct { + ID int64 `orm:"name(id);ai"` + Author string `orm:"name(author);len(20)"` // 显示的作者信息 + Content string `orm:"name(content);len(-1)"` // 文章内容 + Target int64 `orm:"name(target)"` // 关联对象的 ID + Creator int64 `orm:"name(creator)"` // 作者 ID + Created time.Time `orm:"name(created)"` + Modified time.Time `orm:"name(modified)"` + Deleted sql.NullTime `orm:"name(deleted);nullable;default(NULL)"` + Deleter int64 `orm:"name(deleter)"` + Parent int64 `orm:"name(parent)"` // 父评论 +} + +func (*commentPO) TableName() string { return `_comments` } diff --git a/cmfx/contents/comment/module.go b/cmfx/contents/comment/module.go new file mode 100644 index 0000000..981fa0e --- /dev/null +++ b/cmfx/contents/comment/module.go @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package comment + +import "github.com/issue9/cmfx/cmfx" + +type Module struct { + mod *cmfx.Module +} + +func Load(mod *cmfx.Module) *Module { + m := &Module{ + mod: mod, + } + + // TODO + + return m +} From d60754417013137f9debb9d9b8edb2c98ba4cf92 Mon Sep 17 00:00:00 2001 From: caixw Date: Thu, 2 Jan 2025 21:26:29 +0800 Subject: [PATCH 03/12] =?UTF-8?q?refactor(cmfx/contents/article):=20?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=AD=97=E6=AE=B5=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/article/article.go | 5 ++- cmfx/contents/article/install.go | 4 ++- cmfx/contents/article/models.go | 48 +++++++++++++++++++++++-- cmfx/contents/article/module.go | 34 ++++++++++++++++-- cmfx/contents/article/routes.go | 62 -------------------------------- 5 files changed, 84 insertions(+), 69 deletions(-) delete mode 100644 cmfx/contents/article/routes.go diff --git a/cmfx/contents/article/article.go b/cmfx/contents/article/article.go index a8b6ac3..69bbbaf 100644 --- a/cmfx/contents/article/article.go +++ b/cmfx/contents/article/article.go @@ -5,4 +5,7 @@ // Package article 文章管理 package article -const topicsTableName = "topics" +const ( + topicsTableName = "topics" + tagsTableName = "tags" +) diff --git a/cmfx/contents/article/install.go b/cmfx/contents/article/install.go index 9db89fb..71027b8 100644 --- a/cmfx/contents/article/install.go +++ b/cmfx/contents/article/install.go @@ -9,10 +9,12 @@ import ( "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/categories/linkage" + "github.com/issue9/cmfx/cmfx/categories/tag" ) -func Install(mod *cmfx.Module) { +func Install(mod *cmfx.Module, ts ...string) { linkage.Install(mod, topicsTableName, &linkage.LinkageVO{}) + tag.Install(mod, tagsTableName, ts...) if err := mod.DB().Create(&articlePO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go index 95ac660..065de18 100644 --- a/cmfx/contents/article/models.go +++ b/cmfx/contents/article/models.go @@ -30,8 +30,8 @@ type articlePO struct { // 分类信息 - Topics []types.Int64s `orm:"name(topics)"` - Tags []types.Int64s `orm:"name(tags)"` + Topics types.Int64s `orm:"name(topics)"` + Tags types.Int64s `orm:"name(tags)"` // 各类时间属性 @@ -41,6 +41,7 @@ type articlePO struct { Modifier int64 `orm:"name(modifier)"` Deleted sql.NullTime `orm:"name(deleted);nullable;default(NULL)"` Deleter int64 `orm:"name(deleter)"` + Version int `orm:"name(version);occ"` // TODO 每个版本保存为不同记录?如果分版本记录,不需要 Modifier 字段 } func (*articlePO) TableName() string { return `_articles` } @@ -72,3 +73,46 @@ func (l *articlePO) BeforeUpdate() error { return nil } + +type Article struct { + Slug string + Author string + Views int + Order int + Title string + Images []string + Keywords string + Summary string + Content string + Topics []int64 + Tags []int64 + CreatorModifier int64 // 添加时为创建者否则为修改者 +} + +func (a *Article) toPO() *articlePO { + return &articlePO{ + Slug: a.Slug, + + Author: a.Author, + Views: a.Views, + Order: a.Order, + + // 内容 + + Title: a.Title, + Images: types.Strings(a.Images), + Keywords: a.Keywords, + Summary: a.Summary, + Content: a.Content, + + // 分类信息 + + Topics: types.Int64s(a.Topics), + Tags: types.Int64s(a.Tags), + + // 各类时间属性 + + Creator: a.CreatorModifier, + Modifier: a.CreatorModifier, + } +} diff --git a/cmfx/contents/article/module.go b/cmfx/contents/article/module.go index 61e5cd2..7f62bc0 100644 --- a/cmfx/contents/article/module.go +++ b/cmfx/contents/article/module.go @@ -5,26 +5,54 @@ package article import ( + "github.com/issue9/orm/v6" + "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/categories/linkage" + "github.com/issue9/cmfx/cmfx/categories/tag" + "github.com/issue9/cmfx/cmfx/query" "github.com/issue9/cmfx/cmfx/user" ) type Module struct { mod *cmfx.Module topics *linkage.Module + tags *tag.Module } // Load 加载内容管理模块 func Load(mod *cmfx.Module, u *user.Module) *Module { - topics := linkage.Load(mod, "topics") - m := &Module{ mod: mod, - topics: topics, + topics: linkage.Load(mod, topicsTableName), + tags: tag.Load(mod, tagsTableName), } // TODO return m } + +// New 添加新的文章 +func (m *Module) New(tx *orm.Tx, a *Article) error { + e := m.mod.Engine(tx) + + _, err := e.Insert(a.toPO()) + return err +} + +type articlesQuery struct { + query.Text + Topic []int64 `json:"topic"` + Creator []int64 `json:"creator"` +} + +func (m *Module) Articles(q *articlesQuery) (query.Page[Article], error) { + // TODO +} + +// Topics 用到的主题分类 +func (m *Module) Topics() *linkage.Module { return m.topics } + +// Tags 用到的标签分类 +func (m *Module) Tags() *tag.Module { return m.tags } diff --git a/cmfx/contents/article/routes.go b/cmfx/contents/article/routes.go deleted file mode 100644 index ac54722..0000000 --- a/cmfx/contents/article/routes.go +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-FileCopyrightText: 2025 caixw -// -// SPDX-License-Identifier: MIT - -package article - -import ( - "github.com/issue9/web" - - "github.com/issue9/cmfx/cmfx" - "github.com/issue9/cmfx/cmfx/query" -) - -type adminArticlesQuery struct { - query.Text - Topic []int64 `json:"topic"` - Creator []int64 `json:"creator"` -} - -func (q *adminArticlesQuery) Filter(ctx *web.FilterContext) { - q.Text.Filter(ctx) - // TODO -} - -func (m *Module) adminGetArticles(ctx *web.Context) web.Responser { - q := &adminArticlesQuery{} - if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { - return resp - } - - // TODO - - return ctx.NotImplemented() -} - -func (m *Module) adminGetArticle(ctx *web.Context) web.Responser { - // TODO - return ctx.NotImplemented() -} - -func (m *Module) getTopics(ctx *web.Context) web.Responser { - // TODO - return ctx.NotImplemented() -} - -func (m *Module) memberGetArticle(ctx *web.Context) web.Responser { - // TODO - return ctx.NotImplemented() -} - -type memberArticlesQuery struct { - // TODO -} - -func (q *memberArticlesQuery) Filter(ctx *web.Context) { - // TODO -} - -func (m *Module) memberGetArticles(ctx *web.Context) web.Responser { - // TODO - return ctx.NotImplemented() -} From 87ead7ac25cd0e030c37846d087c664b2f648ff9 Mon Sep 17 00:00:00 2001 From: caixw Date: Mon, 6 Jan 2025 18:58:52 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat(cmfx/contents/article):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20articleSnapshotPO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/article/install.go | 4 ++-- cmfx/contents/article/models.go | 7 +++++++ cmfx/contents/article/models_test.go | 2 ++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/cmfx/contents/article/install.go b/cmfx/contents/article/install.go index 71027b8..f5bf8cc 100644 --- a/cmfx/contents/article/install.go +++ b/cmfx/contents/article/install.go @@ -13,10 +13,10 @@ import ( ) func Install(mod *cmfx.Module, ts ...string) { - linkage.Install(mod, topicsTableName, &linkage.LinkageVO{}) + linkage.Install(mod, topicsTableName, &linkage.Linkage{}) tag.Install(mod, tagsTableName, ts...) - if err := mod.DB().Create(&articlePO{}); err != nil { + if err := mod.DB().Create(&articlePO{}, &articleSnapshotPO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } } diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go index 065de18..1708ac3 100644 --- a/cmfx/contents/article/models.go +++ b/cmfx/contents/article/models.go @@ -74,6 +74,13 @@ func (l *articlePO) BeforeUpdate() error { return nil } +type articleSnapshotPO struct { + articlePO + Main int64 `orm:"name(main)"` +} + +func (*articleSnapshotPO) TableName() string { return `_article_snapshots` } + type Article struct { Slug string Author string diff --git a/cmfx/contents/article/models_test.go b/cmfx/contents/article/models_test.go index 7470f8e..bdfa6cb 100644 --- a/cmfx/contents/article/models_test.go +++ b/cmfx/contents/article/models_test.go @@ -10,4 +10,6 @@ var ( _ orm.TableNamer = &articlePO{} _ orm.BeforeInserter = &articlePO{} _ orm.BeforeUpdater = &articlePO{} + + _ orm.TableNamer = &articleSnapshotPO{} ) From 964d858f715f15ed2ce60d6fd1d22d2d07703676 Mon Sep 17 00:00:00 2001 From: caixw Date: Tue, 7 Jan 2025 16:13:11 +0800 Subject: [PATCH 05/12] =?UTF-8?q?refactor(cmfx/contents/article):=20?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/article/article.go | 10 +++++ cmfx/contents/article/install.go | 5 ++- cmfx/contents/article/models.go | 67 ++++++++++------------------ cmfx/contents/article/models_test.go | 8 ++-- cmfx/contents/article/module.go | 54 +++++++++++++++++++--- 5 files changed, 88 insertions(+), 56 deletions(-) diff --git a/cmfx/contents/article/article.go b/cmfx/contents/article/article.go index 69bbbaf..22b5c4e 100644 --- a/cmfx/contents/article/article.go +++ b/cmfx/contents/article/article.go @@ -5,7 +5,17 @@ // Package article 文章管理 package article +import ( + "github.com/issue9/orm/v6" + + "github.com/issue9/cmfx/cmfx" +) + const ( topicsTableName = "topics" tagsTableName = "tags" ) + +func buildDB(mod *cmfx.Module, tableName string) *orm.DB { + return mod.DB().New(mod.DB().TablePrefix() + "_" + tableName) +} diff --git a/cmfx/contents/article/install.go b/cmfx/contents/article/install.go index f5bf8cc..0496a1f 100644 --- a/cmfx/contents/article/install.go +++ b/cmfx/contents/article/install.go @@ -12,11 +12,12 @@ import ( "github.com/issue9/cmfx/cmfx/categories/tag" ) -func Install(mod *cmfx.Module, ts ...string) { +func Install(mod *cmfx.Module, tableName string, ts ...string) { linkage.Install(mod, topicsTableName, &linkage.Linkage{}) tag.Install(mod, tagsTableName, ts...) - if err := mod.DB().Create(&articlePO{}, &articleSnapshotPO{}); err != nil { + db := buildDB(mod, tableName) + if err := db.Create(&snapshotPO{}, &articlePO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } } diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go index 1708ac3..86a550e 100644 --- a/cmfx/contents/article/models.go +++ b/cmfx/contents/article/models.go @@ -6,34 +6,37 @@ package article import ( "database/sql" + "errors" "html" "time" "github.com/issue9/cmfx/cmfx/types" ) -type articlePO struct { - ID int64 `orm:"name(id);ai"` - Slug string `orm:"name(slug);len(100);unique(slug)"` - - Author string `orm:"name(author);len(20)"` // 显示的作者信息 - Views int `orm:"name(views)"` // 查看数量 - Order int `orm:"name(order)"` // 排序,按从小到大排序 - - // 内容 - +// 文章快照的内容 +type snapshotPO struct { + ID int64 `orm:"name(id);ai"` + Author string `orm:"name(author);len(20)"` // 显示的作者信息 Title string `orm:"name(title);len(100)"` // 标题 Images types.Strings `orm:"name(images);len(1000)"` // 缩略图 Keywords string `orm:"name(keywords)"` // 关键字 Summary string `orm:"name(summary);len(2000)"` // 摘要 Content string `orm:"name(content);len(-1)"` // 文章内容 - // 分类信息 + Created time.Time `orm:"name(created)"` + Creator int64 `orm:"name(creator)"` + // 分类信息 Topics types.Int64s `orm:"name(topics)"` Tags types.Int64s `orm:"name(tags)"` +} - // 各类时间属性 +type articlePO struct { + ID int64 `orm:"name(id);ai"` + Slug string `orm:"name(slug);len(100);unique(slug)"` + Last int64 `orm:"name(last)"` // 最后一次的快照 ID + Views int `orm:"name(views)"` // 查看数量 + Order int `orm:"name(order)"` // 排序,按从小到大排序 Created time.Time `orm:"name(created)"` Creator int64 `orm:"name(creator)"` @@ -41,48 +44,29 @@ type articlePO struct { Modifier int64 `orm:"name(modifier)"` Deleted sql.NullTime `orm:"name(deleted);nullable;default(NULL)"` Deleter int64 `orm:"name(deleter)"` - Version int `orm:"name(version);occ"` // TODO 每个版本保存为不同记录?如果分版本记录,不需要 Modifier 字段 } -func (*articlePO) TableName() string { return `_articles` } +func (*snapshotPO) TableName() string { return `_snapshots` } -func (l *articlePO) BeforeInsert() error { +func (l *snapshotPO) BeforeInsert() error { l.ID = 0 l.Title = html.EscapeString(l.Title) - l.Slug = html.EscapeString(l.Slug) l.Author = html.EscapeString(l.Author) l.Keywords = html.EscapeString(l.Keywords) l.Summary = html.EscapeString(l.Summary) l.Content = html.EscapeString(l.Content) - l.Created = time.Now() - l.Modified = l.Created - - return nil -} - -func (l *articlePO) BeforeUpdate() error { - l.Title = html.EscapeString(l.Title) - l.Slug = html.EscapeString(l.Slug) - l.Author = html.EscapeString(l.Author) - l.Keywords = html.EscapeString(l.Keywords) - l.Summary = html.EscapeString(l.Summary) - l.Content = html.EscapeString(l.Content) - - l.Modified = time.Now() return nil } -type articleSnapshotPO struct { - articlePO - Main int64 `orm:"name(main)"` +func (l *snapshotPO) BeforeUpdate() error { + return errors.New("快照不会执行更新操作") } -func (*articleSnapshotPO) TableName() string { return `_article_snapshots` } +func (*articlePO) TableName() string { return `` } type Article struct { - Slug string Author string Views int Order int @@ -96,13 +80,9 @@ type Article struct { CreatorModifier int64 // 添加时为创建者否则为修改者 } -func (a *Article) toPO() *articlePO { - return &articlePO{ - Slug: a.Slug, - +func (a *Article) toPO() *snapshotPO { + return &snapshotPO{ Author: a.Author, - Views: a.Views, - Order: a.Order, // 内容 @@ -119,7 +99,6 @@ func (a *Article) toPO() *articlePO { // 各类时间属性 - Creator: a.CreatorModifier, - Modifier: a.CreatorModifier, + Creator: a.CreatorModifier, } } diff --git a/cmfx/contents/article/models_test.go b/cmfx/contents/article/models_test.go index bdfa6cb..aee1a45 100644 --- a/cmfx/contents/article/models_test.go +++ b/cmfx/contents/article/models_test.go @@ -7,9 +7,9 @@ package article import "github.com/issue9/orm/v6" var ( - _ orm.TableNamer = &articlePO{} - _ orm.BeforeInserter = &articlePO{} - _ orm.BeforeUpdater = &articlePO{} + _ orm.TableNamer = &snapshotPO{} + _ orm.BeforeInserter = &snapshotPO{} + _ orm.BeforeUpdater = &snapshotPO{} - _ orm.TableNamer = &articleSnapshotPO{} + _ orm.TableNamer = &articlePO{} ) diff --git a/cmfx/contents/article/module.go b/cmfx/contents/article/module.go index 7f62bc0..1b8b17d 100644 --- a/cmfx/contents/article/module.go +++ b/cmfx/contents/article/module.go @@ -5,24 +5,28 @@ package article import ( + "database/sql" + "time" + "github.com/issue9/orm/v6" "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/categories/linkage" "github.com/issue9/cmfx/cmfx/categories/tag" "github.com/issue9/cmfx/cmfx/query" - "github.com/issue9/cmfx/cmfx/user" ) type Module struct { + db *orm.DB mod *cmfx.Module topics *linkage.Module tags *tag.Module } // Load 加载内容管理模块 -func Load(mod *cmfx.Module, u *user.Module) *Module { +func Load(mod *cmfx.Module, tableName string) *Module { m := &Module{ + db: buildDB(mod, tableName), mod: mod, topics: linkage.Load(mod, topicsTableName), tags: tag.Load(mod, tagsTableName), @@ -34,21 +38,59 @@ func Load(mod *cmfx.Module, u *user.Module) *Module { } // New 添加新的文章 -func (m *Module) New(tx *orm.Tx, a *Article) error { +func (m *Module) New(tx *orm.Tx, slug string, a *Article) error { e := m.mod.Engine(tx) + // TODO + _, err := e.Insert(a.toPO()) return err } +func (m *Module) Set(tx *orm.Tx, a *Article) error { + e := m.mod.Engine(tx) + + _, err := e.Insert(a.toPO()) + return err +} + +func (m *Module) Get(id int64) (*Article, error) { + // TODO + return nil, nil +} + +func (m *Module) GetBySlug(slug string) (*Article, error) { + // TODO + return nil, nil +} + +// Delete 删除指定的文章 +// +// id 为文章的 ID; +// t 为删除的时间; +// deleter 为删除者的 ID; +func (m *Module) Delete(tx *orm.Tx, id int64, t time.Time, deleter int64) error { + po := &articlePO{ + ID: id, + Deleted: sql.NullTime{Valid: true, Time: t}, + Deleter: deleter, + } + + _, err := m.mod.Engine(tx).Update(po) + return err +} + type articlesQuery struct { query.Text - Topic []int64 `json:"topic"` - Creator []int64 `json:"creator"` + Topic []int64 `query:"topic"` + Creator []int64 `query:"creator"` + Modifier []int64 `query:"modifier"` + // TODO } -func (m *Module) Articles(q *articlesQuery) (query.Page[Article], error) { +func (m *Module) Articles(q *articlesQuery) (*query.Page[Article], error) { // TODO + return nil, nil } // Topics 用到的主题分类 From 41bba0d69540e0d9e4d7d34f30e68cd17f417d70 Mon Sep 17 00:00:00 2001 From: caixw Date: Tue, 7 Jan 2025 21:36:14 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat(cmfx/contents/article):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=90=84=E4=B8=AA=E8=B7=AF=E7=94=B1=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/article/install.go | 2 +- cmfx/contents/article/models.go | 57 ++--- cmfx/contents/article/module.go | 62 ----- cmfx/contents/article/routes.go | 404 +++++++++++++++++++++++++++++++ 4 files changed, 420 insertions(+), 105 deletions(-) create mode 100644 cmfx/contents/article/routes.go diff --git a/cmfx/contents/article/install.go b/cmfx/contents/article/install.go index 0496a1f..a798a7c 100644 --- a/cmfx/contents/article/install.go +++ b/cmfx/contents/article/install.go @@ -17,7 +17,7 @@ func Install(mod *cmfx.Module, tableName string, ts ...string) { tag.Install(mod, tagsTableName, ts...) db := buildDB(mod, tableName) - if err := db.Create(&snapshotPO{}, &articlePO{}); err != nil { + if err := db.Create(&snapshotPO{}, &articlePO{}, &tagRelationPO{}, &topicRelationPO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } } diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go index 86a550e..8f3ade8 100644 --- a/cmfx/contents/article/models.go +++ b/cmfx/contents/article/models.go @@ -16,19 +16,15 @@ import ( // 文章快照的内容 type snapshotPO struct { ID int64 `orm:"name(id);ai"` + Article int64 `orm:"name(article)"` // 所属的文章 ID Author string `orm:"name(author);len(20)"` // 显示的作者信息 Title string `orm:"name(title);len(100)"` // 标题 Images types.Strings `orm:"name(images);len(1000)"` // 缩略图 Keywords string `orm:"name(keywords)"` // 关键字 Summary string `orm:"name(summary);len(2000)"` // 摘要 Content string `orm:"name(content);len(-1)"` // 文章内容 - - Created time.Time `orm:"name(created)"` - Creator int64 `orm:"name(creator)"` - - // 分类信息 - Topics types.Int64s `orm:"name(topics)"` - Tags types.Int64s `orm:"name(tags)"` + Created time.Time `orm:"name(created)"` + Creator int64 `orm:"name(creator)"` } type articlePO struct { @@ -46,6 +42,16 @@ type articlePO struct { Deleter int64 `orm:"name(deleter)"` } +type tagRelationPO struct { + Tag int64 `orm:"name(tag)"` + Snapshot int64 `orm:"name(snapshot)"` +} + +type topicRelationPO struct { + Topic int64 `orm:"name(topic)"` + Snapshot int64 `orm:"name(snapshot)"` +} + func (*snapshotPO) TableName() string { return `_snapshots` } func (l *snapshotPO) BeforeInsert() error { @@ -66,39 +72,6 @@ func (l *snapshotPO) BeforeUpdate() error { func (*articlePO) TableName() string { return `` } -type Article struct { - Author string - Views int - Order int - Title string - Images []string - Keywords string - Summary string - Content string - Topics []int64 - Tags []int64 - CreatorModifier int64 // 添加时为创建者否则为修改者 -} - -func (a *Article) toPO() *snapshotPO { - return &snapshotPO{ - Author: a.Author, - - // 内容 - - Title: a.Title, - Images: types.Strings(a.Images), - Keywords: a.Keywords, - Summary: a.Summary, - Content: a.Content, - - // 分类信息 +func (*tagRelationPO) TableName() string { return "_snapshots_tags_re" } - Topics: types.Int64s(a.Topics), - Tags: types.Int64s(a.Tags), - - // 各类时间属性 - - Creator: a.CreatorModifier, - } -} +func (*topicRelationPO) TableName() string { return "_snapshots_topics_rel" } diff --git a/cmfx/contents/article/module.go b/cmfx/contents/article/module.go index 1b8b17d..271d3dc 100644 --- a/cmfx/contents/article/module.go +++ b/cmfx/contents/article/module.go @@ -5,15 +5,11 @@ package article import ( - "database/sql" - "time" - "github.com/issue9/orm/v6" "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/categories/linkage" "github.com/issue9/cmfx/cmfx/categories/tag" - "github.com/issue9/cmfx/cmfx/query" ) type Module struct { @@ -32,67 +28,9 @@ func Load(mod *cmfx.Module, tableName string) *Module { tags: tag.Load(mod, tagsTableName), } - // TODO - return m } -// New 添加新的文章 -func (m *Module) New(tx *orm.Tx, slug string, a *Article) error { - e := m.mod.Engine(tx) - - // TODO - - _, err := e.Insert(a.toPO()) - return err -} - -func (m *Module) Set(tx *orm.Tx, a *Article) error { - e := m.mod.Engine(tx) - - _, err := e.Insert(a.toPO()) - return err -} - -func (m *Module) Get(id int64) (*Article, error) { - // TODO - return nil, nil -} - -func (m *Module) GetBySlug(slug string) (*Article, error) { - // TODO - return nil, nil -} - -// Delete 删除指定的文章 -// -// id 为文章的 ID; -// t 为删除的时间; -// deleter 为删除者的 ID; -func (m *Module) Delete(tx *orm.Tx, id int64, t time.Time, deleter int64) error { - po := &articlePO{ - ID: id, - Deleted: sql.NullTime{Valid: true, Time: t}, - Deleter: deleter, - } - - _, err := m.mod.Engine(tx).Update(po) - return err -} - -type articlesQuery struct { - query.Text - Topic []int64 `query:"topic"` - Creator []int64 `query:"creator"` - Modifier []int64 `query:"modifier"` - // TODO -} - -func (m *Module) Articles(q *articlesQuery) (*query.Page[Article], error) { - // TODO - return nil, nil -} - // Topics 用到的主题分类 func (m *Module) Topics() *linkage.Module { return m.topics } diff --git a/cmfx/contents/article/routes.go b/cmfx/contents/article/routes.go new file mode 100644 index 0000000..90596a5 --- /dev/null +++ b/cmfx/contents/article/routes.go @@ -0,0 +1,404 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package article + +import ( + "database/sql" + "time" + + "github.com/issue9/orm/v6" + "github.com/issue9/orm/v6/fetch" + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/filters" + "github.com/issue9/cmfx/cmfx/query" + "github.com/issue9/cmfx/cmfx/types" +) + +// OverviewVO 文章摘要信息 +type OverviewVO struct { + XMLName struct{} `xml:"overview" json:"-" yaml:"-" cbor:"-" orm:"-"` + + ID int64 `xml:"id" json:"id" yaml:"id" cbor:"id" orm:"name(id)"` + Slug string `xml:"slug" json:"slug" yaml:"slug" cbor:"slug" orm:"name(slug)"` + Views int `xml:"views" json:"views" yaml:"views" cbor:"views" orm:"name(views)"` + Order int `xml:"order" json:"order" yaml:"order" cbor:"order" orm:"name(order)"` + Author string `xml:"author" json:"author" yaml:"author" cbor:"author" orm:"name(author)"` + Title string `xml:"title" json:"title" yaml:"title" cbor:"title" orm:"name(title)"` + Created time.Time `xml:"created" json:"created" yaml:"created" cbor:"created" orm:"name(created)"` + Modified time.Time `xml:"modified" json:"modified" yaml:"modified" cbor:"modified" orm:"name(modified)"` +} + +type OverviewQuery struct { + m *Module + query.Text + Created time.Time `query:"created"` + // TODO 添加 Tags,Topics 查询 +} + +func (q *OverviewQuery) Filter(ctx *web.FilterContext) { + q.Text.Filter(ctx) +} + +// HandleGetArticles 获取文章列表 +// +// 查询参数为 [OverviewQuery],返回对象为 [query.Page[OverviewVO]] +func (m *Module) HandleGetArticles(ctx *web.Context) web.Responser { + q := &OverviewQuery{m: m} + if resp := ctx.Read(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp + } + + sql := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id") + if !q.Created.IsZero() { + sql.Where("a.created>?", q.Created) + } + if q.Text.Text != "" { + txt := "%" + q.Text.Text + "%" + sql.Where("a.slug LIKE ? OR s.title LIKE ? OR s.author LIKE ?", txt, txt, txt) + } + + return query.PagingResponser[OverviewVO](ctx, &q.Limit, sql, nil) +} + +// ArticleVO 文章的详细内容 +type ArticleVO struct { + XMLName struct{} `xml:"article" json:"-" yaml:"-" cbor:"-" orm:"-"` + + ID int64 `orm:"name(id);ai" json:"id" yaml:"id" cbor:"id" xml:"id,attr"` + Slug string `orm:"name(slug);len(100);unique(slug)" json:"slug" yaml:"slug" cbor:"slug" xml:"slug"` + Views int `orm:"name(views)" json:"views" yaml:"views" cbor:"views" xml:"views,attr"` + Order int `orm:"name(order)" json:"order" yaml:"order" cbor:"order" xml:"order,attr"` + Author string `orm:"name(author);len(20)" json:"author" yaml:"author" cbor:"author" xml:"author"` + Title string `orm:"name(title);len(100)" json:"title" yaml:"title" cbor:"title" xml:"title"` + Images types.Strings `orm:"name(images);len(1000)" json:"images" yaml:"images" cbor:"images" xml:"images>image"` + Keywords string `orm:"name(keywords)" json:"keywords" yaml:"keywords" cbor:"keywords" xml:"keywords"` + Summary string `orm:"name(summary);len(2000)" json:"summary" yaml:"summary" cbor:"summary" xml:"summary,cdata"` + Content string `orm:"name(content);len(-1)" json:"content" yaml:"content" cbor:"content" xml:"content,cdata"` + + Created time.Time `orm:"name(created)" json:"created" yaml:"created" cbor:"created" xml:"created"` + Modified time.Time `orm:"name(modified)" json:"modified" yaml:"modified" cbor:"modified" xml:"modified"` + + // 分类信息 + Topics []int64 `orm:"-" json:"topics" yaml:"topics" cbor:"topics" xml:"topics>topic"` + Tags []int64 `orm:"-" json:"tags" yaml:"tags" cbor:"tags" xml:"tags>tag"` + + // 用于保存最后一次的快照 ID + Last int64 `orm:"name(last)" json:"-" yaml:"-" cbor:"-" xml:"-"` +} + +// HandleGetArticle 获取指定文章的详细信息 +// +// 返回参数的实际类型为 [ArticleVO]; +// article 为文章的 ID,使用都需要确保值的正确性; +func (m *Module) HandleGetArticle(ctx *web.Context, article string) web.Responser { + a := &ArticleVO{} + size, err := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). + Column("a.slug,a.views,a.order,a.created,a.modified,a.deleted,a.deleter,a.last"). + Column("s.author,s.title,s.images,s.summary,s.content,s.tags,s.topics"). + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id"). + Where("a.id=?", article). + QueryObject(true, a) + if err != nil { + return ctx.Error(err, "") + } + if size == 0 { + return ctx.NotFound() + } + + rows, err := m.db.SQLBuilder().Select(). + Where("snapshot=?", a.Last). + From(orm.TableName(&tagRelationPO{})). + Column("tag"). + Query() + if err != nil { + return ctx.Error(err, "") + } + a.Tags, err = fetch.Column[int64](true, "tag", rows) + if err != nil { + return ctx.Error(err, "") + } + + rows, err = m.db.SQLBuilder().Select(). + Where("snapshot=?", a.Last). + From(orm.TableName(&topicRelationPO{})). + Column("topic"). + Query() + if err != nil { + return ctx.Error(err, "") + } + a.Topics, err = fetch.Column[int64](true, "topic", rows) + if err != nil { + return ctx.Error(err, "") + } + + return web.OK(a) +} + +type ArticleTO struct { + m *Module + + XMLName struct{} `xml:"article" json:"-" yaml:"-" cbor:"-"` + Author string `json:"author" yaml:"author" cbor:"author" xml:"author"` + Title string `json:"title" yaml:"title" cbor:"title" xml:"title"` + Images []string `json:"images" yaml:"images" cbor:"images" xml:"images>image"` + Keywords string `json:"keywords" yaml:"keywords" cbor:"keywords" xml:"keywords"` + Summary string `json:"summary" yaml:"summary" cbor:"summary" xml:"summary"` + Content string `json:"content" yaml:"content" cbor:"content" xml:"content"` + Topics []int64 `json:"topics" yaml:"topics" cbor:"topics" xml:"topics>topic"` + Tags []int64 `json:"tags" yaml:"tags" cbor:"tags" xml:"tags>tag"` + Slug string `json:"slug" yaml:"slug" cbor:"slug" xml:"slug"` + Views int `json:"views" yaml:"views" cbor:"views" xml:"views"` + Order int `json:"order" yaml:"order" cbor:"order" xml:"order"` +} + +func (to *ArticleTO) Filter(ctx *web.FilterContext) { + ctx.Add(filters.NotEmpty("author", &to.Author)). + Add(filters.NotEmpty("title", &to.Title)). + Add(filters.NotEmpty("summary", &to.Summary)). + Add(filters.NotEmpty("content", &to.Content)). + Add(to.m.Tags().SliceFilter()("tags", &to.Tags)). + Add(to.m.Topics().SliceFilter()("topics", &to.Topics)). + Add(filters.GreatEqual(0)("views", &to.Views)). + Add(filters.GreatEqual(0)("order", &to.Order)) +} + +// HandlePostArticle 创建新的文章 +// +// creator 为创建者的 ID,调用者需要确保值的正确性; +func (m *Module) HandlePostArticle(ctx *web.Context, creator int64) web.Responser { + a := &ArticleTO{m: m} + if resp := ctx.Read(true, a, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + err := m.db.DoTransactionTx(ctx, nil, func(tx *orm.Tx) error { + last, err := tx.LastInsertID(&snapshotPO{ // 添加快照 + Author: a.Author, + Title: a.Title, + Images: a.Images, + Keywords: a.Keywords, + Summary: a.Summary, + Content: a.Content, + Created: ctx.Begin(), + Creator: creator, + }) + if err != nil { + return err + } + + article, err := tx.LastInsertID(&articlePO{ // 添加文章 + Slug: a.Slug, + Last: last, + Views: a.Views, + Order: a.Order, + Created: ctx.Begin(), + Creator: creator, + Modified: ctx.Begin(), + Modifier: creator, + }) + if err != nil { + return err + } + + // 插入标签关系表 + if l := len(a.Tags); l > 0 { + tags := make([]orm.TableNamer, 0, l) + for _, t := range a.Tags { + tags = append(tags, &tagRelationPO{Tag: t, Snapshot: last}) + } + if err = tx.InsertMany(50, tags...); err != nil { + return err + } + } + + // 插入主题关系表 + if l := len(a.Topics); l > 0 { + topics := make([]orm.TableNamer, 0, l) + for _, t := range a.Topics { + topics = append(topics, &topicRelationPO{Topic: t, Snapshot: last}) + } + if err = tx.InsertMany(50, topics...); err != nil { + return err + } + } + + // 更新快照对应的文章 ID + _, err = tx.Update(&snapshotPO{ID: last, Article: article}) + return err + }) + if err != nil { + return ctx.Error(err, "") + } + + return web.Created(nil, "") +} + +type ArticlePatchTO struct { + m *Module + + XMLName struct{} `xml:"article" json:"-" yaml:"-" cbor:"-"` + Author string `json:"author" yaml:"author" cbor:"author" xml:"author"` + Title string `json:"title" yaml:"title" cbor:"title" xml:"title"` + Images []string `json:"images" yaml:"images" cbor:"images" xml:"images>image"` + Keywords string `json:"keywords" yaml:"keywords" cbor:"keywords" xml:"keywords"` + Summary string `json:"summary" yaml:"summary" cbor:"summary" xml:"summary"` + Content string `json:"content" yaml:"content" cbor:"content" xml:"content"` + Topics []int64 `json:"topics" yaml:"topics" cbor:"topics" xml:"topics>topic"` + Tags []int64 `json:"tags" yaml:"tags" cbor:"tags" xml:"tags>tag"` +} + +func (to *ArticlePatchTO) Filter(ctx *web.FilterContext) { + ctx.Add(to.m.Topics().SliceFilter()("topics", &to.Topics)). + Add(to.m.Tags().SliceFilter()("tags", &to.Tags)) +} + +// HandlePatchArticle 修改文章的内容 +// +// article 为文章的 ID,调用者需要确保值的正确性; +// modifier 为修改者的 ID,调用者需要确保值的正确性; +func (m *Module) HandlePatchArticle(ctx *web.Context, article, modifier int64) web.Responser { + a := &ArticlePatchTO{m: m} + if resp := ctx.Read(true, a, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + m.db.DoTransactionTx(ctx, nil, func(tx *orm.Tx) error { + last, err := tx.LastInsertID(&snapshotPO{ // 添加快照 + Author: a.Author, + Article: article, + Title: a.Title, + Images: a.Images, + Keywords: a.Keywords, + Summary: a.Summary, + Content: a.Content, + Created: ctx.Begin(), + Creator: modifier, + }) + if err != nil { + return err + } + + // 插入标签关系表,不需要删除旧的关系因为 snapshot 是新的。 + if l := len(a.Tags); l > 0 { + tags := make([]orm.TableNamer, 0, l) + for _, t := range a.Tags { + tags = append(tags, &tagRelationPO{Tag: t, Snapshot: last}) + } + if err = tx.InsertMany(50, tags...); err != nil { + return err + } + } + + // 插入主题关系表 + if l := len(a.Topics); l > 0 { + topics := make([]orm.TableNamer, 0, l) + for _, t := range a.Topics { + topics = append(topics, &topicRelationPO{Topic: t, Snapshot: last}) + } + if err = tx.InsertMany(50, topics...); err != nil { + return err + } + } + + _, err = tx.Update(&articlePO{ // 更新文章 + ID: article, + Last: last, + Modified: ctx.Begin(), + Modifier: modifier, + }) + return err + }) + + return web.NoContent() +} + +// HandleDeleteArticle 删除指定的文章 +// +// article 为文章的 ID,调用者需要确保值的正确性; +// deleter 为删除者的 ID,调用者需要确保值的正确性; +func (m *Module) HandleDeleteArticle(ctx *web.Context, article, deleter int64) web.Responser { + po := &articlePO{ + ID: article, + Deleted: sql.NullTime{Valid: true, Time: ctx.Begin()}, + Deleter: deleter, + } + + if _, err := m.db.Update(po); err != nil { + return ctx.Error(err, "") + } + return web.NoContent() +} + +// HandleGetSnapshots 获取文章的快照列表 +// +// article 更新快照对应的文章 ID; +// 返回 []string,为文章对应的快照 ID 列表; +func (m *Module) HandleGetSnapshots(ctx *web.Context, article int64) web.Responser { + rows, err := m.db.SQLBuilder().Select(). + From(orm.TableName(&snapshotPO{})). + Column("id"). + Where("article=?", article). + Query() + if err != nil { + return ctx.Error(err, "") + } + + ids, err := fetch.Column[int64](false, "id", rows) + if err != nil { + return ctx.Error(err, "") + } + return web.OK(ids) +} + +// HandleGetSnapshot 获取快照的详细信息 +// +// snapshot 为快照的 ID; +func (m *Module) HandleGetSnapshot(ctx *web.Context, snapshot int64) web.Responser { + sql := m.db.SQLBuilder().Select().From(orm.TableName(&snapshotPO{}), "s"). + Column("a.slug,a.views,a.order,a.created,a.modified,a.deleted,a.deleter,a.last"). + Column("s.author,s.title,s.images,s.summary,s.content,s.tags,s.topics"). + Join("LEFT", orm.TableName(&articlePO{}), "a", "a.last=s.id"). + Where("s.id=?", snapshot) + + a := &ArticleVO{} + size, err := sql.QueryObject(true, a) + if err != nil { + return ctx.Error(err, "") + } + if size == 0 { + return ctx.NotFound() + } + + rows, err := m.db.SQLBuilder().Select(). + Where("snapshot=?", a.Last). + From(orm.TableName(&tagRelationPO{})). + Column("tag"). + Query() + if err != nil { + return ctx.Error(err, "") + } + a.Tags, err = fetch.Column[int64](true, "tag", rows) + if err != nil { + return ctx.Error(err, "") + } + + rows, err = m.db.SQLBuilder().Select(). + Where("snapshot=?", a.Last). + From(orm.TableName(&topicRelationPO{})). + Column("topic"). + Query() + if err != nil { + return ctx.Error(err, "") + } + a.Topics, err = fetch.Column[int64](true, "topic", rows) + if err != nil { + return ctx.Error(err, "") + } + + return web.OK(a) +} From 212256ba03d263c4ddd449ec60b3f4d1b20673f0 Mon Sep 17 00:00:00 2001 From: caixw Date: Tue, 7 Jan 2025 23:58:12 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat(cmfx/contents/article):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20OverviewQuery.Tags=20=E5=92=8C=20OverviewQuery.Topi?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/article/install.go | 4 ++-- cmfx/contents/article/install_test.go | 31 +++++++++++++++++++++++++++ cmfx/contents/article/models.go | 8 +++---- cmfx/contents/article/module.go | 3 +++ cmfx/contents/article/routes.go | 24 +++++++++++++++++++-- 5 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 cmfx/contents/article/install_test.go diff --git a/cmfx/contents/article/install.go b/cmfx/contents/article/install.go index a798a7c..f02e107 100644 --- a/cmfx/contents/article/install.go +++ b/cmfx/contents/article/install.go @@ -13,8 +13,8 @@ import ( ) func Install(mod *cmfx.Module, tableName string, ts ...string) { - linkage.Install(mod, topicsTableName, &linkage.Linkage{}) - tag.Install(mod, tagsTableName, ts...) + linkage.Install(mod, tableName+"_"+topicsTableName, &linkage.Linkage{}) + tag.Install(mod, tableName+"_"+tagsTableName, ts...) db := buildDB(mod, tableName) if err := db.Create(&snapshotPO{}, &articlePO{}, &tagRelationPO{}, &topicRelationPO{}); err != nil { diff --git a/cmfx/contents/article/install_test.go b/cmfx/contents/article/install_test.go new file mode 100644 index 0000000..bd7f0b4 --- /dev/null +++ b/cmfx/contents/article/install_test.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package article + +import ( + "testing" + + "github.com/issue9/assert/v4" + + "github.com/issue9/cmfx/cmfx/initial/test" +) + +func TestInstall(t *testing.T) { + a := assert.New(t, false) + + s := test.NewSuite(a) + defer s.Close() + + mod := s.NewModule("test") + Install(mod, "abc") + prefix := mod.DB().TablePrefix() + "_" + "abc" + + s.TableExists(prefix + "_snapshots"). + TableExists(prefix). + TableExists(prefix + "_tags"). + TableExists(prefix + "_topics"). + TableExists(prefix + "_snapshots_tags_rel"). + TableExists(prefix + "_snapshots_topics_rel") +} diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go index 8f3ade8..e4be413 100644 --- a/cmfx/contents/article/models.go +++ b/cmfx/contents/article/models.go @@ -29,7 +29,7 @@ type snapshotPO struct { type articlePO struct { ID int64 `orm:"name(id);ai"` - Slug string `orm:"name(slug);len(100);unique(slug)"` + Slug string `orm:"name(slug);len(200);unique(slug)"` Last int64 `orm:"name(last)"` // 最后一次的快照 ID Views int `orm:"name(views)"` // 查看数量 Order int `orm:"name(order)"` // 排序,按从小到大排序 @@ -38,7 +38,7 @@ type articlePO struct { Creator int64 `orm:"name(creator)"` Modified time.Time `orm:"name(modified)"` Modifier int64 `orm:"name(modifier)"` - Deleted sql.NullTime `orm:"name(deleted);nullable;default(NULL)"` + Deleted sql.NullTime `orm:"name(deleted);nullable"` Deleter int64 `orm:"name(deleter)"` } @@ -59,8 +59,6 @@ func (l *snapshotPO) BeforeInsert() error { l.Title = html.EscapeString(l.Title) l.Author = html.EscapeString(l.Author) l.Keywords = html.EscapeString(l.Keywords) - l.Summary = html.EscapeString(l.Summary) - l.Content = html.EscapeString(l.Content) l.Created = time.Now() return nil @@ -72,6 +70,6 @@ func (l *snapshotPO) BeforeUpdate() error { func (*articlePO) TableName() string { return `` } -func (*tagRelationPO) TableName() string { return "_snapshots_tags_re" } +func (*tagRelationPO) TableName() string { return "_snapshots_tags_rel" } func (*topicRelationPO) TableName() string { return "_snapshots_topics_rel" } diff --git a/cmfx/contents/article/module.go b/cmfx/contents/article/module.go index 271d3dc..d6f3d3c 100644 --- a/cmfx/contents/article/module.go +++ b/cmfx/contents/article/module.go @@ -20,6 +20,9 @@ type Module struct { } // Load 加载内容管理模块 +// +// tableName 文章内容的表名部分,其它表都以此作为表名前缀; +// mod 所属的模块; func Load(mod *cmfx.Module, tableName string) *Module { m := &Module{ db: buildDB(mod, tableName), diff --git a/cmfx/contents/article/routes.go b/cmfx/contents/article/routes.go index 90596a5..95c2a28 100644 --- a/cmfx/contents/article/routes.go +++ b/cmfx/contents/article/routes.go @@ -8,6 +8,7 @@ import ( "database/sql" "time" + "github.com/issue9/orm/v6/sqlbuilder" "github.com/issue9/orm/v6" "github.com/issue9/orm/v6/fetch" "github.com/issue9/web" @@ -36,11 +37,14 @@ type OverviewQuery struct { m *Module query.Text Created time.Time `query:"created"` - // TODO 添加 Tags,Topics 查询 + Tags []int64 `query:"tag"` + Topics []int64 `query:"topic"` } func (q *OverviewQuery) Filter(ctx *web.FilterContext) { q.Text.Filter(ctx) + ctx.Add(q.m.Tags().SliceFilter()("tag", &q.Tags)). + Add(q.m.Topics().SliceFilter()("topic", &q.Topics)) } // HandleGetArticles 获取文章列表 @@ -53,7 +57,9 @@ func (m *Module) HandleGetArticles(ctx *web.Context) web.Responser { } sql := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). - Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id") + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id"). + Join("LEFT", orm.TableName(&tagRelationPO{}), "tags", "tags.snapshot=s.id"). + Join("LEFT", orm.TableName(&topicRelationPO{}), "topics", "topics.snapshot=s.id") if !q.Created.IsZero() { sql.Where("a.created>?", q.Created) } @@ -61,6 +67,20 @@ func (m *Module) HandleGetArticles(ctx *web.Context) web.Responser { txt := "%" + q.Text.Text + "%" sql.Where("a.slug LIKE ? OR s.title LIKE ? OR s.author LIKE ?", txt, txt, txt) } + if len(q.Tags) > 0 { + sql.AndGroup(func(ws *sqlbuilder.WhereStmt) { + for _, t := range q.Tags { + ws.Or("tags.tag=?", t) + } + }) + } + if len(q.Topics) > 0 { + sql.AndGroup(func(ws *sqlbuilder.WhereStmt) { + for _, t := range q.Topics { + ws.Or("topics.topic=?", t) + } + }) + } return query.PagingResponser[OverviewVO](ctx, &q.Limit, sql, nil) } From 5d019297d99de847008d3f61c7add03739e01e78 Mon Sep 17 00:00:00 2001 From: caixw Date: Wed, 8 Jan 2025 10:13:55 +0800 Subject: [PATCH 08/12] =?UTF-8?q?refactor(cmfx/contents/article):=20topics?= =?UTF-8?q?=20=E5=92=8C=20tags=20=E5=85=B3=E8=81=94=E5=9C=A8=20Article=20?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/article/install.go | 13 +++-- cmfx/contents/article/install_test.go | 12 ++--- cmfx/contents/article/models.go | 14 ++--- cmfx/contents/article/models_test.go | 2 + cmfx/contents/article/module.go | 10 ++-- cmfx/contents/article/routes.go | 76 +++++++++++++------------- cmfx/contents/article/routes_test.go | 78 +++++++++++++++++++++++++++ cmfx/contents/comment/models.go | 1 + 8 files changed, 145 insertions(+), 61 deletions(-) create mode 100644 cmfx/contents/article/routes_test.go diff --git a/cmfx/contents/article/install.go b/cmfx/contents/article/install.go index f02e107..7e67e46 100644 --- a/cmfx/contents/article/install.go +++ b/cmfx/contents/article/install.go @@ -12,12 +12,17 @@ import ( "github.com/issue9/cmfx/cmfx/categories/tag" ) -func Install(mod *cmfx.Module, tableName string, ts ...string) { - linkage.Install(mod, tableName+"_"+topicsTableName, &linkage.Linkage{}) - tag.Install(mod, tableName+"_"+tagsTableName, ts...) +// Install 安装数据 +// +// mod 所属模块;tablePrefix 表名前缀;ts 关联的标签列表; +func Install(mod *cmfx.Module, tablePrefix string, ts ...string) *Module { + linkage.Install(mod, tablePrefix+"_"+topicsTableName, &linkage.Linkage{Title: "topics"}) + tag.Install(mod, tablePrefix+"_"+tagsTableName, ts...) - db := buildDB(mod, tableName) + db := buildDB(mod, tablePrefix) if err := db.Create(&snapshotPO{}, &articlePO{}, &tagRelationPO{}, &topicRelationPO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } + + return Load(mod, tablePrefix) } diff --git a/cmfx/contents/article/install_test.go b/cmfx/contents/article/install_test.go index bd7f0b4..2838410 100644 --- a/cmfx/contents/article/install_test.go +++ b/cmfx/contents/article/install_test.go @@ -20,12 +20,12 @@ func TestInstall(t *testing.T) { mod := s.NewModule("test") Install(mod, "abc") - prefix := mod.DB().TablePrefix() + "_" + "abc" + prefix := mod.ID() + "_" + "abc" s.TableExists(prefix + "_snapshots"). - TableExists(prefix). - TableExists(prefix + "_tags"). - TableExists(prefix + "_topics"). - TableExists(prefix + "_snapshots_tags_rel"). - TableExists(prefix + "_snapshots_topics_rel") + TableExists(prefix + "_articles"). + TableExists(prefix + "_" + tagsTableName). + TableExists(prefix + "_" + topicsTableName). + TableExists(prefix + "_article_tag_rel"). + TableExists(prefix + "_article_topic_rel") } diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go index e4be413..bfed9c2 100644 --- a/cmfx/contents/article/models.go +++ b/cmfx/contents/article/models.go @@ -43,13 +43,13 @@ type articlePO struct { } type tagRelationPO struct { - Tag int64 `orm:"name(tag)"` - Snapshot int64 `orm:"name(snapshot)"` + Tag int64 `orm:"name(tag)"` + Article int64 `orm:"name(article)"` } type topicRelationPO struct { - Topic int64 `orm:"name(topic)"` - Snapshot int64 `orm:"name(snapshot)"` + Topic int64 `orm:"name(topic)"` + Article int64 `orm:"name(article)"` } func (*snapshotPO) TableName() string { return `_snapshots` } @@ -68,8 +68,8 @@ func (l *snapshotPO) BeforeUpdate() error { return errors.New("快照不会执行更新操作") } -func (*articlePO) TableName() string { return `` } +func (*articlePO) TableName() string { return `_articles` } -func (*tagRelationPO) TableName() string { return "_snapshots_tags_rel" } +func (*tagRelationPO) TableName() string { return "_article_tag_rel" } -func (*topicRelationPO) TableName() string { return "_snapshots_topics_rel" } +func (*topicRelationPO) TableName() string { return "_article_topic_rel" } diff --git a/cmfx/contents/article/models_test.go b/cmfx/contents/article/models_test.go index aee1a45..79160a1 100644 --- a/cmfx/contents/article/models_test.go +++ b/cmfx/contents/article/models_test.go @@ -12,4 +12,6 @@ var ( _ orm.BeforeUpdater = &snapshotPO{} _ orm.TableNamer = &articlePO{} + _ orm.TableNamer = &tagRelationPO{} + _ orm.TableNamer = &topicRelationPO{} ) diff --git a/cmfx/contents/article/module.go b/cmfx/contents/article/module.go index d6f3d3c..7c3fb33 100644 --- a/cmfx/contents/article/module.go +++ b/cmfx/contents/article/module.go @@ -21,14 +21,14 @@ type Module struct { // Load 加载内容管理模块 // -// tableName 文章内容的表名部分,其它表都以此作为表名前缀; +// tablePrefix 其它表都以此作为表名前缀; // mod 所属的模块; -func Load(mod *cmfx.Module, tableName string) *Module { +func Load(mod *cmfx.Module, tablePrefix string) *Module { m := &Module{ - db: buildDB(mod, tableName), + db: buildDB(mod, tablePrefix), mod: mod, - topics: linkage.Load(mod, topicsTableName), - tags: tag.Load(mod, tagsTableName), + topics: linkage.Load(mod, tablePrefix+"_"+topicsTableName), + tags: tag.Load(mod, tablePrefix+"_"+tagsTableName), } return m diff --git a/cmfx/contents/article/routes.go b/cmfx/contents/article/routes.go index 95c2a28..28edcad 100644 --- a/cmfx/contents/article/routes.go +++ b/cmfx/contents/article/routes.go @@ -8,9 +8,9 @@ import ( "database/sql" "time" - "github.com/issue9/orm/v6/sqlbuilder" "github.com/issue9/orm/v6" "github.com/issue9/orm/v6/fetch" + "github.com/issue9/orm/v6/sqlbuilder" "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" @@ -58,8 +58,8 @@ func (m *Module) HandleGetArticles(ctx *web.Context) web.Responser { sql := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id"). - Join("LEFT", orm.TableName(&tagRelationPO{}), "tags", "tags.snapshot=s.id"). - Join("LEFT", orm.TableName(&topicRelationPO{}), "topics", "topics.snapshot=s.id") + Join("LEFT", orm.TableName(&tagRelationPO{}), "tags", "tags.article=a.id"). + Join("LEFT", orm.TableName(&topicRelationPO{}), "topics", "topics.article=a.id") if !q.Created.IsZero() { sql.Where("a.created>?", q.Created) } @@ -115,7 +115,7 @@ type ArticleVO struct { // // 返回参数的实际类型为 [ArticleVO]; // article 为文章的 ID,使用都需要确保值的正确性; -func (m *Module) HandleGetArticle(ctx *web.Context, article string) web.Responser { +func (m *Module) HandleGetArticle(ctx *web.Context, article int64) web.Responser { a := &ArticleVO{} size, err := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). Column("a.slug,a.views,a.order,a.created,a.modified,a.deleted,a.deleter,a.last"). @@ -130,28 +130,7 @@ func (m *Module) HandleGetArticle(ctx *web.Context, article string) web.Response return ctx.NotFound() } - rows, err := m.db.SQLBuilder().Select(). - Where("snapshot=?", a.Last). - From(orm.TableName(&tagRelationPO{})). - Column("tag"). - Query() - if err != nil { - return ctx.Error(err, "") - } - a.Tags, err = fetch.Column[int64](true, "tag", rows) - if err != nil { - return ctx.Error(err, "") - } - - rows, err = m.db.SQLBuilder().Select(). - Where("snapshot=?", a.Last). - From(orm.TableName(&topicRelationPO{})). - Column("topic"). - Query() - if err != nil { - return ctx.Error(err, "") - } - a.Topics, err = fetch.Column[int64](true, "topic", rows) + a.Topics, a.Tags, err = m.getArticleAttribute(a.ID) if err != nil { return ctx.Error(err, "") } @@ -190,6 +169,7 @@ func (to *ArticleTO) Filter(ctx *web.FilterContext) { // HandlePostArticle 创建新的文章 // // creator 为创建者的 ID,调用者需要确保值的正确性; +// 提交类型为 [ArticleTO]; func (m *Module) HandlePostArticle(ctx *web.Context, creator int64) web.Responser { a := &ArticleTO{m: m} if resp := ctx.Read(true, a, cmfx.BadRequestInvalidBody); resp != nil { @@ -229,7 +209,7 @@ func (m *Module) HandlePostArticle(ctx *web.Context, creator int64) web.Response if l := len(a.Tags); l > 0 { tags := make([]orm.TableNamer, 0, l) for _, t := range a.Tags { - tags = append(tags, &tagRelationPO{Tag: t, Snapshot: last}) + tags = append(tags, &tagRelationPO{Tag: t, Article: last}) } if err = tx.InsertMany(50, tags...); err != nil { return err @@ -240,7 +220,10 @@ func (m *Module) HandlePostArticle(ctx *web.Context, creator int64) web.Response if l := len(a.Topics); l > 0 { topics := make([]orm.TableNamer, 0, l) for _, t := range a.Topics { - topics = append(topics, &topicRelationPO{Topic: t, Snapshot: last}) + topics = append(topics, &topicRelationPO{Topic: t, Article: last}) + if err := m.topics.AddCount(t, 1); err != nil { + ctx.Logs().ERROR().Error(err) // 影响不大,输出错误即可。 + } } if err = tx.InsertMany(50, topics...); err != nil { return err @@ -307,7 +290,7 @@ func (m *Module) HandlePatchArticle(ctx *web.Context, article, modifier int64) w if l := len(a.Tags); l > 0 { tags := make([]orm.TableNamer, 0, l) for _, t := range a.Tags { - tags = append(tags, &tagRelationPO{Tag: t, Snapshot: last}) + tags = append(tags, &tagRelationPO{Tag: t, Article: last}) } if err = tx.InsertMany(50, tags...); err != nil { return err @@ -318,7 +301,10 @@ func (m *Module) HandlePatchArticle(ctx *web.Context, article, modifier int64) w if l := len(a.Topics); l > 0 { topics := make([]orm.TableNamer, 0, l) for _, t := range a.Topics { - topics = append(topics, &topicRelationPO{Topic: t, Snapshot: last}) + topics = append(topics, &topicRelationPO{Topic: t, Article: last}) + if err := m.topics.AddCount(t, 1); err != nil { + ctx.Logs().ERROR().Error(err) // 影响不大,输出错误即可 + } } if err = tx.InsertMany(50, topics...); err != nil { return err @@ -348,6 +334,8 @@ func (m *Module) HandleDeleteArticle(ctx *web.Context, article, deleter int64) w Deleter: deleter, } + // delete topics + if _, err := m.db.Update(po); err != nil { return ctx.Error(err, "") } @@ -394,31 +382,41 @@ func (m *Module) HandleGetSnapshot(ctx *web.Context, snapshot int64) web.Respons return ctx.NotFound() } + a.Topics, a.Tags, err = m.getArticleAttribute(a.ID) + if err != nil { + return ctx.Error(err, "") + } + + return web.OK(a) +} + +func (m *Module) getArticleAttribute(article int64) (topics, tags []int64, err error) { rows, err := m.db.SQLBuilder().Select(). - Where("snapshot=?", a.Last). + Where("article=?", article). From(orm.TableName(&tagRelationPO{})). Column("tag"). Query() if err != nil { - return ctx.Error(err, "") + return nil, nil, err } - a.Tags, err = fetch.Column[int64](true, "tag", rows) + + tags, err = fetch.Column[int64](true, "tag", rows) if err != nil { - return ctx.Error(err, "") + return nil, nil, err } rows, err = m.db.SQLBuilder().Select(). - Where("snapshot=?", a.Last). + Where("article=?", article). From(orm.TableName(&topicRelationPO{})). Column("topic"). Query() if err != nil { - return ctx.Error(err, "") + return nil, nil, err } - a.Topics, err = fetch.Column[int64](true, "topic", rows) + topics, err = fetch.Column[int64](true, "topic", rows) if err != nil { - return ctx.Error(err, "") + return nil, nil, err } - return web.OK(a) + return topics, tags, nil } diff --git a/cmfx/contents/article/routes_test.go b/cmfx/contents/article/routes_test.go new file mode 100644 index 0000000..267c167 --- /dev/null +++ b/cmfx/contents/article/routes_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package article + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/issue9/assert/v4" + "github.com/issue9/mux/v9/header" + "github.com/issue9/web" + "github.com/issue9/web/server/servertest" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/initial/test" +) + +func TestModule_Handle(t *testing.T) { + a := assert.New(t, false) + s := test.NewSuite(a) + + mod := s.NewModule("article") + m := Install(mod, "a", "t1", "t2") + + s.Router().Post("/articles", func(ctx *web.Context) web.Responser { return m.HandlePostArticle(ctx, 1) }). + Get("/articles/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetArticle(ctx, id) + }) + + defer servertest.Run(a, s.Module().Server())() + defer s.Close() + + t.Run("HandlePostArticles", func(t *testing.T) { + article := &ArticleTO{ + Author: "author", + Title: "title", + Images: []string{"https://example.com/img1.png", "https://example.com/img2.png"}, + Keywords: "k1,k2", + Summary: "summary", + Content: "content

line

", + Topics: []int64{1}, + Tags: []int64{1, 2}, + Slug: "slug", + Views: 10, + Order: 1, + } + data, err := json.Marshal(article) + a.NotError(err) + s.Post("/articles", data). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusCreated). + BodyFunc(func(a *assert.Assertion, body []byte) { + println(string(body)) + }) + + s.Get("/articles/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + BodyFunc(func(a *assert.Assertion, body []byte) { + vo := &ArticleVO{} + err := json.Unmarshal(body, vo) + a.NotError(err). + Equal(vo.Author, article.Author). + Equal(vo.Title, article.Title). + Equal(vo.Images, article.Images). + Equal(vo.Content, article.Content). + Equal(vo.Order, article.Order) + }) + }) +} diff --git a/cmfx/contents/comment/models.go b/cmfx/contents/comment/models.go index 3d48ac9..728b480 100644 --- a/cmfx/contents/comment/models.go +++ b/cmfx/contents/comment/models.go @@ -15,6 +15,7 @@ type commentPO struct { Content string `orm:"name(content);len(-1)"` // 文章内容 Target int64 `orm:"name(target)"` // 关联对象的 ID Creator int64 `orm:"name(creator)"` // 作者 ID + Rate int `orm:"name(rate)"` // 评分 Created time.Time `orm:"name(created)"` Modified time.Time `orm:"name(modified)"` Deleted sql.NullTime `orm:"name(deleted);nullable;default(NULL)"` From 00f75aba5341c0dbc37674cdf6fa50675bf358da Mon Sep 17 00:00:00 2001 From: caixw Date: Wed, 8 Jan 2025 15:32:40 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix(cmfx/contents/articles):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E5=90=84=E7=B1=BB=E5=B0=8F=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/categories/linkage/module.go | 4 + cmfx/contents/article/install.go | 5 +- cmfx/contents/article/install_test.go | 4 +- cmfx/contents/article/models.go | 22 ++- cmfx/contents/article/models_test.go | 2 - cmfx/contents/article/module.go | 21 +-- cmfx/contents/article/routes.go | 195 +++++++++++++------------- cmfx/contents/article/routes_test.go | 116 ++++++++++++++- cmfx/relationship/module.go | 9 ++ 9 files changed, 249 insertions(+), 129 deletions(-) diff --git a/cmfx/categories/linkage/module.go b/cmfx/categories/linkage/module.go index 66b705b..95d9f66 100644 --- a/cmfx/categories/linkage/module.go +++ b/cmfx/categories/linkage/module.go @@ -258,6 +258,10 @@ func (m *Module) Validator(v int64) bool { return false } + if v == root.ID { + return true + } + curr, _ := find(v, root) return curr != nil } diff --git a/cmfx/contents/article/install.go b/cmfx/contents/article/install.go index 7e67e46..d345605 100644 --- a/cmfx/contents/article/install.go +++ b/cmfx/contents/article/install.go @@ -10,6 +10,7 @@ import ( "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/categories/linkage" "github.com/issue9/cmfx/cmfx/categories/tag" + "github.com/issue9/cmfx/cmfx/relationship" ) // Install 安装数据 @@ -18,9 +19,11 @@ import ( func Install(mod *cmfx.Module, tablePrefix string, ts ...string) *Module { linkage.Install(mod, tablePrefix+"_"+topicsTableName, &linkage.Linkage{Title: "topics"}) tag.Install(mod, tablePrefix+"_"+tagsTableName, ts...) + relationship.Install[int64, int64](mod, tablePrefix+"_article_topic") + relationship.Install[int64, int64](mod, tablePrefix+"_article_tag") db := buildDB(mod, tablePrefix) - if err := db.Create(&snapshotPO{}, &articlePO{}, &tagRelationPO{}, &topicRelationPO{}); err != nil { + if err := db.Create(&snapshotPO{}, &articlePO{}); err != nil { panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) } diff --git a/cmfx/contents/article/install_test.go b/cmfx/contents/article/install_test.go index 2838410..d45e330 100644 --- a/cmfx/contents/article/install_test.go +++ b/cmfx/contents/article/install_test.go @@ -25,7 +25,5 @@ func TestInstall(t *testing.T) { s.TableExists(prefix + "_snapshots"). TableExists(prefix + "_articles"). TableExists(prefix + "_" + tagsTableName). - TableExists(prefix + "_" + topicsTableName). - TableExists(prefix + "_article_tag_rel"). - TableExists(prefix + "_article_topic_rel") + TableExists(prefix + "_" + topicsTableName) } diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go index bfed9c2..772923d 100644 --- a/cmfx/contents/article/models.go +++ b/cmfx/contents/article/models.go @@ -42,16 +42,6 @@ type articlePO struct { Deleter int64 `orm:"name(deleter)"` } -type tagRelationPO struct { - Tag int64 `orm:"name(tag)"` - Article int64 `orm:"name(article)"` -} - -type topicRelationPO struct { - Topic int64 `orm:"name(topic)"` - Article int64 `orm:"name(article)"` -} - func (*snapshotPO) TableName() string { return `_snapshots` } func (l *snapshotPO) BeforeInsert() error { @@ -70,6 +60,14 @@ func (l *snapshotPO) BeforeUpdate() error { func (*articlePO) TableName() string { return `_articles` } -func (*tagRelationPO) TableName() string { return "_article_tag_rel" } +func (l *articlePO) BeforeInsert() error { + l.ID = 0 + l.Created = time.Now() -func (*topicRelationPO) TableName() string { return "_article_topic_rel" } + return nil +} + +func (l *articlePO) BeforeUpdate() error { + l.Modified = time.Now() + return nil +} diff --git a/cmfx/contents/article/models_test.go b/cmfx/contents/article/models_test.go index 79160a1..aee1a45 100644 --- a/cmfx/contents/article/models_test.go +++ b/cmfx/contents/article/models_test.go @@ -12,6 +12,4 @@ var ( _ orm.BeforeUpdater = &snapshotPO{} _ orm.TableNamer = &articlePO{} - _ orm.TableNamer = &tagRelationPO{} - _ orm.TableNamer = &topicRelationPO{} ) diff --git a/cmfx/contents/article/module.go b/cmfx/contents/article/module.go index 7c3fb33..7f2a31a 100644 --- a/cmfx/contents/article/module.go +++ b/cmfx/contents/article/module.go @@ -10,13 +10,16 @@ import ( "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/categories/linkage" "github.com/issue9/cmfx/cmfx/categories/tag" + "github.com/issue9/cmfx/cmfx/relationship" ) type Module struct { - db *orm.DB - mod *cmfx.Module - topics *linkage.Module - tags *tag.Module + db *orm.DB + mod *cmfx.Module + topics *linkage.Module + tags *tag.Module + topicRel *relationship.Module[int64, int64] + tagRel *relationship.Module[int64, int64] } // Load 加载内容管理模块 @@ -25,10 +28,12 @@ type Module struct { // mod 所属的模块; func Load(mod *cmfx.Module, tablePrefix string) *Module { m := &Module{ - db: buildDB(mod, tablePrefix), - mod: mod, - topics: linkage.Load(mod, tablePrefix+"_"+topicsTableName), - tags: tag.Load(mod, tablePrefix+"_"+tagsTableName), + db: buildDB(mod, tablePrefix), + mod: mod, + topics: linkage.Load(mod, tablePrefix+"_"+topicsTableName), + tags: tag.Load(mod, tablePrefix+"_"+tagsTableName), + topicRel: relationship.Load[int64, int64](mod, tablePrefix+"_article_topic"), + tagRel: relationship.Load[int64, int64](mod, tablePrefix+"_article_tag"), } return m diff --git a/cmfx/contents/article/routes.go b/cmfx/contents/article/routes.go index 28edcad..dc0ea60 100644 --- a/cmfx/contents/article/routes.go +++ b/cmfx/contents/article/routes.go @@ -9,7 +9,6 @@ import ( "time" "github.com/issue9/orm/v6" - "github.com/issue9/orm/v6/fetch" "github.com/issue9/orm/v6/sqlbuilder" "github.com/issue9/web" @@ -52,14 +51,15 @@ func (q *OverviewQuery) Filter(ctx *web.FilterContext) { // 查询参数为 [OverviewQuery],返回对象为 [query.Page[OverviewVO]] func (m *Module) HandleGetArticles(ctx *web.Context) web.Responser { q := &OverviewQuery{m: m} - if resp := ctx.Read(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { return resp } sql := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). + Column("a.id,a.slug,a.views,a.{order},s.title,a.created,a.modified"). Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id"). - Join("LEFT", orm.TableName(&tagRelationPO{}), "tags", "tags.article=a.id"). - Join("LEFT", orm.TableName(&topicRelationPO{}), "topics", "topics.article=a.id") + AndIsNull("a.deleted"). + Desc("a.{order}") if !q.Created.IsZero() { sql.Where("a.created>?", q.Created) } @@ -68,16 +68,18 @@ func (m *Module) HandleGetArticles(ctx *web.Context) web.Responser { sql.Where("a.slug LIKE ? OR s.title LIKE ? OR s.author LIKE ?", txt, txt, txt) } if len(q.Tags) > 0 { + m.tagRel.LeftJoin(sql, "tags", "tags.v1=a.id") sql.AndGroup(func(ws *sqlbuilder.WhereStmt) { for _, t := range q.Tags { - ws.Or("tags.tag=?", t) + ws.Or("tags.v2=?", t) } }) } if len(q.Topics) > 0 { + m.topicRel.LeftJoin(sql, "topics", "topics.v1=a.id") sql.AndGroup(func(ws *sqlbuilder.WhereStmt) { for _, t := range q.Topics { - ws.Or("topics.topic=?", t) + ws.Or("topics.v2=?", t) } }) } @@ -89,7 +91,8 @@ func (m *Module) HandleGetArticles(ctx *web.Context) web.Responser { type ArticleVO struct { XMLName struct{} `xml:"article" json:"-" yaml:"-" cbor:"-" orm:"-"` - ID int64 `orm:"name(id);ai" json:"id" yaml:"id" cbor:"id" xml:"id,attr"` + ID int64 `orm:"name(id)" json:"id" yaml:"id" cbor:"id" xml:"id,attr"` + Snapshot int64 `orm:"name(snapshot)" json:"snapshot" yaml:"snapshot" cbor:"snapshot" xml:"snapshot,attr"` // 此文章对应的快照 ID Slug string `orm:"name(slug);len(100);unique(slug)" json:"slug" yaml:"slug" cbor:"slug" xml:"slug"` Views int `orm:"name(views)" json:"views" yaml:"views" cbor:"views" xml:"views,attr"` Order int `orm:"name(order)" json:"order" yaml:"order" cbor:"order" xml:"order,attr"` @@ -118,10 +121,11 @@ type ArticleVO struct { func (m *Module) HandleGetArticle(ctx *web.Context, article int64) web.Responser { a := &ArticleVO{} size, err := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). - Column("a.slug,a.views,a.order,a.created,a.modified,a.deleted,a.deleter,a.last"). - Column("s.author,s.title,s.images,s.summary,s.content,s.tags,s.topics"). + Column("a.id,a.slug,a.views,a.{order},a.created,a.modified,a.deleted,a.deleter,a.last"). + Column("s.author,s.title,s.images,s.summary,s.content,s.id as snapshot"). Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id"). Where("a.id=?", article). + AndIsNull("a.deleted"). QueryObject(true, a) if err != nil { return ctx.Error(err, "") @@ -177,61 +181,50 @@ func (m *Module) HandlePostArticle(ctx *web.Context, creator int64) web.Response } err := m.db.DoTransactionTx(ctx, nil, func(tx *orm.Tx) error { - last, err := tx.LastInsertID(&snapshotPO{ // 添加快照 - Author: a.Author, - Title: a.Title, - Images: a.Images, - Keywords: a.Keywords, - Summary: a.Summary, - Content: a.Content, + article, err := tx.LastInsertID(&articlePO{ // 添加文章 + Slug: a.Slug, + Views: a.Views, + Order: a.Order, Created: ctx.Begin(), Creator: creator, + Modified: ctx.Begin(), + Modifier: creator, }) if err != nil { return err } - article, err := tx.LastInsertID(&articlePO{ // 添加文章 - Slug: a.Slug, - Last: last, - Views: a.Views, - Order: a.Order, + snapshot, err := tx.LastInsertID(&snapshotPO{ // 添加快照 + Article: article, + Author: a.Author, + Title: a.Title, + Images: a.Images, + Keywords: a.Keywords, + Summary: a.Summary, + Content: a.Content, Created: ctx.Begin(), Creator: creator, - Modified: ctx.Begin(), - Modifier: creator, }) if err != nil { return err } // 插入标签关系表 - if l := len(a.Tags); l > 0 { - tags := make([]orm.TableNamer, 0, l) - for _, t := range a.Tags { - tags = append(tags, &tagRelationPO{Tag: t, Article: last}) - } - if err = tx.InsertMany(50, tags...); err != nil { + for _, t := range a.Tags { + if err := m.tagRel.Add(tx, article, t); err != nil { return err } } // 插入主题关系表 - if l := len(a.Topics); l > 0 { - topics := make([]orm.TableNamer, 0, l) - for _, t := range a.Topics { - topics = append(topics, &topicRelationPO{Topic: t, Article: last}) - if err := m.topics.AddCount(t, 1); err != nil { - ctx.Logs().ERROR().Error(err) // 影响不大,输出错误即可。 - } - } - if err = tx.InsertMany(50, topics...); err != nil { + for _, t := range a.Topics { + if err := m.topicRel.Add(tx, article, t); err != nil { return err } } - // 更新快照对应的文章 ID - _, err = tx.Update(&snapshotPO{ID: last, Article: article}) + // 更新 Article.Last + _, err = tx.Update(&articlePO{ID: article, Last: snapshot}) return err }) if err != nil { @@ -262,9 +255,18 @@ func (to *ArticlePatchTO) Filter(ctx *web.FilterContext) { // HandlePatchArticle 修改文章的内容 // -// article 为文章的 ID,调用者需要确保值的正确性; +// article 为文章的 ID; // modifier 为修改者的 ID,调用者需要确保值的正确性; func (m *Module) HandlePatchArticle(ctx *web.Context, article, modifier int64) web.Responser { + ar := &articlePO{ID: article} + found, err := m.db.Select(ar) + if err != nil { + return ctx.Error(err, "") + } + if !found || ar.Deleted.Valid { + return ctx.NotFound() + } + a := &ArticlePatchTO{m: m} if resp := ctx.Read(true, a, cmfx.BadRequestInvalidBody); resp != nil { return resp @@ -286,27 +288,20 @@ func (m *Module) HandlePatchArticle(ctx *web.Context, article, modifier int64) w return err } - // 插入标签关系表,不需要删除旧的关系因为 snapshot 是新的。 - if l := len(a.Tags); l > 0 { - tags := make([]orm.TableNamer, 0, l) - for _, t := range a.Tags { - tags = append(tags, &tagRelationPO{Tag: t, Article: last}) - } - if err = tx.InsertMany(50, tags...); err != nil { + if err = m.tagRel.DeleteByV1(tx, article); err != nil { // 删除旧的关系 + return err + } + for _, t := range a.Tags { // 插入标签关系表 + if err := m.tagRel.Add(tx, last, t); err != nil { return err } } - // 插入主题关系表 - if l := len(a.Topics); l > 0 { - topics := make([]orm.TableNamer, 0, l) - for _, t := range a.Topics { - topics = append(topics, &topicRelationPO{Topic: t, Article: last}) - if err := m.topics.AddCount(t, 1); err != nil { - ctx.Logs().ERROR().Error(err) // 影响不大,输出错误即可 - } - } - if err = tx.InsertMany(50, topics...); err != nil { + if err = m.topicRel.DeleteByV1(tx, article); err != nil { // 删除旧的关系 + return err + } + for _, t := range a.Topics { // 插入主题关系表 + if err := m.topicRel.Add(tx, last, t); err != nil { return err } } @@ -325,16 +320,33 @@ func (m *Module) HandlePatchArticle(ctx *web.Context, article, modifier int64) w // HandleDeleteArticle 删除指定的文章 // -// article 为文章的 ID,调用者需要确保值的正确性; +// article 为文章的 ID; // deleter 为删除者的 ID,调用者需要确保值的正确性; func (m *Module) HandleDeleteArticle(ctx *web.Context, article, deleter int64) web.Responser { + a := &articlePO{ID: article} + found, err := m.db.Select(a) + if err != nil { + return ctx.Error(err, "") + } + if !found || a.Deleted.Valid { + return ctx.NotFound() + } + po := &articlePO{ ID: article, Deleted: sql.NullTime{Valid: true, Time: ctx.Begin()}, Deleter: deleter, } - // delete topics + err = m.db.DoTransactionTx(ctx, nil, func(tx *orm.Tx) error { + if err := m.tagRel.DeleteByV1(tx, article); err != nil { // 删除旧的关系 + return err + } + return m.topicRel.DeleteByV1(tx, article) // 删除旧的关系 + }) + if err != nil { + return ctx.Error(err, "") + } if _, err := m.db.Update(po); err != nil { return ctx.Error(err, "") @@ -345,33 +357,39 @@ func (m *Module) HandleDeleteArticle(ctx *web.Context, article, deleter int64) w // HandleGetSnapshots 获取文章的快照列表 // // article 更新快照对应的文章 ID; -// 返回 []string,为文章对应的快照 ID 列表; +// 返回 []OverviewVO; func (m *Module) HandleGetSnapshots(ctx *web.Context, article int64) web.Responser { - rows, err := m.db.SQLBuilder().Select(). - From(orm.TableName(&snapshotPO{})). - Column("id"). - Where("article=?", article). - Query() - if err != nil { - return ctx.Error(err, "") + q := &query.Text{} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp } - ids, err := fetch.Column[int64](false, "id", rows) - if err != nil { - return ctx.Error(err, "") + sql := m.db.SQLBuilder().Select().From(orm.TableName(&snapshotPO{}), "s"). + Column("a.id,a.slug,a.views,a.{order},s.title,a.created,a.modified"). + Join("LEFT", orm.TableName(&articlePO{}), "a", "a.id=s.article"). + Where("a.id=?", article). + AndIsNull("a.deleted") + + if q.Text != "" { + txt := "%" + q.Text + "%" + sql.Where("a.slug LIKE ? OR s.title LIKE ? OR s.author LIKE ?", txt, txt, txt) } - return web.OK(ids) + + return query.PagingResponser[OverviewVO](ctx, &q.Limit, sql, nil) } // HandleGetSnapshot 获取快照的详细信息 // +// NOTE: 关联的文章一旦删除,快照也将不可获取。 +// // snapshot 为快照的 ID; func (m *Module) HandleGetSnapshot(ctx *web.Context, snapshot int64) web.Responser { sql := m.db.SQLBuilder().Select().From(orm.TableName(&snapshotPO{}), "s"). - Column("a.slug,a.views,a.order,a.created,a.modified,a.deleted,a.deleter,a.last"). - Column("s.author,s.title,s.images,s.summary,s.content,s.tags,s.topics"). + Column("a.id,a.slug,a.views,a.{order},a.created,a.modified,a.deleted,a.deleter,a.last"). + Column("s.author,s.title,s.images,s.summary,s.content,s.id as snapshot"). Join("LEFT", orm.TableName(&articlePO{}), "a", "a.last=s.id"). - Where("s.id=?", snapshot) + Where("s.id=?", snapshot). + AndIsNull("a.deleted") a := &ArticleVO{} size, err := sql.QueryObject(true, a) @@ -391,32 +409,11 @@ func (m *Module) HandleGetSnapshot(ctx *web.Context, snapshot int64) web.Respons } func (m *Module) getArticleAttribute(article int64) (topics, tags []int64, err error) { - rows, err := m.db.SQLBuilder().Select(). - Where("article=?", article). - From(orm.TableName(&tagRelationPO{})). - Column("tag"). - Query() - if err != nil { - return nil, nil, err - } - - tags, err = fetch.Column[int64](true, "tag", rows) - if err != nil { - return nil, nil, err - } - - rows, err = m.db.SQLBuilder().Select(). - Where("article=?", article). - From(orm.TableName(&topicRelationPO{})). - Column("topic"). - Query() - if err != nil { - return nil, nil, err - } - topics, err = fetch.Column[int64](true, "topic", rows) + tags, err = m.tagRel.ListV2(article) if err != nil { return nil, nil, err } - return topics, tags, nil + topics, err = m.topicRel.ListV2(article) + return } diff --git a/cmfx/contents/article/routes_test.go b/cmfx/contents/article/routes_test.go index 267c167..e335bb2 100644 --- a/cmfx/contents/article/routes_test.go +++ b/cmfx/contents/article/routes_test.go @@ -5,8 +5,10 @@ package article import ( + "bytes" "encoding/json" "net/http" + "strconv" "testing" "github.com/issue9/assert/v4" @@ -16,6 +18,7 @@ import ( "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/initial/test" + "github.com/issue9/cmfx/cmfx/query" ) func TestModule_Handle(t *testing.T) { @@ -25,13 +28,43 @@ func TestModule_Handle(t *testing.T) { mod := s.NewModule("article") m := Install(mod, "a", "t1", "t2") - s.Router().Post("/articles", func(ctx *web.Context) web.Responser { return m.HandlePostArticle(ctx, 1) }). + s.Router(). + Post("/articles", func(ctx *web.Context) web.Responser { return m.HandlePostArticle(ctx, 1) }). + Get("/articles", m.HandleGetArticles). Get("/articles/{id}", func(ctx *web.Context) web.Responser { id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) if resp != nil { return resp } return m.HandleGetArticle(ctx, id) + }). + Patch("/articles/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandlePatchArticle(ctx, id, 1) + }). + Delete("/articles/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleDeleteArticle(ctx, id, 1) + }). + Get("/articles/{id}/snapshots", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetSnapshots(ctx, id) + }). + Get("/snapshots/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetSnapshot(ctx, id) }) defer servertest.Run(a, s.Module().Server())() @@ -56,9 +89,21 @@ func TestModule_Handle(t *testing.T) { s.Post("/articles", data). Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). Do(nil). - Status(http.StatusCreated). + Status(http.StatusCreated) + spo := &snapshotPO{} + ssize, err := m.db.Where("true").Select(true, spo) + a.NotError(err).Equal(ssize, 1) + apo := &articlePO{} + asize, err := m.db.Where("true").Select(true, apo) + a.NotError(err).Equal(asize, ssize). + Equal(apo.Last, spo.ID) + + s.Get("/articles?size=10&page=0"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusOK). BodyFunc(func(a *assert.Assertion, body []byte) { - println(string(body)) + a.True(bytes.Index(body, []byte(`"count":1`)) >= 0) }) s.Get("/articles/1"). @@ -72,7 +117,70 @@ func TestModule_Handle(t *testing.T) { Equal(vo.Title, article.Title). Equal(vo.Images, article.Images). Equal(vo.Content, article.Content). - Equal(vo.Order, article.Order) + Equal(vo.Order, article.Order). + Equal(vo.ID, 1) }) + + // 更新数据,快照加 1 + + s.Patch("/articles/1", data). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusNoContent) + spos := make([]*snapshotPO, 0, 10) + ssize, err = m.db.Where("true").Select(true, &spos) + a.NotError(err).Equal(ssize, 2).Length(spos, 2) + apo = &articlePO{} + asize, err = m.db.Where("true").Select(true, apo) + a.NotError(err).Equal(asize, 1). + Equal(apo.Last, 2). // 快照+1 + Equal(spos[0].Article, apo.ID). + Equal(spos[1].Article, apo.ID) + + s.Get("/articles/1/snapshots?size=10&page=0"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + BodyFunc(func(a *assert.Assertion, body []byte) { + p := &query.Page[OverviewVO]{} + a.NotError(json.Unmarshal(body, p)) + a.Length(p.Current, 2).Equal(p.Count, 2, string(body)) + }) + + // 分页 + s.Get("/articles/1/snapshots?size=1&page=0"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + BodyFunc(func(a *assert.Assertion, body []byte) { + p := &query.Page[OverviewVO]{} + a.NotError(json.Unmarshal(body, p)) + a.Length(p.Current, 1).Equal(p.Count, 2, string(body)) + }) + + s.Get("/snapshots/"+strconv.FormatInt(spos[1].ID, 10)). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + BodyFunc(func(a *assert.Assertion, body []byte) { + article := &ArticleVO{} + a.NotError(json.Unmarshal(body, article)) + a.Equal(article.ID, 1). + Equal(article.Snapshot, 2) + }) + + // 删除之后,内容不再可获取 + + s.Delete("/articles/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusNoContent) + + s.Get("/snapshots/"+strconv.FormatInt(spos[1].ID, 10)). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusNotFound) + + s.Get("/articles/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusNotFound) }) } diff --git a/cmfx/relationship/module.go b/cmfx/relationship/module.go index 5427c9f..fa267ce 100644 --- a/cmfx/relationship/module.go +++ b/cmfx/relationship/module.go @@ -7,6 +7,7 @@ package relationship import ( "github.com/issue9/orm/v6" "github.com/issue9/orm/v6/fetch" + "github.com/issue9/orm/v6/sqlbuilder" "github.com/issue9/web" "github.com/issue9/cmfx/cmfx" @@ -85,3 +86,11 @@ func (m *Module[T1, T2]) ListV2(v1 T1) ([]T2, error) { } return fetch.Column[T2](false, "v2", rows) } + +// LeftJoin LEFT JOIN 至 sql +// +// alias 为当前的 relationshipPO 表指定别名,该别名可能在 on 参数中可能会用到; +func (m *Module[T1, T2]) LeftJoin(sql *sqlbuilder.SelectStmt, alias, on string) { + tb := m.db.TablePrefix() + (&relationshipPO[T1, T2]{}).TableName() + sql.Join("LEFT", tb, alias, on) +} From 986f31866562ec3bf7d3959f2b158b4dcd0ec007 Mon Sep 17 00:00:00 2001 From: caixw Date: Thu, 9 Jan 2025 12:01:57 +0800 Subject: [PATCH 10/12] =?UTF-8?q?feat(cmfx/contents/articles):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=8C=89=E6=A0=87=E7=AD=BE=E5=92=8C=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E5=86=85=E5=AE=B9=E7=9A=84=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/article/routes.go | 125 ++++++++++++++++++++++++--- cmfx/contents/article/routes_test.go | 38 +++++++- cmfx/contents/contents.go | 2 +- 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/cmfx/contents/article/routes.go b/cmfx/contents/article/routes.go index dc0ea60..4acdd1c 100644 --- a/cmfx/contents/article/routes.go +++ b/cmfx/contents/article/routes.go @@ -46,6 +46,30 @@ func (q *OverviewQuery) Filter(ctx *web.FilterContext) { Add(q.m.Topics().SliceFilter()("topic", &q.Topics)) } +type TopicOverviewQuery struct { + m *Module + query.Text + Created time.Time `query:"created"` + Tags []int64 `query:"tag"` +} + +func (q *TopicOverviewQuery) Filter(ctx *web.FilterContext) { + q.Text.Filter(ctx) + ctx.Add(q.m.Tags().SliceFilter()("tag", &q.Tags)) +} + +type TagOverviewQuery struct { + m *Module + query.Text + Created time.Time `query:"created"` + Topics []int64 `query:"topic"` +} + +func (q *TagOverviewQuery) Filter(ctx *web.FilterContext) { + q.Text.Filter(ctx) + ctx.Add(q.m.Topics().SliceFilter()("topic", &q.Topics)) +} + // HandleGetArticles 获取文章列表 // // 查询参数为 [OverviewQuery],返回对象为 [query.Page[OverviewVO]] @@ -87,6 +111,76 @@ func (m *Module) HandleGetArticles(ctx *web.Context) web.Responser { return query.PagingResponser[OverviewVO](ctx, &q.Limit, sql, nil) } +// HandleGetArticlesByTopic 获取文章列表 +// +// 查询参数为 [TopicOverviewQuery],返回对象为 [query.Page[OverviewVO]] +func (m *Module) HandleGetArticlesByTopic(ctx *web.Context, topic int64) web.Responser { + q := &OverviewQuery{m: m} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp + } + + sql := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). + Column("a.id,a.slug,a.views,a.{order},s.title,a.created,a.modified"). + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id"). + AndIsNull("a.deleted"). + And("topic.v2=?", topic). + Desc("a.{order}") + m.topicRel.LeftJoin(sql, "topic", "topic.v1=a.id") + if !q.Created.IsZero() { + sql.Where("a.created>?", q.Created) + } + if q.Text.Text != "" { + txt := "%" + q.Text.Text + "%" + sql.Where("a.slug LIKE ? OR s.title LIKE ? OR s.author LIKE ?", txt, txt, txt) + } + if len(q.Tags) > 0 { + m.tagRel.LeftJoin(sql, "tags", "tags.v1=a.id") + sql.AndGroup(func(ws *sqlbuilder.WhereStmt) { + for _, t := range q.Tags { + ws.Or("tags.v2=?", t) + } + }) + } + + return query.PagingResponser[OverviewVO](ctx, &q.Limit, sql, nil) +} + +// HandleGetArticlesByTag 获取文章列表 +// +// 查询参数为 [TagOverviewQuery],返回对象为 [query.Page[OverviewVO]] +func (m *Module) HandleGetArticlesByTag(ctx *web.Context, tag int64) web.Responser { + q := &OverviewQuery{m: m} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp + } + + sql := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). + Column("a.id,a.slug,a.views,a.{order},s.title,a.created,a.modified"). + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id"). + AndIsNull("a.deleted"). + And("tag.v2=?", tag). + Desc("a.{order}") + m.tagRel.LeftJoin(sql, "tag", "tag.v1=a.id") + if !q.Created.IsZero() { + sql.Where("a.created>?", q.Created) + } + if q.Text.Text != "" { + txt := "%" + q.Text.Text + "%" + sql.Where("a.slug LIKE ? OR s.title LIKE ? OR s.author LIKE ?", txt, txt, txt) + } + if len(q.Tags) > 0 { + m.tagRel.LeftJoin(sql, "tags", "tags.v1=a.id") + sql.AndGroup(func(ws *sqlbuilder.WhereStmt) { + for _, t := range q.Tags { + ws.Or("tags.v2=?", t) + } + }) + } + + return query.PagingResponser[OverviewVO](ctx, &q.Limit, sql, nil) +} + // ArticleVO 文章的详细内容 type ArticleVO struct { XMLName struct{} `xml:"article" json:"-" yaml:"-" cbor:"-" orm:"-"` @@ -121,7 +215,7 @@ type ArticleVO struct { func (m *Module) HandleGetArticle(ctx *web.Context, article int64) web.Responser { a := &ArticleVO{} size, err := m.db.SQLBuilder().Select().From(orm.TableName(&articlePO{}), "a"). - Column("a.id,a.slug,a.views,a.{order},a.created,a.modified,a.deleted,a.deleter,a.last"). + Column("a.id,a.slug,a.views,a.{order},a.created,a.modified,a.last"). Column("s.author,s.title,s.images,s.summary,s.content,s.id as snapshot"). Join("LEFT", orm.TableName(&snapshotPO{}), "s", "a.last=s.id"). Where("a.id=?", article). @@ -365,7 +459,7 @@ func (m *Module) HandleGetSnapshots(ctx *web.Context, article int64) web.Respons } sql := m.db.SQLBuilder().Select().From(orm.TableName(&snapshotPO{}), "s"). - Column("a.id,a.slug,a.views,a.{order},s.title,a.created,a.modified"). + Column("a.id,a.slug,a.views,a.{order},s.title,s.created,s.created AS modified"). Join("LEFT", orm.TableName(&articlePO{}), "a", "a.id=s.article"). Where("a.id=?", article). AndIsNull("a.deleted") @@ -378,6 +472,22 @@ func (m *Module) HandleGetSnapshots(ctx *web.Context, article int64) web.Respons return query.PagingResponser[OverviewVO](ctx, &q.Limit, sql, nil) } +// SnapshotVO 文章快照详细内容 +type SnapshotVO struct { + XMLName struct{} `xml:"article" json:"-" yaml:"-" cbor:"-" orm:"-"` + + ID int64 `orm:"name(id)" json:"id" yaml:"id" cbor:"id" xml:"id,attr"` // 快照 ID + Article int64 `orm:"name(article)" json:"article" yaml:"article" cbor:"article" xml:"article,attr"` // 快照关联的文章 ID + Slug string `orm:"name(slug);len(100);unique(slug)" json:"slug" yaml:"slug" cbor:"slug" xml:"slug"` + Author string `orm:"name(author);len(20)" json:"author" yaml:"author" cbor:"author" xml:"author"` + Title string `orm:"name(title);len(100)" json:"title" yaml:"title" cbor:"title" xml:"title"` + Images types.Strings `orm:"name(images);len(1000)" json:"images" yaml:"images" cbor:"images" xml:"images>image"` + Keywords string `orm:"name(keywords)" json:"keywords" yaml:"keywords" cbor:"keywords" xml:"keywords"` + Summary string `orm:"name(summary);len(2000)" json:"summary" yaml:"summary" cbor:"summary" xml:"summary,cdata"` + Content string `orm:"name(content);len(-1)" json:"content" yaml:"content" cbor:"content" xml:"content,cdata"` + Created time.Time `orm:"name(created)" json:"created" yaml:"created" cbor:"created" xml:"created"` +} + // HandleGetSnapshot 获取快照的详细信息 // // NOTE: 关联的文章一旦删除,快照也将不可获取。 @@ -385,13 +495,13 @@ func (m *Module) HandleGetSnapshots(ctx *web.Context, article int64) web.Respons // snapshot 为快照的 ID; func (m *Module) HandleGetSnapshot(ctx *web.Context, snapshot int64) web.Responser { sql := m.db.SQLBuilder().Select().From(orm.TableName(&snapshotPO{}), "s"). - Column("a.id,a.slug,a.views,a.{order},a.created,a.modified,a.deleted,a.deleter,a.last"). - Column("s.author,s.title,s.images,s.summary,s.content,s.id as snapshot"). + Column("a.id as article,a.slug"). + Column("s.author,s.created,s.title,s.images,s.summary,s.content,s.id"). Join("LEFT", orm.TableName(&articlePO{}), "a", "a.last=s.id"). Where("s.id=?", snapshot). AndIsNull("a.deleted") - a := &ArticleVO{} + a := &SnapshotVO{} size, err := sql.QueryObject(true, a) if err != nil { return ctx.Error(err, "") @@ -400,11 +510,6 @@ func (m *Module) HandleGetSnapshot(ctx *web.Context, snapshot int64) web.Respons return ctx.NotFound() } - a.Topics, a.Tags, err = m.getArticleAttribute(a.ID) - if err != nil { - return ctx.Error(err, "") - } - return web.OK(a) } diff --git a/cmfx/contents/article/routes_test.go b/cmfx/contents/article/routes_test.go index e335bb2..49715ff 100644 --- a/cmfx/contents/article/routes_test.go +++ b/cmfx/contents/article/routes_test.go @@ -31,6 +31,20 @@ func TestModule_Handle(t *testing.T) { s.Router(). Post("/articles", func(ctx *web.Context) web.Responser { return m.HandlePostArticle(ctx, 1) }). Get("/articles", m.HandleGetArticles). + Get("/topics/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetArticlesByTopic(ctx, id) + }). + Get("/tags/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetArticlesByTag(ctx, id) + }). Get("/articles/{id}", func(ctx *web.Context) web.Responser { id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) if resp != nil { @@ -106,6 +120,22 @@ func TestModule_Handle(t *testing.T) { a.True(bytes.Index(body, []byte(`"count":1`)) >= 0) }) + s.Get("/tags/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusOK). + BodyFunc(func(a *assert.Assertion, body []byte) { + a.True(bytes.Index(body, []byte(`"count":1`)) >= 0) + }) + + s.Get("/topics/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusOK). + BodyFunc(func(a *assert.Assertion, body []byte) { + a.True(bytes.Index(body, []byte(`"count":1`)) >= 0) + }) + s.Get("/articles/1"). Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). Do(nil). @@ -160,10 +190,10 @@ func TestModule_Handle(t *testing.T) { Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). Do(nil). BodyFunc(func(a *assert.Assertion, body []byte) { - article := &ArticleVO{} - a.NotError(json.Unmarshal(body, article)) - a.Equal(article.ID, 1). - Equal(article.Snapshot, 2) + snapshot := &SnapshotVO{} + a.NotError(json.Unmarshal(body, snapshot)) + a.Equal(snapshot.ID, 2). + Equal(snapshot.Article, 1) }) // 删除之后,内容不再可获取 diff --git a/cmfx/contents/contents.go b/cmfx/contents/contents.go index 672f7f7..60361e5 100644 --- a/cmfx/contents/contents.go +++ b/cmfx/contents/contents.go @@ -2,5 +2,5 @@ // // SPDX-License-Identifier: MIT -// Package contents 提供文章评论等以文字内容为主的对象 +// Package contents 以内容为主的对象 package contents From 28d9f3c2923a9e75996604f0b45e4c64e2f55154 Mon Sep 17 00:00:00 2001 From: caixw Date: Thu, 9 Jan 2025 16:39:52 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat(cmfx/contents/comment):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E8=AF=84=E8=AE=BA=E7=9A=84=E5=A4=A7=E9=83=A8=E5=88=86?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/cmfx.go | 1 + cmfx/contents/article/install_test.go | 2 +- cmfx/contents/article/models.go | 2 +- cmfx/contents/article/routes.go | 35 ++- cmfx/contents/article/routes_test.go | 2 +- cmfx/contents/comment/comment.go | 10 + cmfx/contents/comment/install.go | 23 ++ cmfx/contents/comment/install_test.go | 27 +++ cmfx/contents/comment/models.go | 53 ++++- cmfx/contents/comment/models_test.go | 16 ++ cmfx/contents/comment/module.go | 12 +- cmfx/contents/comment/routes.go | 316 ++++++++++++++++++++++++++ cmfx/contents/comment/routes_test.go | 191 ++++++++++++++++ cmfx/filters/number.go | 11 +- cmfx/locales/locales.go | 9 + cmfx/module.go | 1 + 16 files changed, 683 insertions(+), 28 deletions(-) create mode 100644 cmfx/contents/comment/install.go create mode 100644 cmfx/contents/comment/install_test.go create mode 100644 cmfx/contents/comment/models_test.go create mode 100644 cmfx/contents/comment/routes.go create mode 100644 cmfx/contents/comment/routes_test.go diff --git a/cmfx/cmfx.go b/cmfx/cmfx.go index 17b24dc..0e35ea7 100644 --- a/cmfx/cmfx.go +++ b/cmfx/cmfx.go @@ -41,6 +41,7 @@ const ( const ( Forbidden = web.ProblemForbidden ForbiddenCaNotDeleteYourself = "40301" + ForbiddenMustBeAuthor = "40302" ) // 404 diff --git a/cmfx/contents/article/install_test.go b/cmfx/contents/article/install_test.go index d45e330..4f468a4 100644 --- a/cmfx/contents/article/install_test.go +++ b/cmfx/contents/article/install_test.go @@ -22,7 +22,7 @@ func TestInstall(t *testing.T) { Install(mod, "abc") prefix := mod.ID() + "_" + "abc" - s.TableExists(prefix + "_snapshots"). + s.TableExists(prefix + "_article_snapshots"). TableExists(prefix + "_articles"). TableExists(prefix + "_" + tagsTableName). TableExists(prefix + "_" + topicsTableName) diff --git a/cmfx/contents/article/models.go b/cmfx/contents/article/models.go index 772923d..23e5da2 100644 --- a/cmfx/contents/article/models.go +++ b/cmfx/contents/article/models.go @@ -42,7 +42,7 @@ type articlePO struct { Deleter int64 `orm:"name(deleter)"` } -func (*snapshotPO) TableName() string { return `_snapshots` } +func (*snapshotPO) TableName() string { return `_article_snapshots` } func (l *snapshotPO) BeforeInsert() error { l.ID = 0 diff --git a/cmfx/contents/article/routes.go b/cmfx/contents/article/routes.go index 4acdd1c..6e81baf 100644 --- a/cmfx/contents/article/routes.go +++ b/cmfx/contents/article/routes.go @@ -426,31 +426,42 @@ func (m *Module) HandleDeleteArticle(ctx *web.Context, article, deleter int64) w return ctx.NotFound() } - po := &articlePO{ - ID: article, - Deleted: sql.NullTime{Valid: true, Time: ctx.Begin()}, - Deleter: deleter, - } - err = m.db.DoTransactionTx(ctx, nil, func(tx *orm.Tx) error { if err := m.tagRel.DeleteByV1(tx, article); err != nil { // 删除旧的关系 return err } - return m.topicRel.DeleteByV1(tx, article) // 删除旧的关系 + if err = m.topicRel.DeleteByV1(tx, article); err != nil { // 删除旧的关系 + return err + } + + _, err = tx.NewEngine(m.db.TablePrefix()).Update(&articlePO{ + ID: article, + Deleted: sql.NullTime{Valid: true, Time: ctx.Begin()}, + Deleter: deleter, + }) + return err }) if err != nil { return ctx.Error(err, "") } - if _, err := m.db.Update(po); err != nil { - return ctx.Error(err, "") - } return web.NoContent() } +// SnapshotOverviewVO 文章快照的摘要信息 +type SnapshotOverviewVO struct { + XMLName struct{} `xml:"snapshot" json:"-" yaml:"-" cbor:"-" orm:"-"` + + Article int64 `xml:"article" json:"article" yaml:"article" cbor:"article" orm:"name(article)"` // 关联的文章 + ID int64 `xml:"id" json:"id" yaml:"id" cbor:"id" orm:"name(id)"` // 快照 ID + Author string `xml:"author" json:"author" yaml:"author" cbor:"author" orm:"name(author)"` + Title string `xml:"title" json:"title" yaml:"title" cbor:"title" orm:"name(title)"` + Created time.Time `xml:"created" json:"created" yaml:"created" cbor:"created" orm:"name(created)"` +} + // HandleGetSnapshots 获取文章的快照列表 // -// article 更新快照对应的文章 ID; +// article 文章 ID; // 返回 []OverviewVO; func (m *Module) HandleGetSnapshots(ctx *web.Context, article int64) web.Responser { q := &query.Text{} @@ -459,7 +470,7 @@ func (m *Module) HandleGetSnapshots(ctx *web.Context, article int64) web.Respons } sql := m.db.SQLBuilder().Select().From(orm.TableName(&snapshotPO{}), "s"). - Column("a.id,a.slug,a.views,a.{order},s.title,s.created,s.created AS modified"). + Column("a.id as article,s.id,s.author,s.title,s.created"). Join("LEFT", orm.TableName(&articlePO{}), "a", "a.id=s.article"). Where("a.id=?", article). AndIsNull("a.deleted") diff --git a/cmfx/contents/article/routes_test.go b/cmfx/contents/article/routes_test.go index 49715ff..6465199 100644 --- a/cmfx/contents/article/routes_test.go +++ b/cmfx/contents/article/routes_test.go @@ -181,7 +181,7 @@ func TestModule_Handle(t *testing.T) { Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). Do(nil). BodyFunc(func(a *assert.Assertion, body []byte) { - p := &query.Page[OverviewVO]{} + p := &query.Page[SnapshotOverviewVO]{} a.NotError(json.Unmarshal(body, p)) a.Length(p.Current, 1).Equal(p.Count, 2, string(body)) }) diff --git a/cmfx/contents/comment/comment.go b/cmfx/contents/comment/comment.go index fec240d..20c1367 100644 --- a/cmfx/contents/comment/comment.go +++ b/cmfx/contents/comment/comment.go @@ -4,3 +4,13 @@ // Package comment 评论 package comment + +import ( + "github.com/issue9/orm/v6" + + "github.com/issue9/cmfx/cmfx" +) + +func buildDB(mod *cmfx.Module, tableName string) *orm.DB { + return mod.DB().New(mod.DB().TablePrefix() + "_" + tableName) +} diff --git a/cmfx/contents/comment/install.go b/cmfx/contents/comment/install.go new file mode 100644 index 0000000..d6eb8b4 --- /dev/null +++ b/cmfx/contents/comment/install.go @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package comment + +import ( + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx" +) + +// Install 安装数据 +// +// mod 所属模块;tablePrefix 表名前缀; +func Install(mod *cmfx.Module, tablePrefix string) *Module { + db := buildDB(mod, tablePrefix) + if err := db.Create(&snapshotPO{}, &commentPO{}); err != nil { + panic(web.SprintError(mod.Server().Locale().Printer(), true, err)) + } + + return Load(mod, tablePrefix) +} diff --git a/cmfx/contents/comment/install_test.go b/cmfx/contents/comment/install_test.go new file mode 100644 index 0000000..82cc9e7 --- /dev/null +++ b/cmfx/contents/comment/install_test.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package comment + +import ( + "testing" + + "github.com/issue9/assert/v4" + + "github.com/issue9/cmfx/cmfx/initial/test" +) + +func TestInstall(t *testing.T) { + a := assert.New(t, false) + + s := test.NewSuite(a) + defer s.Close() + + mod := s.NewModule("test") + Install(mod, "abc") + prefix := mod.ID() + "_" + "abc" + + s.TableExists(prefix + "_comment_snapshots"). + TableExists(prefix + "_comments") +} diff --git a/cmfx/contents/comment/models.go b/cmfx/contents/comment/models.go index 728b480..bad9b50 100644 --- a/cmfx/contents/comment/models.go +++ b/cmfx/contents/comment/models.go @@ -6,21 +6,58 @@ package comment import ( "database/sql" + "errors" + "html" "time" ) +// 不需要生成 sql 相关的方法,SQL 对可阅读性并不需要太高,直接用数据就可以了。 +//go:generate web enum -i=./models.go -o=./models_enums.go -t=State -sql=false + +type State int8 + +const ( + StateVisible State = iota // 显示 + StateHidden // 隐藏 + StateTop // 置顶 +) + +// 评论的快照 +type snapshotPO struct { + Comment int64 `orm:"name(comment)"` + ID int64 `orm:"name(id);ai"` + Content string `orm:"name(content);len(-1)"` // 文章内容 + Rate int `orm:"name(rate)"` // 评分 + Created time.Time `orm:"name(created)"` +} + type commentPO struct { ID int64 `orm:"name(id);ai"` - Author string `orm:"name(author);len(20)"` // 显示的作者信息 - Content string `orm:"name(content);len(-1)"` // 文章内容 - Target int64 `orm:"name(target)"` // 关联对象的 ID - Creator int64 `orm:"name(creator)"` // 作者 ID - Rate int `orm:"name(rate)"` // 评分 + State State `orm:"name(state)"` + Last int64 `orm:"name(last)"` // 最后一次的快照 ID + Author string `orm:"name(author);len(20)"` // 显示的作者信息 + Target int64 `orm:"name(target)"` // 被评论的对象 + Parent int64 `orm:"name(parent)"` // 父评论 + Creator int64 `orm:"name(creator)"` // 作者 ID Created time.Time `orm:"name(created)"` Modified time.Time `orm:"name(modified)"` - Deleted sql.NullTime `orm:"name(deleted);nullable;default(NULL)"` - Deleter int64 `orm:"name(deleter)"` - Parent int64 `orm:"name(parent)"` // 父评论 + Deleted sql.NullTime `orm:"name(deleted);nullable"` +} + +func (*snapshotPO) TableName() string { return `_comment_snapshots` } + +func (l *snapshotPO) BeforeUpdate() error { + return errors.New("快照不会执行更新操作") } func (*commentPO) TableName() string { return `_comments` } + +func (l *commentPO) BeforeInsert() error { + l.Author = html.EscapeString(l.Author) + return nil +} + +func (l *commentPO) BeforeUpdate() error { + l.Author = html.EscapeString(l.Author) + return nil +} diff --git a/cmfx/contents/comment/models_test.go b/cmfx/contents/comment/models_test.go new file mode 100644 index 0000000..d240c43 --- /dev/null +++ b/cmfx/contents/comment/models_test.go @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package comment + +import "github.com/issue9/orm/v6" + +var ( + _ orm.TableNamer = &snapshotPO{} + _ orm.BeforeUpdater = &snapshotPO{} + + _ orm.TableNamer = &commentPO{} + _ orm.BeforeInserter = &commentPO{} + _ orm.BeforeUpdater = &commentPO{} +) diff --git a/cmfx/contents/comment/module.go b/cmfx/contents/comment/module.go index 981fa0e..fdf60a7 100644 --- a/cmfx/contents/comment/module.go +++ b/cmfx/contents/comment/module.go @@ -4,18 +4,22 @@ package comment -import "github.com/issue9/cmfx/cmfx" +import ( + "github.com/issue9/orm/v6" + + "github.com/issue9/cmfx/cmfx" +) type Module struct { mod *cmfx.Module + db *orm.DB } -func Load(mod *cmfx.Module) *Module { +func Load(mod *cmfx.Module, tablePrefix string) *Module { m := &Module{ mod: mod, + db: buildDB(mod, tablePrefix), } - // TODO - return m } diff --git a/cmfx/contents/comment/routes.go b/cmfx/contents/comment/routes.go new file mode 100644 index 0000000..c7bb43e --- /dev/null +++ b/cmfx/contents/comment/routes.go @@ -0,0 +1,316 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package comment + +import ( + "database/sql" + "time" + + "github.com/issue9/orm/v6" + "github.com/issue9/web" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/filters" + "github.com/issue9/cmfx/cmfx/query" +) + +// CommentVO 摘要信息 +type CommentVO struct { + XMLName struct{} `xml:"comment" json:"-" yaml:"-" cbor:"-" orm:"-"` + ID int64 `xml:"id" json:"id" yaml:"id" cbor:"id" orm:"name(id)"` + Author string `xml:"author" json:"author" yaml:"author" cbor:"author" orm:"name(author)"` + Created time.Time `xml:"created" json:"created" yaml:"created" cbor:"created" orm:"name(created)"` + Modified time.Time `xml:"modified" json:"modified" yaml:"modified" cbor:"modified" orm:"name(modified)"` + Content string `xml:"content" json:"content" yaml:"content" cbor:"content" orm:"name(content)"` + Rate int `xml:"rate" json:"rate" yaml:"rate" cbor:"rate" orm:"name(rate)"` +} + +type CommentQuery struct { + m *Module + query.Text + Created time.Time `query:"created"` +} + +func (q *CommentQuery) Filter(ctx *web.FilterContext) { + q.Text.Filter(ctx) +} + +// HandleSetState 设置状态 +func (m *Module) HandleSetState(ctx *web.Context, comment int64, s State, status int) web.Responser { + po := &commentPO{ID: comment} + if _, err := m.db.Select(po); err != nil { + return ctx.Error(err, "") + } + + if _, err := m.db.Update(&commentPO{ID: comment, State: s}, "state"); err != nil { + return ctx.Error(err, "") + } + return web.Status(status) +} + +// HandleGetComments 获取评论列表 +// +// 查询参数为 [CommentQuery],返回对象为 [query.Page[CommentVO]] +func (m *Module) HandleGetComments(ctx *web.Context) web.Responser { + q := &CommentQuery{m: m} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp + } + + sql := m.db.SQLBuilder().Select().From(orm.TableName(&commentPO{}), "c"). + Column("c.id,c.created,c.modified,s.rate,s.content"). + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "c.last=s.id"). + AndIsNull("c.deleted"). + Desc("c.{created}") + if !q.Created.IsZero() { + sql.Where("c.created>?", q.Created) + } + if q.Text.Text != "" { + txt := "%" + q.Text.Text + "%" + sql.Where("s.content ? OR c.author LIKE ?", txt, txt) + } + + return query.PagingResponser[CommentVO](ctx, &q.Limit, sql, nil) +} + +// HandleGetCommentsByTarget 获取指定对象的评论列表 +// +// 查询参数为 [CommentQuery],返回对象为 [query.Page[CommentVO]] +func (m *Module) HandleGetCommentsByTarget(ctx *web.Context, target int64) web.Responser { + q := &CommentQuery{m: m} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp + } + + sql := m.db.SQLBuilder().Select().From(orm.TableName(&commentPO{}), "c"). + Column("c.id,c.created,c.modified,s.rate,s.content"). + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "c.last=s.id"). + Where("target=?", target). + AndIsNull("c.deleted"). + Desc("c.{created}") + if q.Text.Text != "" { + txt := "%" + q.Text.Text + "%" + sql.Where("s.content LIKE ? OR c.author LIKE ?", txt, txt) + } + if !q.Created.IsZero() { + sql.Where("c.created>?", q.Created) + } + + return query.PagingResponser[CommentVO](ctx, &q.Limit, sql, nil) +} + +// HandleGetComment 获取评论信息 +// +// 返回参数的实际类型为 [CommentVO]; +// comment 为评论的 ID,使用都需要确保值的正确性; +func (m *Module) HandleGetComment(ctx *web.Context, comment int64) web.Responser { + a := &CommentVO{} + size, err := m.db.SQLBuilder().Select().From(orm.TableName(&commentPO{}), "c"). + Column("c.id,c.created,c.modified,s.rate,s.content,c.author"). + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "c.last=s.id"). + Where("c.id=?", comment). + AndIsNull("c.deleted"). + QueryObject(true, a) + if err != nil { + return ctx.Error(err, "") + } + if size == 0 { + return ctx.NotFound() + } + + return web.OK(a) +} + +type CommentTO struct { + m *Module + + XMLName struct{} `xml:"comment" json:"-" yaml:"-" cbor:"-"` + Content string `json:"content" yaml:"content" cbor:"content" xml:"content"` + Rate int `json:"rate" yaml:"rate" cbor:"rate" xml:"rate,attr"` + Author string `json:"author" yaml:"author" cbor:"author" xml:"author"` // 显示的作者信息 + Parent int64 `json:"parent" yaml:"parent" cbor:"parent" xml:"parent"` // 父评论 +} + +func (to *CommentTO) Filter(ctx *web.FilterContext) { + ctx.Add(filters.NotEmpty("content", &to.Content)). + Add(filters.BetweenEqual(0, 10)("rate", &to.Rate)). + Add(filters.NotEmpty("author", &to.Author)) +} + +// HandlePostComment 添加新的评论 +// +// creator 为创建者的 ID,调用者需要确保值的正确性;target 为评论对象的 ID; +// 提交类型为 [CommentTO]; +func (m *Module) HandlePostComment(ctx *web.Context, creator, target int64) web.Responser { + a := &CommentTO{m: m} + if resp := ctx.Read(true, a, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + err := m.db.DoTransactionTx(ctx, nil, func(tx *orm.Tx) error { + comment, err := tx.LastInsertID(&commentPO{ // 添加评论 + Author: a.Author, + Parent: a.Parent, + Created: ctx.Begin(), + Creator: creator, + Modified: ctx.Begin(), + Target: target, + }) + if err != nil { + return err + } + + snapshot, err := tx.LastInsertID(&snapshotPO{ // 添加快照 + Comment: comment, + Content: a.Content, + Created: ctx.Begin(), + Rate: a.Rate, + }) + if err != nil { + return err + } + + // 更新 Comment.Last + _, err = tx.Update(&commentPO{ID: comment, Last: snapshot}) + return err + }) + if err != nil { + return ctx.Error(err, "") + } + + return web.Created(nil, "") +} + +// HandlePatchComment 修改评论的内容 +// +// comment 为评论的 ID; +// creator 作者,必须与添加时的 creator 一致; +func (m *Module) HandlePatchComment(ctx *web.Context, creator, comment int64) web.Responser { + ar := &commentPO{ID: comment} + found, err := m.db.Select(ar) + if err != nil { + return ctx.Error(err, "") + } + if !found || ar.Deleted.Valid { + return ctx.NotFound() + } + + if ar.Creator != creator { + return ctx.Problem(cmfx.ForbiddenMustBeAuthor) + } + + a := &CommentTO{m: m} + if resp := ctx.Read(true, a, cmfx.BadRequestInvalidBody); resp != nil { + return resp + } + + m.db.DoTransactionTx(ctx, nil, func(tx *orm.Tx) error { + last, err := tx.LastInsertID(&snapshotPO{ // 添加快照 + Comment: comment, + Content: a.Content, + Created: ctx.Begin(), + Rate: a.Rate, + }) + if err != nil { + return err + } + + _, err = tx.Update(&commentPO{ // 更新评论 + ID: comment, + Last: last, + Modified: ctx.Begin(), + }) + return err + }) + + return web.NoContent() +} + +// HandleDeleteComment 删除指定的评论 +// +// comment 为评论的 ID; +// creator 作者,必须与添加时的 creator 一致; +func (m *Module) HandleDeleteComment(ctx *web.Context, creator, comment int64) web.Responser { + a := &commentPO{ID: comment} + found, err := m.db.Select(a) + if err != nil { + return ctx.Error(err, "") + } + if !found || a.Deleted.Valid { + return ctx.NotFound() + } + + if a.Creator != creator { + return ctx.Problem(cmfx.ForbiddenMustBeAuthor) + } + + po := &commentPO{ + ID: comment, + Deleted: sql.NullTime{Valid: true, Time: ctx.Begin()}, + } + if _, err := m.db.Update(po); err != nil { + return ctx.Error(err, "") + } + return web.NoContent() +} + +// SnapshotVO 摘要信息 +type SnapshotVO struct { + XMLName struct{} `xml:"comment" json:"-" yaml:"-" cbor:"-" orm:"-"` + Comment int64 `xml:"comment" json:"comment" yaml:"comment" cbor:"comment" orm:"name(comment)"` // 关联的评论 + ID int64 `xml:"id" json:"id" yaml:"id" cbor:"id" orm:"name(id)"` + Created time.Time `xml:"created" json:"created" yaml:"created" cbor:"created" orm:"name(created)"` + Content string `xml:"content" json:"content" yaml:"content" cbor:"content" orm:"name(content)"` + Rate int64 `xml:"rate" json:"rate" yaml:"rate" cbor:"rate" orm:"name(rate)"` +} + +// HandleGetSnapshots 获取评论的快照列表 +// +// comment 评论的 ID; +// 返回 []SnapshotVO; +func (m *Module) HandleGetSnapshots(ctx *web.Context, comment int64) web.Responser { + q := &query.Text{} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp + } + + sql := m.db.SQLBuilder().Select().From(orm.TableName(&snapshotPO{}), "s"). + Column("s.id,a.id as comment,s.created,s.created AS modified,s.content,s.rate"). + Join("LEFT", orm.TableName(&commentPO{}), "a", "a.id=s.comment"). + Where("a.id=?", comment). + AndIsNull("a.deleted") + + if q.Text != "" { + txt := "%" + q.Text + "%" + sql.Where("a.slug LIKE ? OR s.title LIKE ? OR s.author LIKE ?", txt, txt, txt) + } + + return query.PagingResponser[SnapshotVO](ctx, &q.Limit, sql, nil) +} + +// HandleGetSnapshot 获取快照的详细信息 +// +// NOTE: 关联的评论一旦删除,快照也将不可获取。 +// +// snapshot 为快照的 ID; +func (m *Module) HandleGetSnapshot(ctx *web.Context, snapshot int64) web.Responser { + sql := m.db.SQLBuilder().Select().From(orm.TableName(&snapshotPO{}), "s"). + Column("a.id as comment"). + Column("s.created,s.content,s.rate,s.id"). + Join("LEFT", orm.TableName(&commentPO{}), "a", "a.last=s.id"). + Where("s.id=?", snapshot). + AndIsNull("a.deleted") + + a := &SnapshotVO{} + size, err := sql.QueryObject(true, a) + if err != nil { + return ctx.Error(err, "") + } + if size == 0 { + return ctx.NotFound() + } + + return web.OK(a) +} diff --git a/cmfx/contents/comment/routes_test.go b/cmfx/contents/comment/routes_test.go new file mode 100644 index 0000000..9cc76c4 --- /dev/null +++ b/cmfx/contents/comment/routes_test.go @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: 2025 caixw +// +// SPDX-License-Identifier: MIT + +package comment + +import ( + "bytes" + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/issue9/assert/v4" + "github.com/issue9/mux/v9/header" + "github.com/issue9/web" + "github.com/issue9/web/server/servertest" + + "github.com/issue9/cmfx/cmfx" + "github.com/issue9/cmfx/cmfx/initial/test" + "github.com/issue9/cmfx/cmfx/query" +) + +func TestModule_Handle(t *testing.T) { + a := assert.New(t, false) + s := test.NewSuite(a) + + mod := s.NewModule("comments") + m := Install(mod, "a") + + s.Router(). + Post("/comments", func(ctx *web.Context) web.Responser { return m.HandlePostComment(ctx, 1, 1) }). + Get("/comments", m.HandleGetComments). + Get("/targets/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetCommentsByTarget(ctx, id) + }). + Get("/comments/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetComment(ctx, id) + }). + Patch("/comments/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandlePatchComment(ctx, id, 1) + }). + Delete("/comments/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleDeleteComment(ctx, id, 1) + }). + Get("/comments/{id}/snapshots", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetSnapshots(ctx, id) + }). + Get("/snapshots/{id}", func(ctx *web.Context) web.Responser { + id, resp := ctx.PathID("id", cmfx.NotFoundInvalidPath) + if resp != nil { + return resp + } + return m.HandleGetSnapshot(ctx, id) + }) + + defer servertest.Run(a, s.Module().Server())() + defer s.Close() + + t.Run("HandlePostArticles", func(t *testing.T) { + article := &CommentTO{ + Author: "author", + Content: "content

line

", + Rate: 5, + } + data, err := json.Marshal(article) + a.NotError(err) + s.Post("/comments", data). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusCreated) + spo := &snapshotPO{} + ssize, err := m.db.Where("true").Select(true, spo) + a.NotError(err).Equal(ssize, 1) + apo := &commentPO{} + asize, err := m.db.Where("true").Select(true, apo) + a.NotError(err).Equal(asize, ssize). + Equal(apo.Last, spo.ID) + + s.Get("/comments?size=10&page=0"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusOK). + BodyFunc(func(a *assert.Assertion, body []byte) { + a.True(bytes.Index(body, []byte(`"count":1`)) >= 0) + }) + + s.Get("/targets/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusOK). + BodyFunc(func(a *assert.Assertion, body []byte) { + a.True(bytes.Index(body, []byte(`"count":1`)) >= 0) + }) + + s.Get("/comments/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + BodyFunc(func(a *assert.Assertion, body []byte) { + vo := &CommentVO{} + err := json.Unmarshal(body, vo) + a.NotError(err). + Equal(vo.Author, article.Author). + Equal(vo.Content, article.Content). + Equal(vo.Rate, article.Rate). + Equal(vo.ID, 1) + }) + + // 更新数据,快照加 1 + + s.Patch("/comments/1", data). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusNoContent) + spos := make([]*snapshotPO, 0, 10) + ssize, err = m.db.Where("true").Select(true, &spos) + a.NotError(err).Equal(ssize, 2).Length(spos, 2) + apo = &commentPO{} + asize, err = m.db.Where("true").Select(true, apo) + a.NotError(err).Equal(asize, 1). + Equal(apo.Last, 2). // 快照+1 + Equal(spos[0].Comment, apo.ID). + Equal(spos[1].Comment, apo.ID) + + s.Get("/comments/1/snapshots?size=10&page=0"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + BodyFunc(func(a *assert.Assertion, body []byte) { + p := &query.Page[CommentVO]{} + a.NotError(json.Unmarshal(body, p)) + a.Length(p.Current, 2).Equal(p.Count, 2, string(body)) + }) + + // 分页 + s.Get("/comments/1/snapshots?size=1&page=0"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + BodyFunc(func(a *assert.Assertion, body []byte) { + p := &query.Page[SnapshotVO]{} + a.NotError(json.Unmarshal(body, p)) + a.Length(p.Current, 1).Equal(p.Count, 2, string(body)) + }) + + s.Get("/snapshots/"+strconv.FormatInt(spos[1].ID, 10)). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + BodyFunc(func(a *assert.Assertion, body []byte) { + snapshot := &SnapshotVO{} + a.NotError(json.Unmarshal(body, snapshot)) + a.Equal(snapshot.ID, 2). + Equal(snapshot.Comment, 1) + }) + + // 删除之后,内容不再可获取 + + s.Delete("/comments/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusNoContent) + + s.Get("/snapshots/"+strconv.FormatInt(spos[1].ID, 10)). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusNotFound) + + s.Get("/comments/1"). + Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). + Do(nil). + Status(http.StatusNotFound) + }) +} diff --git a/cmfx/filters/number.go b/cmfx/filters/number.go index 33eb9eb..11e92a4 100644 --- a/cmfx/filters/number.go +++ b/cmfx/filters/number.go @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022-2024 caixw +// SPDX-FileCopyrightText: 2022-2025 caixw // // SPDX-License-Identifier: MIT @@ -6,6 +6,7 @@ package filters import ( "github.com/issue9/web/filter" + "github.com/issue9/webfilter/validator" v "github.com/issue9/webfilter/validator" "github.com/issue9/cmfx/cmfx/locales" @@ -42,3 +43,11 @@ func GreatEqual[T Number](n T) filter.Builder[T] { func LessEqual[T Number](n T) filter.Builder[T] { return filter.NewBuilder(v.V(v.LessEqual(n), locales.MustBeLessThan(float64(n)))) } + +func Between[T Number](min, max T) filter.Builder[T] { + return filter.NewBuilder(v.V(validator.Between(min, max), locales.MustBeBetween(min, max))) +} + +func BetweenEqual[T Number](min, max T) filter.Builder[T] { + return filter.NewBuilder(v.V(validator.BetweenEqual(min, max), locales.MustBeBetweenEqual(min, max))) +} diff --git a/cmfx/locales/locales.go b/cmfx/locales/locales.go index d7767e2..f450805 100644 --- a/cmfx/locales/locales.go +++ b/cmfx/locales/locales.go @@ -6,6 +6,7 @@ package locales import ( + "cmp" "embed" "io/fs" @@ -31,6 +32,14 @@ const ( NotFound = web.StringPhrase("not found") ) +func MustBeBetween[T cmp.Ordered](min, max T) web.LocaleStringer { + return web.Phrase("must be between (%v,%v)", min, max) +} + +func MustBeBetweenEqual[T cmp.Ordered](min, max T) web.LocaleStringer { + return web.Phrase("must be between [%v,%v]", min, max) +} + func MustBeGreaterThan[T any](v T) web.LocaleStringer { return web.Phrase("must be greater than %v", v) } diff --git a/cmfx/module.go b/cmfx/module.go index c06c1a4..dba8865 100644 --- a/cmfx/module.go +++ b/cmfx/module.go @@ -159,6 +159,7 @@ func problems(s web.Server) { ).Add(http.StatusForbidden, &web.LocaleProblem{ID: ConflictStateNotAllow, Title: web.StringPhrase("forbidden state not allow"), Detail: web.StringPhrase("forbidden state not allow detail")}, &web.LocaleProblem{ID: ForbiddenCaNotDeleteYourself, Title: web.StringPhrase("forbidden can not delete yourself"), Detail: web.StringPhrase("forbidden can not delete yourself detail")}, + &web.LocaleProblem{ID: ForbiddenMustBeAuthor, Title: web.StringPhrase("forbidden must be author"), Detail: web.StringPhrase("forbidden must be author detail")}, ).Add(http.StatusNotFound, &web.LocaleProblem{ID: NotFoundInvalidPath, Title: web.StringPhrase("not found invalid path"), Detail: web.StringPhrase("not found invalid path detail")}, ).Add(http.StatusPreconditionFailed, From 78efc672344766739aaf4ad82bcd3a881fe0f9ec Mon Sep 17 00:00:00 2001 From: caixw Date: Fri, 10 Jan 2025 12:40:04 +0800 Subject: [PATCH 12/12] =?UTF-8?q?fix(cmfx/contents/comment):=20=E5=8C=BA?= =?UTF-8?q?=E5=88=AB=20HandleGetComments=20=E5=92=8C=20HandleGetCommentsBy?= =?UTF-8?q?Target?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmfx/contents/comment/models_enums.go | 81 +++++++++++++++++++++++++ cmfx/contents/comment/routes.go | 86 ++++++++++++++++++++------- cmfx/contents/comment/routes_test.go | 3 +- 3 files changed, 146 insertions(+), 24 deletions(-) create mode 100755 cmfx/contents/comment/models_enums.go diff --git a/cmfx/contents/comment/models_enums.go b/cmfx/contents/comment/models_enums.go new file mode 100755 index 0000000..779434c --- /dev/null +++ b/cmfx/contents/comment/models_enums.go @@ -0,0 +1,81 @@ +// 当前文件由 web 生成,请勿手动编辑! + +package comment + +import ( + "fmt" + + "github.com/issue9/web/filter" + "github.com/issue9/web/locales" + "github.com/issue9/web/openapi" +) + +//--------------------- State ------------------------ + +var _StateToString = map[State]string{ + StateHidden: "hidden", + StateTop: "top", + StateVisible: "visible", +} + +var _StateFromString = map[string]State{ + "hidden": StateHidden, + "top": StateTop, + "visible": StateVisible, +} + +// String fmt.Stringer +func (s State) String() string { + if v, found := _StateToString[s]; found { + return v + } + return fmt.Sprintf("State(%d)", s) +} + +func ParseState(v string) (State, error) { + if t, found := _StateFromString[v]; found { + return t, nil + } + return 0, locales.ErrInvalidValue() +} + +// MarshalText encoding.TextMarshaler +func (s State) MarshalText() ([]byte, error) { + if v, found := _StateToString[s]; found { + return []byte(v), nil + } + return nil, locales.ErrInvalidValue() +} + +// UnmarshalText encoding.TextUnmarshaler +func (s *State) UnmarshalText(p []byte) error { + tmp, err := ParseState(string(p)) + if err == nil { + *s = tmp + } + return err +} + +func (s State) IsValid() bool { + _, found := _StateToString[s] + return found +} + +func StateValidator(v State) bool { return v.IsValid() } + +var ( + StateRule = filter.V(StateValidator, locales.InvalidValue) + + StateSliceRule = filter.SV[[]State](StateValidator, locales.InvalidValue) + + StateFilter = filter.NewBuilder(StateRule) + + StateSliceFilter = filter.NewBuilder(StateSliceRule) +) + +func (State) OpenAPISchema(s *openapi.Schema) { + s.Type = openapi.TypeString + s.Enum = []any{StateHidden.String(), StateTop.String(), StateVisible.String()} +} + +//--------------------- end State -------------------- diff --git a/cmfx/contents/comment/routes.go b/cmfx/contents/comment/routes.go index c7bb43e..a170249 100644 --- a/cmfx/contents/comment/routes.go +++ b/cmfx/contents/comment/routes.go @@ -9,14 +9,16 @@ import ( "time" "github.com/issue9/orm/v6" + "github.com/issue9/orm/v6/sqlbuilder" "github.com/issue9/web" + "github.com/issue9/web/filter" "github.com/issue9/cmfx/cmfx" "github.com/issue9/cmfx/cmfx/filters" + "github.com/issue9/cmfx/cmfx/locales" "github.com/issue9/cmfx/cmfx/query" ) -// CommentVO 摘要信息 type CommentVO struct { XMLName struct{} `xml:"comment" json:"-" yaml:"-" cbor:"-" orm:"-"` ID int64 `xml:"id" json:"id" yaml:"id" cbor:"id" orm:"name(id)"` @@ -24,17 +26,22 @@ type CommentVO struct { Created time.Time `xml:"created" json:"created" yaml:"created" cbor:"created" orm:"name(created)"` Modified time.Time `xml:"modified" json:"modified" yaml:"modified" cbor:"modified" orm:"name(modified)"` Content string `xml:"content" json:"content" yaml:"content" cbor:"content" orm:"name(content)"` - Rate int `xml:"rate" json:"rate" yaml:"rate" cbor:"rate" orm:"name(rate)"` + + Rate int `xml:"rate,omitempty" json:"rate,omitempty" yaml:"rate,omitempty" cbor:"rate,omitempty" orm:"name(rate)"` + State State `xml:"state,omitempty" json:"state,omitempty" yaml:"state,omitempty" cbor:"state,omitempty" orm:"name(state)"` + Items []*CommentVO `xml:"items>item,omitempty" json:"items,omitempty" yaml:"items,omitempty" cbor:"items,omitempty" orm:"-"` } type CommentQuery struct { m *Module query.Text Created time.Time `query:"created"` + State []State `query:"state,visible,top"` } func (q *CommentQuery) Filter(ctx *web.FilterContext) { q.Text.Filter(ctx) + ctx.Add(StateSliceFilter("state", &q.State)) } // HandleSetState 设置状态 @@ -60,13 +67,20 @@ func (m *Module) HandleGetComments(ctx *web.Context) web.Responser { } sql := m.db.SQLBuilder().Select().From(orm.TableName(&commentPO{}), "c"). - Column("c.id,c.created,c.modified,s.rate,s.content"). + Column("c.id,c.created,c.modified,s.rate,s.content,c.state"). Join("LEFT", orm.TableName(&snapshotPO{}), "s", "c.last=s.id"). AndIsNull("c.deleted"). - Desc("c.{created}") + Desc("c.{state}", "c.{created}") if !q.Created.IsZero() { sql.Where("c.created>?", q.Created) } + if len(q.State) > 0 { + sql.AndGroup(func(ws *sqlbuilder.WhereStmt) { + for _, s := range q.State { + ws.Or("c.{state}=?", s) + } + }) + } if q.Text.Text != "" { txt := "%" + q.Text.Text + "%" sql.Where("s.content ? OR c.author LIKE ?", txt, txt) @@ -77,28 +91,34 @@ func (m *Module) HandleGetComments(ctx *web.Context) web.Responser { // HandleGetCommentsByTarget 获取指定对象的评论列表 // -// 查询参数为 [CommentQuery],返回对象为 [query.Page[CommentVO]] +// 查询参数为 [query.Limit],返回对象为 [query.Page[CommentVO]],只返回不 hidden 的条目。 func (m *Module) HandleGetCommentsByTarget(ctx *web.Context, target int64) web.Responser { - q := &CommentQuery{m: m} - if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { - return resp + buildSQL := func(parent int64) *sqlbuilder.SelectStmt { + return m.db.SQLBuilder().Select().From(orm.TableName(&commentPO{}), "c"). + Column("c.id,c.created,c.modified,s.content"). + Join("LEFT", orm.TableName(&snapshotPO{}), "s", "c.last=s.id"). + Where("target=?", target). + And("c.state<>?", StateHidden). + And("c.parent=?", parent). + AndIsNull("c.deleted"). + Desc("c.{state}", "c.{created}") } - sql := m.db.SQLBuilder().Select().From(orm.TableName(&commentPO{}), "c"). - Column("c.id,c.created,c.modified,s.rate,s.content"). - Join("LEFT", orm.TableName(&snapshotPO{}), "s", "c.last=s.id"). - Where("target=?", target). - AndIsNull("c.deleted"). - Desc("c.{created}") - if q.Text.Text != "" { - txt := "%" + q.Text.Text + "%" - sql.Where("s.content LIKE ? OR c.author LIKE ?", txt, txt) - } - if !q.Created.IsZero() { - sql.Where("c.created>?", q.Created) + q := &query.Limit{} + if resp := ctx.QueryObject(true, q, cmfx.BadRequestInvalidQuery); resp != nil { + return resp } - return query.PagingResponser[CommentVO](ctx, &q.Limit, sql, nil) + return query.PagingResponser[CommentVO](ctx, q, buildSQL(0), func(cv *CommentVO) { + items := make([]*CommentVO, 0, 10) + switch size, err := buildSQL(cv.ID).QueryObject(true, items); { + case err != nil: + ctx.Logs().ERROR().Error(err) + case size == 0: + default: + cv.Items = items + } + }) } // HandleGetComment 获取评论信息 @@ -135,8 +155,28 @@ type CommentTO struct { func (to *CommentTO) Filter(ctx *web.FilterContext) { ctx.Add(filters.NotEmpty("content", &to.Content)). - Add(filters.BetweenEqual(0, 10)("rate", &to.Rate)). - Add(filters.NotEmpty("author", &to.Author)) + When(to.Parent == 0, func(v *web.FilterContext) { + v.Add(filters.BetweenEqual(0, 10)("rate", &to.Rate)) + }). + When(to.Parent > 0, func(v *web.FilterContext) { + v.Add(filters.Equal(0)("rate", &to.Rate)) + }). + Add(filters.NotEmpty("author", &to.Author)). + Add(filter.NewBuilder(filter.V(func(p int64) bool { + if p == 0 { + return true + } + + po := &commentPO{ID: p} + found, err := to.m.db.Select(po) + if err != nil { + ctx.Context().Logs().ERROR().Error(err) + return false + } else if !found { + return false + } + return po.Parent == 0 // 不能多级嵌套 + }, locales.MustBeEmpty))("parent", &to.Parent)) } // HandlePostComment 添加新的评论 diff --git a/cmfx/contents/comment/routes_test.go b/cmfx/contents/comment/routes_test.go index 9cc76c4..20e921e 100644 --- a/cmfx/contents/comment/routes_test.go +++ b/cmfx/contents/comment/routes_test.go @@ -7,6 +7,7 @@ package comment import ( "bytes" "encoding/json" + "fmt" "net/http" "strconv" "testing" @@ -88,7 +89,7 @@ func TestModule_Handle(t *testing.T) { s.Post("/comments", data). Header(header.ContentType, header.JSON).Header(header.Accept, header.JSON). Do(nil). - Status(http.StatusCreated) + Status(http.StatusCreated).BodyFunc(func(a *assert.Assertion, body []byte) {fmt.Println(string(body))}) spo := &snapshotPO{} ssize, err := m.db.Where("true").Select(true, spo) a.NotError(err).Equal(ssize, 1)