diff --git a/README.md b/README.md index a1a88cd..079c7f1 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ ## TODO -长短效合并 +清除代理的并发问题,redis 归还后,还有可能 gost 没有完成解除占用,导致端口冲突,考虑先等几秒再释放 redis 现在我需要提供一套首页接口,用来展示一些统计信息。检查 [README.md](README.md) 的后台首页接口部分,确保你正确理解了每个接口的含义,如何进行统计。如果有不清除的接口,先提出问题,我会补充相关信息。在没有问题后,给出实施计划 后台首页接口: + - 待认领客户数量 - 活跃代理数 - 今日交易额 @@ -31,7 +32,7 @@ 错误提示增强,展示整链路信息 -交易信息持久化 +交易信息持久化 订单关闭问题,在前端关闭窗口后直接调用了全部订单接口,应改成先确认再关闭 @@ -53,20 +54,24 @@ 1. 创建 model 文件 2. 将 model 按照格式添加声明到 `cmd/gen/main.go` 中 3. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意: - - 先写 drop table if exists 语句,确保脚本可以幂等执行 - - 为有必要的字段添加索引 - - 为数据表及其字段添加注释 - - 在文件末尾创建数据表流程全部结束后,为字段添加外键 + +- 先写 drop table if exists 语句,确保脚本可以幂等执行 +- 为有必要的字段添加索引 +- 为数据表及其字段添加注释 +- 在文件末尾创建数据表流程全部结束后,为字段添加外键 + 4. 调用 `go run ./cmd/gen/main.go` 生成查询文件 ### 更新数据表流程 1. 更新 model 文件 2. 编辑 `scripts/sql/init.sql` 文件,参照原有格式,需要注意: - - 先写 drop table if exists 语句,确保脚本可以幂等执行 - - 为有必要的字段添加索引 - - 为数据表及其字段添加注释 - - 在文件末尾创建数据表流程全部结束后,为字段添加外键 + +- 先写 drop table if exists 语句,确保脚本可以幂等执行 +- 为有必要的字段添加索引 +- 为数据表及其字段添加注释 +- 在文件末尾创建数据表流程全部结束后,为字段添加外键 + 3. 调用 `go run ./cmd/gen/main.go` 更新查询文件 ### 新增接口或修改接口权限 diff --git a/docs/api.yaml b/docs/api.yaml index c949de3..40e75f8 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -1944,17 +1944,39 @@ components: properties: keyword: type: string + description: 按文章标题或编码模糊查询 group_id: type: integer status: type: integer 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: type: object properties: group_id: type: integer + code: + type: string + description: 可选的文章编码,用于固定引用链接;在未删除文章中唯一 + minLength: 1 + maxLength: 64 + pattern: "^[a-z0-9][a-z0-9_-]{0,63}$" title: type: string content: @@ -1975,6 +1997,12 @@ components: type: integer group_id: type: integer + code: + type: string + description: 可修改的文章编码;在未删除文章中唯一 + minLength: 1 + maxLength: 64 + pattern: "^[a-z0-9][a-z0-9_-]{0,63}$" title: type: string content: @@ -2057,6 +2085,9 @@ components: properties: id: type: integer + code: + type: string + description: 旧文章可能没有编码 title: type: string updated_at: @@ -2105,6 +2136,9 @@ components: properties: id: type: integer + code: + type: string + description: 旧文章可能没有编码 title: type: string content: @@ -2468,6 +2502,9 @@ components: properties: group_id: type: integer + code: + type: string + description: 旧文章可能没有编码 title: type: string sort: @@ -3834,7 +3871,7 @@ paths: post: tags: [client/article] summary: 获取文档详情 - description: 仅官方客户端可调用。 + description: 仅官方客户端可调用。支持按文章 ID 或编码查询,二者必须且只能提供一个。 security: - basicAuth: [] - bearerAuth: [] @@ -3843,7 +3880,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/IdRequest" + $ref: "#/components/schemas/GetArticleRequest" responses: "200": description: 文档详情 diff --git a/scripts/sql/init.sql b/scripts/sql/init.sql index 21e46f6..5243187 100644 --- a/scripts/sql/init.sql +++ b/scripts/sql/init.sql @@ -175,6 +175,7 @@ drop table if exists article cascade; create table article ( id int generated by default as identity primary key, group_id int not null, + code text, title text not null, content text, sort int not null default 0, @@ -183,6 +184,7 @@ create table article ( updated_at timestamptz default current_timestamp, 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_status on article (status) 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 column article.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.content is '文章内容'; comment on column article.sort is '文章排序'; diff --git a/web/handlers/article.go b/web/handlers/article.go index 5b5e994..40f24f4 100644 --- a/web/handlers/article.go +++ b/web/handlers/article.go @@ -31,12 +31,12 @@ func GetArticle(c *fiber.Ctx) error { return err } - var req core.IdReq + var req s.GetArticleReq if err := g.Validator.ParseBody(c, &req); err != nil { return err } - article, err := s.Article.GetPublic(req.Id) + article, err := s.Article.GetPublic(req) if err != nil { return err } diff --git a/web/models/article.go b/web/models/article.go index 078e3b1..4df5c26 100644 --- a/web/models/article.go +++ b/web/models/article.go @@ -6,6 +6,7 @@ import "platform/web/core" type Article struct { core.Model 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"` // 文章标题 Content *string `json:"content,omitempty" gorm:"column:content"` // 文章内容 Sort int32 `json:"sort" gorm:"column:sort"` // 文章排序 diff --git a/web/queries/article.gen.go b/web/queries/article.gen.go index 617a515..3d7ead9 100644 --- a/web/queries/article.gen.go +++ b/web/queries/article.gen.go @@ -32,6 +32,7 @@ func newArticle(db *gorm.DB, opts ...gen.DOOption) article { _article.UpdatedAt = field.NewTime(tableName, "updated_at") _article.DeletedAt = field.NewField(tableName, "deleted_at") _article.GroupID = field.NewInt32(tableName, "group_id") + _article.Code = field.NewString(tableName, "code") _article.Title = field.NewString(tableName, "title") _article.Content = field.NewString(tableName, "content") _article.Sort = field.NewInt32(tableName, "sort") @@ -56,6 +57,7 @@ type article struct { UpdatedAt field.Time DeletedAt field.Field GroupID field.Int32 + Code field.String Title field.String Content field.String Sort field.Int32 @@ -82,6 +84,7 @@ func (a *article) updateTableName(table string) *article { a.UpdatedAt = field.NewTime(table, "updated_at") a.DeletedAt = field.NewField(table, "deleted_at") a.GroupID = field.NewInt32(table, "group_id") + a.Code = field.NewString(table, "code") a.Title = field.NewString(table, "title") a.Content = field.NewString(table, "content") a.Sort = field.NewInt32(table, "sort") @@ -102,12 +105,13 @@ func (a *article) GetFieldByName(fieldName string) (field.OrderExpr, bool) { } 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["created_at"] = a.CreatedAt a.fieldMap["updated_at"] = a.UpdatedAt a.fieldMap["deleted_at"] = a.DeletedAt a.fieldMap["group_id"] = a.GroupID + a.fieldMap["code"] = a.Code a.fieldMap["title"] = a.Title a.fieldMap["content"] = a.Content a.fieldMap["sort"] = a.Sort diff --git a/web/services/article.go b/web/services/article.go index ac82272..6fbdabe 100644 --- a/web/services/article.go +++ b/web/services/article.go @@ -14,6 +14,7 @@ import ( "platform/web/core" m "platform/web/models" q "platform/web/queries" + "regexp" "strings" "time" @@ -33,6 +34,8 @@ var articleUploadMimeExt = map[string]string{ "image/webp": ".webp", } +var articleCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`) + type ArticleUploadResult struct { URL string `json:"url"` 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) { do := q.Article.Where() 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 { do = do.Where(q.Article.GroupID.Eq(*req.GroupID)) @@ -194,17 +203,28 @@ func (s *articleService) Create(data CreateArticleData) error { 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, + Code: code, Title: data.Title, Content: data.Content, Sort: u.Else(data.Sort, 0), Status: u.Else(data.Status, m.ArticleStatusEnabled), }) + if errors.Is(err, gorm.ErrDuplicatedKey) { + return core.NewBizErr("文档编码已存在") + } + return err } type CreateArticleData struct { GroupID int32 `json:"group_id" validate:"required"` + Code *string `json:"code"` Title string `json:"title" validate:"required"` Content *string `json:"content"` Sort *int32 `json:"sort"` @@ -222,6 +242,13 @@ func (s *articleService) Update(data UpdateArticleData) error { if data.GroupID != nil { 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 { 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...) + if errors.Is(err, gorm.ErrDuplicatedKey) { + return core.NewBizErr("文档编码已存在") + } if err != nil { return err } @@ -251,6 +281,7 @@ func (s *articleService) Update(data UpdateArticleData) error { type UpdateArticleData struct { ID int32 `json:"id" validate:"required"` GroupID *int32 `json:"group_id"` + Code *string `json:"code"` Title *string `json:"title"` Content *string `json:"content"` Sort *int32 `json:"sort"` @@ -314,6 +345,7 @@ func (s *articleService) Nav() ([]*ArticleNavGroup, error) { } group.Articles = append(group.Articles, &ArticleNavArticle{ ID: article.ID, + Code: article.Code, Title: article.Title, UpdatedAt: article.UpdatedAt, }) @@ -331,18 +363,40 @@ type ArticleNavGroup struct { type ArticleNavArticle struct { ID int32 `json:"id"` + Code *string `json:"code,omitempty"` Title string `json:"title"` UpdatedAt time.Time `json:"updated_at"` } -func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) { - article, err := q.Article. +type GetArticleReq struct { + ID *int32 `json:"id"` + Code *string `json:"code"` +} + +func (s *articleService) GetPublic(req GetArticleReq) (*ArticlePublicDetail, error) { + do := q.Article. Preload(q.Article.Group). - Where( - q.Article.ID.Eq(id), - q.Article.Status.Eq(int(m.ArticleStatusEnabled)), - ). - Take() + Where(q.Article.Status.Eq(int(m.ArticleStatusEnabled))) + + switch { + case req.ID != nil && req.Code != nil: + 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) { return nil, core.NewBizErr("文档不存在") } @@ -355,6 +409,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) { return &ArticlePublicDetail{ ID: article.ID, + Code: article.Code, Title: article.Title, Content: article.Content, UpdatedAt: article.UpdatedAt, @@ -368,6 +423,7 @@ func (s *articleService) GetPublic(id int32) (*ArticlePublicDetail, error) { type ArticlePublicDetail struct { ID int32 `json:"id"` + Code *string `json:"code,omitempty"` Title string `json:"title"` Content *string `json:"content,omitempty"` UpdatedAt time.Time `json:"updated_at"` @@ -387,3 +443,22 @@ func (s *articleService) ensureGroupExists(groupID int32) error { } 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 +}