固定文章编码

This commit is contained in:
2026-07-21 15:30:33 +08:00
parent d961538027
commit 83d38956ca
7 changed files with 149 additions and 24 deletions

View File

@@ -1,10 +1,11 @@
## TODO ## TODO
长短效合并 清除代理的并发问题,redis 归还后,还有可能 gost 没有完成解除占用,导致端口冲突,考虑先等几秒再释放 redis
现在我需要提供一套首页接口,用来展示一些统计信息。检查 [README.md](README.md) 的后台首页接口部分,确保你正确理解了每个接口的含义,如何进行统计。如果有不清除的接口,先提出问题,我会补充相关信息。在没有问题后,给出实施计划 现在我需要提供一套首页接口,用来展示一些统计信息。检查 [README.md](README.md) 的后台首页接口部分,确保你正确理解了每个接口的含义,如何进行统计。如果有不清除的接口,先提出问题,我会补充相关信息。在没有问题后,给出实施计划
后台首页接口: 后台首页接口:
- 待认领客户数量 - 待认领客户数量
- 活跃代理数 - 活跃代理数
- 今日交易额 - 今日交易额
@@ -53,20 +54,24 @@
1. 创建 model 文件 1. 创建 model 文件
2. 将 model 按照格式添加声明到 `cmd/gen/main.go` 中 2. 将 model 按照格式添加声明到 `cmd/gen/main.go` 中
3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意: 3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
- 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为有必要的字段添加索引 - 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为数据表及其字段添加注释 - 为有必要的字段添加索引
- 在文件末尾创建数据表流程全部结束后,为字段添加外键 - 为数据表及其字段添加注释
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
4. 调用 `go run ./cmd/gen/main.go` 生成查询文件 4. 调用 `go run ./cmd/gen/main.go` 生成查询文件
### 更新数据表流程 ### 更新数据表流程
1. 更新 model 文件 1. 更新 model 文件
2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意: 2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意:
- 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为有必要的字段添加索引 - 先写 drop table if exists 语句,确保脚本可以幂等执行
- 为数据表及其字段添加注释 - 为有必要的字段添加索引
- 在文件末尾创建数据表流程全部结束后,为字段添加外键 - 为数据表及其字段添加注释
- 在文件末尾创建数据表流程全部结束后,为字段添加外键
3. 调用 `go run ./cmd/gen/main.go` 更新查询文件 3. 调用 `go run ./cmd/gen/main.go` 更新查询文件
### 新增接口或修改接口权限 ### 新增接口或修改接口权限

View File

@@ -1944,17 +1944,39 @@ components:
properties: properties:
keyword: keyword:
type: string type: string
description: 按文章标题或编码模糊查询
group_id: group_id:
type: integer type: integer
status: status:
type: integer type: integer
enum: [0, 1] enum: [0, 1]
GetArticleRequest:
type: object
description: `id` 与 `code` 必须且只能提供一个。按 `id` 查询用于兼容旧文章链接,按 `code` 查询用于固定引用链接。
properties:
id:
type: integer
code:
type: string
minLength: 1
maxLength: 64
pattern: "^[a-z0-9][a-z0-9_-]{0,63}$"
oneOf:
- required: [id]
- required: [code]
CreateArticleRequest: CreateArticleRequest:
type: object type: object
properties: properties:
group_id: group_id:
type: integer type: integer
code:
type: string
description: 可选的文章编码,用于固定引用链接;在未删除文章中唯一
minLength: 1
maxLength: 64
pattern: "^[a-z0-9][a-z0-9_-]{0,63}$"
title: title:
type: string type: string
content: content:
@@ -1975,6 +1997,12 @@ components:
type: integer type: integer
group_id: group_id:
type: integer type: integer
code:
type: string
description: 可修改的文章编码;在未删除文章中唯一
minLength: 1
maxLength: 64
pattern: "^[a-z0-9][a-z0-9_-]{0,63}$"
title: title:
type: string type: string
content: content:
@@ -2057,6 +2085,9 @@ components:
properties: properties:
id: id:
type: integer type: integer
code:
type: string
description: 旧文章可能没有编码
title: title:
type: string type: string
updated_at: updated_at:
@@ -2105,6 +2136,9 @@ components:
properties: properties:
id: id:
type: integer type: integer
code:
type: string
description: 旧文章可能没有编码
title: title:
type: string type: string
content: content:
@@ -2468,6 +2502,9 @@ components:
properties: properties:
group_id: group_id:
type: integer type: integer
code:
type: string
description: 旧文章可能没有编码
title: title:
type: string type: string
sort: sort:
@@ -3834,7 +3871,7 @@ paths:
post: post:
tags: [client/article] tags: [client/article]
summary: 获取文档详情 summary: 获取文档详情
description: 仅官方客户端可调用。 description: 仅官方客户端可调用。支持按文章 ID 或编码查询,二者必须且只能提供一个。
security: security:
- basicAuth: [] - basicAuth: []
- bearerAuth: [] - bearerAuth: []
@@ -3843,7 +3880,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/IdRequest" $ref: "#/components/schemas/GetArticleRequest"
responses: responses:
"200": "200":
description: 文档详情 description: 文档详情

View File

@@ -175,6 +175,7 @@ drop table if exists article cascade;
create table article ( create table article (
id int generated by default as identity primary key, id int generated by default as identity primary key,
group_id int not null, group_id int not null,
code text,
title text not null, title text not null,
content text, content text,
sort int not null default 0, sort int not null default 0,
@@ -183,6 +184,7 @@ create table article (
updated_at timestamptz default current_timestamp, updated_at timestamptz default current_timestamp,
deleted_at timestamptz deleted_at timestamptz
); );
create unique index udx_article_code on article (code) where code is not null and deleted_at is null;
create index idx_article_group_id on article (group_id) where deleted_at is null; create index idx_article_group_id on article (group_id) where deleted_at is null;
create index idx_article_status on article (status) where deleted_at is null; create index idx_article_status on article (status) where deleted_at is null;
create index idx_article_sort on article (sort) where deleted_at is null; create index idx_article_sort on article (sort) where deleted_at is null;
@@ -191,6 +193,7 @@ create index idx_article_sort on article (sort) where deleted_at is null;
comment on table article is '文章表'; comment on table article is '文章表';
comment on column article.id is '文章ID'; comment on column article.id is '文章ID';
comment on column article.group_id is '分组ID'; comment on column article.group_id is '分组ID';
comment on column article.code is '文章编码,用于固定引用链接';
comment on column article.title is '文章标题'; comment on column article.title is '文章标题';
comment on column article.content is '文章内容'; comment on column article.content is '文章内容';
comment on column article.sort is '文章排序'; comment on column article.sort is '文章排序';

View File

@@ -31,12 +31,12 @@ func GetArticle(c *fiber.Ctx) error {
return err return err
} }
var req core.IdReq var req s.GetArticleReq
if err := g.Validator.ParseBody(c, &req); err != nil { if err := g.Validator.ParseBody(c, &req); err != nil {
return err return err
} }
article, err := s.Article.GetPublic(req.Id) article, err := s.Article.GetPublic(req)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -6,6 +6,7 @@ import "platform/web/core"
type Article struct { type Article struct {
core.Model core.Model
GroupID int32 `json:"group_id" gorm:"column:group_id"` // 分组ID GroupID int32 `json:"group_id" gorm:"column:group_id"` // 分组ID
Code *string `json:"code,omitempty" gorm:"column:code"` // 文章编码,用于固定引用链接
Title string `json:"title" gorm:"column:title"` // 文章标题 Title string `json:"title" gorm:"column:title"` // 文章标题
Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容 Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容
Sort int32 `json:"sort" gorm:"column:sort"` // 文章排序 Sort int32 `json:"sort" gorm:"column:sort"` // 文章排序

View File

@@ -32,6 +32,7 @@ func newArticle(db *gorm.DB, opts ...gen.DOOption) article {
_article.UpdatedAt = field.NewTime(tableName, "updated_at") _article.UpdatedAt = field.NewTime(tableName, "updated_at")
_article.DeletedAt = field.NewField(tableName, "deleted_at") _article.DeletedAt = field.NewField(tableName, "deleted_at")
_article.GroupID = field.NewInt32(tableName, "group_id") _article.GroupID = field.NewInt32(tableName, "group_id")
_article.Code = field.NewString(tableName, "code")
_article.Title = field.NewString(tableName, "title") _article.Title = field.NewString(tableName, "title")
_article.Content = field.NewString(tableName, "content") _article.Content = field.NewString(tableName, "content")
_article.Sort = field.NewInt32(tableName, "sort") _article.Sort = field.NewInt32(tableName, "sort")
@@ -56,6 +57,7 @@ type article struct {
UpdatedAt field.Time UpdatedAt field.Time
DeletedAt field.Field DeletedAt field.Field
GroupID field.Int32 GroupID field.Int32
Code field.String
Title field.String Title field.String
Content field.String Content field.String
Sort field.Int32 Sort field.Int32
@@ -82,6 +84,7 @@ func (a *article) updateTableName(table string) *article {
a.UpdatedAt = field.NewTime(table, "updated_at") a.UpdatedAt = field.NewTime(table, "updated_at")
a.DeletedAt = field.NewField(table, "deleted_at") a.DeletedAt = field.NewField(table, "deleted_at")
a.GroupID = field.NewInt32(table, "group_id") a.GroupID = field.NewInt32(table, "group_id")
a.Code = field.NewString(table, "code")
a.Title = field.NewString(table, "title") a.Title = field.NewString(table, "title")
a.Content = field.NewString(table, "content") a.Content = field.NewString(table, "content")
a.Sort = field.NewInt32(table, "sort") a.Sort = field.NewInt32(table, "sort")
@@ -102,12 +105,13 @@ func (a *article) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
} }
func (a *article) fillFieldMap() { func (a *article) fillFieldMap() {
a.fieldMap = make(map[string]field.Expr, 10) a.fieldMap = make(map[string]field.Expr, 11)
a.fieldMap["id"] = a.ID a.fieldMap["id"] = a.ID
a.fieldMap["created_at"] = a.CreatedAt a.fieldMap["created_at"] = a.CreatedAt
a.fieldMap["updated_at"] = a.UpdatedAt a.fieldMap["updated_at"] = a.UpdatedAt
a.fieldMap["deleted_at"] = a.DeletedAt a.fieldMap["deleted_at"] = a.DeletedAt
a.fieldMap["group_id"] = a.GroupID a.fieldMap["group_id"] = a.GroupID
a.fieldMap["code"] = a.Code
a.fieldMap["title"] = a.Title a.fieldMap["title"] = a.Title
a.fieldMap["content"] = a.Content a.fieldMap["content"] = a.Content
a.fieldMap["sort"] = a.Sort a.fieldMap["sort"] = a.Sort

View File

@@ -14,6 +14,7 @@ import (
"platform/web/core" "platform/web/core"
m "platform/web/models" m "platform/web/models"
q "platform/web/queries" q "platform/web/queries"
"regexp"
"strings" "strings"
"time" "time"
@@ -33,6 +34,8 @@ var articleUploadMimeExt = map[string]string{
"image/webp": ".webp", "image/webp": ".webp",
} }
var articleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
type ArticleUploadResult struct { type ArticleUploadResult struct {
URL string `json:"url"` URL string `json:"url"`
Path string `json:"path"` Path string `json:"path"`
@@ -151,7 +154,13 @@ func formatUploadSizeLimit(bytes int) string {
func (s *articleService) Page(req *PageArticleReq) (result []*m.Article, count int64, err error) { func (s *articleService) Page(req *PageArticleReq) (result []*m.Article, count int64, err error) {
do := q.Article.Where() do := q.Article.Where()
if req.Keyword != nil && *req.Keyword != "" { if req.Keyword != nil && *req.Keyword != "" {
do = do.Where(q.Article.Title.Like("%" + *req.Keyword + "%")) do = do.Where(
q.Article.Where(
q.Article.Title.Like("%" + *req.Keyword + "%"),
).Or(
q.Article.Code.Like("%" + *req.Keyword + "%"),
),
)
} }
if req.GroupID != nil { if req.GroupID != nil {
do = do.Where(q.Article.GroupID.Eq(*req.GroupID)) do = do.Where(q.Article.GroupID.Eq(*req.GroupID))
@@ -194,17 +203,28 @@ func (s *articleService) Create(data CreateArticleData) error {
return err return err
} }
return q.Article.Create(&m.Article{ code, err := optionalArticleCode(data.Code)
if err != nil {
return err
}
err = q.Article.Create(&m.Article{
GroupID: data.GroupID, GroupID: data.GroupID,
Code: code,
Title: data.Title, Title: data.Title,
Content: data.Content, Content: data.Content,
Sort: u.Else(data.Sort, 0), Sort: u.Else(data.Sort, 0),
Status: u.Else(data.Status, m.ArticleStatusEnabled), Status: u.Else(data.Status, m.ArticleStatusEnabled),
}) })
if errors.Is(err, gorm.ErrDuplicatedKey) {
return core.NewBizErr("文档编码已存在")
}
return err
} }
type CreateArticleData struct { type CreateArticleData struct {
GroupID int32 `json:"group_id" validate:"required"` GroupID int32 `json:"group_id" validate:"required"`
Code *string `json:"code"`
Title string `json:"title" validate:"required"` Title string `json:"title" validate:"required"`
Content *string `json:"content"` Content *string `json:"content"`
Sort *int32 `json:"sort"` Sort *int32 `json:"sort"`
@@ -222,6 +242,13 @@ func (s *articleService) Update(data UpdateArticleData) error {
if data.GroupID != nil { if data.GroupID != nil {
do = append(do, q.Article.GroupID.Value(*data.GroupID)) do = append(do, q.Article.GroupID.Value(*data.GroupID))
} }
if data.Code != nil {
code, err := normalizeArticleCode(*data.Code)
if err != nil {
return err
}
do = append(do, q.Article.Code.Value(code))
}
if data.Title != nil { if data.Title != nil {
do = append(do, q.Article.Title.Value(*data.Title)) do = append(do, q.Article.Title.Value(*data.Title))
} }
@@ -239,6 +266,9 @@ func (s *articleService) Update(data UpdateArticleData) error {
} }
r, err := q.Article.Where(q.Article.ID.Eq(data.ID)).UpdateSimple(do...) r, err := q.Article.Where(q.Article.ID.Eq(data.ID)).UpdateSimple(do...)
if errors.Is(err, gorm.ErrDuplicatedKey) {
return core.NewBizErr("文档编码已存在")
}
if err != nil { if err != nil {
return err return err
} }
@@ -251,6 +281,7 @@ func (s *articleService) Update(data UpdateArticleData) error {
type UpdateArticleData struct { type UpdateArticleData struct {
ID int32 `json:"id" validate:"required"` ID int32 `json:"id" validate:"required"`
GroupID *int32 `json:"group_id"` GroupID *int32 `json:"group_id"`
Code *string `json:"code"`
Title *string `json:"title"` Title *string `json:"title"`
Content *string `json:"content"` Content *string `json:"content"`
Sort *int32 `json:"sort"` Sort *int32 `json:"sort"`
@@ -314,6 +345,7 @@ func (s *articleService) Nav() ([]*ArticleNavGroup, error) {
} }
group.Articles = append(group.Articles, &ArticleNavArticle{ group.Articles = append(group.Articles, &ArticleNavArticle{
ID: article.ID, ID: article.ID,
Code: article.Code,
Title: article.Title, Title: article.Title,
UpdatedAt: article.UpdatedAt, UpdatedAt: article.UpdatedAt,
}) })
@@ -331,18 +363,40 @@ type ArticleNavGroup struct {
type ArticleNavArticle struct { type ArticleNavArticle struct {
ID int32 `json:"id"` ID int32 `json:"id"`
Code *string `json:"code,omitempty"`
Title string `json:"title"` Title string `json:"title"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) { type GetArticleReq struct {
article, err := q.Article. ID *int32 `json:"id"`
Code *string `json:"code"`
}
func (s *articleService) GetPublic(req GetArticleReq) (*ArticlePublicDetail, error) {
do := q.Article.
Preload(q.Article.Group). Preload(q.Article.Group).
Where( Where(q.Article.Status.Eq(int(m.ArticleStatusEnabled)))
q.Article.ID.Eq(id),
q.Article.Status.Eq(int(m.ArticleStatusEnabled)), switch {
). case req.ID != nil && req.Code != nil:
Take() return nil, core.NewBizErr("文档 ID 和编码不能同时提供")
case req.ID != nil:
if *req.ID <= 0 {
return nil, core.NewBizErr("文档 ID 不正确")
}
do = do.Where(q.Article.ID.Eq(*req.ID))
case req.Code != nil:
code, err := normalizeArticleCode(*req.Code)
if err != nil {
return nil, err
}
do = do.Where(q.Article.Code.Eq(code))
default:
return nil, core.NewBizErr("必须提供文档 ID 或编码")
}
article, err := do.Take()
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, core.NewBizErr("文档不存在") return nil, core.NewBizErr("文档不存在")
} }
@@ -355,6 +409,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
return &ArticlePublicDetail{ return &ArticlePublicDetail{
ID: article.ID, ID: article.ID,
Code: article.Code,
Title: article.Title, Title: article.Title,
Content: article.Content, Content: article.Content,
UpdatedAt: article.UpdatedAt, UpdatedAt: article.UpdatedAt,
@@ -368,6 +423,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) {
type ArticlePublicDetail struct { type ArticlePublicDetail struct {
ID int32 `json:"id"` ID int32 `json:"id"`
Code *string `json:"code,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Content *string `json:"content,omitempty"` Content *string `json:"content,omitempty"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -387,3 +443,22 @@ func (s *articleService) ensureGroupExists(groupID int32) error {
} }
return err return err
} }
func optionalArticleCode(code *string) (*string, error) {
if code == nil || strings.TrimSpace(*code) == "" {
return nil, nil
}
normalized, err := normalizeArticleCode(*code)
if err != nil {
return nil, err
}
return &normalized, nil
}
func normalizeArticleCode(code string) (string, error) {
normalized := strings.TrimSpace(code)
if !articleCodePattern.MatchString(normalized) {
return "", core.NewBizErr("文档编码只能包含小写字母、数字、下划线或连字符,长度为 1-64 位")
}
return normalized, nil
}