From 1e336406293356f0d4f6a077c63d8bafdb640dfd Mon Sep 17 00:00:00 2001 From: wdh-home <243823965@qq.com> Date: Sun, 26 Apr 2026 15:32:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E7=A1=80=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/.ai-skills/admin-menu-doc-spec.md | 256 ++++++++++++++++++ .../coding-specs/module-admin-crud-default.md | 6 +- server/.ai-specs/doc-api/admin/book.md | 29 ++ server/.ai-specs/doc-api/admin/book_author.md | 29 ++ .../doc-api/admin/book_author_relation.md | 29 ++ .../.ai-specs/doc-api/admin/book_chapter.md | 29 ++ .../.ai-specs/doc-api/admin/book_comment.md | 29 ++ .../doc-api/admin/book_comment_like_record.md | 29 ++ .../doc-api/admin/book_favorite_record.md | 29 ++ .../doc-api/admin/book_read_record.md | 29 ++ server/.ai-specs/doc-api/admin/book_series.md | 29 ++ server/.ai-specs/doc-api/admin/sys_api.md | 39 +++ .../.ai-specs/doc-dict/book_author_status.md | 1 + .../.ai-specs/doc-dict/book_comment_status.md | 10 + .../doc-dict/book_completion_status.md | 1 + server/.ai-specs/doc-dict/book_era_tag.md | 18 ++ .../.ai-specs/doc-dict/book_process_status.md | 11 - .../.ai-specs/doc-dict/book_publish_status.md | 11 + server/.ai-specs/doc-dict/book_type.md | 9 + server/.ai-specs/doc-sql/book.sql | 66 +++++ .../{book_author.md => book_author.sql} | 27 +- .../doc-sql/book_author_relation.sql | 31 +++ server/.ai-specs/doc-sql/book_chapter.sql | 40 +++ server/.ai-specs/doc-sql/book_comment.sql | 43 +++ .../doc-sql/book_comment_like_record.sql | 31 +++ .../doc-sql/book_favorite_record.sql | 31 +++ server/.ai-specs/doc-sql/book_read_record.sql | 39 +++ server/.ai-specs/doc-sql/book_series.sql | 33 +++ .../{requirements => prd-draft}/book.md | 3 +- .../sys-specs/business-dictionary-spec.md | 41 ++- .../sys-specs/business-table-spec.md | 72 +++-- .../sys-specs/database-upgrade-doc-spec.md | 59 ++++ .../sys-specs/requirements-stage-spec.md | 107 -------- .../admin-web-prd/book-admin-prd.md | 181 +++++++++++++ .../database-upgrade-doc/v1.sql | 132 +++++++++ .../gorm-auto-migrate-and-upgrade-sql.md | 86 ++++++ server/AGENTS.md | 85 ++++-- server/README.md | 6 + server/api/v1/book/book.go | 138 ++++++++++ server/api/v1/book/book_author.go | 100 +++++++ server/api/v1/book/book_author_relation.go | 100 +++++++ server/api/v1/book/book_chapter.go | 100 +++++++ server/api/v1/book/book_comment.go | 100 +++++++ .../api/v1/book/book_comment_like_record.go | 100 +++++++ server/api/v1/book/book_favorite_record.go | 100 +++++++ server/api/v1/book/book_read_record.go | 100 +++++++ server/api/v1/book/book_series.go | 100 +++++++ server/api/v1/book/common.go | 50 ++++ server/api/v1/book/common_test.go | 33 +++ server/api/v1/book/enter.go | 27 ++ server/api/v1/enter.go | 2 + server/api/v1/system/sys_api.go | 5 +- server/initialize/gorm_biz.go | 13 +- server/initialize/router_biz.go | 2 + server/model/book/base.go | 9 + server/model/book/book.go | 50 ++++ server/model/book/book_author.go | 13 + server/model/book/book_author_relation.go | 12 + server/model/book/book_chapter.go | 16 ++ server/model/book/book_comment.go | 16 ++ server/model/book/book_comment_like_record.go | 14 + server/model/book/book_favorite_record.go | 14 + server/model/book/book_models_test.go | 29 ++ server/model/book/book_read_record.go | 18 ++ server/model/book/book_series.go | 13 + server/model/book/request/book.go | 64 +++++ server/model/book/response/book.go | 31 +++ server/router/book/book.go | 23 ++ server/router/book/book_author.go | 12 + server/router/book/book_author_relation.go | 12 + server/router/book/book_chapter.go | 12 + server/router/book/book_comment.go | 12 + .../router/book/book_comment_like_record.go | 12 + server/router/book/book_favorite_record.go | 12 + server/router/book/book_read_record.go | 12 + server/router/book/book_routes.go | 12 + server/router/book/book_series.go | 12 + server/router/book/enter.go | 9 + server/router/enter.go | 2 + server/service/book/book.go | 66 +++++ server/service/book/book_author.go | 56 ++++ server/service/book/book_author_relation.go | 54 ++++ server/service/book/book_chapter.go | 59 ++++ server/service/book/book_comment.go | 59 ++++ .../service/book/book_comment_like_record.go | 47 ++++ server/service/book/book_favorite_record.go | 47 ++++ server/service/book/book_read_record.go | 53 ++++ server/service/book/book_series.go | 50 ++++ server/service/book/common.go | 13 + server/service/book/enter.go | 13 + server/service/book/validation.go | 132 +++++++++ server/service/book/validation_test.go | 116 ++++++++ server/service/enter.go | 2 + .../service/system/auto_code_package_test.go | 4 +- server/service/system/sys_api.go | 84 ++++++ server/service/system/sys_api_test.go | 84 ++++++ server/source/system/api.go | 2 + server/source/system/api_test.go | 30 ++ server/source/system/casbin.go | 2 + server/source/system/casbin_test.go | 30 ++ server/source/system/dictionary.go | 6 + server/source/system/dictionary_detail.go | 29 ++ 102 files changed, 4088 insertions(+), 197 deletions(-) create mode 100644 server/.ai-skills/admin-menu-doc-spec.md create mode 100644 server/.ai-specs/doc-api/admin/book.md create mode 100644 server/.ai-specs/doc-api/admin/book_author.md create mode 100644 server/.ai-specs/doc-api/admin/book_author_relation.md create mode 100644 server/.ai-specs/doc-api/admin/book_chapter.md create mode 100644 server/.ai-specs/doc-api/admin/book_comment.md create mode 100644 server/.ai-specs/doc-api/admin/book_comment_like_record.md create mode 100644 server/.ai-specs/doc-api/admin/book_favorite_record.md create mode 100644 server/.ai-specs/doc-api/admin/book_read_record.md create mode 100644 server/.ai-specs/doc-api/admin/book_series.md create mode 100644 server/.ai-specs/doc-api/admin/sys_api.md create mode 100644 server/.ai-specs/doc-dict/book_comment_status.md create mode 100644 server/.ai-specs/doc-dict/book_era_tag.md delete mode 100644 server/.ai-specs/doc-dict/book_process_status.md create mode 100644 server/.ai-specs/doc-dict/book_publish_status.md create mode 100644 server/.ai-specs/doc-dict/book_type.md create mode 100644 server/.ai-specs/doc-sql/book.sql rename server/.ai-specs/doc-sql/{book_author.md => book_author.sql} (67%) create mode 100644 server/.ai-specs/doc-sql/book_author_relation.sql create mode 100644 server/.ai-specs/doc-sql/book_chapter.sql create mode 100644 server/.ai-specs/doc-sql/book_comment.sql create mode 100644 server/.ai-specs/doc-sql/book_comment_like_record.sql create mode 100644 server/.ai-specs/doc-sql/book_favorite_record.sql create mode 100644 server/.ai-specs/doc-sql/book_read_record.sql create mode 100644 server/.ai-specs/doc-sql/book_series.sql rename server/.ai-specs/{requirements => prd-draft}/book.md (98%) create mode 100644 server/.ai-specs/sys-specs/database-upgrade-doc-spec.md delete mode 100644 server/.ai-specs/sys-specs/requirements-stage-spec.md create mode 100644 server/.ai-transition/admin-web-prd/book-admin-prd.md create mode 100644 server/.ai-transition/database-upgrade-doc/v1.sql create mode 100644 server/.ai-transition/remake/gorm-auto-migrate-and-upgrade-sql.md create mode 100644 server/api/v1/book/book.go create mode 100644 server/api/v1/book/book_author.go create mode 100644 server/api/v1/book/book_author_relation.go create mode 100644 server/api/v1/book/book_chapter.go create mode 100644 server/api/v1/book/book_comment.go create mode 100644 server/api/v1/book/book_comment_like_record.go create mode 100644 server/api/v1/book/book_favorite_record.go create mode 100644 server/api/v1/book/book_read_record.go create mode 100644 server/api/v1/book/book_series.go create mode 100644 server/api/v1/book/common.go create mode 100644 server/api/v1/book/common_test.go create mode 100644 server/api/v1/book/enter.go create mode 100644 server/model/book/base.go create mode 100644 server/model/book/book.go create mode 100644 server/model/book/book_author.go create mode 100644 server/model/book/book_author_relation.go create mode 100644 server/model/book/book_chapter.go create mode 100644 server/model/book/book_comment.go create mode 100644 server/model/book/book_comment_like_record.go create mode 100644 server/model/book/book_favorite_record.go create mode 100644 server/model/book/book_models_test.go create mode 100644 server/model/book/book_read_record.go create mode 100644 server/model/book/book_series.go create mode 100644 server/model/book/request/book.go create mode 100644 server/model/book/response/book.go create mode 100644 server/router/book/book.go create mode 100644 server/router/book/book_author.go create mode 100644 server/router/book/book_author_relation.go create mode 100644 server/router/book/book_chapter.go create mode 100644 server/router/book/book_comment.go create mode 100644 server/router/book/book_comment_like_record.go create mode 100644 server/router/book/book_favorite_record.go create mode 100644 server/router/book/book_read_record.go create mode 100644 server/router/book/book_routes.go create mode 100644 server/router/book/book_series.go create mode 100644 server/router/book/enter.go create mode 100644 server/service/book/book.go create mode 100644 server/service/book/book_author.go create mode 100644 server/service/book/book_author_relation.go create mode 100644 server/service/book/book_chapter.go create mode 100644 server/service/book/book_comment.go create mode 100644 server/service/book/book_comment_like_record.go create mode 100644 server/service/book/book_favorite_record.go create mode 100644 server/service/book/book_read_record.go create mode 100644 server/service/book/book_series.go create mode 100644 server/service/book/common.go create mode 100644 server/service/book/enter.go create mode 100644 server/service/book/validation.go create mode 100644 server/service/book/validation_test.go create mode 100644 server/service/system/sys_api_test.go create mode 100644 server/source/system/api_test.go create mode 100644 server/source/system/casbin_test.go diff --git a/server/.ai-skills/admin-menu-doc-spec.md b/server/.ai-skills/admin-menu-doc-spec.md new file mode 100644 index 0000000..f25080c --- /dev/null +++ b/server/.ai-skills/admin-menu-doc-spec.md @@ -0,0 +1,256 @@ +# admin 菜单文档生成规范 + +## 触发方式 + +当用户输入以下类似指令时,按本规范生成文档: + +- `生成admin菜单文档` +- `生成后台菜单文档` +- `根据admin接口文档生成前端菜单文档` +- `给前端整理admin管理页面` + +## 目标 + +生成一份给前端看的后台页面需求文档,用来指导 `admin` 端通用管理页面建设。 + +文档只回答四件事: + +- 有哪些一级菜单。 +- 一级菜单下有哪些一级页面。 +- 每个一级页面有哪些功能跳转。 +- 每个一级页面有哪些页面内功能和弹出框。 + +## 输入来源 + +默认从以下目录读取接口文档: + +- `.ai-specs/doc-api/admin` + +如果用户指定其他目录,以用户指定目录为准。 + +生成前必须先查看该目录的 diff 或文件列表: + +- 优先看 `git diff -- .ai-specs/doc-api/admin`。 +- 如果没有 diff,再看 staged diff、未跟踪文件和目录内现有文件。 +- 只基于实际存在的 admin 接口文档推导,不凭空增加模块。 + +## 输出位置 + +默认写入: + +- `.ai-skills/<业务名>-admin-frontend-pages.md` + +业务名优先从接口文档的模块、资源或文件名前缀推断。 + +示例: + +- 书籍模块:`.ai-skills/book-admin-frontend-pages.md` + +## 文档语言 + +- 中文为主体语言。 +- 技术词保留英文原文,例如 `admin`、`CRUD`、`Mermaid`。 +- 面向前端,不写后端实现细节。 + +## 内容边界 + +必须写: + +- 一级菜单。 +- 一级页面清单。 +- 每个一级页面一张独立 Mermaid 图。 +- 特殊功能,例如状态调整、审核、上下架、启停、绑定、排序、选择、导入、导出等。 +- 不单独成页的关联表说明。 + +不要写: + +- 通用 CRUD 明细。 +- 接口 Method、Path、API 方法名、Service 方法名。 +- 字段级详细表单需求。 +- 后端分层实现说明。 +- 权限、审计、Casbin 等后端细节,除非用户明确要求。 + +## 页面命名规范 + +统一使用以下概念,不要混用: + +- `一级菜单`:侧边栏顶层业务菜单。 +- `一级页面`:菜单下可直接进入的管理列表页。 +- `功能跳转`:从一级页面进入的详情页、编辑页、配置页等。 +- `页面内功能`:当前页面内完成的功能,不代表新页面。 +- `弹出框`:选择、确认、绑定、排序、状态调整等弹窗。 +- `不独立成页`:纯关联表、日志表、明细表等不应成为独立管理页的资源。 + +不要使用: + +- `N级区域` +- `二级页面`,除非用户明确要求该词 +- `模块页面` 这类不清晰命名 + +## 关联表处理规则 + +纯关联表默认不做独立一级页面。 + +判断依据: + +- 文件名或资源名包含 `relation`、`binding`、`mapping`、`link`。 +- 表意是两个主体资源之间的绑定关系。 +- 没有独立业务生命周期,只依附主体资源维护。 + +处理方式: + +- 放到主体表的编辑页或详情页中。 +- 用 `页面内功能:绑定xxx`、`页面内功能:排序xxx` 表达。 +- 如需选择对象,用 `弹出框:选择xxx` 表达。 +- 在文档末尾 `不单独成页` 中说明原因。 + +示例: + +- `书籍作者关系` 不做独立页面;放在 `书籍管理` 的作者绑定和作者排序功能中。 + +## 记录表处理规则 + +记录表可作为一级页面,但通常只提供查看和必要维护。 + +判断依据: + +- 文件名或资源名包含 `record`、`log`、`history`。 +- 表意是用户行为、操作痕迹、统计明细。 + +处理方式: + +- 可生成 `xxx记录管理` 一级页面。 +- 通常只生成 `功能跳转:xxx记录详情页`。 +- 页面内功能重点写查看关系、查看用户、查看主体对象、查看时间/进度等。 +- 不要主动设计复杂状态流转。 + +## 状态类功能处理规则 + +如果接口文档、SQL 文档或字典文档中出现状态字段,应在页面内功能中体现。 + +常见表达: + +- `页面内功能:上下架状态调整` +- `页面内功能:完结状态调整` +- `页面内功能:作者状态调整` +- `页面内功能:系列启停/展示状态调整` +- `页面内功能:评论状态处理` +- `弹出框:状态调整确认` +- `弹出框:状态处理确认` + +如果 admin 接口文档没有独立状态流转接口,只能写成页面需求,不要暗示后端已经有专用接口。 + +## Mermaid 图规范 + +每个一级页面单独一张图。 + +图必须使用 `flowchart TD`。 + +节点命名格式: + +```text +一级页面:xxx管理 +功能跳转:xxx详情页 +功能跳转:xxx编辑页 +页面内功能:xxx +弹出框:xxx +不独立成页:xxx +``` + +推荐结构: + +```mermaid +flowchart TD + A["一级页面:xxx管理"] + + A --> B1["功能跳转:xxx详情页"] + A --> B2["功能跳转:xxx编辑页"] + + B1 --> C1["页面内功能:查看xxx"] + B2 --> C2["页面内功能:维护xxx"] + + C2 --> D1["弹出框:选择xxx"] + A --> C3["页面内功能:xxx状态调整"] + C3 --> D2["弹出框:状态调整确认"] +``` + +关联表不独立成页示例: + +```mermaid +flowchart TD + A["一级页面:主体管理"] + A --> B1["功能跳转:主体编辑页"] + B1 --> C1["页面内功能:维护关联对象绑定"] + B1 --> C2["页面内功能:维护关联对象排序"] + C1 --> D1["弹出框:选择关联对象"] + + X["不独立成页:主体关联关系"] -.-> C1 + X -.-> C2 +``` + +## 文档结构模板 + +生成文档时使用以下结构: + +```markdown +# <业务名>后台管理页面需求 + +## 说明 + +- 本文档面向前端 `admin` 后台页面实现。 +- 只描述菜单、页面跳转、页面内功能和弹出框关系。 +- 通用 CRUD 能力不重复描述,按后台管理通用列表页、详情页、编辑页能力实现。 +- 纯关联表不单独做管理菜单;在主体资源详情/编辑中维护。 + +## 一级菜单 + +- <一级菜单名> + +## 一级页面清单 + +- <一级页面1> +- <一级页面2> + +## <一级页面1> + +```mermaid +flowchart TD + A["一级页面:<一级页面1>"] +``` + +## <一级页面2> + +```mermaid +flowchart TD + A["一级页面:<一级页面2>"] +``` + +## 不单独成页 + +- <关联表资源>:不做独立管理菜单,不做独立一级页面;在 `<主体页面>` 中作为 `<页面内功能>` 维护。 +``` + +## 生成步骤 + +1. 读取 `.ai-specs/doc-api/admin` 的 diff、staged diff、未跟踪文件和文件列表。 +2. 从文件名和文档标题提取资源清单。 +3. 合并同一业务域为一级菜单。 +4. 把主体资源、记录资源生成一级页面。 +5. 把纯关联资源挂到主体资源页面内功能。 +6. 为每个一级页面生成一张 Mermaid 图。 +7. 删除所有通用 CRUD、接口路径、后端方法名。 +8. 写入 `.ai-skills/<业务名>-admin-frontend-pages.md`。 +9. 用 UTF-8 读取文件做一次快速确认。 + +## 质量检查 + +生成后逐项检查: + +- 是否每个一级页面都有独立 Mermaid 图。 +- 是否没有使用 `N级区域`。 +- 是否没有把纯关联表做成独立页面。 +- 是否没有展开通用 CRUD。 +- 是否没有写接口 Method、Path、API 方法名、Service 方法名。 +- 是否特殊功能都体现为 `页面内功能` 或 `弹出框`。 +- 是否文档能在没有对话上下文时独立理解。 + diff --git a/server/.ai-specs/coding-specs/module-admin-crud-default.md b/server/.ai-specs/coding-specs/module-admin-crud-default.md index d8ccd70..76b3c98 100644 --- a/server/.ai-specs/coding-specs/module-admin-crud-default.md +++ b/server/.ai-specs/coding-specs/module-admin-crud-default.md @@ -1,4 +1,4 @@ -# 业务 admin 端默认 CRUD 接口规范 +# 业务 admin 端默认 CRUD 接口规范 ## 适用范围 @@ -35,6 +35,10 @@ - 新增业务 `admin` 模块时,如无明确例外,先提供这 6 个接口,再叠加业务特有接口。 - 路由统一放在 `router/`,接口统一放在 `api/v1/`,业务统一放在 `service/`,模型统一放在 `model/`。 - 新增业务路由后,必须同步在 `initialize/router_biz.go` 注册。 +- 单个业务模块包含多个独立资源或多张业务表时,`api/v1/`、`service/`、`router/` 必须按资源拆分文件;禁止把多个资源的 CRUD 长期堆在同一个大文件。 +- 单个业务模块包含多个独立资源或多张业务表时,`.ai-specs/doc-api/<端>/.md` 必须按资源拆分文档;禁止把多个资源的接口 contract 长期堆在同一个 doc-api 大文档。 +- 每个资源文件只承载该资源的 `API`、`Service` 或 `Router` 方法;跨资源复用逻辑只能放在明确命名的 `common.go`、`helper.go` 等公共文件,且公共文件禁止承载具体资源 CRUD 主流程。 +- `enter.go` 只负责聚合结构体、公共变量或分发注册,不承载具体业务逻辑和具体 CRUD handler。 - 同一模块如果同时有 `admin/app` 两套接口,目录仍按业务模块落点,`admin` 与 `app` 必须分文件或分承载结构体,不能长期混写。 - Swagger 注解里的 `@Router`、`@Security ApiKeyAuth`、`Method` 必须和真实 router 挂载一致。 diff --git a/server/.ai-specs/doc-api/admin/book.md b/server/.ai-specs/doc-api/admin/book.md new file mode 100644 index 0000000..7a806fe --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book.md @@ -0,0 +1,29 @@ +# 书籍信息 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:书籍 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBook` | `CreateBook` | `CreateBook` | +| 单删 | `DELETE` | `/book/deleteBook` | `DeleteBook` | `DeleteBook` | +| 批量删除 | `DELETE` | `/book/deleteBookByIds` | `DeleteBookByIds` | `DeleteBookByIds` | +| 更新 | `PUT` | `/book/updateBook` | `UpdateBook` | `UpdateBook` | +| 详情 | `GET` | `/book/findBook` | `FindBook` | `GetBook` | +| 分页列表 | `GET` | `/book/getBookList` | `GetBookList` | `GetBookInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.Book`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookResponse`。 diff --git a/server/.ai-specs/doc-api/admin/book_author.md b/server/.ai-specs/doc-api/admin/book_author.md new file mode 100644 index 0000000..817ea74 --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book_author.md @@ -0,0 +1,29 @@ +# 书籍作者 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:作者 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBookAuthor` | `CreateBookAuthor` | `CreateBookAuthor` | +| 单删 | `DELETE` | `/book/deleteBookAuthor` | `DeleteBookAuthor` | `DeleteBookAuthor` | +| 批量删除 | `DELETE` | `/book/deleteBookAuthorByIds` | `DeleteBookAuthorByIds` | `DeleteBookAuthorByIds` | +| 更新 | `PUT` | `/book/updateBookAuthor` | `UpdateBookAuthor` | `UpdateBookAuthor` | +| 详情 | `GET` | `/book/findBookAuthor` | `FindBookAuthor` | `GetBookAuthor` | +| 分页列表 | `GET` | `/book/getBookAuthorList` | `GetBookAuthorList` | `GetBookAuthorInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.BookAuthor`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookAuthorSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookAuthorResponse`。 diff --git a/server/.ai-specs/doc-api/admin/book_author_relation.md b/server/.ai-specs/doc-api/admin/book_author_relation.md new file mode 100644 index 0000000..d741a93 --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book_author_relation.md @@ -0,0 +1,29 @@ +# 书籍作者关系 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:书籍作者关系 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBookAuthorRelation` | `CreateBookAuthorRelation` | `CreateBookAuthorRelation` | +| 单删 | `DELETE` | `/book/deleteBookAuthorRelation` | `DeleteBookAuthorRelation` | `DeleteBookAuthorRelation` | +| 批量删除 | `DELETE` | `/book/deleteBookAuthorRelationByIds` | `DeleteBookAuthorRelationByIds` | `DeleteBookAuthorRelationByIds` | +| 更新 | `PUT` | `/book/updateBookAuthorRelation` | `UpdateBookAuthorRelation` | `UpdateBookAuthorRelation` | +| 详情 | `GET` | `/book/findBookAuthorRelation` | `FindBookAuthorRelation` | `GetBookAuthorRelation` | +| 分页列表 | `GET` | `/book/getBookAuthorRelationList` | `GetBookAuthorRelationList` | `GetBookAuthorRelationInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.BookAuthorRelation`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookAuthorRelationSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookAuthorRelationResponse`。 diff --git a/server/.ai-specs/doc-api/admin/book_chapter.md b/server/.ai-specs/doc-api/admin/book_chapter.md new file mode 100644 index 0000000..1d5fdd8 --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book_chapter.md @@ -0,0 +1,29 @@ +# 书籍章节 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:章节 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBookChapter` | `CreateBookChapter` | `CreateBookChapter` | +| 单删 | `DELETE` | `/book/deleteBookChapter` | `DeleteBookChapter` | `DeleteBookChapter` | +| 批量删除 | `DELETE` | `/book/deleteBookChapterByIds` | `DeleteBookChapterByIds` | `DeleteBookChapterByIds` | +| 更新 | `PUT` | `/book/updateBookChapter` | `UpdateBookChapter` | `UpdateBookChapter` | +| 详情 | `GET` | `/book/findBookChapter` | `FindBookChapter` | `GetBookChapter` | +| 分页列表 | `GET` | `/book/getBookChapterList` | `GetBookChapterList` | `GetBookChapterInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.BookChapter`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookChapterSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookChapterResponse`。 diff --git a/server/.ai-specs/doc-api/admin/book_comment.md b/server/.ai-specs/doc-api/admin/book_comment.md new file mode 100644 index 0000000..c290381 --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book_comment.md @@ -0,0 +1,29 @@ +# 书籍评论 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:评论 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBookComment` | `CreateBookComment` | `CreateBookComment` | +| 单删 | `DELETE` | `/book/deleteBookComment` | `DeleteBookComment` | `DeleteBookComment` | +| 批量删除 | `DELETE` | `/book/deleteBookCommentByIds` | `DeleteBookCommentByIds` | `DeleteBookCommentByIds` | +| 更新 | `PUT` | `/book/updateBookComment` | `UpdateBookComment` | `UpdateBookComment` | +| 详情 | `GET` | `/book/findBookComment` | `FindBookComment` | `GetBookComment` | +| 分页列表 | `GET` | `/book/getBookCommentList` | `GetBookCommentList` | `GetBookCommentInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.BookComment`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookCommentSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookCommentResponse`。 diff --git a/server/.ai-specs/doc-api/admin/book_comment_like_record.md b/server/.ai-specs/doc-api/admin/book_comment_like_record.md new file mode 100644 index 0000000..ccd10dc --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book_comment_like_record.md @@ -0,0 +1,29 @@ +# 评论点赞记录 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:评论点赞记录 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBookCommentLikeRecord` | `CreateBookCommentLikeRecord` | `CreateBookCommentLikeRecord` | +| 单删 | `DELETE` | `/book/deleteBookCommentLikeRecord` | `DeleteBookCommentLikeRecord` | `DeleteBookCommentLikeRecord` | +| 批量删除 | `DELETE` | `/book/deleteBookCommentLikeRecordByIds` | `DeleteBookCommentLikeRecordByIds` | `DeleteBookCommentLikeRecordByIds` | +| 更新 | `PUT` | `/book/updateBookCommentLikeRecord` | `UpdateBookCommentLikeRecord` | `UpdateBookCommentLikeRecord` | +| 详情 | `GET` | `/book/findBookCommentLikeRecord` | `FindBookCommentLikeRecord` | `GetBookCommentLikeRecord` | +| 分页列表 | `GET` | `/book/getBookCommentLikeRecordList` | `GetBookCommentLikeRecordList` | `GetBookCommentLikeRecordInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.BookCommentLikeRecord`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookCommentLikeRecordSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookCommentLikeRecordResponse`。 diff --git a/server/.ai-specs/doc-api/admin/book_favorite_record.md b/server/.ai-specs/doc-api/admin/book_favorite_record.md new file mode 100644 index 0000000..7e77d94 --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book_favorite_record.md @@ -0,0 +1,29 @@ +# 书籍收藏记录 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:收藏记录 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBookFavoriteRecord` | `CreateBookFavoriteRecord` | `CreateBookFavoriteRecord` | +| 单删 | `DELETE` | `/book/deleteBookFavoriteRecord` | `DeleteBookFavoriteRecord` | `DeleteBookFavoriteRecord` | +| 批量删除 | `DELETE` | `/book/deleteBookFavoriteRecordByIds` | `DeleteBookFavoriteRecordByIds` | `DeleteBookFavoriteRecordByIds` | +| 更新 | `PUT` | `/book/updateBookFavoriteRecord` | `UpdateBookFavoriteRecord` | `UpdateBookFavoriteRecord` | +| 详情 | `GET` | `/book/findBookFavoriteRecord` | `FindBookFavoriteRecord` | `GetBookFavoriteRecord` | +| 分页列表 | `GET` | `/book/getBookFavoriteRecordList` | `GetBookFavoriteRecordList` | `GetBookFavoriteRecordInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.BookFavoriteRecord`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookFavoriteRecordSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookFavoriteRecordResponse`。 diff --git a/server/.ai-specs/doc-api/admin/book_read_record.md b/server/.ai-specs/doc-api/admin/book_read_record.md new file mode 100644 index 0000000..f54c703 --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book_read_record.md @@ -0,0 +1,29 @@ +# 书籍阅读记录 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:阅读记录 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBookReadRecord` | `CreateBookReadRecord` | `CreateBookReadRecord` | +| 单删 | `DELETE` | `/book/deleteBookReadRecord` | `DeleteBookReadRecord` | `DeleteBookReadRecord` | +| 批量删除 | `DELETE` | `/book/deleteBookReadRecordByIds` | `DeleteBookReadRecordByIds` | `DeleteBookReadRecordByIds` | +| 更新 | `PUT` | `/book/updateBookReadRecord` | `UpdateBookReadRecord` | `UpdateBookReadRecord` | +| 详情 | `GET` | `/book/findBookReadRecord` | `FindBookReadRecord` | `GetBookReadRecord` | +| 分页列表 | `GET` | `/book/getBookReadRecordList` | `GetBookReadRecordList` | `GetBookReadRecordInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.BookReadRecord`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookReadRecordSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookReadRecordResponse`。 diff --git a/server/.ai-specs/doc-api/admin/book_series.md b/server/.ai-specs/doc-api/admin/book_series.md new file mode 100644 index 0000000..9a1ceb3 --- /dev/null +++ b/server/.ai-specs/doc-api/admin/book_series.md @@ -0,0 +1,29 @@ +# 书籍系列 admin 接口 + +## 基本信息 + +- 模块:book +- 资源:系列 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:创建、更新、单删、批量删除写操作启用 `OperationRecord` +- 路由前缀:`/book` + +## 默认 CRUD + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 创建 | `POST` | `/book/createBookSeries` | `CreateBookSeries` | `CreateBookSeries` | +| 单删 | `DELETE` | `/book/deleteBookSeries` | `DeleteBookSeries` | `DeleteBookSeries` | +| 批量删除 | `DELETE` | `/book/deleteBookSeriesByIds` | `DeleteBookSeriesByIds` | `DeleteBookSeriesByIds` | +| 更新 | `PUT` | `/book/updateBookSeries` | `UpdateBookSeries` | `UpdateBookSeries` | +| 详情 | `GET` | `/book/findBookSeries` | `FindBookSeries` | `GetBookSeries` | +| 分页列表 | `GET` | `/book/getBookSeriesList` | `GetBookSeriesList` | `GetBookSeriesInfoList` | + +## 参数与返回 + +- 创建、更新:`body` 使用 `book.BookSeries`。 +- 单删、详情:`query id`。 +- 批量删除:`query ids[]`。 +- 分页列表:`query` 使用 `bookReq.BookSeriesSearch`,返回 `response.PageResult`。 +- 详情返回:`bookRes.BookSeriesResponse`。 diff --git a/server/.ai-specs/doc-api/admin/sys_api.md b/server/.ai-specs/doc-api/admin/sys_api.md new file mode 100644 index 0000000..09e538a --- /dev/null +++ b/server/.ai-specs/doc-api/admin/sys_api.md @@ -0,0 +1,39 @@ +# API 管理 admin 接口 + +## 基本信息 + +- 模块:sys_api +- 资源:API 管理 +- 端:admin +- 鉴权:挂载 `PrivateGroup`,需要 `JWT + Casbin` +- 操作审计:`/api/enterSyncApi` 启用 `OperationRecord` +- 路由前缀:`/api` + +## 同步接口 + +| 动作 | Method | 路径 | API 方法 | Service 方法 | +|:---|:---|:---|:---|:---| +| 预览同步差异 | `GET` | `/api/syncApi` | `SyncApi` | `SyncApi` | +| 确认同步路由 | `POST` | `/api/enterSyncApi` | `EnterSyncApi` | `EnterSyncApi` / `SyncApiToDB` | +| 获取 API 关联角色 | `GET` | `/api/getApiRoles` | `GetApiRoles` | `GetAuthoritiesByApi` | +| 覆盖 API 关联角色 | `POST` | `/api/setApiRoles` | `SetApiRoles` | `SetApiAuthorities` | +| 刷新 Casbin 缓存 | `GET` | `/api/freshCasbin` | `FreshCasbin` | `FreshCasbin` | + +## 参数与返回 + +- `/api/syncApi`:无入参,返回 `newApis`、`deleteApis`、`ignoreApis`,只预览差异,不写库。 +- `/api/enterSyncApi`:允许空 body 或 `{}`,默认以当前 Gin 路由表 `global.GVA_ROUTERS` 为事实来源同步 `sys_apis`。 +- `/api/enterSyncApi`:兼容旧请求体 `systemRes.SysSyncApis`,传入 `newApis/deleteApis` 时按传入列表写入或删除。 +- `/api/getApiRoles`:query 传入 `path`、`method`,返回该 API 已关联的角色 ID 列表。 +- `/api/setApiRoles`:body 传入 `path`、`method`、`authorityIds`,全量覆盖该 API 的角色权限。 +- `/api/freshCasbin`:无入参,刷新 Casbin 缓存;该接口走公开路由但写入 `sys_ignore_apis`,不作为角色权限点维护。 +- 返回:统一使用 `response.Response`。 + +## 同步规则 + +- 新路由:`sys_apis` 中不存在同一 `path + method` 时自动新增。 +- 已有路由:保留原 `description`、`apiGroup` 等人工维护字段,不自动覆盖。 +- 忽略路由:存在于 `sys_ignore_apis` 的 `path + method` 不写入 `sys_apis`。 +- 失效路由:数据库中存在但当前 Gin 路由表不存在时,从 `sys_apis` 删除,并清理对应 Casbin 权限。 +- 默认管理员角色 `888` 必须具备 `/api/getApiRoles` 与 `/api/setApiRoles` 权限,避免出现“无权分配权限”的系统初始化死锁。 +- 公开接口、仅登录接口不应长期作为角色权限点维护;需要通过 `sys_ignore_apis` 排除。 diff --git a/server/.ai-specs/doc-dict/book_author_status.md b/server/.ai-specs/doc-dict/book_author_status.md index 02b72ea..81c6775 100644 --- a/server/.ai-specs/doc-dict/book_author_status.md +++ b/server/.ai-specs/doc-dict/book_author_status.md @@ -2,6 +2,7 @@ - 模块:book - 字典编码:`book_author_status` +- 字典类型:`固定值域字典` | Label | Value | Sort | Status | Desc | |:---|:---|:---|:---|:---| diff --git a/server/.ai-specs/doc-dict/book_comment_status.md b/server/.ai-specs/doc-dict/book_comment_status.md new file mode 100644 index 0000000..9633a63 --- /dev/null +++ b/server/.ai-specs/doc-dict/book_comment_status.md @@ -0,0 +1,10 @@ +# 书籍评论状态 + +- 模块:book +- 字典编码:`book_comment_status` +- 字典类型:`固定值域字典` + +| Label | Value | Sort | Status | Desc | +|:---|:---|:---|:---|:---| +| 正常 | `normal` | 10 | true | 评论可正常展示,并参与书籍评论列表与详情页输出 | +| 隐藏 | `hidden` | 20 | true | 评论因违规或运营处理被隐藏,不再对普通用户展示 | diff --git a/server/.ai-specs/doc-dict/book_completion_status.md b/server/.ai-specs/doc-dict/book_completion_status.md index e112720..fefa99f 100644 --- a/server/.ai-specs/doc-dict/book_completion_status.md +++ b/server/.ai-specs/doc-dict/book_completion_status.md @@ -2,6 +2,7 @@ - 模块:book - 字典编码:`book_completion_status` +- 字典类型:`固定值域字典` | Label | Value | Sort | Status | Desc | |:---|:---|:---|:---|:---| diff --git a/server/.ai-specs/doc-dict/book_era_tag.md b/server/.ai-specs/doc-dict/book_era_tag.md new file mode 100644 index 0000000..41aea40 --- /dev/null +++ b/server/.ai-specs/doc-dict/book_era_tag.md @@ -0,0 +1,18 @@ +# 书籍时代标签 + +- 模块:book +- 字典编码:`book_era_tag` +- 字典类型:`固定值域字典` + +| Label | Value | Sort | Status | Desc | +|:---|:---|:---|:---|:---| +| 未知时代 | `unknown` | 10 | true | 无法明确判断所属时代时使用的兜底值 | +| 远古 | `ancient` | 20 | true | 远古或先秦等早期历史阶段作品 | +| 汉 | `han` | 30 | true | 汉代背景或汉代成书作品 | +| 唐 | `tang` | 40 | true | 唐代背景或唐代成书作品 | +| 宋 | `song` | 50 | true | 宋代背景或宋代成书作品 | +| 元 | `yuan` | 60 | true | 元代背景或元代成书作品 | +| 明 | `ming` | 70 | true | 明代背景或明代成书作品 | +| 清 | `qing` | 80 | true | 清代背景或清代成书作品 | +| 近代 | `modern` | 90 | true | 晚清至民国等近代历史阶段作品 | +| 现代 | `contemporary` | 100 | true | 当代或新中国以来现代阶段作品 | diff --git a/server/.ai-specs/doc-dict/book_process_status.md b/server/.ai-specs/doc-dict/book_process_status.md deleted file mode 100644 index a85a06c..0000000 --- a/server/.ai-specs/doc-dict/book_process_status.md +++ /dev/null @@ -1,11 +0,0 @@ -# 书籍处理状态 - -- 模块:book -- 字典编码:`book_process_status` - -| Label | Value | Sort | Status | Desc | -|:---|:---|:---|:---|:---| -| 文件准备 | `file_ready` | 10 | true | 原始整本书文件已准备好,可进入拆分流程 | -| 拆分章节 | `chapter_splitting` | 20 | true | 系统正在把整本书拆分成多个章节文件 | -| 验证章节 | `chapter_verifying` | 30 | true | 系统正在校验章节拆分结果与内容完整性 | -| 完成 | `process_completed` | 40 | true | 章节文件已生成并通过校验,可对外提供阅读 | diff --git a/server/.ai-specs/doc-dict/book_publish_status.md b/server/.ai-specs/doc-dict/book_publish_status.md new file mode 100644 index 0000000..c585e9f --- /dev/null +++ b/server/.ai-specs/doc-dict/book_publish_status.md @@ -0,0 +1,11 @@ +# 书籍上下架状态 + +- 模块:book +- 字典编码:`book_publish_status` +- 字典类型:`固定值域字典` + +| Label | Value | Sort | Status | Desc | +|:---|:---|:---|:---|:---| +| 草稿 | `draft` | 10 | true | 书籍资料仍在整理,后台可维护但不对 `app` 端开放 | +| 下架 | `off_shelf` | 20 | true | 书籍已停止对外展示,历史阅读和收藏可保留但不再新增访问 | +| 上架 | `on_shelf` | 30 | true | 书籍可在 `app` 端正常展示、收藏和参与评论 | diff --git a/server/.ai-specs/doc-dict/book_type.md b/server/.ai-specs/doc-dict/book_type.md new file mode 100644 index 0000000..f482683 --- /dev/null +++ b/server/.ai-specs/doc-dict/book_type.md @@ -0,0 +1,9 @@ +# 书籍类型 + +- 模块:book +- 字典编码:`book_type` +- 字典类型:`动态值域字典` +- 数据来源:`系统字典` +- 业务用途:用于书籍筛选、聚合和展示 +- 逻辑约束:不参与业务状态流转、默认值判断和分支逻辑;业务侧只校验值存在性,并通过独立封装入口读写 +- 升级条件:如果后续 `book_type` 参与默认值、状态流转、业务分支或稳定接口 contract,必须改为 `固定值域字典` 并补齐字典项清单 diff --git a/server/.ai-specs/doc-sql/book.sql b/server/.ai-specs/doc-sql/book.sql new file mode 100644 index 0000000..2a45c9f --- /dev/null +++ b/server/.ai-specs/doc-sql/book.sql @@ -0,0 +1,66 @@ +-- # 书籍信息表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book +-- 模型:model/book/book.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 书籍类型字典:book_type +-- 时代标签字典:book_era_tag +-- 完结状态字典:book_completion_status +-- 上下架状态字典:book_publish_status +-- 职责:承载书籍主资料、展示聚合值和章节来源配置,是书籍列表、详情、搜索和推荐的核心主体。 + +CREATE TABLE book ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + title varchar(255) NOT NULL, + subtitle varchar(255), + book_type varchar(64) NOT NULL, + era_tag varchar(32) NOT NULL DEFAULT 'unknown', + cover_url varchar(500), + publisher varchar(128), + published_at date, + intro text, + hot_score bigint NOT NULL DEFAULT 0 CHECK (hot_score >= 0), + rating numeric(3,1) NOT NULL DEFAULT 0.0 CHECK (rating >= 0 AND rating <= 10), + comment_count bigint NOT NULL DEFAULT 0 CHECK (comment_count >= 0), + word_count bigint NOT NULL DEFAULT 0 CHECK (word_count >= 0), + completion_status varchar(32) NOT NULL DEFAULT 'serializing', + publish_status varchar(32) NOT NULL DEFAULT 'draft', + series_id bigint, + series_sort integer NOT NULL DEFAULT 0 CHECK (series_sort >= 0), + raw_txt_url varchar(500) +); + +COMMENT ON TABLE book IS '书籍信息表'; +COMMENT ON COLUMN book.id IS '主键'; +COMMENT ON COLUMN book.created_at IS '创建时间'; +COMMENT ON COLUMN book.updated_at IS '更新时间'; +COMMENT ON COLUMN book.title IS '书名主标题'; +COMMENT ON COLUMN book.subtitle IS '书籍副标题'; +COMMENT ON COLUMN book.book_type IS '书籍类型字典值,对应 book_type'; +COMMENT ON COLUMN book.era_tag IS '时代标签字典值,对应 book_era_tag'; +COMMENT ON COLUMN book.cover_url IS '封面图片 URL'; +COMMENT ON COLUMN book.publisher IS '出版社名称'; +COMMENT ON COLUMN book.published_at IS '出版日期'; +COMMENT ON COLUMN book.intro IS '书籍简介'; +COMMENT ON COLUMN book.hot_score IS '热度聚合值'; +COMMENT ON COLUMN book.rating IS '书籍评分,范围 0-10'; +COMMENT ON COLUMN book.comment_count IS '点评数聚合值'; +COMMENT ON COLUMN book.word_count IS '书籍总字数'; +COMMENT ON COLUMN book.completion_status IS '书籍完结状态字典值,对应 book_completion_status'; +COMMENT ON COLUMN book.publish_status IS '书籍上下架状态字典值,对应 book_publish_status'; +COMMENT ON COLUMN book.series_id IS '所属系列 ID,可为空'; +COMMENT ON COLUMN book.series_sort IS '同系列内展示排序'; +COMMENT ON COLUMN book.raw_txt_url IS '原始 txt 文件 URL'; + +CREATE INDEX idx_book_book_type ON book (book_type); +CREATE INDEX idx_book_era_tag ON book (era_tag); +CREATE INDEX idx_book_publish_status ON book (publish_status); +CREATE INDEX idx_book_completion_status ON book (completion_status); +CREATE INDEX idx_book_series_id_series_sort ON book (series_id, series_sort); +CREATE INDEX idx_book_created_at ON book (created_at); diff --git a/server/.ai-specs/doc-sql/book_author.md b/server/.ai-specs/doc-sql/book_author.sql similarity index 67% rename from server/.ai-specs/doc-sql/book_author.md rename to server/.ai-specs/doc-sql/book_author.sql index 738efb4..f258c30 100644 --- a/server/.ai-specs/doc-sql/book_author.md +++ b/server/.ai-specs/doc-sql/book_author.sql @@ -1,19 +1,15 @@ -# 书籍作者表 +-- # 书籍作者表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book_author +-- 模型:model/book/book_author.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 状态字典:book_author_status +-- 职责:承载书籍作者主体信息,用于作者资料展示、书籍作者关联和后台作者管理。 -## 基本信息 - -- 模块:book -- 表名:`book_author` -- 模型:`model/book/book_author.go` -- 迁移接入:`initialize/gorm_biz.go` -- 状态字典:`book_author_status` -- 职责:承载书籍作者主体信息,用于作者资料展示、书籍作者关联和后台作者管理。 - -## 建议 SQL - -> 以下 SQL 以当前项目 PostgreSQL 为准,主要表达字段、约束和索引语义;实际落库以 `GORM Model` 和 `initialize/gorm_biz.go` 为准。 - -```sql CREATE TABLE book_author ( id bigserial PRIMARY KEY, created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -36,4 +32,3 @@ COMMENT ON COLUMN book_author.cover_url IS '作者封面图片 URL'; CREATE UNIQUE INDEX uk_book_author_name ON book_author (name); CREATE INDEX idx_book_author_author_status ON book_author (author_status); CREATE INDEX idx_book_author_created_at ON book_author (created_at); -``` diff --git a/server/.ai-specs/doc-sql/book_author_relation.sql b/server/.ai-specs/doc-sql/book_author_relation.sql new file mode 100644 index 0000000..0af0166 --- /dev/null +++ b/server/.ai-specs/doc-sql/book_author_relation.sql @@ -0,0 +1,31 @@ +-- # 书籍作者关联表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book_author_relation +-- 模型:model/book/book_author_relation.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 职责:承载书籍与作者的多对多关系及展示顺序,保证同一本书下作者关联唯一。 + +CREATE TABLE book_author_relation ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + book_id bigint NOT NULL, + author_id bigint NOT NULL, + author_sort integer NOT NULL DEFAULT 1 CHECK (author_sort > 0) +); + +COMMENT ON TABLE book_author_relation IS '书籍作者关联表'; +COMMENT ON COLUMN book_author_relation.id IS '主键'; +COMMENT ON COLUMN book_author_relation.created_at IS '创建时间'; +COMMENT ON COLUMN book_author_relation.updated_at IS '更新时间'; +COMMENT ON COLUMN book_author_relation.book_id IS '关联书籍 ID'; +COMMENT ON COLUMN book_author_relation.author_id IS '关联作者 ID'; +COMMENT ON COLUMN book_author_relation.author_sort IS '作者展示顺序'; + +CREATE UNIQUE INDEX uk_book_author_relation_book_id_author_id ON book_author_relation (book_id, author_id); +CREATE INDEX idx_book_author_relation_author_id ON book_author_relation (author_id); +CREATE INDEX idx_book_author_relation_book_id_author_sort ON book_author_relation (book_id, author_sort); diff --git a/server/.ai-specs/doc-sql/book_chapter.sql b/server/.ai-specs/doc-sql/book_chapter.sql new file mode 100644 index 0000000..3a03fa5 --- /dev/null +++ b/server/.ai-specs/doc-sql/book_chapter.sql @@ -0,0 +1,40 @@ +-- # 书籍章节表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book_chapter +-- 模型:model/book/book_chapter.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 职责:承载书籍章节发布单元、章节文件地址和阅读锚点基础信息,保证同一本书内章节编号稳定且唯一。 + +CREATE TABLE book_chapter ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + book_id bigint NOT NULL, + title varchar(255) NOT NULL, + chapter_no integer NOT NULL CHECK (chapter_no > 0), + is_readable boolean NOT NULL DEFAULT false, + content_file_url varchar(500) NOT NULL, + total_lines integer NOT NULL DEFAULT 0 CHECK (total_lines >= 0), + is_enabled boolean NOT NULL DEFAULT true, + CHECK (NOT is_readable OR total_lines > 0) +); + +COMMENT ON TABLE book_chapter IS '书籍章节表'; +COMMENT ON COLUMN book_chapter.id IS '主键'; +COMMENT ON COLUMN book_chapter.created_at IS '创建时间'; +COMMENT ON COLUMN book_chapter.updated_at IS '更新时间'; +COMMENT ON COLUMN book_chapter.book_id IS '所属书籍 ID'; +COMMENT ON COLUMN book_chapter.title IS '章节标题'; +COMMENT ON COLUMN book_chapter.chapter_no IS '同书内章节顺序编号'; +COMMENT ON COLUMN book_chapter.is_readable IS '是否对 app 端开放阅读'; +COMMENT ON COLUMN book_chapter.content_file_url IS '章节内容文件 URL'; +COMMENT ON COLUMN book_chapter.total_lines IS '章节正文总行数'; +COMMENT ON COLUMN book_chapter.is_enabled IS '章节是否启用'; + +CREATE UNIQUE INDEX uk_book_chapter_book_id_chapter_no ON book_chapter (book_id, chapter_no); +CREATE INDEX idx_book_chapter_book_id_is_enabled_is_readable ON book_chapter (book_id, is_enabled, is_readable); +CREATE INDEX idx_book_chapter_created_at ON book_chapter (created_at); diff --git a/server/.ai-specs/doc-sql/book_comment.sql b/server/.ai-specs/doc-sql/book_comment.sql new file mode 100644 index 0000000..1e9d309 --- /dev/null +++ b/server/.ai-specs/doc-sql/book_comment.sql @@ -0,0 +1,43 @@ +-- # 书籍评论表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book_comment +-- 模型:model/book/book_comment.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 评论状态字典:book_comment_status +-- 职责:承载书籍、章节和文本行三级评论定位信息,并冗余维护评论点赞聚合值。 + +CREATE TABLE book_comment ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + member_user_id bigint NOT NULL, + book_id bigint NOT NULL, + chapter_id bigint NOT NULL DEFAULT 0 CHECK (chapter_id >= 0), + line_index integer NOT NULL DEFAULT 0 CHECK (line_index >= 0), + content text NOT NULL, + like_count bigint NOT NULL DEFAULT 0 CHECK (like_count >= 0), + comment_status varchar(32) NOT NULL DEFAULT 'normal', + CHECK ((chapter_id = 0 AND line_index = 0) OR (chapter_id > 0 AND line_index >= 0)) +); + +COMMENT ON TABLE book_comment IS '书籍评论表'; +COMMENT ON COLUMN book_comment.id IS '主键'; +COMMENT ON COLUMN book_comment.created_at IS '创建时间'; +COMMENT ON COLUMN book_comment.updated_at IS '更新时间'; +COMMENT ON COLUMN book_comment.member_user_id IS '评论用户的会员 ID'; +COMMENT ON COLUMN book_comment.book_id IS '所属书籍 ID'; +COMMENT ON COLUMN book_comment.chapter_id IS '评论目标章节 ID,0 表示整本书'; +COMMENT ON COLUMN book_comment.line_index IS '评论目标文本行下标,0 表示整章或整本书'; +COMMENT ON COLUMN book_comment.content IS '评论正文内容'; +COMMENT ON COLUMN book_comment.like_count IS '评论点赞聚合值'; +COMMENT ON COLUMN book_comment.comment_status IS '评论状态字典值,对应 book_comment_status'; + +CREATE INDEX idx_book_comment_book_id ON book_comment (book_id); +CREATE INDEX idx_book_comment_book_id_chapter_id_line_index ON book_comment (book_id, chapter_id, line_index); +CREATE INDEX idx_book_comment_member_user_id ON book_comment (member_user_id); +CREATE INDEX idx_book_comment_comment_status ON book_comment (comment_status); +CREATE INDEX idx_book_comment_created_at ON book_comment (created_at); diff --git a/server/.ai-specs/doc-sql/book_comment_like_record.sql b/server/.ai-specs/doc-sql/book_comment_like_record.sql new file mode 100644 index 0000000..7653997 --- /dev/null +++ b/server/.ai-specs/doc-sql/book_comment_like_record.sql @@ -0,0 +1,31 @@ +-- # 评论点赞记录表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book_comment_like_record +-- 模型:model/book/book_comment_like_record.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 职责:承载用户对评论的有效点赞关系,支持点赞/取消点赞的幂等判断和计数维护。 + +CREATE TABLE book_comment_like_record ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + comment_id bigint NOT NULL, + member_user_id bigint NOT NULL, + liked_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE book_comment_like_record IS '评论点赞记录表'; +COMMENT ON COLUMN book_comment_like_record.id IS '主键'; +COMMENT ON COLUMN book_comment_like_record.created_at IS '创建时间'; +COMMENT ON COLUMN book_comment_like_record.updated_at IS '更新时间'; +COMMENT ON COLUMN book_comment_like_record.comment_id IS '被点赞评论 ID'; +COMMENT ON COLUMN book_comment_like_record.member_user_id IS '点赞用户的会员 ID'; +COMMENT ON COLUMN book_comment_like_record.liked_at IS '点赞发生时间'; + +CREATE UNIQUE INDEX uk_book_comment_like_record_comment_id_member_user_id ON book_comment_like_record (comment_id, member_user_id); +CREATE INDEX idx_book_comment_like_record_member_user_id ON book_comment_like_record (member_user_id); +CREATE INDEX idx_book_comment_like_record_liked_at ON book_comment_like_record (liked_at); diff --git a/server/.ai-specs/doc-sql/book_favorite_record.sql b/server/.ai-specs/doc-sql/book_favorite_record.sql new file mode 100644 index 0000000..fd94e2c --- /dev/null +++ b/server/.ai-specs/doc-sql/book_favorite_record.sql @@ -0,0 +1,31 @@ +-- # 用户收藏记录表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book_favorite_record +-- 模型:model/book/book_favorite_record.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 职责:承载用户对书籍的收藏关系,保证同一用户对同一本书只保留一条有效收藏记录。 + +CREATE TABLE book_favorite_record ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + member_user_id bigint NOT NULL, + book_id bigint NOT NULL, + favorited_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE book_favorite_record IS '用户收藏记录表'; +COMMENT ON COLUMN book_favorite_record.id IS '主键'; +COMMENT ON COLUMN book_favorite_record.created_at IS '创建时间'; +COMMENT ON COLUMN book_favorite_record.updated_at IS '更新时间'; +COMMENT ON COLUMN book_favorite_record.member_user_id IS '收藏用户的会员 ID'; +COMMENT ON COLUMN book_favorite_record.book_id IS '收藏书籍 ID'; +COMMENT ON COLUMN book_favorite_record.favorited_at IS '收藏发生时间'; + +CREATE UNIQUE INDEX uk_book_favorite_record_member_user_id_book_id ON book_favorite_record (member_user_id, book_id); +CREATE INDEX idx_book_favorite_record_member_user_id_favorited_at ON book_favorite_record (member_user_id, favorited_at); +CREATE INDEX idx_book_favorite_record_book_id ON book_favorite_record (book_id); diff --git a/server/.ai-specs/doc-sql/book_read_record.sql b/server/.ai-specs/doc-sql/book_read_record.sql new file mode 100644 index 0000000..61b7494 --- /dev/null +++ b/server/.ai-specs/doc-sql/book_read_record.sql @@ -0,0 +1,39 @@ +-- # 用户阅读记录表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book_read_record +-- 模型:model/book/book_read_record.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 职责:承载用户按“用户+书籍”维度的最新阅读历史和续读锚点,支持阅读恢复与历史展示。 + +CREATE TABLE book_read_record ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + member_user_id bigint NOT NULL, + book_id bigint NOT NULL, + book_title_snapshot varchar(255) NOT NULL, + read_progress numeric(5,2) NOT NULL DEFAULT 0.00 CHECK (read_progress >= 0 AND read_progress <= 100), + chapter_id bigint NOT NULL CHECK (chapter_id > 0), + line_index integer NOT NULL CHECK (line_index > 0), + last_read_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE book_read_record IS '用户阅读记录表'; +COMMENT ON COLUMN book_read_record.id IS '主键'; +COMMENT ON COLUMN book_read_record.created_at IS '创建时间'; +COMMENT ON COLUMN book_read_record.updated_at IS '更新时间'; +COMMENT ON COLUMN book_read_record.member_user_id IS '阅读用户的会员 ID'; +COMMENT ON COLUMN book_read_record.book_id IS '所属书籍 ID'; +COMMENT ON COLUMN book_read_record.book_title_snapshot IS '阅读书籍标题快照'; +COMMENT ON COLUMN book_read_record.read_progress IS '阅读进度百分比'; +COMMENT ON COLUMN book_read_record.chapter_id IS '当前续读章节 ID'; +COMMENT ON COLUMN book_read_record.line_index IS '当前续读文本行下标,正文首行为 1'; +COMMENT ON COLUMN book_read_record.last_read_at IS '最近一次阅读时间'; + +CREATE UNIQUE INDEX uk_book_read_record_member_user_id_book_id ON book_read_record (member_user_id, book_id); +CREATE INDEX idx_book_read_record_member_user_id_last_read_at ON book_read_record (member_user_id, last_read_at); +CREATE INDEX idx_book_read_record_book_id ON book_read_record (book_id); diff --git a/server/.ai-specs/doc-sql/book_series.sql b/server/.ai-specs/doc-sql/book_series.sql new file mode 100644 index 0000000..81bfa57 --- /dev/null +++ b/server/.ai-specs/doc-sql/book_series.sql @@ -0,0 +1,33 @@ +-- # 书籍系列表 +-- +-- ## 基本信息 +-- +-- 模块:book +-- 表名:book_series +-- 模型:model/book/book_series.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- 职责:承载书籍系列主体信息,用于组织同一作品的分部顺序和系列展示。 + +CREATE TABLE book_series ( + id bigserial PRIMARY KEY, + created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + name varchar(128) NOT NULL, + cover_url varchar(500), + intro text, + is_enabled boolean NOT NULL DEFAULT true +); + +COMMENT ON TABLE book_series IS '书籍系列表'; +COMMENT ON COLUMN book_series.id IS '主键'; +COMMENT ON COLUMN book_series.created_at IS '创建时间'; +COMMENT ON COLUMN book_series.updated_at IS '更新时间'; +COMMENT ON COLUMN book_series.name IS '系列名称'; +COMMENT ON COLUMN book_series.cover_url IS '系列封面图片 URL'; +COMMENT ON COLUMN book_series.intro IS '系列简介'; +COMMENT ON COLUMN book_series.is_enabled IS '系列是否启用'; + +CREATE INDEX idx_book_series_name ON book_series (name); +CREATE INDEX idx_book_series_is_enabled ON book_series (is_enabled); +CREATE INDEX idx_book_series_created_at ON book_series (created_at); diff --git a/server/.ai-specs/requirements/book.md b/server/.ai-specs/prd-draft/book.md similarity index 98% rename from server/.ai-specs/requirements/book.md rename to server/.ai-specs/prd-draft/book.md index 903bc87..778152c 100644 --- a/server/.ai-specs/requirements/book.md +++ b/server/.ai-specs/prd-draft/book.md @@ -8,7 +8,7 @@ ### 书籍信息表 - 书名:主标题 - 子标题:副标题 -- 书籍类型:字典 `book_type`,必填 +- 书籍类型:系统字典 `book_type`,动态值,必填 - 时代标签:字典 `book_era_tag`,必填 - 封面图片URL:封面图地址 - 出版社:出版机构 @@ -85,6 +85,7 @@ - 一本书可以不属于任何系列;属于系列时,按 `系列内排序` 展示。 - 一本书不直接存作者名字符串;作者通过关联关系维护,时代标签通过书籍主表字段维护。 - 每本书必须关联 `1` 个书籍类型;`book_type` 使用系统字典动态值承载,并通过业务侧独立封装读写。 +- `book_type` 当前仅用于筛选、聚合和展示,不参与业务分支和状态流转。 - `book_publish_status` 当前值域为:`draft`、`off_shelf`、`on_shelf`。 - 热度、评分、点评数可冗余存储,但业务上按聚合值理解。 diff --git a/server/.ai-specs/sys-specs/business-dictionary-spec.md b/server/.ai-specs/sys-specs/business-dictionary-spec.md index 72945e4..6cc8f7b 100644 --- a/server/.ai-specs/sys-specs/business-dictionary-spec.md +++ b/server/.ai-specs/sys-specs/business-dictionary-spec.md @@ -3,39 +3,63 @@ ## 适用范围 - 当任务是新增业务状态、类型、级别、来源、模式、分类等值域时,先读本文件。 -- 本文件只规定新增业务字典怎么写,不重复说明系统字典实现细节。 +- 本文件规定业务字典怎么写,统一分为 `固定值域字典` 和 `动态值域字典` 两类;不重复说明系统字典底层实现细节。 ## 强制规则 -- 业务枚举就是业务字典。禁止在代码中脱离字典单独发明枚举值域。 +- 业务字典是值域定义来源;禁止在代码中脱离字典单独发明枚举值域。 - 新增业务值域时,先写字典文档,再写表结构、接口、校验和前端展示。 - 具体业务字典必须写到 `.ai-specs/doc-dict/` 下。 +- 新增/修改业务字典时,必须同步维护初始化数据和当前版本升级 SQL;初始化数据覆盖新库,升级 SQL 覆盖老库。 - 一个 `.md` 文件只能写一个字典。 - 字典文件名必须等于字典编码,推荐路径:`.ai-specs/doc-dict/.md`。 - 字典编码使用 `snake_case`,固定格式 `_`,例如 `device_status`。 - `` 就是 `_`。 -- 字典项 `Value` 使用稳定 machine value;代码常量值必须与字典项 `Value` 完全一致。 -- 代码判断统一使用字典项 `Value`;禁止使用 `Label` 做逻辑分支。 -- 已上线字典的编码和字典项 `Value` 默认不可变;下线优先禁用,不直接删除。 +- 字典只分两类:`固定值域字典` 和 `动态值域字典`。 +- `固定值域字典`:当前业务已确认值域,字典项必须在文档中完整定义,并与代码枚举、接口校验、默认值、状态流转和展示映射保持同步。 +- `动态值域字典`:当前业务不固化值域,必须保留对应 `.md` 文档,但文档只说明字典编码、类型、来源、业务用途和逻辑边界,不要求在业务文档里枚举全部值项。 +- `固定值域字典` 通常需要同步代码枚举或常量定义;`动态值域字典` 默认不新增固定枚举常量。 +- `固定值域字典` 的字典项 `Value` 使用稳定 machine value;代码常量值必须与字典项 `Value` 完全一致。 +- 代码判断统一使用 `固定值域字典` 的字典项 `Value`;禁止使用 `Label` 做逻辑分支。 +- `固定值域字典` 必须同步写入 `source/system` 初始化数据和 `.ai-transition/database-upgrade-doc/.sql` 的 `sys_dictionaries`、`sys_dictionary_details` 数据。 +- `动态值域字典` 必须同步写入 `source/system` 初始化数据和 `.ai-transition/database-upgrade-doc/.sql` 的 `sys_dictionaries` 主字典;默认不写固定字典项。 +- `动态值域字典` 禁止为了占位在文档或代码中伪造临时 `Value`、伪造枚举常量或伪造默认值。 +- 已上线 `固定值域字典` 的编码和字典项 `Value` 默认不可变;下线优先禁用,不直接删除。 +- `动态值域字典` 如果后续参与默认值、状态流转、业务分支、稳定接口 contract 或展示映射,必须升级为 `固定值域字典` 并补齐字典项定义。 - 禁止出现数据库存 `1/2/3`,但没有对应字典文档说明语义。 - 禁止代码新增枚举值,但未同步字典文档和字典数据。 ## MD 模板 .ai-specs/doc-dict/.md +固定值域字典模板: ```md # <字典中文名> - 模块: - 字典编码:`_` +- 字典类型:`固定值域字典` | Label | Value | Sort | Status | Desc | |:---|:---|:---|:---|:---| | <中文名> | `` | 10 | true | <说明> | ``` +动态值域字典模板: +```md +# <字典中文名> + +- 模块: +- 字典编码:`_` +- 字典类型:`动态值域字典` +- 数据来源:`系统字典` +- 业务用途:<筛选 / 展示 / 聚合 / 其他> +- 逻辑约束:<不参与业务状态流转、默认值判断和分支逻辑等约束> +``` + ## 代码模板 +固定值域字典代码模板: ```go package @@ -47,7 +71,12 @@ const ( ) ``` +- `动态值域字典` 默认不在业务代码里新增固定枚举常量;如需独立封装,优先封装查询、存在性校验和展示转换入口。 + ## 与 SQL 的关系 -- 值域字段先有字典定义,再进入表设计。 +- `固定值域字典` 字段先有值项定义,再进入表设计。 +- `动态值域字典` 字段先有字典说明文档,再进入表设计。 +- `动态值域字典` 字段进入 `doc-sql` 时,只记录字典编码、是否必填、索引和通用结构约束;禁止伪造固定值项、伪造默认值或伪造 `CHECK IN (...)`。 - 值域字段如何落库,按 `.ai-specs/sys-specs/business-table-spec.md` 执行。 +- 字典数据升级 SQL 按 `.ai-specs/sys-specs/database-upgrade-doc-spec.md` 执行,必须考虑重复执行、人工已建数据和部分明细缺失。 diff --git a/server/.ai-specs/sys-specs/business-table-spec.md b/server/.ai-specs/sys-specs/business-table-spec.md index 3acf441..9adc925 100644 --- a/server/.ai-specs/sys-specs/business-table-spec.md +++ b/server/.ai-specs/sys-specs/business-table-spec.md @@ -2,51 +2,61 @@ ## 适用范围 -- 当任务是新增/修改 `.ai-specs/doc-sql/*.md` 时,先读本文件。 -- 当前项目关系型数据库默认是 `PostgreSQL`,`doc-sql` 的 `建议 SQL` 默认输出 `PostgreSQL` 写法。 -- 涉及状态、类型、级别、来源、模式、分类等值域字段时,必须先读 `.ai-specs/sys-specs/business-dictionary-spec.md`,并先补 `.ai-specs/doc-dict/.md`。 +- 当任务是新增/修改 `.ai-specs/doc-sql/*.sql` 时,先读本文件。 +- 当前项目关系型数据库默认是 `PostgreSQL`,`doc-sql` 文件必须直接使用 `PostgreSQL` 写法。 +- 涉及状态、类型、级别、来源、模式、分类等值域字段时,必须先读 `.ai-specs/sys-specs/business-dictionary-spec.md`,并先补对应的 `.ai-specs/doc-dict/.md`;固定值域字典补值项定义,动态值域字典补说明文档。 ## 强制输出 -- 文档路径固定:`.ai-specs/doc-sql/.md` -- 文档结构固定只保留:`# 标题`、`## 基本信息`、`## 建议 SQL` -- `## 基本信息` 固定顺序:`模块` → `表名` → `模型` → `迁移接入` → 按需补 `字典` 行 → `职责` -- 有字典字段时,每个字典单独占一行:`- <字段中文名>字典:\`\`` -- `## 建议 SQL` 前的说明固定写为:`> 以下 SQL 以当前项目 PostgreSQL 为准,主要表达字段、约束和索引语义;实际落库以 \`GORM Model\` 和 \`initialize/gorm_biz.go\` 为准。` -- `SQL` 代码块固定按这个顺序组织:`CREATE TABLE` → `COMMENT ON TABLE` → `COMMENT ON COLUMN` → `CREATE UNIQUE INDEX` / `CREATE INDEX` +- 文档路径固定:`.ai-specs/doc-sql/.sql` +- 文件必须是可执行 SQL,不再使用 Markdown 包裹 SQL。 +- 文件头部必须使用 SQL 注释声明基本信息,注释后直接输出 SQL 主体。 +- 基本信息固定顺序:`# 标题` → `## 基本信息` → `模块` → `表名` → `模型` → `迁移接入` → `删除策略` → 按需补 `字典` 行 → `职责` +- 每张业务表必须显式声明删除策略,只允许两种:`软删表`、`硬删表` +- `软删表`:`Model` 应使用 `global.GVA_MODEL` 或显式包含 `DeletedAt`;`doc-sql` 必须包含 `deleted_at` 字段和对应索引 +- `硬删表`:`Model` 不应因沿用默认基类而隐式带出 `DeletedAt`;`doc-sql` 不写 `deleted_at` +- `硬删表` 建模时禁止直接嵌入 `global.GVA_MODEL`;应显式定义 `ID`、`CreatedAt`、`UpdatedAt`,或使用不包含 `DeletedAt` 的硬删基类 +- `硬删表` 的 `Service` 删除逻辑必须执行物理删除;禁止依赖 `GORM` 软删行为或查询侧隐式过滤 `deleted_at` +- `硬删表` 如果后续需要保留删除痕迹,必须先把 `doc-sql` 删除策略改为 `软删表`,再同步修改 `Model`、索引和删除逻辑 +- 当 `doc-sql` 声明为 `硬删表` 时,后续生成 `model//*.go` 必须按 `doc-sql` 字段逐项建模;即使项目默认模型带软删,也不得自动补 `DeletedAt` +- 当 `doc-sql` 声明为 `硬删表` 时,后续生成 `initialize/gorm_biz.go` 迁移只注册该硬删模型;迁移结果不得比 `doc-sql` 多出 `deleted_at` +- 当 `doc-sql` 声明为 `硬删表` 时,唯一索引直接按业务唯一性生成;不得为了兼容软删把 `deleted_at` 拼入唯一索引 +- `book` 模块当前所有 `doc-sql/book*.sql` 均按 `硬删表` 处理;生成 `model/book/*.go` 时必须使用硬删建模规则 +- 有字典字段时,每个字典单独占一行:`-- <字段中文名>字典:` +- SQL 主体固定按这个顺序组织:`CREATE TABLE` → `COMMENT ON TABLE` → `COMMENT ON COLUMN` → `CREATE UNIQUE INDEX` / `CREATE INDEX` - 表名、字段名、索引名统一使用 `snake_case` - 唯一索引命名固定:`uk__` - 普通索引命名固定:`idx__` - 字典字段只存字典项 `Value`,禁止存 `Label` -- 文档只写已确认字段、约束、索引;禁止再补 `字段设计`、`索引设计`、`关联关系`、`删除与兼容`、`自检` 等重复章节 +- `固定值域字典` 字段如果默认值来自字典,直接写稳定字典项 `Value`。 +- `动态值域字典` 字段在 `doc-sql` 中只表达字段本身的结构约束、是否必填和索引;禁止伪造默认值、伪造固定值项或伪造 `CHECK IN (...)`。 +- 文件只写已确认字段、约束、索引;禁止再补 `字段设计`、`索引设计`、`关联关系`、`删除与兼容`、`自检` 等重复章节 +- 仅修改 SQL 注释说明且不改变字段、约束、索引、默认值、注释等落库语义时,不需要补数据库升级 SQL。 ## PostgreSQL 写法 - 主键自增优先写 `bigserial PRIMARY KEY` - 时间字段优先写 `timestamp with time zone` +- `软删表` 的软删字段统一写 `deleted_at timestamp with time zone` - 注释统一写 `COMMENT ON TABLE`、`COMMENT ON COLUMN` - 索引统一单独写 `CREATE UNIQUE INDEX`、`CREATE INDEX` - 禁止混入 `AUTO_INCREMENT`、反引号、`ENGINE=InnoDB`、行内 `COMMENT`、`UNIQUE KEY`、`KEY ...` 等 `MySQL` 方言 ## 输出模板 -````md -# <表中文名> - -## 基本信息 - -- 模块: -- 表名:`` -- 模型:`model//.go` -- 迁移接入:`initialize/gorm_biz.go` -- <字段中文名>字典:`` -- 职责:<一句话职责> - -## 建议 SQL - -> 以下 SQL 以当前项目 PostgreSQL 为准,主要表达字段、约束和索引语义;实际落库以 `GORM Model` 和 `initialize/gorm_biz.go` 为准。 - ```sql +-- # <表中文名> +-- +-- ## 基本信息 +-- +-- 模块: +-- 表名: +-- 模型:model//.go +-- 迁移接入:initialize/gorm_biz.go +-- 删除策略:硬删表 +-- <字段中文名>字典: +-- 职责:<一句话职责> + CREATE TABLE ( id bigserial PRIMARY KEY, created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -65,13 +75,15 @@ COMMENT ON COLUMN . IS '<字段说明>'; CREATE UNIQUE INDEX uk__ ON (); CREATE INDEX idx__ ON (); ``` -```` -- 没有字典字段,就删除对应“字典”行 +- `软删表` 参考上面的硬删模板,在 `updated_at` 后追加 `deleted_at timestamp with time zone`,并同步补 `COMMENT ON COLUMN .deleted_at IS '删除时间';` 与 `CREATE INDEX idx__deleted_at ON (deleted_at);` +- `硬删表` 按模板直接输出,不追加 `deleted_at` +- 没有字典字段,就删除对应“字典”注释行 - 没有唯一索引或普通索引,就删除对应 SQL 行 -- 如果字段默认值来自字典,直接写字典项 `Value`,例如 `DEFAULT 'enabled'` +- 如果字段默认值来自 `固定值域字典`,直接写字典项 `Value`,例如 `DEFAULT 'enabled'` +- 如果字段引用 `动态值域字典`,就删除默认值示例,按业务真实规则只保留结构约束和索引 ## 复刻目标 -- 只看本文件,应能直接写出类似 `.ai-specs/doc-sql/book_author.md` 的文档 +- 只看本文件,应能直接写出类似 `.ai-specs/doc-sql/book_author.sql` 的文件 - 先定字典,再写 `doc-sql`,再进入 `Model`、迁移和业务代码 diff --git a/server/.ai-specs/sys-specs/database-upgrade-doc-spec.md b/server/.ai-specs/sys-specs/database-upgrade-doc-spec.md new file mode 100644 index 0000000..3e9cb3c --- /dev/null +++ b/server/.ai-specs/sys-specs/database-upgrade-doc-spec.md @@ -0,0 +1,59 @@ +# 数据库升级文档规范 + +- **当前数据库表结构版本**:`v1` + +## 适用范围 + +- 当任务新增/修改 `.ai-specs\doc-sql\*.sql` 并导致数据库结构、字段、索引、约束、默认值或注释变化时,必须先读本文件。 +- 当任务新增/修改 `.ai-specs\doc-dict\*.md` 并导致系统字典主数据或字典项变化时,必须先读本文件。 +- 数据库升级 SQL 固定存放在 `.ai-transition\database-upgrade-doc`。 +- 当前升级版本固定读取本文件中 `当前数据库表结构版本` 的值。 + +## 强制规则 + +- 升级 SQL 文件命名固定为 `.sql`,例如 `v1.sql`、`v2.sql`、`v3.sql`。 +- 当前数据库表结构版本声明为 `v1` 时,兼容变更 SQL 必须写入 `.ai-transition\database-upgrade-doc\v1.sql`。 +- 默认版本文件为 `v1.sql`;不存在时,首次产生数据库结构变更必须创建。 +- 修改 `.ai-specs\doc-sql\*.sql` 时,禁止只改文档;必须同步检查并补充当前版本 SQL 文件。 +- 修改 `.ai-specs\doc-dict\*.md` 时,禁止只改文档;必须同步检查初始化数据和当前版本 SQL 文件。 +- SQL 只记录从既有数据库升级到当前 `doc-sql` 目标结构所需的兼容变更,不重复粘贴完整建表基线。 +- 同一次业务变更涉及多张表时,必须写在同一个当前版本 SQL 文件中,并按表分组。 +- 修改字段名、类型、默认值、非空约束、唯一约束、普通索引、软硬删字段、注释时,都必须写对应升级 SQL。 +- 新增/修改固定值域字典时,必须写入 `sys_dictionaries` 和 `sys_dictionary_details` 的幂等升级 SQL。 +- 新增/修改动态值域字典时,必须至少写入 `sys_dictionaries` 的幂等升级 SQL;无固定值项时不写 `sys_dictionary_details`。 +- 仅修改文档表述且不改变实际落库语义时,可以不写 SQL,但必须在最终说明中明确原因。 + +## SQL 要求 + +- SQL 默认使用 `PostgreSQL` 写法。 +- 优先使用 `ALTER TABLE`、`CREATE INDEX`、`DROP INDEX`、`COMMENT ON TABLE`、`COMMENT ON COLUMN`、`UPDATE` 回填语句。 +- 数据类升级 SQL 必须幂等,禁止裸 `INSERT`;必须使用 `WHERE NOT EXISTS`、`UPDATE ... FROM` 等方式兼容重复执行。 +- 新增 `NOT NULL` 字段必须先给默认值或先回填历史数据,再追加 `NOT NULL` 约束。 +- 新增唯一约束或唯一索引前,必须考虑历史重复数据;不能直接写会必然失败的 SQL。 +- 索引创建应使用项目统一命名:唯一索引 `uk__`,普通索引 `idx__`。 +- 字典主表重复判断固定使用 `sys_dictionaries.type`。 +- 字典明细重复判断固定使用 `sys_dictionary_details.sys_dictionary_id + value`。 +- 固定值域字典项变更时,先 `UPDATE` 已有 `value` 的 `label`、`sort`、`status`、`updated_at` 等展示字段,再 `INSERT` 缺失项。 +- 字典主表或明细表命中已存在数据时,若表包含 `deleted_at`,恢复启用数据必须同步置空 `deleted_at`。 +- 固定值域字典项废弃时,优先将 `status` 改为 `false`,禁止直接删除历史值项。 +- 禁止混入 `MySQL` 方言,例如反引号、`AUTO_INCREMENT`、`ENGINE=InnoDB`、行内 `COMMENT`。 + +## 文件格式 + +```sql +-- <业务说明> / <表名> / + +ALTER TABLE + ADD COLUMN ; + +COMMENT ON COLUMN . IS '<字段说明>'; + +CREATE INDEX idx__ ON (); +``` + +## 联动关系 + +- 改 `doc-sql`:同步改 `Model`、`Service`、`initialize/gorm_biz.go`,并补当前版本升级 SQL。 +- 改 `doc-dict`:同步改代码枚举/校验/展示逻辑、`source/system` 初始化数据,并补当前版本升级 SQL。 +- 改当前版本升级 SQL:同步确认 `doc-sql`、`Model`、迁移注册与业务读写逻辑一致。 +- 版本升级:先修改本文件的 `当前数据库表结构版本`,再在 `.ai-transition\database-upgrade-doc` 新增对应 `.sql`。 diff --git a/server/.ai-specs/sys-specs/requirements-stage-spec.md b/server/.ai-specs/sys-specs/requirements-stage-spec.md deleted file mode 100644 index e7f9cc7..0000000 --- a/server/.ai-specs/sys-specs/requirements-stage-spec.md +++ /dev/null @@ -1,107 +0,0 @@ -# 需求草案阶段规范 - -## 适用范围 - -- 新增/修改 `.ai-specs/requirements/*.md` 前必须先读本文件。 -- 本文件只约束 `requirements` 文档,不替代 `doc-dict`、`doc-sql`、`doc-api` 和代码实现规范。 - -## 阶段目标 - -- `制定需求草案阶段`:只整理本需求文档,归并已确认需求,未确认项显式标注“需补”,不要求同步修改代码和下游文档。 -- `归档阶段`:需求草案已稳定,可作为下游文档和实现依据;新增需求或未确认变更必须退回 `制定需求草案阶段`。 -- 两个阶段正文结构一致:涉及表时先写 `## 表`,再写 `## 需求描述`;不涉及表时只写 `## 需求描述`。 - -## 强制规则 - -- 文档路径固定:`.ai-specs/requirements/.md` -- 文档标题固定:`# <主题> 需求草案` -- 标题下方空一行后必须紧跟两行阶段元信息,格式固定为 `- **阶段** <阶段说明>` 和 `- **要求** <阶段要求>`。 -- 阶段元信息只能从下表选择一组,禁止混用、改名或自行扩写。 - -| 阶段 | 阶段行 | 要求行 | -|:---|:---|:---| -| 制定需求草案阶段 | `- **阶段** 当前阶段只整理本需求文档,归并已确认需求,未确认项显式标注“需补”,不要求同步修改代码和下游文档` | `- **要求** 对错误、冲突或不闭环需求应及时纠正并提示;需求整理以逻辑闭环、链路清晰、实现轻量为目标,避免为单点功能引入复杂链路` | -| 归档阶段 | `- **阶段** 当前阶段为需求归档,需求草案已稳定,可作为下游文档和实现依据,原则上不再追加未确认内容` | `- **要求** 只允许修正错误、消除歧义和同步已确认结论;新增需求或未确认变更必须退回制定需求草案阶段处理` | - -- 一个文档只描述一个业务主题。 -- 正文只允许出现 `## 表` 和 `## 需求描述` 两类二级标题。 -- `## 需求描述` 必须存在;`## 表` 按需存在,不涉及表时必须省略。 -- 禁止为了满足格式硬造表或保留空表区。 -- 有表时,每张表使用一个 `### <表名>`;字段统一写成 `- <字段名>:<字段说明>`。 -- 有表时,`## 表` 只写字段和极简业务含义;不写流程、规则、性能、实现判断。 -- 有表且字段涉及字典时,只引用字典码,例如“字典 ``”或“字典,需补 ``”;确认值域写到 `## 需求描述`。 -- 有表且字段本质上是多值、多对多或独立主体时,必须拆成独立表或关联表,禁止塞进主表字符串字段。 -- `## 需求描述` 下每个业务主题使用一个 `### <需求标题>`,只写规则、流程、边界、范围和例外。 -- `###` 下只允许 `-` 扁平列表;禁止四级标题、嵌套标题、编号大纲、代码块、图和表格。 - -## 禁止事项 - -- 禁止写 API 路径、Method、鉴权方式、请求响应结构。 -- 禁止写 Router、API、Service、定时任务、缓存、消息队列等实现设计。 -- 禁止写 SQL 建表语句、字段类型、索引、唯一约束、迁移脚本。 -- 禁止新增 `## 背景`、`## 范围`、`## 总览`、`## 关系图`、`## TODO` 等既定结构外标题。 -- 禁止把需求规则混写到 `## 表`,也禁止把字段清单重复抄到 `## 需求描述`。 -- 禁止无表业务为了套模板编造表、字段、关联关系或空的 `## 表`。 -- 禁止把未确认事项写成确定结论;未确认内容必须标记“需补”或“待确认”。 -- 禁止在 `归档阶段` 继续收集新需求;新增需求必须退回 `制定需求草案阶段`。 - -## 文档结构 - -- 有表结构:`# <主题> 需求草案` → 两行阶段元信息 → `## 表` → 多个 `### <表名>` → `## 需求描述` → 多个 `### <需求标题>`。 -- 无表结构:`# <主题> 需求草案` → 两行阶段元信息 → `## 需求描述` → 多个 `### <需求标题>`。 -- 有表时,`### <表名>` 使用业务表名,例如“书籍信息表”“书籍章节表”“订单支付记录表”,不要求等于最终数据库表名。 -- `### <需求标题>` 使用业务主题名,例如“数据来源与上传”“章节重建策略”“评论点赞”“当前范围”。 -- 没有合适标题的信息,归并到语义最近的 `### <需求标题>`;确需新增标题时,只能在 `## 需求描述` 下新增三级标题。 - -## 输出模板 - -无表业务模板: - -````md -# <主题> 需求草案 - -- **阶段** 当前阶段只整理本需求文档,归并已确认需求,未确认项显式标注“需补”,不要求同步修改代码和下游文档 -- **要求** 对错误、冲突或不闭环需求应及时纠正并提示;需求整理以逻辑闭环、链路清晰、实现轻量为目标,避免为单点功能引入复杂链路 - -## 需求描述 - -### <需求1> -- <规则、边界或待确认事项> - -### <需求2> -- <规则、例外或范围> -```` - -有表业务模板: - -````md -# <主题> 需求草案 - -- **阶段** 当前阶段只整理本需求文档,归并已确认需求,未确认项显式标注“需补”,不要求同步修改代码和下游文档 -- **要求** 对错误、冲突或不闭环需求应及时纠正并提示;需求整理以逻辑闭环、链路清晰、实现轻量为目标,避免为单点功能引入复杂链路 - -## 表 - -### <表1> -- <字段1>:<说明> -- <字段2>:<说明或需补> -- <字段3>:字典 `` - -### <表2> -- <字段1>:<说明> - -## 需求描述 - -### <需求1> -- <规则、边界或待确认事项> - -### <需求2> -- <规则、例外或范围> -```` - -## 与后续文档的关系 - -- `requirements` 是 `doc-dict`、`doc-sql`、`doc-api` 和代码实现的上游输入,不是替代物。 -- 出现状态、类型、级别、来源、模式、分类等值域时,下一步先补 `.ai-specs/doc-dict/*.md`。 -- 涉及表、字段、关系且已稳定后,下一步补 `.ai-specs/doc-sql/*.md`;无表需求不强制补 `doc-sql`。 -- 默认链路:`requirements(制定需求草案阶段 -> 归档阶段) -> doc-dict / doc-sql -> Model -> Service -> API -> Router`。 diff --git a/server/.ai-transition/admin-web-prd/book-admin-prd.md b/server/.ai-transition/admin-web-prd/book-admin-prd.md new file mode 100644 index 0000000..74adea2 --- /dev/null +++ b/server/.ai-transition/admin-web-prd/book-admin-prd.md @@ -0,0 +1,181 @@ +# 书籍后台管理页面需求 + +## 说明 + +- 本文档面向前端 `admin` 后台页面实现。 +- 只描述菜单、页面跳转、页面内功能和弹出框关系。 +- 通用 CRUD 能力不重复描述,按后台管理通用列表页、详情页、编辑页能力实现。 +- 纯关联表不单独做管理菜单;例如 `书籍作者关系` 在 `书籍管理` 的书籍编辑/详情中维护。 + +## 一级菜单 + +- 书籍管理 + +## 一级页面清单 + +- 书籍管理 +- 章节管理 +- 作者管理 +- 系列管理 +- 评论管理 +- 阅读记录管理 +- 收藏记录管理 +- 评论点赞记录管理 + +## 书籍管理 + +```mermaid +flowchart TD + A["一级页面:书籍管理"] + + A --> B1["功能跳转:书籍详情页"] + A --> B2["功能跳转:书籍编辑页"] + + B1 --> C1["页面内功能:查看基础信息"] + B1 --> C2["页面内功能:查看状态信息"] + B1 --> C3["页面内功能:查看关联作者"] + B1 --> C4["页面内功能:查看关联章节"] + B1 --> C5["页面内功能:查看关联评论"] + + B2 --> C6["页面内功能:维护基础信息"] + B2 --> C7["页面内功能:维护类型/标签"] + B2 --> C8["页面内功能:维护系列归属"] + B2 --> C9["页面内功能:维护作者绑定"] + B2 --> C10["页面内功能:维护作者排序"] + + C8 --> D1["弹出框:选择系列"] + C9 --> D2["弹出框:选择作者"] + C10 --> D3["弹出框:调整作者排序"] + + A --> C11["页面内功能:上下架状态调整"] + A --> C12["页面内功能:完结状态调整"] + C11 --> D4["弹出框:状态调整确认"] + C12 --> D5["弹出框:状态调整确认"] + + X["不独立成页:书籍作者关系"] -.-> C9 + X -.-> C10 +``` + +## 章节管理 + +```mermaid +flowchart TD + A["一级页面:章节管理"] + + A --> B1["功能跳转:章节详情页"] + A --> B2["功能跳转:章节编辑页"] + + B1 --> C1["页面内功能:查看章节基础信息"] + B1 --> C2["页面内功能:查看章节内容/文件信息"] + B1 --> C3["页面内功能:查看所属书籍"] + + B2 --> C4["页面内功能:维护章节基础信息"] + B2 --> C5["页面内功能:维护章节内容/文件"] + B2 --> C6["页面内功能:维护所属书籍"] + + C6 --> D1["弹出框:选择所属书籍"] + + A --> C7["页面内功能:章节开放/发布状态调整"] + C7 --> D2["弹出框:状态调整确认"] +``` + +## 作者管理 + +```mermaid +flowchart TD + A["一级页面:作者管理"] + + A --> B1["功能跳转:作者详情页"] + A --> B2["功能跳转:作者编辑页"] + + B1 --> C1["页面内功能:查看作者基础信息"] + B1 --> C2["页面内功能:查看作者状态"] + B1 --> C3["页面内功能:查看关联书籍"] + + B2 --> C4["页面内功能:维护作者基础信息"] + + A --> C5["页面内功能:作者状态调整"] + C5 --> D1["弹出框:状态调整确认"] +``` + +## 系列管理 + +```mermaid +flowchart TD + A["一级页面:系列管理"] + + A --> B1["功能跳转:系列详情页"] + A --> B2["功能跳转:系列编辑页"] + + B1 --> C1["页面内功能:查看系列基础信息"] + B1 --> C2["页面内功能:查看系列状态"] + B1 --> C3["页面内功能:查看系列下书籍"] + + B2 --> C4["页面内功能:维护系列基础信息"] + + A --> C5["页面内功能:系列启停/展示状态调整"] + C5 --> D1["弹出框:状态调整确认"] +``` + +## 评论管理 + +```mermaid +flowchart TD + A["一级页面:评论管理"] + + A --> B1["功能跳转:评论详情页"] + + B1 --> C1["页面内功能:查看评论内容"] + B1 --> C2["页面内功能:查看评论锚点"] + B1 --> C3["页面内功能:查看关联书籍"] + B1 --> C4["页面内功能:查看关联章节"] + B1 --> C5["页面内功能:查看用户信息"] + + A --> C6["页面内功能:评论状态处理"] + C6 --> D1["弹出框:状态处理确认"] +``` + +## 阅读记录管理 + +```mermaid +flowchart TD + A["一级页面:阅读记录管理"] + + A --> B1["功能跳转:阅读记录详情页"] + + B1 --> C1["页面内功能:查看用户信息"] + B1 --> C2["页面内功能:查看书籍信息"] + B1 --> C3["页面内功能:查看续读锚点"] + B1 --> C4["页面内功能:查看章节进度"] +``` + +## 收藏记录管理 + +```mermaid +flowchart TD + A["一级页面:收藏记录管理"] + + A --> B1["功能跳转:收藏记录详情页"] + + B1 --> C1["页面内功能:查看用户信息"] + B1 --> C2["页面内功能:查看书籍信息"] + B1 --> C3["页面内功能:查看收藏关系"] +``` + +## 评论点赞记录管理 + +```mermaid +flowchart TD + A["一级页面:评论点赞记录管理"] + + A --> B1["功能跳转:评论点赞记录详情页"] + + B1 --> C1["页面内功能:查看用户信息"] + B1 --> C2["页面内功能:查看评论信息"] + B1 --> C3["页面内功能:查看点赞关系"] +``` + +## 不单独成页 + +- 书籍作者关系:不做独立管理菜单,不做独立一级页面;在 `书籍管理` 的书籍详情/编辑中作为作者绑定与排序功能维护。 + diff --git a/server/.ai-transition/database-upgrade-doc/v1.sql b/server/.ai-transition/database-upgrade-doc/v1.sql new file mode 100644 index 0000000..0e25e61 --- /dev/null +++ b/server/.ai-transition/database-upgrade-doc/v1.sql @@ -0,0 +1,132 @@ +-- API 权限分配接口初始化补齐 / sys_apis, casbin_rule / 2026-04-25 + +INSERT INTO sys_apis (created_at, updated_at, path, description, api_group, method) +SELECT NOW(), NOW(), '/api/getApiRoles', '获取API关联角色ID列表', 'api', 'GET' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_apis WHERE path = '/api/getApiRoles' AND method = 'GET' +); + +INSERT INTO sys_apis (created_at, updated_at, path, description, api_group, method) +SELECT NOW(), NOW(), '/api/setApiRoles', '全量覆盖API关联角色', 'api', 'POST' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_apis WHERE path = '/api/setApiRoles' AND method = 'POST' +); + +INSERT INTO casbin_rule (ptype, v0, v1, v2) +SELECT 'p', '888', '/api/getApiRoles', 'GET' +WHERE NOT EXISTS ( + SELECT 1 FROM casbin_rule + WHERE ptype = 'p' AND v0 = '888' AND v1 = '/api/getApiRoles' AND v2 = 'GET' +); + +INSERT INTO casbin_rule (ptype, v0, v1, v2) +SELECT 'p', '888', '/api/setApiRoles', 'POST' +WHERE NOT EXISTS ( + SELECT 1 FROM casbin_rule + WHERE ptype = 'p' AND v0 = '888' AND v1 = '/api/setApiRoles' AND v2 = 'POST' +); + +-- book 业务字典初始化补齐 / sys_dictionaries, sys_dictionary_details / 2026-04-26 + +WITH dict_seed(name, type, status, description) AS ( + VALUES + ('作者状态', 'book_author_status', true, '作者状态字典'), + ('书籍评论状态', 'book_comment_status', true, '书籍评论状态字典'), + ('书籍完结状态', 'book_completion_status', true, '书籍完结状态字典'), + ('书籍时代标签', 'book_era_tag', true, '书籍时代标签字典'), + ('书籍上下架状态', 'book_publish_status', true, '书籍上下架状态字典'), + ('书籍类型', 'book_type', true, '书籍类型动态字典') +) +UPDATE sys_dictionaries d +SET name = s.name, + status = s.status, + "desc" = s.description, + updated_at = NOW(), + deleted_at = NULL +FROM dict_seed s +WHERE d.type = s.type; + +WITH dict_seed(name, type, status, description) AS ( + VALUES + ('作者状态', 'book_author_status', true, '作者状态字典'), + ('书籍评论状态', 'book_comment_status', true, '书籍评论状态字典'), + ('书籍完结状态', 'book_completion_status', true, '书籍完结状态字典'), + ('书籍时代标签', 'book_era_tag', true, '书籍时代标签字典'), + ('书籍上下架状态', 'book_publish_status', true, '书籍上下架状态字典'), + ('书籍类型', 'book_type', true, '书籍类型动态字典') +) +INSERT INTO sys_dictionaries (created_at, updated_at, name, type, status, "desc") +SELECT NOW(), NOW(), s.name, s.type, s.status, s.description +FROM dict_seed s +WHERE NOT EXISTS ( + SELECT 1 FROM sys_dictionaries d WHERE d.type = s.type +); + +WITH detail_seed(dict_type, label, value, sort, status) AS ( + VALUES + ('book_author_status', '启用', 'enabled', 10, true), + ('book_author_status', '禁用', 'disabled', 20, true), + ('book_comment_status', '正常', 'normal', 10, true), + ('book_comment_status', '隐藏', 'hidden', 20, true), + ('book_completion_status', '完结', 'completed', 10, true), + ('book_completion_status', '连载', 'serializing', 20, true), + ('book_era_tag', '未知时代', 'unknown', 10, true), + ('book_era_tag', '远古', 'ancient', 20, true), + ('book_era_tag', '汉', 'han', 30, true), + ('book_era_tag', '唐', 'tang', 40, true), + ('book_era_tag', '宋', 'song', 50, true), + ('book_era_tag', '元', 'yuan', 60, true), + ('book_era_tag', '明', 'ming', 70, true), + ('book_era_tag', '清', 'qing', 80, true), + ('book_era_tag', '近代', 'modern', 90, true), + ('book_era_tag', '现代', 'contemporary', 100, true), + ('book_publish_status', '草稿', 'draft', 10, true), + ('book_publish_status', '下架', 'off_shelf', 20, true), + ('book_publish_status', '上架', 'on_shelf', 30, true) +) +UPDATE sys_dictionary_details d +SET label = s.label, + sort = s.sort, + status = s.status, + extend = '', + level = 0, + path = '', + updated_at = NOW(), + deleted_at = NULL +FROM detail_seed s +JOIN sys_dictionaries dict ON dict.type = s.dict_type +WHERE d.sys_dictionary_id = dict.id + AND d.value = s.value; + +WITH detail_seed(dict_type, label, value, sort, status) AS ( + VALUES + ('book_author_status', '启用', 'enabled', 10, true), + ('book_author_status', '禁用', 'disabled', 20, true), + ('book_comment_status', '正常', 'normal', 10, true), + ('book_comment_status', '隐藏', 'hidden', 20, true), + ('book_completion_status', '完结', 'completed', 10, true), + ('book_completion_status', '连载', 'serializing', 20, true), + ('book_era_tag', '未知时代', 'unknown', 10, true), + ('book_era_tag', '远古', 'ancient', 20, true), + ('book_era_tag', '汉', 'han', 30, true), + ('book_era_tag', '唐', 'tang', 40, true), + ('book_era_tag', '宋', 'song', 50, true), + ('book_era_tag', '元', 'yuan', 60, true), + ('book_era_tag', '明', 'ming', 70, true), + ('book_era_tag', '清', 'qing', 80, true), + ('book_era_tag', '近代', 'modern', 90, true), + ('book_era_tag', '现代', 'contemporary', 100, true), + ('book_publish_status', '草稿', 'draft', 10, true), + ('book_publish_status', '下架', 'off_shelf', 20, true), + ('book_publish_status', '上架', 'on_shelf', 30, true) +) +INSERT INTO sys_dictionary_details (created_at, updated_at, label, value, extend, status, sort, sys_dictionary_id, level, path) +SELECT NOW(), NOW(), s.label, s.value, '', s.status, s.sort, dict.id, 0, '' +FROM detail_seed s +JOIN sys_dictionaries dict ON dict.type = s.dict_type +WHERE NOT EXISTS ( + SELECT 1 + FROM sys_dictionary_details d + WHERE d.sys_dictionary_id = dict.id + AND d.value = s.value +); diff --git a/server/.ai-transition/remake/gorm-auto-migrate-and-upgrade-sql.md b/server/.ai-transition/remake/gorm-auto-migrate-and-upgrade-sql.md new file mode 100644 index 0000000..902fcbd --- /dev/null +++ b/server/.ai-transition/remake/gorm-auto-migrate-and-upgrade-sql.md @@ -0,0 +1,86 @@ +# GORM 自动迁移与升级 SQL 交接说明 + +## 背景 + +- 当前服务启动时会执行 `main.go -> initialize.RegisterTables() -> initialize.bizModel()`。 +- `initialize/gorm_biz.go` 中注册了 `book` 业务模型,并调用 `db.AutoMigrate(...)`。 +- 因此即使没有手动执行 `.sql` 文件,只要服务启动并连接到数据库,`book` 相关业务表也可能被 GORM 自动创建。 + +## AutoMigrate 的信息来源 + +- GORM 不读取 `.ai-specs/doc-sql/*.sql` 中的基线 SQL。 +- 表名来自各模型的 `TableName()`。 +- 字段名来自 Go struct 字段名的 snake_case 映射。 +- 字段类型、默认值、非空、索引、唯一索引、注释主要来自 `gorm` tag。 +- 基础字段来自模型嵌入结构,例如 `model/book/base.go` 的 `HardDeleteModel`。 + +示例: + +```go +Title string `gorm:"type:varchar(255);not null;comment:书名主标题"` +BookType string `gorm:"type:varchar(64);not null;index;comment:书籍类型字典值,对应 book_type"` +Rating float64 `gorm:"type:numeric(3,1);not null;default:0.0;comment:书籍评分,范围 0-10"` +``` + +## AutoMigrate 能做什么 + +- 新表不存在时,按 Model 创建表。 +- 新字段不存在时,通常会补充字段。 +- 新索引不存在时,通常会尝试创建索引。 +- 部分字段类型、默认值、注释可能会按数据库驱动能力尝试调整。 + +## AutoMigrate 不能当作升级 SQL + +- 它不知道旧版本到新版本的业务升级意图。 +- 它不会可靠删除废弃字段。 +- 它不会可靠删除废弃索引。 +- 字段重命名通常会被识别为新增字段,旧字段仍保留。 +- 字段类型收缩、非空约束、唯一约束、CHECK 约束、历史数据回填等需要人工判断。 +- 它不能保证生产升级顺序、幂等性、兼容性和数据安全。 + +结论:`AutoMigrate` 是开发期快速补结构工具,不是正式数据库版本迁移方案。 + +## 升级 SQL 维护规则 + +- 修改 `.ai-specs/doc-sql/*.sql` 并导致表结构、字段、索引、约束、默认值或注释变化时,必须同步维护升级 SQL。 +- 修改 `.ai-specs/doc-dict/*.md` 并导致系统字典主数据或字典项变化时,必须同步维护初始化数据和升级 SQL。 +- 升级 SQL 固定放在 `.ai-transition/database-upgrade-doc`。 +- 当前版本号以 `.ai-specs/sys-specs/database-upgrade-doc-spec.md` 的 `当前数据库表结构版本` 为准。 +- 当前为 `v1` 时,兼容变更写入 `.ai-transition/database-upgrade-doc/v1.sql`。 +- 升级 SQL 只写从既有数据库升级到当前目标结构的变更,不重复粘贴完整建表 SQL。 +- 同一次业务变更涉及多张表时,写入同一个当前版本 SQL 文件,并按表分组。 +- 字典主数据升级按 `sys_dictionaries.type` 防重复。 +- 字典明细升级按 `sys_dictionary_details.sys_dictionary_id + value` 防重复。 +- 固定值域字典需要写 `sys_dictionaries` 和 `sys_dictionary_details`;动态值域字典默认只写 `sys_dictionaries`。 +- 字典升级 SQL 禁止裸 `INSERT`,必须兼容重复执行、手工已建数据和部分明细缺失。 +- 字典升级 SQL 命中已软删数据时,恢复启用数据必须同步置空 `deleted_at`。 + +## 常见变更处理 + +- 新增字段:写 `ALTER TABLE ... ADD COLUMN ...`,并补 `COMMENT ON COLUMN`。 +- 新增 `NOT NULL` 字段:先给默认值或先回填历史数据,再追加 `NOT NULL`。 +- 修改字段类型:先评估历史数据是否兼容,再写 `ALTER TABLE ... ALTER COLUMN ... TYPE ...`。 +- 删除字段:必须人工确认无业务读写依赖,再写 `ALTER TABLE ... DROP COLUMN IF EXISTS ...`。 +- 新增普通索引:写 `CREATE INDEX IF NOT EXISTS idx__ ...`。 +- 删除索引:写 `DROP INDEX IF EXISTS ...`。 +- 新增唯一索引:先排查历史重复数据,必要时先清洗,再创建唯一索引。 +- 修改字段名:优先写 `ALTER TABLE ... RENAME COLUMN ...`,不要依赖 GORM 自动识别。 +- 修改注释:写 `COMMENT ON TABLE` 或 `COMMENT ON COLUMN`。 +- 新增固定值域字典:同步修改 `source/system/dictionary.go`、`source/system/dictionary_detail.go` 和当前版本 SQL。 +- 新增动态值域字典:同步修改 `source/system/dictionary.go` 和当前版本 SQL;没有固定值项时不改 `dictionary_detail.go`。 +- 修改固定字典项展示信息:升级 SQL 先 `UPDATE` 已有 `value`,再 `INSERT` 缺失项。 +- 废弃固定字典项:优先将 `status` 改为 `false`,不要直接删除历史值项。 + +## 配置建议 + +- 开发环境可以保留 `config.yaml` 中 `system.disable-auto-migrate: false`,便于快速补表。 +- 生产或正式联调环境建议设置 `system.disable-auto-migrate: true`,统一使用 `.ai-transition/database-upgrade-doc/*.sql` 做可审计升级。 +- 如果生产环境仍开启 `AutoMigrate`,必须接受它可能自动补字段或索引、但不会完整处理删除和复杂变更的风险。 + +## 交接检查清单 + +- 已确认 `.ai-specs/doc-sql/*.sql` 是目标结构说明,不会被程序自动执行。 +- 已确认真实自动建表入口是 `initialize/gorm_biz.go` 的 `AutoMigrate`。 +- 已确认 Model 的 `gorm` tag 是 AutoMigrate 生成 DDL 的主要依据。 +- 已确认结构升级 SQL 需要人工维护,不能指望 GORM 生成。 +- 已确认生产环境是否关闭自动迁移,并形成团队约定。 diff --git a/server/AGENTS.md b/server/AGENTS.md index d76b990..97835a4 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -4,6 +4,7 @@ - **本文档要求**:本文档只允许在已有的结构上CURD,不允许增加其他标题区 - **本文档要求**:.ai-specs 目录下新增/删除任何文档的时候都应该 在本文档中修改 `## 项目文档` - **文档要求**:规范型文档是给 AI 的顶层入口文档,不是“解释得更全”就更好,而是要 更短、更硬、更可判定 +- **文档要求**:如果我的`需求/要求`和`规范文档`冲突,你应该及时提醒我,让我决策是修正`需求/规范文档` - **代码优化**:先复用再新增,允许抽公共逻辑,但公共逻辑必须保证边界仍清晰。 - **代码优化**:优化代码时必须同时考虑冗余、孤岛代码、代码清晰度、复杂度、边界条件和兼容性,不能只追求功能跑通。不要修改ui/ux 视觉效果,除非明确要求。 - **代码默认遵循**:业务流程需要遵循 `主流做法` `工业级正规` @@ -14,7 +15,7 @@ - **搜索范围限制**:Grep/Glob 严禁全盘搜索,绝对禁止扫描 `.gitignore` 忽略的目录,以避免性能卡顿。 - **读写**:所有文件读取/写入统一使用 UTF-8(建议无 BOM) - **读写**:PowerShell/脚本读取项目文件必须显式指定 `-Encoding utf8` -- **画图**:优先使用 `mermaid flowchart ` +- **画图**:优先使用 `Mermaid 图` 不能混入非 Mermaid 语法文本。 - **对话/文档编写**:必须是 中文为主体语言,技术术语保留英文原文 - **git push**:每次 push 的时候,如果当前是子分支就合并到主分支上 @@ -56,6 +57,12 @@ | Model | 定义表结构、`request`、`response` 结构体,作为分层之间的数据载体 | 实体放在 `model/`;入参放在 `model//request`;出参放在 `model//response`;结构体名使用 PascalCase | | Database | 持久化存储,由 `Service` 通过 `GORM` 直接访问 | 数据库访问统一走 `global.GVA_DB`;表迁移统一在 `initialize/gorm_biz.go` 注册 | +### 链路关系 +- **修改/新增对应落点时必须严格遵循以下规范** +#### 链路总线 +- 关系总线:`doc-sql / doc-dict → Model → Service → API → Router`,其中 `Model` 贯穿 `API`、`Service` 与数据库映射;`doc-sql` 约束表结构,`doc-dict` 约束值域与枚举。 + +#### 新增链路规范 - 新增业务模块时,主落点一律放在 `service/`。 - 新增业务模块时,接口配套放在 `api/v1/`。 - 新增业务模块时,路由配套放在 `router/`。 @@ -64,21 +71,7 @@ - 涉及表结构变更时,统一在 `initialize/gorm_biz.go` 注册迁移。 - 本项目有`admin/app` 2套接口,一般默认是admin接口,app接口按需增加 -### 架构关系 -- 关系总线:`doc-sql / doc-dict → Model → Service → API → Router`,其中 `Model` 贯穿 `API`、`Service` 与数据库映射;`doc-sql` 约束表结构,`doc-dict` 约束值域与枚举。 - -```mermaid -flowchart LR - DOCSQL["doc-sql
表结构/字段/索引/约束"] --> Model - DOCDICT["doc-dict
状态/类型/枚举值域"] --> Model - DOCDICT --> Service - DOCDICT --> API - Model --> Service - Service --> API - API --> Router 无法显示出图像啊 -``` - - +#### 代码联动 - 改 `doc-sql`:不能只改文档;必须同步检查并修改 `model/` 实体字段、`service/` 读写逻辑、`initialize/gorm_biz.go` 迁移注册。若字段名、类型、默认值、索引、唯一约束变化,相关查询条件、排序、唯一性校验、兼容逻辑都要一起改。 - 改 `doc-dict`:必须同步修改代码里的枚举/常量、`Model` 字段注释与值域约束、`API` 参数校验、`Service` 分支判断与展示转换。若接口出入参暴露该值域,相关 `doc-api` 也必须同步。 - 改 `Model`:必须区分是改实体、`request` 还是 `response`。改实体时,同步检查 `Service` 的查询/写入/扫描字段,以及 `doc-sql` 是否仍一致;改 `request/response` 时,同步检查 `API` 绑定、返回和 `doc-api`。 @@ -87,11 +80,36 @@ flowchart LR - 改 `Router`:同步检查 `API` 是否已有对应处理函数;新增或调整业务路由时,必须同步修改 `initialize/router_biz.go` 注册。若分组、版本、权限中间件变化,相关接口文档与调用方也要一起检查。 - 新增/修改业务功能时,默认顺序是:先定 `doc-sql` / `doc-dict`,再落 `Model`,再写 `Service`,再接 `API`,最后挂 `Router`;禁止只改链路中的单点而不回查上下游。 +#### 关系图 +- 关系图必须覆盖文档约束、代码落点、注册入口和数据库落点;禁止只画 `Model → Service → API → Router` 单线调用关系。 + +```mermaid +flowchart LR + DOCSQL["doc-sql
表结构/字段/索引/约束"] --> Entity["Model Entity
数据库实体"] + DOCDICT["doc-dict
状态/类型/枚举值域"] --> Entity + DOCDICT --> Service + DOCDICT --> API + + DOCAPI["doc-api
路径/鉴权/参数/返回"] --> ReqResp["Model request/response
入参/出参"] + DOCAPI --> API + DOCAPI --> Router + + Entity --> Service + ReqResp --> API + Service --> API + API --> Router + Router --> RouterInit["initialize/router_biz.go
路由注册"] + Service --> DB["Database
GORM"] + Entity --> GormInit["initialize/gorm_biz.go
迁移注册"] + GormInit --> DB +``` + ## 项目文档 - **根目录**:.ai-specs - **要求**:开始写代码前,根据任务类型先定位目录,再读取对应文档;有对应文档必须先读。 - **兜底**:索引表无匹配时,按本文件通用规则和现有同层代码风格实现 +- **阶段入口**:`/admin-service-prd-draft`、`/admin-service-data-model`、`/admin-service-coding` 属于全局 Skill 强交互入口,不在 `.ai-specs\sys-specs` 中维护 ### coding-specs 存放对功能代码的说明/限制/要求 @@ -103,11 +121,11 @@ flowchart LR | `.ai-specs\coding-specs\sys-params.md` | 规定 `sys_params` 的读写方式与单参数独立 API 的封装方式 | 涉及系统参数读写、基于 `sys_params` 封装业务配置接口时必读 | | `.ai-specs\coding-specs\vo-model-request-response.md` | 规定项目中实体、API 入参、API 出参与通用结构在 `model` 体系内的落点与复用边界 | 涉及 `vo` 放置方式、是否复用实体、何时新增 `request/response` 结构时必读 | -### requirements 存放需求草案文档 +### prd-draft 存放需求草案文档 | 路径 | 用途 | 说明 | |:---|:---|:---| -| `.ai-specs\requirements\book.md` | 记录书籍需求草案,先列相关表和字段,再列业务需求描述 | 涉及书籍、书籍系列、书籍作者、书籍评论等业务设计时先读 | +| `.ai-specs\prd-draft\book.md` | 记录书籍需求草案,先列相关表和字段,再列业务需求描述 | 涉及书籍、书籍系列、书籍作者、书籍评论等业务设计时先读 | ### sys-specs 存放系统级文档 @@ -116,29 +134,54 @@ flowchart LR | `.ai-specs\sys-specs\business-table-spec.md` | 规定新增业务表的 SQL 设计、索引、约束、迁移和兼容要求 | 涉及新增/修改业务表、字段、索引、唯一约束、迁移注册时必读 | | `.ai-specs\sys-specs\business-dictionary-spec.md` | 规定新增业务字典的定义方式,以及代码枚举与字典值的一一对应关系 | 涉及新增业务状态、类型、级别、来源、模式、分类等值域时必读 | | `.ai-specs\sys-specs\module-naming-spec.md` | 规定业务模块中文名与英文名的统一登记方式 | 涉及新增/修改业务模块命名、中英文对照、目录命名时必读 | -| `.ai-specs\sys-specs\requirements-stage-spec.md` | 规定 `requirements` 需求草案阶段的文档边界、结构和输出方式 | 涉及新增/修改 `.ai-specs\requirements\*.md` 时必读 | +| `.ai-specs\sys-specs\database-upgrade-doc-spec.md` | 规定 `.ai-transition\database-upgrade-doc` 数据库升级 SQL 的版本定位、写入时机和兼容 SQL 要求 | 涉及修改 `doc-sql` 并产生数据库结构变更、维护 `v1.sql`/`v2.sql` 等升级 SQL 时必读 | ### doc-api | 路径 | 用途 | 说明 | |:---|:---|:---| +| `.ai-specs\doc-api\admin\book.md` | 定义书籍信息 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及书籍主资料后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\book_author.md` | 定义书籍作者 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及作者后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\book_author_relation.md` | 定义书籍作者关系 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及书籍作者绑定后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\book_chapter.md` | 定义书籍章节 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及章节后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\book_comment.md` | 定义书籍评论 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及评论后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\book_comment_like_record.md` | 定义评论点赞记录 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及评论点赞记录后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\book_favorite_record.md` | 定义书籍收藏记录 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及收藏记录后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\book_read_record.md` | 定义书籍阅读记录 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及阅读记录后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\book_series.md` | 定义书籍系列 admin 端 CRUD 接口路径、鉴权、审计和参数返回约定 | 涉及系列后台接口实现、路由挂载和接口 contract 调整时必读 | +| `.ai-specs\doc-api\admin\sys_api.md` | 定义 API 管理 admin 端同步接口路径、鉴权、审计和参数返回约定 | 涉及 API 路由同步、权限点自动入库、忽略接口和 Casbin 清理时必读 | ### doc-sql 存放具体业务表文档 | 路径 | 用途 | 说明 | |:---|:---|:---| -| `.ai-specs\doc-sql\book_author.md` | 定义书籍作者表的字段、索引、SQL 基线和兼容要求 | 涉及书籍作者表建模、迁移、唯一性约束和书籍作者关系改造时必读 | +| `.ai-specs\doc-sql\book.sql` | 定义书籍主表的字段、字典引用、聚合展示字段和系列挂载方式 | 涉及书籍主资料建模、书籍列表/详情查询、上下架与完结状态落库时必读 | +| `.ai-specs\doc-sql\book_author.sql` | 定义书籍作者表的字段、索引、SQL 基线和兼容要求 | 涉及书籍作者表建模、迁移、唯一性约束和书籍作者关系改造时必读 | +| `.ai-specs\doc-sql\book_author_relation.sql` | 定义书籍与作者关联表的字段、唯一性约束和排序落库方式 | 涉及书籍作者绑定、作者排序和书籍作者关系维护时必读 | +| `.ai-specs\doc-sql\book_chapter.sql` | 定义书籍章节表的字段、章节编号唯一性和阅读发布基础约束 | 涉及章节建模、章节排序、阅读开放状态和章节文件地址维护时必读 | +| `.ai-specs\doc-sql\book_comment.sql` | 定义书籍评论表的字段、评论锚点结构和评论状态落库方式 | 涉及整书/整章/文本行评论定位、评论列表和评论状态处理时必读 | +| `.ai-specs\doc-sql\book_comment_like_record.sql` | 定义评论点赞记录表的字段和幂等唯一约束 | 涉及评论点赞、取消点赞和点赞关系判断时必读 | +| `.ai-specs\doc-sql\book_favorite_record.sql` | 定义用户收藏记录表的字段和用户-书籍唯一约束 | 涉及用户收藏、取消收藏和收藏列表维护时必读 | +| `.ai-specs\doc-sql\book_read_record.sql` | 定义用户阅读记录表的字段、续读锚点和用户-书籍唯一约束 | 涉及阅读历史、续读恢复和阅读进度回写时必读 | +| `.ai-specs\doc-sql\book_series.sql` | 定义书籍系列表的字段和系列启停基础结构 | 涉及书籍系列建模、系列展示和系列启停处理时必读 | ### doc-dict 存放具体业务字典文档 | 路径 | 用途 | 说明 | |:---|:---|:---| | `.ai-specs\doc-dict\book_author_status.md` | 定义作者状态的标准值域 | 涉及作者状态的存储、校验、展示和接口出入参时必读 | +| `.ai-specs\doc-dict\book_comment_status.md` | 定义书籍评论状态的标准值域 | 涉及评论状态存储、评论隐藏和评论出入参展示时必读 | | `.ai-specs\doc-dict\book_completion_status.md` | 定义书籍完结状态的标准值域 | 涉及书籍完结状态的存储、校验、展示和接口出入参时必读 | -| `.ai-specs\doc-dict\book_process_status.md` | 定义书籍文件处理状态的标准值域 | 涉及原始文件准备、章节拆分、章节校验、处理完成等流程状态时必读 | +| `.ai-specs\doc-dict\book_era_tag.md` | 定义书籍时代标签的标准值域 | 涉及时代标签存储、筛选聚合和接口出入参时必读 | +| `.ai-specs\doc-dict\book_publish_status.md` | 定义书籍上下架状态的标准值域 | 涉及书籍上架、下架、草稿状态存储和展示时必读 | +| `.ai-specs\doc-dict\book_type.md` | 定义书籍类型动态值域字典的来源、用途和逻辑边界 | 涉及书籍类型落库、类型校验和系统字典独立封装时必读 | ## 可复用代码/组件 | 中文 | 代码文件名 | 说明 | |:---|:---|:---| + + + + diff --git a/server/README.md b/server/README.md index b43315b..087e52b 100644 --- a/server/README.md +++ b/server/README.md @@ -4,6 +4,12 @@ go mod tidy # 在 server 目录下运行 go run main.go +# 更新api文档 +swag init + +go install github.com/swaggo/swag/cmd/swag@latest + + ## server项目结构 diff --git a/server/api/v1/book/book.go b/server/api/v1/book/book.go new file mode 100644 index 0000000..04956be --- /dev/null +++ b/server/api/v1/book/book.go @@ -0,0 +1,138 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookApi struct{} + +// @Tags Book +// @Summary 创建书籍信息 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body book.Book true "书籍信息" +// @Success 200 {object} response.Response{msg=string} +// @Router /book/createBook [post] +func (a *BookApi) CreateBook(c *gin.Context) { + var item book.Book + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookService.CreateBook(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +// @Tags Book +// @Summary 删除书籍信息 +// @Security ApiKeyAuth +// @Param id query int true "主键ID" +// @Success 200 {object} response.Response{msg=string} +// @Router /book/deleteBook [delete] +func (a *BookApi) DeleteBook(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookService.DeleteBook(book.Book{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +// @Tags Book +// @Summary 批量删除书籍信息 +// @Security ApiKeyAuth +// @Param ids query []int true "主键ID数组" +// @Success 200 {object} response.Response{msg=string} +// @Router /book/deleteBookByIds [delete] +func (a *BookApi) DeleteBookByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookService.DeleteBookByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +// @Tags Book +// @Summary 更新书籍信息 +// @Security ApiKeyAuth +// @Param data body book.Book true "书籍信息" +// @Success 200 {object} response.Response{msg=string} +// @Router /book/updateBook [put] +func (a *BookApi) UpdateBook(c *gin.Context) { + var item book.Book + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookService.UpdateBook(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +// @Tags Book +// @Summary 查询书籍详情 +// @Security ApiKeyAuth +// @Param id query int true "主键ID" +// @Success 200 {object} response.Response{data=bookRes.BookResponse,msg=string} +// @Router /book/findBook [get] +func (a *BookApi) FindBook(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookService.GetBook(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookResponse{Book: item}, "获取成功", c) +} + +// @Tags Book +// @Summary 分页获取书籍列表 +// @Security ApiKeyAuth +// @Param data query bookReq.BookSearch true "分页与筛选条件" +// @Success 200 {object} response.Response{data=response.PageResult,msg=string} +// @Router /book/getBookList [get] +func (a *BookApi) GetBookList(c *gin.Context) { + var info bookReq.BookSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookService.GetBookInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/book_author.go b/server/api/v1/book/book_author.go new file mode 100644 index 0000000..5c0fd5d --- /dev/null +++ b/server/api/v1/book/book_author.go @@ -0,0 +1,100 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookAuthorApi struct{} + +func (a *BookAuthorApi) CreateBookAuthor(c *gin.Context) { + var item book.BookAuthor + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookAuthorService.CreateBookAuthor(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +func (a *BookAuthorApi) DeleteBookAuthor(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookAuthorService.DeleteBookAuthor(book.BookAuthor{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +func (a *BookAuthorApi) DeleteBookAuthorByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookAuthorService.DeleteBookAuthorByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +func (a *BookAuthorApi) UpdateBookAuthor(c *gin.Context) { + var item book.BookAuthor + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookAuthorService.UpdateBookAuthor(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +func (a *BookAuthorApi) FindBookAuthor(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookAuthorService.GetBookAuthor(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookAuthorResponse{BookAuthor: item}, "获取成功", c) +} + +func (a *BookAuthorApi) GetBookAuthorList(c *gin.Context) { + var info bookReq.BookAuthorSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookAuthorService.GetBookAuthorInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/book_author_relation.go b/server/api/v1/book/book_author_relation.go new file mode 100644 index 0000000..09e1cba --- /dev/null +++ b/server/api/v1/book/book_author_relation.go @@ -0,0 +1,100 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookAuthorRelationApi struct{} + +func (a *BookAuthorRelationApi) CreateBookAuthorRelation(c *gin.Context) { + var item book.BookAuthorRelation + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookAuthorRelationService.CreateBookAuthorRelation(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +func (a *BookAuthorRelationApi) DeleteBookAuthorRelation(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookAuthorRelationService.DeleteBookAuthorRelation(book.BookAuthorRelation{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +func (a *BookAuthorRelationApi) DeleteBookAuthorRelationByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookAuthorRelationService.DeleteBookAuthorRelationByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +func (a *BookAuthorRelationApi) UpdateBookAuthorRelation(c *gin.Context) { + var item book.BookAuthorRelation + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookAuthorRelationService.UpdateBookAuthorRelation(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +func (a *BookAuthorRelationApi) FindBookAuthorRelation(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookAuthorRelationService.GetBookAuthorRelation(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookAuthorRelationResponse{BookAuthorRelation: item}, "获取成功", c) +} + +func (a *BookAuthorRelationApi) GetBookAuthorRelationList(c *gin.Context) { + var info bookReq.BookAuthorRelationSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookAuthorRelationService.GetBookAuthorRelationInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/book_chapter.go b/server/api/v1/book/book_chapter.go new file mode 100644 index 0000000..ef4762e --- /dev/null +++ b/server/api/v1/book/book_chapter.go @@ -0,0 +1,100 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookChapterApi struct{} + +func (a *BookChapterApi) CreateBookChapter(c *gin.Context) { + var item book.BookChapter + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookChapterService.CreateBookChapter(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +func (a *BookChapterApi) DeleteBookChapter(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookChapterService.DeleteBookChapter(book.BookChapter{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +func (a *BookChapterApi) DeleteBookChapterByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookChapterService.DeleteBookChapterByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +func (a *BookChapterApi) UpdateBookChapter(c *gin.Context) { + var item book.BookChapter + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookChapterService.UpdateBookChapter(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +func (a *BookChapterApi) FindBookChapter(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookChapterService.GetBookChapter(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookChapterResponse{BookChapter: item}, "获取成功", c) +} + +func (a *BookChapterApi) GetBookChapterList(c *gin.Context) { + var info bookReq.BookChapterSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookChapterService.GetBookChapterInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/book_comment.go b/server/api/v1/book/book_comment.go new file mode 100644 index 0000000..932477f --- /dev/null +++ b/server/api/v1/book/book_comment.go @@ -0,0 +1,100 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookCommentApi struct{} + +func (a *BookCommentApi) CreateBookComment(c *gin.Context) { + var item book.BookComment + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookCommentService.CreateBookComment(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +func (a *BookCommentApi) DeleteBookComment(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookCommentService.DeleteBookComment(book.BookComment{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +func (a *BookCommentApi) DeleteBookCommentByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookCommentService.DeleteBookCommentByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +func (a *BookCommentApi) UpdateBookComment(c *gin.Context) { + var item book.BookComment + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookCommentService.UpdateBookComment(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +func (a *BookCommentApi) FindBookComment(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookCommentService.GetBookComment(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookCommentResponse{BookComment: item}, "获取成功", c) +} + +func (a *BookCommentApi) GetBookCommentList(c *gin.Context) { + var info bookReq.BookCommentSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookCommentService.GetBookCommentInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/book_comment_like_record.go b/server/api/v1/book/book_comment_like_record.go new file mode 100644 index 0000000..73e4ef6 --- /dev/null +++ b/server/api/v1/book/book_comment_like_record.go @@ -0,0 +1,100 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookCommentLikeRecordApi struct{} + +func (a *BookCommentLikeRecordApi) CreateBookCommentLikeRecord(c *gin.Context) { + var item book.BookCommentLikeRecord + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookCommentLikeRecordService.CreateBookCommentLikeRecord(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +func (a *BookCommentLikeRecordApi) DeleteBookCommentLikeRecord(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookCommentLikeRecordService.DeleteBookCommentLikeRecord(book.BookCommentLikeRecord{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +func (a *BookCommentLikeRecordApi) DeleteBookCommentLikeRecordByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookCommentLikeRecordService.DeleteBookCommentLikeRecordByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +func (a *BookCommentLikeRecordApi) UpdateBookCommentLikeRecord(c *gin.Context) { + var item book.BookCommentLikeRecord + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookCommentLikeRecordService.UpdateBookCommentLikeRecord(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +func (a *BookCommentLikeRecordApi) FindBookCommentLikeRecord(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookCommentLikeRecordService.GetBookCommentLikeRecord(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookCommentLikeRecordResponse{BookCommentLikeRecord: item}, "获取成功", c) +} + +func (a *BookCommentLikeRecordApi) GetBookCommentLikeRecordList(c *gin.Context) { + var info bookReq.BookCommentLikeRecordSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookCommentLikeRecordService.GetBookCommentLikeRecordInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/book_favorite_record.go b/server/api/v1/book/book_favorite_record.go new file mode 100644 index 0000000..4384e8c --- /dev/null +++ b/server/api/v1/book/book_favorite_record.go @@ -0,0 +1,100 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookFavoriteRecordApi struct{} + +func (a *BookFavoriteRecordApi) CreateBookFavoriteRecord(c *gin.Context) { + var item book.BookFavoriteRecord + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookFavoriteRecordService.CreateBookFavoriteRecord(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +func (a *BookFavoriteRecordApi) DeleteBookFavoriteRecord(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookFavoriteRecordService.DeleteBookFavoriteRecord(book.BookFavoriteRecord{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +func (a *BookFavoriteRecordApi) DeleteBookFavoriteRecordByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookFavoriteRecordService.DeleteBookFavoriteRecordByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +func (a *BookFavoriteRecordApi) UpdateBookFavoriteRecord(c *gin.Context) { + var item book.BookFavoriteRecord + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookFavoriteRecordService.UpdateBookFavoriteRecord(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +func (a *BookFavoriteRecordApi) FindBookFavoriteRecord(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookFavoriteRecordService.GetBookFavoriteRecord(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookFavoriteRecordResponse{BookFavoriteRecord: item}, "获取成功", c) +} + +func (a *BookFavoriteRecordApi) GetBookFavoriteRecordList(c *gin.Context) { + var info bookReq.BookFavoriteRecordSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookFavoriteRecordService.GetBookFavoriteRecordInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/book_read_record.go b/server/api/v1/book/book_read_record.go new file mode 100644 index 0000000..60b8a03 --- /dev/null +++ b/server/api/v1/book/book_read_record.go @@ -0,0 +1,100 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookReadRecordApi struct{} + +func (a *BookReadRecordApi) CreateBookReadRecord(c *gin.Context) { + var item book.BookReadRecord + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookReadRecordService.CreateBookReadRecord(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +func (a *BookReadRecordApi) DeleteBookReadRecord(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookReadRecordService.DeleteBookReadRecord(book.BookReadRecord{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +func (a *BookReadRecordApi) DeleteBookReadRecordByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookReadRecordService.DeleteBookReadRecordByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +func (a *BookReadRecordApi) UpdateBookReadRecord(c *gin.Context) { + var item book.BookReadRecord + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookReadRecordService.UpdateBookReadRecord(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +func (a *BookReadRecordApi) FindBookReadRecord(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookReadRecordService.GetBookReadRecord(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookReadRecordResponse{BookReadRecord: item}, "获取成功", c) +} + +func (a *BookReadRecordApi) GetBookReadRecordList(c *gin.Context) { + var info bookReq.BookReadRecordSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookReadRecordService.GetBookReadRecordInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/book_series.go b/server/api/v1/book/book_series.go new file mode 100644 index 0000000..f54dd59 --- /dev/null +++ b/server/api/v1/book/book_series.go @@ -0,0 +1,100 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + bookRes "github.com/flipped-aurora/gin-vue-admin/server/model/book/response" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type BookSeriesApi struct{} + +func (a *BookSeriesApi) CreateBookSeries(c *gin.Context) { + var item book.BookSeries + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if err := bookSeriesService.CreateBookSeries(item); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + return + } + response.OkWithMessage("创建成功", c) +} + +func (a *BookSeriesApi) DeleteBookSeries(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + if err := bookSeriesService.DeleteBookSeries(book.BookSeries{HardDeleteModel: book.HardDeleteModel{ID: id}}); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + return + } + response.OkWithMessage("删除成功", c) +} + +func (a *BookSeriesApi) DeleteBookSeriesByIds(c *gin.Context) { + ids, ok := bindIDs(c) + if !ok { + return + } + if err := bookSeriesService.DeleteBookSeriesByIds(ids); err != nil { + global.GVA_LOG.Error("批量删除失败!", zap.Error(err)) + response.FailWithMessage("批量删除失败", c) + return + } + response.OkWithMessage("批量删除成功", c) +} + +func (a *BookSeriesApi) UpdateBookSeries(c *gin.Context) { + var item book.BookSeries + if err := c.ShouldBindJSON(&item); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + if item.ID == 0 { + response.FailWithMessage("ID不能为空", c) + return + } + if err := bookSeriesService.UpdateBookSeries(item); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + return + } + response.OkWithMessage("更新成功", c) +} + +func (a *BookSeriesApi) FindBookSeries(c *gin.Context) { + id, ok := bindID(c) + if !ok { + return + } + item, err := bookSeriesService.GetBookSeries(id) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + response.OkWithDetailed(bookRes.BookSeriesResponse{BookSeries: item}, "获取成功", c) +} + +func (a *BookSeriesApi) GetBookSeriesList(c *gin.Context) { + var info bookReq.BookSeriesSearch + if err := c.ShouldBindQuery(&info); err != nil { + response.FailWithMessage(err.Error(), c) + return + } + list, total, err := bookSeriesService.GetBookSeriesInfoList(info) + if err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + return + } + pageResult(c, list, total, info.PageInfo) +} diff --git a/server/api/v1/book/common.go b/server/api/v1/book/common.go new file mode 100644 index 0000000..09bd5df --- /dev/null +++ b/server/api/v1/book/common.go @@ -0,0 +1,50 @@ +package book + +import ( + "fmt" + + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" + "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" + "github.com/flipped-aurora/gin-vue-admin/server/utils" + "github.com/gin-gonic/gin" +) + +func bindID(c *gin.Context) (uint, bool) { + var req commonReq.GetById + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return 0, false + } + if err := utils.Verify(req, utils.IdVerify); err != nil { + response.FailWithMessage(err.Error(), c) + return 0, false + } + return req.Uint(), true +} + +func bindIDs(c *gin.Context) (commonReq.IdsReq, bool) { + var req commonReq.IdsReq + if err := c.ShouldBindQuery(&req); err != nil { + response.FailWithMessage(err.Error(), c) + return req, false + } + if len(req.Ids) == 0 { + for _, rawID := range c.QueryArray("ids[]") { + var id int + if _, err := fmt.Sscanf(rawID, "%d", &id); err != nil { + response.FailWithMessage(err.Error(), c) + return req, false + } + req.Ids = append(req.Ids, id) + } + } + if len(req.Ids) == 0 { + response.FailWithMessage("ids不能为空", c) + return req, false + } + return req, true +} + +func pageResult(c *gin.Context, list interface{}, total int64, page commonReq.PageInfo) { + response.OkWithDetailed(response.PageResult{List: list, Total: total, Page: page.Page, PageSize: page.PageSize}, "获取成功", c) +} diff --git a/server/api/v1/book/common_test.go b/server/api/v1/book/common_test.go new file mode 100644 index 0000000..54d510e --- /dev/null +++ b/server/api/v1/book/common_test.go @@ -0,0 +1,33 @@ +package book + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestBindIDsAcceptsDocumentedIdsArrayQuery(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("DELETE", "/book/deleteBookByIds?ids[]=1&ids[]=2", nil) + + ids, ok := bindIDs(ctx) + if !ok { + t.Fatal("bindIDs() ok = false, want true") + } + if len(ids.Ids) != 2 || ids.Ids[0] != 1 || ids.Ids[1] != 2 { + t.Fatalf("ids = %#v, want [1 2]", ids.Ids) + } +} + +func TestBindIDsRejectsEmptyIDList(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest("DELETE", "/book/deleteBookByIds", nil) + + _, ok := bindIDs(ctx) + if ok { + t.Fatal("bindIDs() ok = true, want false") + } +} diff --git a/server/api/v1/book/enter.go b/server/api/v1/book/enter.go new file mode 100644 index 0000000..624f85a --- /dev/null +++ b/server/api/v1/book/enter.go @@ -0,0 +1,27 @@ +package book + +import "github.com/flipped-aurora/gin-vue-admin/server/service" + +type ApiGroup struct { + BookApi + BookAuthorApi + BookAuthorRelationApi + BookChapterApi + BookCommentApi + BookCommentLikeRecordApi + BookFavoriteRecordApi + BookReadRecordApi + BookSeriesApi +} + +var ( + bookService = service.ServiceGroupApp.BookServiceGroup.BookService + bookAuthorService = service.ServiceGroupApp.BookServiceGroup.BookAuthorService + bookAuthorRelationService = service.ServiceGroupApp.BookServiceGroup.BookAuthorRelationService + bookChapterService = service.ServiceGroupApp.BookServiceGroup.BookChapterService + bookCommentService = service.ServiceGroupApp.BookServiceGroup.BookCommentService + bookCommentLikeRecordService = service.ServiceGroupApp.BookServiceGroup.BookCommentLikeRecordService + bookFavoriteRecordService = service.ServiceGroupApp.BookServiceGroup.BookFavoriteRecordService + bookReadRecordService = service.ServiceGroupApp.BookServiceGroup.BookReadRecordService + bookSeriesService = service.ServiceGroupApp.BookServiceGroup.BookSeriesService +) diff --git a/server/api/v1/enter.go b/server/api/v1/enter.go index 5c1dff4..964d681 100644 --- a/server/api/v1/enter.go +++ b/server/api/v1/enter.go @@ -1,6 +1,7 @@ package v1 import ( + "github.com/flipped-aurora/gin-vue-admin/server/api/v1/book" "github.com/flipped-aurora/gin-vue-admin/server/api/v1/example" "github.com/flipped-aurora/gin-vue-admin/server/api/v1/system" ) @@ -10,4 +11,5 @@ var ApiGroupApp = new(ApiGroup) type ApiGroup struct { SystemApiGroup system.ApiGroup ExampleApiGroup example.ApiGroup + BookApiGroup book.ApiGroup } diff --git a/server/api/v1/system/sys_api.go b/server/api/v1/system/sys_api.go index 97a07b0..e32995d 100644 --- a/server/api/v1/system/sys_api.go +++ b/server/api/v1/system/sys_api.go @@ -1,6 +1,9 @@ package system import ( + "errors" + "io" + "github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" "github.com/flipped-aurora/gin-vue-admin/server/model/common/response" @@ -123,7 +126,7 @@ func (s *SystemApiApi) IgnoreApi(c *gin.Context) { func (s *SystemApiApi) EnterSyncApi(c *gin.Context) { var syncApi systemRes.SysSyncApis err := c.ShouldBindJSON(&syncApi) - if err != nil { + if err != nil && !errors.Is(err, io.EOF) { response.FailWithMessage(err.Error(), c) return } diff --git a/server/initialize/gorm_biz.go b/server/initialize/gorm_biz.go index 9316ccc..b96d593 100644 --- a/server/initialize/gorm_biz.go +++ b/server/initialize/gorm_biz.go @@ -2,11 +2,22 @@ package initialize import ( "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" ) func bizModel() error { db := global.GVA_DB - err := db.AutoMigrate() + err := db.AutoMigrate( + &book.Book{}, + &book.BookAuthor{}, + &book.BookAuthorRelation{}, + &book.BookChapter{}, + &book.BookComment{}, + &book.BookCommentLikeRecord{}, + &book.BookFavoriteRecord{}, + &book.BookReadRecord{}, + &book.BookSeries{}, + ) if err != nil { return err } diff --git a/server/initialize/router_biz.go b/server/initialize/router_biz.go index 279127d..6f74cc4 100644 --- a/server/initialize/router_biz.go +++ b/server/initialize/router_biz.go @@ -16,4 +16,6 @@ func initBizRouter(routers ...*gin.RouterGroup) { publicGroup := routers[1] holder(publicGroup, privateGroup) + router.RouterGroupApp.Book.InitBookRouter(privateGroup) + } diff --git a/server/model/book/base.go b/server/model/book/base.go new file mode 100644 index 0000000..34d2680 --- /dev/null +++ b/server/model/book/base.go @@ -0,0 +1,9 @@ +package book + +import "time" + +type HardDeleteModel struct { + ID uint `json:"id" form:"id" gorm:"primarykey;comment:主键"` + CreatedAt time.Time `json:"createdAt" form:"createdAt" gorm:"comment:创建时间"` + UpdatedAt time.Time `json:"updatedAt" form:"updatedAt" gorm:"comment:更新时间"` +} diff --git a/server/model/book/book.go b/server/model/book/book.go new file mode 100644 index 0000000..6ae9df9 --- /dev/null +++ b/server/model/book/book.go @@ -0,0 +1,50 @@ +package book + +import "time" + +const ( + BookEraTagUnknown = "unknown" + BookEraTagAncient = "ancient" + BookEraTagHan = "han" + BookEraTagTang = "tang" + BookEraTagSong = "song" + BookEraTagYuan = "yuan" + BookEraTagMing = "ming" + BookEraTagQing = "qing" + BookEraTagModern = "modern" + BookEraTagContemporary = "contemporary" + BookCompletionStatusCompleted = "completed" + BookCompletionStatusSerializing = "serializing" + BookPublishStatusDraft = "draft" + BookPublishStatusOffShelf = "off_shelf" + BookPublishStatusOnShelf = "on_shelf" + BookAuthorStatusEnabled = "enabled" + BookAuthorStatusDisabled = "disabled" + BookCommentStatusNormal = "normal" + BookCommentStatusHidden = "hidden" +) + +type Book struct { + HardDeleteModel + Title string `json:"title" form:"title" gorm:"type:varchar(255);not null;comment:书名主标题"` + Subtitle string `json:"subtitle" form:"subtitle" gorm:"type:varchar(255);comment:书籍副标题"` + BookType string `json:"bookType" form:"bookType" gorm:"type:varchar(64);not null;index;comment:书籍类型字典值,对应 book_type"` + EraTag string `json:"eraTag" form:"eraTag" gorm:"type:varchar(32);not null;default:unknown;index;comment:时代标签字典值,对应 book_era_tag"` + CoverUrl string `json:"coverUrl" form:"coverUrl" gorm:"type:varchar(500);comment:封面图片 URL"` + Publisher string `json:"publisher" form:"publisher" gorm:"type:varchar(128);comment:出版社名称"` + PublishedAt *time.Time `json:"publishedAt" form:"publishedAt" gorm:"type:date;comment:出版日期"` + Intro string `json:"intro" form:"intro" gorm:"type:text;comment:书籍简介"` + HotScore int64 `json:"hotScore" form:"hotScore" gorm:"not null;default:0;comment:热度聚合值"` + Rating float64 `json:"rating" form:"rating" gorm:"type:numeric(3,1);not null;default:0.0;comment:书籍评分,范围 0-10"` + CommentCount int64 `json:"commentCount" form:"commentCount" gorm:"not null;default:0;comment:点评数聚合值"` + WordCount int64 `json:"wordCount" form:"wordCount" gorm:"not null;default:0;comment:书籍总字数"` + CompletionStatus string `json:"completionStatus" form:"completionStatus" gorm:"type:varchar(32);not null;default:serializing;index;comment:书籍完结状态字典值,对应 book_completion_status"` + PublishStatus string `json:"publishStatus" form:"publishStatus" gorm:"type:varchar(32);not null;default:draft;index;comment:书籍上下架状态字典值,对应 book_publish_status"` + SeriesID *uint `json:"seriesId" form:"seriesId" gorm:"index:idx_book_series_id_series_sort;comment:所属系列 ID,可为空"` + SeriesSort int `json:"seriesSort" form:"seriesSort" gorm:"not null;default:0;index:idx_book_series_id_series_sort;comment:同系列内展示排序"` + RawTxtUrl string `json:"rawTxtUrl" form:"rawTxtUrl" gorm:"type:varchar(500);comment:原始 txt 文件 URL"` +} + +func (Book) TableName() string { + return "book" +} diff --git a/server/model/book/book_author.go b/server/model/book/book_author.go new file mode 100644 index 0000000..d81df88 --- /dev/null +++ b/server/model/book/book_author.go @@ -0,0 +1,13 @@ +package book + +type BookAuthor struct { + HardDeleteModel + Name string `json:"name" form:"name" gorm:"type:varchar(128);not null;uniqueIndex:uk_book_author_name;comment:作者名称"` + AuthorStatus string `json:"authorStatus" form:"authorStatus" gorm:"type:varchar(32);not null;default:enabled;index;comment:作者状态字典值,对应 book_author_status"` + Intro string `json:"intro" form:"intro" gorm:"type:text;comment:作者简介"` + CoverUrl string `json:"coverUrl" form:"coverUrl" gorm:"type:varchar(500);comment:作者头像或封面 URL"` +} + +func (BookAuthor) TableName() string { + return "book_author" +} diff --git a/server/model/book/book_author_relation.go b/server/model/book/book_author_relation.go new file mode 100644 index 0000000..417012d --- /dev/null +++ b/server/model/book/book_author_relation.go @@ -0,0 +1,12 @@ +package book + +type BookAuthorRelation struct { + HardDeleteModel + BookID uint `json:"bookId" form:"bookId" gorm:"not null;uniqueIndex:uk_book_author_relation_book_id_author_id;index:idx_book_author_relation_book_id_author_sort;comment:书籍 ID"` + AuthorID uint `json:"authorId" form:"authorId" gorm:"not null;uniqueIndex:uk_book_author_relation_book_id_author_id;index;comment:作者 ID"` + AuthorSort int `json:"authorSort" form:"authorSort" gorm:"not null;default:1;index:idx_book_author_relation_book_id_author_sort;comment:作者展示排序"` +} + +func (BookAuthorRelation) TableName() string { + return "book_author_relation" +} diff --git a/server/model/book/book_chapter.go b/server/model/book/book_chapter.go new file mode 100644 index 0000000..5da8e57 --- /dev/null +++ b/server/model/book/book_chapter.go @@ -0,0 +1,16 @@ +package book + +type BookChapter struct { + HardDeleteModel + BookID uint `json:"bookId" form:"bookId" gorm:"not null;uniqueIndex:uk_book_chapter_book_id_chapter_no;index:idx_book_chapter_book_id_is_enabled_is_readable;comment:书籍 ID"` + Title string `json:"title" form:"title" gorm:"type:varchar(255);not null;comment:章节标题"` + ChapterNo int `json:"chapterNo" form:"chapterNo" gorm:"not null;uniqueIndex:uk_book_chapter_book_id_chapter_no;comment:章节序号"` + IsReadable bool `json:"isReadable" form:"isReadable" gorm:"not null;default:false;index:idx_book_chapter_book_id_is_enabled_is_readable;comment:是否开放阅读"` + ContentFileUrl string `json:"contentFileUrl" form:"contentFileUrl" gorm:"type:varchar(500);not null;comment:章节正文文件 URL"` + TotalLines int `json:"totalLines" form:"totalLines" gorm:"not null;default:0;comment:正文总行数"` + IsEnabled bool `json:"isEnabled" form:"isEnabled" gorm:"not null;default:true;index:idx_book_chapter_book_id_is_enabled_is_readable;comment:章节是否启用"` +} + +func (BookChapter) TableName() string { + return "book_chapter" +} diff --git a/server/model/book/book_comment.go b/server/model/book/book_comment.go new file mode 100644 index 0000000..76c23e8 --- /dev/null +++ b/server/model/book/book_comment.go @@ -0,0 +1,16 @@ +package book + +type BookComment struct { + HardDeleteModel + MemberUserID uint `json:"memberUserId" form:"memberUserId" gorm:"not null;index;comment:会员用户 ID"` + BookID uint `json:"bookId" form:"bookId" gorm:"not null;index;index:idx_book_comment_book_id_chapter_id_line_index;comment:书籍 ID"` + ChapterID uint `json:"chapterId" form:"chapterId" gorm:"not null;default:0;index:idx_book_comment_book_id_chapter_id_line_index;comment:章节 ID,0 表示整书评论"` + LineIndex int `json:"lineIndex" form:"lineIndex" gorm:"not null;default:0;index:idx_book_comment_book_id_chapter_id_line_index;comment:文本行序号,0 表示整书或整章"` + Content string `json:"content" form:"content" gorm:"type:text;not null;comment:评论内容"` + LikeCount int64 `json:"likeCount" form:"likeCount" gorm:"not null;default:0;comment:点赞聚合数"` + CommentStatus string `json:"commentStatus" form:"commentStatus" gorm:"type:varchar(32);not null;default:normal;index;comment:评论状态字典值,对应 book_comment_status"` +} + +func (BookComment) TableName() string { + return "book_comment" +} diff --git a/server/model/book/book_comment_like_record.go b/server/model/book/book_comment_like_record.go new file mode 100644 index 0000000..611cd12 --- /dev/null +++ b/server/model/book/book_comment_like_record.go @@ -0,0 +1,14 @@ +package book + +import "time" + +type BookCommentLikeRecord struct { + HardDeleteModel + CommentID uint `json:"commentId" form:"commentId" gorm:"not null;uniqueIndex:uk_book_comment_like_record_comment_id_member_user_id;comment:评论 ID"` + MemberUserID uint `json:"memberUserId" form:"memberUserId" gorm:"not null;uniqueIndex:uk_book_comment_like_record_comment_id_member_user_id;index;comment:会员用户 ID"` + LikedAt time.Time `json:"likedAt" form:"likedAt" gorm:"not null;default:CURRENT_TIMESTAMP;index;comment:点赞时间"` +} + +func (BookCommentLikeRecord) TableName() string { + return "book_comment_like_record" +} diff --git a/server/model/book/book_favorite_record.go b/server/model/book/book_favorite_record.go new file mode 100644 index 0000000..143de25 --- /dev/null +++ b/server/model/book/book_favorite_record.go @@ -0,0 +1,14 @@ +package book + +import "time" + +type BookFavoriteRecord struct { + HardDeleteModel + MemberUserID uint `json:"memberUserId" form:"memberUserId" gorm:"not null;uniqueIndex:uk_book_favorite_record_member_user_id_book_id;index:idx_book_favorite_record_member_user_id_favorited_at;comment:会员用户 ID"` + BookID uint `json:"bookId" form:"bookId" gorm:"not null;uniqueIndex:uk_book_favorite_record_member_user_id_book_id;index;comment:书籍 ID"` + FavoritedAt time.Time `json:"favoritedAt" form:"favoritedAt" gorm:"not null;default:CURRENT_TIMESTAMP;index:idx_book_favorite_record_member_user_id_favorited_at;comment:收藏时间"` +} + +func (BookFavoriteRecord) TableName() string { + return "book_favorite_record" +} diff --git a/server/model/book/book_models_test.go b/server/model/book/book_models_test.go new file mode 100644 index 0000000..352e7df --- /dev/null +++ b/server/model/book/book_models_test.go @@ -0,0 +1,29 @@ +package book + +import "testing" + +func TestBookModelTableNames(t *testing.T) { + tests := []struct { + name string + got string + want string + }{ + {name: "book", got: Book{}.TableName(), want: "book"}, + {name: "book_author", got: BookAuthor{}.TableName(), want: "book_author"}, + {name: "book_author_relation", got: BookAuthorRelation{}.TableName(), want: "book_author_relation"}, + {name: "book_chapter", got: BookChapter{}.TableName(), want: "book_chapter"}, + {name: "book_comment", got: BookComment{}.TableName(), want: "book_comment"}, + {name: "book_comment_like_record", got: BookCommentLikeRecord{}.TableName(), want: "book_comment_like_record"}, + {name: "book_favorite_record", got: BookFavoriteRecord{}.TableName(), want: "book_favorite_record"}, + {name: "book_read_record", got: BookReadRecord{}.TableName(), want: "book_read_record"}, + {name: "book_series", got: BookSeries{}.TableName(), want: "book_series"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Fatalf("TableName() = %q, want %q", tt.got, tt.want) + } + }) + } +} diff --git a/server/model/book/book_read_record.go b/server/model/book/book_read_record.go new file mode 100644 index 0000000..1d70006 --- /dev/null +++ b/server/model/book/book_read_record.go @@ -0,0 +1,18 @@ +package book + +import "time" + +type BookReadRecord struct { + HardDeleteModel + MemberUserID uint `json:"memberUserId" form:"memberUserId" gorm:"not null;uniqueIndex:uk_book_read_record_member_user_id_book_id;index:idx_book_read_record_member_user_id_last_read_at;comment:会员用户 ID"` + BookID uint `json:"bookId" form:"bookId" gorm:"not null;uniqueIndex:uk_book_read_record_member_user_id_book_id;index;comment:书籍 ID"` + BookTitleSnapshot string `json:"bookTitleSnapshot" form:"bookTitleSnapshot" gorm:"type:varchar(255);not null;comment:书名快照"` + ReadProgress float64 `json:"readProgress" form:"readProgress" gorm:"type:numeric(5,2);not null;default:0.00;comment:阅读进度百分比"` + ChapterID uint `json:"chapterId" form:"chapterId" gorm:"not null;comment:续读章节 ID"` + LineIndex int `json:"lineIndex" form:"lineIndex" gorm:"not null;comment:续读文本行序号"` + LastReadAt time.Time `json:"lastReadAt" form:"lastReadAt" gorm:"not null;default:CURRENT_TIMESTAMP;index:idx_book_read_record_member_user_id_last_read_at;comment:最后阅读时间"` +} + +func (BookReadRecord) TableName() string { + return "book_read_record" +} diff --git a/server/model/book/book_series.go b/server/model/book/book_series.go new file mode 100644 index 0000000..c8c3e64 --- /dev/null +++ b/server/model/book/book_series.go @@ -0,0 +1,13 @@ +package book + +type BookSeries struct { + HardDeleteModel + Name string `json:"name" form:"name" gorm:"type:varchar(128);not null;index;comment:系列名称"` + CoverUrl string `json:"coverUrl" form:"coverUrl" gorm:"type:varchar(500);comment:系列封面图片 URL"` + Intro string `json:"intro" form:"intro" gorm:"type:text;comment:系列简介"` + IsEnabled bool `json:"isEnabled" form:"isEnabled" gorm:"not null;default:true;index;comment:系列是否启用"` +} + +func (BookSeries) TableName() string { + return "book_series" +} diff --git a/server/model/book/request/book.go b/server/model/book/request/book.go new file mode 100644 index 0000000..562aff7 --- /dev/null +++ b/server/model/book/request/book.go @@ -0,0 +1,64 @@ +package request + +import commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" + +type BookSearch struct { + commonReq.PageInfo + Title string `json:"title" form:"title"` + BookType string `json:"bookType" form:"bookType"` + EraTag string `json:"eraTag" form:"eraTag"` + CompletionStatus string `json:"completionStatus" form:"completionStatus"` + PublishStatus string `json:"publishStatus" form:"publishStatus"` + SeriesID *uint `json:"seriesId" form:"seriesId"` +} + +type BookAuthorSearch struct { + commonReq.PageInfo + Name string `json:"name" form:"name"` + AuthorStatus string `json:"authorStatus" form:"authorStatus"` +} + +type BookAuthorRelationSearch struct { + commonReq.PageInfo + BookID *uint `json:"bookId" form:"bookId"` + AuthorID *uint `json:"authorId" form:"authorId"` +} + +type BookChapterSearch struct { + commonReq.PageInfo + BookID *uint `json:"bookId" form:"bookId"` + IsReadable *bool `json:"isReadable" form:"isReadable"` + IsEnabled *bool `json:"isEnabled" form:"isEnabled"` +} + +type BookCommentSearch struct { + commonReq.PageInfo + MemberUserID *uint `json:"memberUserId" form:"memberUserId"` + BookID *uint `json:"bookId" form:"bookId"` + ChapterID *uint `json:"chapterId" form:"chapterId"` + CommentStatus string `json:"commentStatus" form:"commentStatus"` +} + +type BookCommentLikeRecordSearch struct { + commonReq.PageInfo + CommentID *uint `json:"commentId" form:"commentId"` + MemberUserID *uint `json:"memberUserId" form:"memberUserId"` +} + +type BookFavoriteRecordSearch struct { + commonReq.PageInfo + MemberUserID *uint `json:"memberUserId" form:"memberUserId"` + BookID *uint `json:"bookId" form:"bookId"` +} + +type BookReadRecordSearch struct { + commonReq.PageInfo + MemberUserID *uint `json:"memberUserId" form:"memberUserId"` + BookID *uint `json:"bookId" form:"bookId"` +} + +type BookSeriesSearch struct { + commonReq.PageInfo + Name string `json:"name" form:"name"` + IsEnabled *bool `json:"isEnabled" form:"isEnabled"` +} diff --git a/server/model/book/response/book.go b/server/model/book/response/book.go new file mode 100644 index 0000000..61a8a97 --- /dev/null +++ b/server/model/book/response/book.go @@ -0,0 +1,31 @@ +package response + +import "github.com/flipped-aurora/gin-vue-admin/server/model/book" + +type BookResponse struct { + Book book.Book `json:"book"` +} +type BookAuthorResponse struct { + BookAuthor book.BookAuthor `json:"bookAuthor"` +} +type BookAuthorRelationResponse struct { + BookAuthorRelation book.BookAuthorRelation `json:"bookAuthorRelation"` +} +type BookChapterResponse struct { + BookChapter book.BookChapter `json:"bookChapter"` +} +type BookCommentResponse struct { + BookComment book.BookComment `json:"bookComment"` +} +type BookCommentLikeRecordResponse struct { + BookCommentLikeRecord book.BookCommentLikeRecord `json:"bookCommentLikeRecord"` +} +type BookFavoriteRecordResponse struct { + BookFavoriteRecord book.BookFavoriteRecord `json:"bookFavoriteRecord"` +} +type BookReadRecordResponse struct { + BookReadRecord book.BookReadRecord `json:"bookReadRecord"` +} +type BookSeriesResponse struct { + BookSeries book.BookSeries `json:"bookSeries"` +} diff --git a/server/router/book/book.go b/server/router/book/book.go new file mode 100644 index 0000000..116ebcb --- /dev/null +++ b/server/router/book/book.go @@ -0,0 +1,23 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/middleware" + "github.com/gin-gonic/gin" +) + +type BookRouter struct{} + +func (r *BookRouter) InitBookRouter(Router *gin.RouterGroup) { + bookRouter := Router.Group("book").Use(middleware.OperationRecord()) + bookRouterWithoutRecord := Router.Group("book") + + registerBookRoutes(bookRouter, bookRouterWithoutRecord) + registerBookAuthorRoutes(bookRouter, bookRouterWithoutRecord) + registerBookAuthorRelationRoutes(bookRouter, bookRouterWithoutRecord) + registerBookChapterRoutes(bookRouter, bookRouterWithoutRecord) + registerBookCommentRoutes(bookRouter, bookRouterWithoutRecord) + registerBookCommentLikeRecordRoutes(bookRouter, bookRouterWithoutRecord) + registerBookFavoriteRecordRoutes(bookRouter, bookRouterWithoutRecord) + registerBookReadRecordRoutes(bookRouter, bookRouterWithoutRecord) + registerBookSeriesRoutes(bookRouter, bookRouterWithoutRecord) +} diff --git a/server/router/book/book_author.go b/server/router/book/book_author.go new file mode 100644 index 0000000..40f5ec5 --- /dev/null +++ b/server/router/book/book_author.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookAuthorRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBookAuthor", bookApiGroup.BookAuthorApi.CreateBookAuthor) + router.DELETE("deleteBookAuthor", bookApiGroup.BookAuthorApi.DeleteBookAuthor) + router.DELETE("deleteBookAuthorByIds", bookApiGroup.BookAuthorApi.DeleteBookAuthorByIds) + router.PUT("updateBookAuthor", bookApiGroup.BookAuthorApi.UpdateBookAuthor) + routerWithoutRecord.GET("findBookAuthor", bookApiGroup.BookAuthorApi.FindBookAuthor) + routerWithoutRecord.GET("getBookAuthorList", bookApiGroup.BookAuthorApi.GetBookAuthorList) +} diff --git a/server/router/book/book_author_relation.go b/server/router/book/book_author_relation.go new file mode 100644 index 0000000..f25ef61 --- /dev/null +++ b/server/router/book/book_author_relation.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookAuthorRelationRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBookAuthorRelation", bookApiGroup.BookAuthorRelationApi.CreateBookAuthorRelation) + router.DELETE("deleteBookAuthorRelation", bookApiGroup.BookAuthorRelationApi.DeleteBookAuthorRelation) + router.DELETE("deleteBookAuthorRelationByIds", bookApiGroup.BookAuthorRelationApi.DeleteBookAuthorRelationByIds) + router.PUT("updateBookAuthorRelation", bookApiGroup.BookAuthorRelationApi.UpdateBookAuthorRelation) + routerWithoutRecord.GET("findBookAuthorRelation", bookApiGroup.BookAuthorRelationApi.FindBookAuthorRelation) + routerWithoutRecord.GET("getBookAuthorRelationList", bookApiGroup.BookAuthorRelationApi.GetBookAuthorRelationList) +} diff --git a/server/router/book/book_chapter.go b/server/router/book/book_chapter.go new file mode 100644 index 0000000..508b157 --- /dev/null +++ b/server/router/book/book_chapter.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookChapterRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBookChapter", bookApiGroup.BookChapterApi.CreateBookChapter) + router.DELETE("deleteBookChapter", bookApiGroup.BookChapterApi.DeleteBookChapter) + router.DELETE("deleteBookChapterByIds", bookApiGroup.BookChapterApi.DeleteBookChapterByIds) + router.PUT("updateBookChapter", bookApiGroup.BookChapterApi.UpdateBookChapter) + routerWithoutRecord.GET("findBookChapter", bookApiGroup.BookChapterApi.FindBookChapter) + routerWithoutRecord.GET("getBookChapterList", bookApiGroup.BookChapterApi.GetBookChapterList) +} diff --git a/server/router/book/book_comment.go b/server/router/book/book_comment.go new file mode 100644 index 0000000..ee5cfa6 --- /dev/null +++ b/server/router/book/book_comment.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookCommentRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBookComment", bookApiGroup.BookCommentApi.CreateBookComment) + router.DELETE("deleteBookComment", bookApiGroup.BookCommentApi.DeleteBookComment) + router.DELETE("deleteBookCommentByIds", bookApiGroup.BookCommentApi.DeleteBookCommentByIds) + router.PUT("updateBookComment", bookApiGroup.BookCommentApi.UpdateBookComment) + routerWithoutRecord.GET("findBookComment", bookApiGroup.BookCommentApi.FindBookComment) + routerWithoutRecord.GET("getBookCommentList", bookApiGroup.BookCommentApi.GetBookCommentList) +} diff --git a/server/router/book/book_comment_like_record.go b/server/router/book/book_comment_like_record.go new file mode 100644 index 0000000..85bab0f --- /dev/null +++ b/server/router/book/book_comment_like_record.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookCommentLikeRecordRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBookCommentLikeRecord", bookApiGroup.BookCommentLikeRecordApi.CreateBookCommentLikeRecord) + router.DELETE("deleteBookCommentLikeRecord", bookApiGroup.BookCommentLikeRecordApi.DeleteBookCommentLikeRecord) + router.DELETE("deleteBookCommentLikeRecordByIds", bookApiGroup.BookCommentLikeRecordApi.DeleteBookCommentLikeRecordByIds) + router.PUT("updateBookCommentLikeRecord", bookApiGroup.BookCommentLikeRecordApi.UpdateBookCommentLikeRecord) + routerWithoutRecord.GET("findBookCommentLikeRecord", bookApiGroup.BookCommentLikeRecordApi.FindBookCommentLikeRecord) + routerWithoutRecord.GET("getBookCommentLikeRecordList", bookApiGroup.BookCommentLikeRecordApi.GetBookCommentLikeRecordList) +} diff --git a/server/router/book/book_favorite_record.go b/server/router/book/book_favorite_record.go new file mode 100644 index 0000000..ac61060 --- /dev/null +++ b/server/router/book/book_favorite_record.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookFavoriteRecordRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBookFavoriteRecord", bookApiGroup.BookFavoriteRecordApi.CreateBookFavoriteRecord) + router.DELETE("deleteBookFavoriteRecord", bookApiGroup.BookFavoriteRecordApi.DeleteBookFavoriteRecord) + router.DELETE("deleteBookFavoriteRecordByIds", bookApiGroup.BookFavoriteRecordApi.DeleteBookFavoriteRecordByIds) + router.PUT("updateBookFavoriteRecord", bookApiGroup.BookFavoriteRecordApi.UpdateBookFavoriteRecord) + routerWithoutRecord.GET("findBookFavoriteRecord", bookApiGroup.BookFavoriteRecordApi.FindBookFavoriteRecord) + routerWithoutRecord.GET("getBookFavoriteRecordList", bookApiGroup.BookFavoriteRecordApi.GetBookFavoriteRecordList) +} diff --git a/server/router/book/book_read_record.go b/server/router/book/book_read_record.go new file mode 100644 index 0000000..54a5e37 --- /dev/null +++ b/server/router/book/book_read_record.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookReadRecordRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBookReadRecord", bookApiGroup.BookReadRecordApi.CreateBookReadRecord) + router.DELETE("deleteBookReadRecord", bookApiGroup.BookReadRecordApi.DeleteBookReadRecord) + router.DELETE("deleteBookReadRecordByIds", bookApiGroup.BookReadRecordApi.DeleteBookReadRecordByIds) + router.PUT("updateBookReadRecord", bookApiGroup.BookReadRecordApi.UpdateBookReadRecord) + routerWithoutRecord.GET("findBookReadRecord", bookApiGroup.BookReadRecordApi.FindBookReadRecord) + routerWithoutRecord.GET("getBookReadRecordList", bookApiGroup.BookReadRecordApi.GetBookReadRecordList) +} diff --git a/server/router/book/book_routes.go b/server/router/book/book_routes.go new file mode 100644 index 0000000..d2fab14 --- /dev/null +++ b/server/router/book/book_routes.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBook", bookApiGroup.BookApi.CreateBook) + router.DELETE("deleteBook", bookApiGroup.BookApi.DeleteBook) + router.DELETE("deleteBookByIds", bookApiGroup.BookApi.DeleteBookByIds) + router.PUT("updateBook", bookApiGroup.BookApi.UpdateBook) + routerWithoutRecord.GET("findBook", bookApiGroup.BookApi.FindBook) + routerWithoutRecord.GET("getBookList", bookApiGroup.BookApi.GetBookList) +} diff --git a/server/router/book/book_series.go b/server/router/book/book_series.go new file mode 100644 index 0000000..6ab8539 --- /dev/null +++ b/server/router/book/book_series.go @@ -0,0 +1,12 @@ +package book + +import "github.com/gin-gonic/gin" + +func registerBookSeriesRoutes(router gin.IRoutes, routerWithoutRecord gin.IRoutes) { + router.POST("createBookSeries", bookApiGroup.BookSeriesApi.CreateBookSeries) + router.DELETE("deleteBookSeries", bookApiGroup.BookSeriesApi.DeleteBookSeries) + router.DELETE("deleteBookSeriesByIds", bookApiGroup.BookSeriesApi.DeleteBookSeriesByIds) + router.PUT("updateBookSeries", bookApiGroup.BookSeriesApi.UpdateBookSeries) + routerWithoutRecord.GET("findBookSeries", bookApiGroup.BookSeriesApi.FindBookSeries) + routerWithoutRecord.GET("getBookSeriesList", bookApiGroup.BookSeriesApi.GetBookSeriesList) +} diff --git a/server/router/book/enter.go b/server/router/book/enter.go new file mode 100644 index 0000000..c46d5c2 --- /dev/null +++ b/server/router/book/enter.go @@ -0,0 +1,9 @@ +package book + +import api "github.com/flipped-aurora/gin-vue-admin/server/api/v1" + +type RouterGroup struct { + BookRouter +} + +var bookApiGroup = api.ApiGroupApp.BookApiGroup diff --git a/server/router/enter.go b/server/router/enter.go index 6e6d811..d6360b1 100644 --- a/server/router/enter.go +++ b/server/router/enter.go @@ -1,6 +1,7 @@ package router import ( + "github.com/flipped-aurora/gin-vue-admin/server/router/book" "github.com/flipped-aurora/gin-vue-admin/server/router/example" "github.com/flipped-aurora/gin-vue-admin/server/router/system" ) @@ -10,4 +11,5 @@ var RouterGroupApp = new(RouterGroup) type RouterGroup struct { System system.RouterGroup Example example.RouterGroup + Book book.RouterGroup } diff --git a/server/service/book/book.go b/server/service/book/book.go new file mode 100644 index 0000000..dc1b379 --- /dev/null +++ b/server/service/book/book.go @@ -0,0 +1,66 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookService struct{} + +func (s *BookService) CreateBook(item book.Book) error { + if err := validateBook(item); err != nil { + return err + } + return global.GVA_DB.Create(&item).Error +} + +func (s *BookService) DeleteBook(item book.Book) error { return global.GVA_DB.Delete(&item).Error } + +func (s *BookService) DeleteBookByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.Book](ids.Ids) +} + +func (s *BookService) UpdateBook(item book.Book) error { + if err := validateBook(item); err != nil { + return err + } + return global.GVA_DB.Save(&item).Error +} + +func (s *BookService) GetBook(id uint) (item book.Book, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookService) GetBookInfoList(info bookReq.BookSearch) (list []book.Book, total int64, err error) { + db := global.GVA_DB.Model(&book.Book{}) + if info.Title != "" { + db = db.Where("title LIKE ?", "%"+info.Title+"%") + } + if info.Keyword != "" { + db = db.Where("title LIKE ? OR subtitle LIKE ?", "%"+info.Keyword+"%", "%"+info.Keyword+"%") + } + if info.BookType != "" { + db = db.Where("book_type = ?", info.BookType) + } + if info.EraTag != "" { + db = db.Where("era_tag = ?", info.EraTag) + } + if info.CompletionStatus != "" { + db = db.Where("completion_status = ?", info.CompletionStatus) + } + if info.PublishStatus != "" { + db = db.Where("publish_status = ?", info.PublishStatus) + } + if info.SeriesID != nil { + db = db.Where("series_id = ?", *info.SeriesID) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("id desc").Find(&list).Error + return +} diff --git a/server/service/book/book_author.go b/server/service/book/book_author.go new file mode 100644 index 0000000..7f8b804 --- /dev/null +++ b/server/service/book/book_author.go @@ -0,0 +1,56 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookAuthorService struct{} + +func (s *BookAuthorService) CreateBookAuthor(item book.BookAuthor) error { + if err := validateBookAuthor(item); err != nil { + return err + } + return global.GVA_DB.Create(&item).Error +} + +func (s *BookAuthorService) DeleteBookAuthor(item book.BookAuthor) error { + return global.GVA_DB.Delete(&item).Error +} + +func (s *BookAuthorService) DeleteBookAuthorByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.BookAuthor](ids.Ids) +} + +func (s *BookAuthorService) UpdateBookAuthor(item book.BookAuthor) error { + if err := validateBookAuthor(item); err != nil { + return err + } + return global.GVA_DB.Save(&item).Error +} + +func (s *BookAuthorService) GetBookAuthor(id uint) (item book.BookAuthor, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookAuthorService) GetBookAuthorInfoList(info bookReq.BookAuthorSearch) (list []book.BookAuthor, total int64, err error) { + db := global.GVA_DB.Model(&book.BookAuthor{}) + if info.Name != "" { + db = db.Where("name LIKE ?", "%"+info.Name+"%") + } + if info.Keyword != "" { + db = db.Where("name LIKE ?", "%"+info.Keyword+"%") + } + if info.AuthorStatus != "" { + db = db.Where("author_status = ?", info.AuthorStatus) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("id desc").Find(&list).Error + return +} diff --git a/server/service/book/book_author_relation.go b/server/service/book/book_author_relation.go new file mode 100644 index 0000000..8ceb545 --- /dev/null +++ b/server/service/book/book_author_relation.go @@ -0,0 +1,54 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookAuthorRelationService struct{} + +func (s *BookAuthorRelationService) CreateBookAuthorRelation(item book.BookAuthorRelation) error { + item = applyBookAuthorRelationDefaults(item) + if err := validateBookAuthorRelation(item); err != nil { + return err + } + return global.GVA_DB.Create(&item).Error +} + +func (s *BookAuthorRelationService) DeleteBookAuthorRelation(item book.BookAuthorRelation) error { + return global.GVA_DB.Delete(&item).Error +} + +func (s *BookAuthorRelationService) DeleteBookAuthorRelationByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.BookAuthorRelation](ids.Ids) +} + +func (s *BookAuthorRelationService) UpdateBookAuthorRelation(item book.BookAuthorRelation) error { + if err := validateBookAuthorRelation(item); err != nil { + return err + } + return global.GVA_DB.Save(&item).Error +} + +func (s *BookAuthorRelationService) GetBookAuthorRelation(id uint) (item book.BookAuthorRelation, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookAuthorRelationService) GetBookAuthorRelationInfoList(info bookReq.BookAuthorRelationSearch) (list []book.BookAuthorRelation, total int64, err error) { + db := global.GVA_DB.Model(&book.BookAuthorRelation{}) + if info.BookID != nil { + db = db.Where("book_id = ?", *info.BookID) + } + if info.AuthorID != nil { + db = db.Where("author_id = ?", *info.AuthorID) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("book_id asc, author_sort asc, id desc").Find(&list).Error + return +} diff --git a/server/service/book/book_chapter.go b/server/service/book/book_chapter.go new file mode 100644 index 0000000..7435631 --- /dev/null +++ b/server/service/book/book_chapter.go @@ -0,0 +1,59 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookChapterService struct{} + +func (s *BookChapterService) CreateBookChapter(item book.BookChapter) error { + if err := validateBookChapter(item); err != nil { + return err + } + return global.GVA_DB.Create(&item).Error +} + +func (s *BookChapterService) DeleteBookChapter(item book.BookChapter) error { + return global.GVA_DB.Delete(&item).Error +} + +func (s *BookChapterService) DeleteBookChapterByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.BookChapter](ids.Ids) +} + +func (s *BookChapterService) UpdateBookChapter(item book.BookChapter) error { + if err := validateBookChapter(item); err != nil { + return err + } + return global.GVA_DB.Save(&item).Error +} + +func (s *BookChapterService) GetBookChapter(id uint) (item book.BookChapter, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookChapterService) GetBookChapterInfoList(info bookReq.BookChapterSearch) (list []book.BookChapter, total int64, err error) { + db := global.GVA_DB.Model(&book.BookChapter{}) + if info.BookID != nil { + db = db.Where("book_id = ?", *info.BookID) + } + if info.IsReadable != nil { + db = db.Where("is_readable = ?", *info.IsReadable) + } + if info.IsEnabled != nil { + db = db.Where("is_enabled = ?", *info.IsEnabled) + } + if info.Keyword != "" { + db = db.Where("title LIKE ?", "%"+info.Keyword+"%") + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("book_id asc, chapter_no asc").Find(&list).Error + return +} diff --git a/server/service/book/book_comment.go b/server/service/book/book_comment.go new file mode 100644 index 0000000..d074fc5 --- /dev/null +++ b/server/service/book/book_comment.go @@ -0,0 +1,59 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookCommentService struct{} + +func (s *BookCommentService) CreateBookComment(item book.BookComment) error { + if err := validateBookComment(item); err != nil { + return err + } + return global.GVA_DB.Create(&item).Error +} + +func (s *BookCommentService) DeleteBookComment(item book.BookComment) error { + return global.GVA_DB.Delete(&item).Error +} + +func (s *BookCommentService) DeleteBookCommentByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.BookComment](ids.Ids) +} + +func (s *BookCommentService) UpdateBookComment(item book.BookComment) error { + if err := validateBookComment(item); err != nil { + return err + } + return global.GVA_DB.Save(&item).Error +} + +func (s *BookCommentService) GetBookComment(id uint) (item book.BookComment, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookCommentService) GetBookCommentInfoList(info bookReq.BookCommentSearch) (list []book.BookComment, total int64, err error) { + db := global.GVA_DB.Model(&book.BookComment{}) + if info.MemberUserID != nil { + db = db.Where("member_user_id = ?", *info.MemberUserID) + } + if info.BookID != nil { + db = db.Where("book_id = ?", *info.BookID) + } + if info.ChapterID != nil { + db = db.Where("chapter_id = ?", *info.ChapterID) + } + if info.CommentStatus != "" { + db = db.Where("comment_status = ?", info.CommentStatus) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("id desc").Find(&list).Error + return +} diff --git a/server/service/book/book_comment_like_record.go b/server/service/book/book_comment_like_record.go new file mode 100644 index 0000000..88b7b13 --- /dev/null +++ b/server/service/book/book_comment_like_record.go @@ -0,0 +1,47 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookCommentLikeRecordService struct{} + +func (s *BookCommentLikeRecordService) CreateBookCommentLikeRecord(item book.BookCommentLikeRecord) error { + return global.GVA_DB.Create(&item).Error +} + +func (s *BookCommentLikeRecordService) DeleteBookCommentLikeRecord(item book.BookCommentLikeRecord) error { + return global.GVA_DB.Delete(&item).Error +} + +func (s *BookCommentLikeRecordService) DeleteBookCommentLikeRecordByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.BookCommentLikeRecord](ids.Ids) +} + +func (s *BookCommentLikeRecordService) UpdateBookCommentLikeRecord(item book.BookCommentLikeRecord) error { + return global.GVA_DB.Save(&item).Error +} + +func (s *BookCommentLikeRecordService) GetBookCommentLikeRecord(id uint) (item book.BookCommentLikeRecord, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookCommentLikeRecordService) GetBookCommentLikeRecordInfoList(info bookReq.BookCommentLikeRecordSearch) (list []book.BookCommentLikeRecord, total int64, err error) { + db := global.GVA_DB.Model(&book.BookCommentLikeRecord{}) + if info.CommentID != nil { + db = db.Where("comment_id = ?", *info.CommentID) + } + if info.MemberUserID != nil { + db = db.Where("member_user_id = ?", *info.MemberUserID) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("id desc").Find(&list).Error + return +} diff --git a/server/service/book/book_favorite_record.go b/server/service/book/book_favorite_record.go new file mode 100644 index 0000000..2e56484 --- /dev/null +++ b/server/service/book/book_favorite_record.go @@ -0,0 +1,47 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookFavoriteRecordService struct{} + +func (s *BookFavoriteRecordService) CreateBookFavoriteRecord(item book.BookFavoriteRecord) error { + return global.GVA_DB.Create(&item).Error +} + +func (s *BookFavoriteRecordService) DeleteBookFavoriteRecord(item book.BookFavoriteRecord) error { + return global.GVA_DB.Delete(&item).Error +} + +func (s *BookFavoriteRecordService) DeleteBookFavoriteRecordByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.BookFavoriteRecord](ids.Ids) +} + +func (s *BookFavoriteRecordService) UpdateBookFavoriteRecord(item book.BookFavoriteRecord) error { + return global.GVA_DB.Save(&item).Error +} + +func (s *BookFavoriteRecordService) GetBookFavoriteRecord(id uint) (item book.BookFavoriteRecord, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookFavoriteRecordService) GetBookFavoriteRecordInfoList(info bookReq.BookFavoriteRecordSearch) (list []book.BookFavoriteRecord, total int64, err error) { + db := global.GVA_DB.Model(&book.BookFavoriteRecord{}) + if info.MemberUserID != nil { + db = db.Where("member_user_id = ?", *info.MemberUserID) + } + if info.BookID != nil { + db = db.Where("book_id = ?", *info.BookID) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("id desc").Find(&list).Error + return +} diff --git a/server/service/book/book_read_record.go b/server/service/book/book_read_record.go new file mode 100644 index 0000000..50d4bea --- /dev/null +++ b/server/service/book/book_read_record.go @@ -0,0 +1,53 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookReadRecordService struct{} + +func (s *BookReadRecordService) CreateBookReadRecord(item book.BookReadRecord) error { + if err := validateBookReadRecord(item); err != nil { + return err + } + return global.GVA_DB.Create(&item).Error +} + +func (s *BookReadRecordService) DeleteBookReadRecord(item book.BookReadRecord) error { + return global.GVA_DB.Delete(&item).Error +} + +func (s *BookReadRecordService) DeleteBookReadRecordByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.BookReadRecord](ids.Ids) +} + +func (s *BookReadRecordService) UpdateBookReadRecord(item book.BookReadRecord) error { + if err := validateBookReadRecord(item); err != nil { + return err + } + return global.GVA_DB.Save(&item).Error +} + +func (s *BookReadRecordService) GetBookReadRecord(id uint) (item book.BookReadRecord, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookReadRecordService) GetBookReadRecordInfoList(info bookReq.BookReadRecordSearch) (list []book.BookReadRecord, total int64, err error) { + db := global.GVA_DB.Model(&book.BookReadRecord{}) + if info.MemberUserID != nil { + db = db.Where("member_user_id = ?", *info.MemberUserID) + } + if info.BookID != nil { + db = db.Where("book_id = ?", *info.BookID) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("last_read_at desc, id desc").Find(&list).Error + return +} diff --git a/server/service/book/book_series.go b/server/service/book/book_series.go new file mode 100644 index 0000000..b7c78f4 --- /dev/null +++ b/server/service/book/book_series.go @@ -0,0 +1,50 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + bookReq "github.com/flipped-aurora/gin-vue-admin/server/model/book/request" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) + +type BookSeriesService struct{} + +func (s *BookSeriesService) CreateBookSeries(item book.BookSeries) error { + return global.GVA_DB.Create(&item).Error +} + +func (s *BookSeriesService) DeleteBookSeries(item book.BookSeries) error { + return global.GVA_DB.Delete(&item).Error +} + +func (s *BookSeriesService) DeleteBookSeriesByIds(ids commonReq.IdsReq) error { + return deleteByIDs[book.BookSeries](ids.Ids) +} + +func (s *BookSeriesService) UpdateBookSeries(item book.BookSeries) error { + return global.GVA_DB.Save(&item).Error +} + +func (s *BookSeriesService) GetBookSeries(id uint) (item book.BookSeries, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&item).Error + return +} + +func (s *BookSeriesService) GetBookSeriesInfoList(info bookReq.BookSeriesSearch) (list []book.BookSeries, total int64, err error) { + db := global.GVA_DB.Model(&book.BookSeries{}) + if info.Name != "" { + db = db.Where("name LIKE ?", "%"+info.Name+"%") + } + if info.Keyword != "" { + db = db.Where("name LIKE ?", "%"+info.Keyword+"%") + } + if info.IsEnabled != nil { + db = db.Where("is_enabled = ?", *info.IsEnabled) + } + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Scopes(paginate(info.PageInfo)).Order("id desc").Find(&list).Error + return +} diff --git a/server/service/book/common.go b/server/service/book/common.go new file mode 100644 index 0000000..8b2d2f0 --- /dev/null +++ b/server/service/book/common.go @@ -0,0 +1,13 @@ +package book + +import ( + "github.com/flipped-aurora/gin-vue-admin/server/global" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" + "gorm.io/gorm" +) + +func paginate(info commonReq.PageInfo) func(db *gorm.DB) *gorm.DB { return (&info).Paginate() } + +func deleteByIDs[T any](ids []int) error { + return global.GVA_DB.Where("id in ?", ids).Delete(new(T)).Error +} diff --git a/server/service/book/enter.go b/server/service/book/enter.go new file mode 100644 index 0000000..89e506e --- /dev/null +++ b/server/service/book/enter.go @@ -0,0 +1,13 @@ +package book + +type ServiceGroup struct { + BookService + BookAuthorService + BookAuthorRelationService + BookChapterService + BookCommentService + BookCommentLikeRecordService + BookFavoriteRecordService + BookReadRecordService + BookSeriesService +} diff --git a/server/service/book/validation.go b/server/service/book/validation.go new file mode 100644 index 0000000..a86f430 --- /dev/null +++ b/server/service/book/validation.go @@ -0,0 +1,132 @@ +package book + +import ( + "errors" + + "github.com/flipped-aurora/gin-vue-admin/server/model/book" +) + +func validateBook(item book.Book) error { + if item.EraTag != "" && !validBookEraTags[item.EraTag] { + return errors.New("eraTag不是有效值") + } + if item.CompletionStatus != "" && !validBookCompletionStatuses[item.CompletionStatus] { + return errors.New("completionStatus不是有效值") + } + if item.PublishStatus != "" && !validBookPublishStatuses[item.PublishStatus] { + return errors.New("publishStatus不是有效值") + } + if item.HotScore < 0 { + return errors.New("hotScore不能小于0") + } + if item.Rating < 0 || item.Rating > 10 { + return errors.New("rating必须在0到10之间") + } + if item.CommentCount < 0 { + return errors.New("commentCount不能小于0") + } + if item.WordCount < 0 { + return errors.New("wordCount不能小于0") + } + if item.SeriesSort < 0 { + return errors.New("seriesSort不能小于0") + } + return nil +} + +var validBookEraTags = map[string]bool{ + book.BookEraTagUnknown: true, + book.BookEraTagAncient: true, + book.BookEraTagHan: true, + book.BookEraTagTang: true, + book.BookEraTagSong: true, + book.BookEraTagYuan: true, + book.BookEraTagMing: true, + book.BookEraTagQing: true, + book.BookEraTagModern: true, + book.BookEraTagContemporary: true, +} + +var validBookCompletionStatuses = map[string]bool{ + book.BookCompletionStatusCompleted: true, + book.BookCompletionStatusSerializing: true, +} + +var validBookPublishStatuses = map[string]bool{ + book.BookPublishStatusDraft: true, + book.BookPublishStatusOffShelf: true, + book.BookPublishStatusOnShelf: true, +} + +var validBookAuthorStatuses = map[string]bool{ + book.BookAuthorStatusEnabled: true, + book.BookAuthorStatusDisabled: true, +} + +var validBookCommentStatuses = map[string]bool{ + book.BookCommentStatusNormal: true, + book.BookCommentStatusHidden: true, +} + +func validateBookAuthor(item book.BookAuthor) error { + if item.AuthorStatus != "" && !validBookAuthorStatuses[item.AuthorStatus] { + return errors.New("authorStatus不是有效值") + } + return nil +} + +func validateBookChapter(item book.BookChapter) error { + if item.ChapterNo <= 0 { + return errors.New("chapterNo必须大于0") + } + if item.TotalLines < 0 { + return errors.New("totalLines不能小于0") + } + if item.IsReadable && item.TotalLines <= 0 { + return errors.New("totalLines必须大于0后才能开放阅读") + } + return nil +} + +func validateBookAuthorRelation(item book.BookAuthorRelation) error { + if item.AuthorSort <= 0 { + return errors.New("authorSort必须大于0") + } + return nil +} + +func applyBookAuthorRelationDefaults(item book.BookAuthorRelation) book.BookAuthorRelation { + if item.AuthorSort == 0 { + item.AuthorSort = 1 + } + return item +} + +func validateBookComment(item book.BookComment) error { + if item.CommentStatus != "" && !validBookCommentStatuses[item.CommentStatus] { + return errors.New("commentStatus不是有效值") + } + if item.LineIndex < 0 { + return errors.New("lineIndex不能小于0") + } + if item.ChapterID == 0 && item.LineIndex != 0 { + return errors.New("lineIndex在整书评论时必须为0") + } + if item.LikeCount < 0 { + return errors.New("likeCount不能小于0") + } + return nil +} + +func validateBookReadRecord(item book.BookReadRecord) error { + if item.ReadProgress < 0 || item.ReadProgress > 100 { + return errors.New("readProgress必须在0到100之间") + } + if item.ChapterID == 0 { + return errors.New("chapterId必须大于0") + } + if item.LineIndex <= 0 { + return errors.New("lineIndex必须大于0") + } + return nil +} diff --git a/server/service/book/validation_test.go b/server/service/book/validation_test.go new file mode 100644 index 0000000..15086a4 --- /dev/null +++ b/server/service/book/validation_test.go @@ -0,0 +1,116 @@ +package book + +import ( + "strings" + "testing" + + "github.com/flipped-aurora/gin-vue-admin/server/model/book" +) + +func TestValidateBookRejectsOutOfRangeAggregates(t *testing.T) { + tests := []struct { + name string + item book.Book + want string + }{ + {name: "negative hot score", item: book.Book{HotScore: -1}, want: "hotScore"}, + {name: "rating below zero", item: book.Book{Rating: -0.1}, want: "rating"}, + {name: "rating above ten", item: book.Book{Rating: 10.1}, want: "rating"}, + {name: "negative comment count", item: book.Book{CommentCount: -1}, want: "commentCount"}, + {name: "negative word count", item: book.Book{WordCount: -1}, want: "wordCount"}, + {name: "negative series sort", item: book.Book{SeriesSort: -1}, want: "seriesSort"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidationErrorContains(t, validateBook(tt.item), tt.want) + }) + } +} + +func TestValidateBookRejectsInvalidFixedDictionaryValues(t *testing.T) { + tests := []struct { + name string + item book.Book + want string + }{ + {name: "invalid era tag", item: book.Book{EraTag: "bad", CompletionStatus: book.BookCompletionStatusSerializing, PublishStatus: book.BookPublishStatusDraft}, want: "eraTag"}, + {name: "invalid completion status", item: book.Book{EraTag: book.BookEraTagUnknown, CompletionStatus: "bad", PublishStatus: book.BookPublishStatusDraft}, want: "completionStatus"}, + {name: "invalid publish status", item: book.Book{EraTag: book.BookEraTagUnknown, CompletionStatus: book.BookCompletionStatusSerializing, PublishStatus: "bad"}, want: "publishStatus"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidationErrorContains(t, validateBook(tt.item), tt.want) + }) + } +} + +func TestValidateBookAuthorRejectsInvalidStatus(t *testing.T) { + err := validateBookAuthor(book.BookAuthor{AuthorStatus: "bad"}) + assertValidationErrorContains(t, err, "authorStatus") +} + +func TestApplyBookAuthorRelationDefaults(t *testing.T) { + item := applyBookAuthorRelationDefaults(book.BookAuthorRelation{}) + if item.AuthorSort != 1 { + t.Fatalf("AuthorSort = %d, want 1", item.AuthorSort) + } +} + +func TestValidateBookChapterRejectsReadableWithoutLines(t *testing.T) { + err := validateBookChapter(book.BookChapter{ChapterNo: 1, IsReadable: true, TotalLines: 0}) + assertValidationErrorContains(t, err, "totalLines") +} + +func TestValidateBookAuthorRelationRejectsInvalidSort(t *testing.T) { + err := validateBookAuthorRelation(book.BookAuthorRelation{AuthorSort: 0}) + assertValidationErrorContains(t, err, "authorSort") +} + +func TestValidateBookCommentRejectsInvalidAnchorLikeCountAndStatus(t *testing.T) { + tests := []struct { + name string + item book.BookComment + want string + }{ + {name: "book comment with line", item: book.BookComment{ChapterID: 0, LineIndex: 1}, want: "lineIndex"}, + {name: "negative like count", item: book.BookComment{LikeCount: -1}, want: "likeCount"}, + {name: "invalid comment status", item: book.BookComment{CommentStatus: "bad"}, want: "commentStatus"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidationErrorContains(t, validateBookComment(tt.item), tt.want) + }) + } +} + +func TestValidateBookReadRecordRejectsInvalidProgressAndAnchor(t *testing.T) { + tests := []struct { + name string + item book.BookReadRecord + want string + }{ + {name: "progress below zero", item: book.BookReadRecord{ReadProgress: -0.1, ChapterID: 1, LineIndex: 1}, want: "readProgress"}, + {name: "progress above one hundred", item: book.BookReadRecord{ReadProgress: 100.1, ChapterID: 1, LineIndex: 1}, want: "readProgress"}, + {name: "missing chapter", item: book.BookReadRecord{ReadProgress: 10, ChapterID: 0, LineIndex: 1}, want: "chapterId"}, + {name: "missing line", item: book.BookReadRecord{ReadProgress: 10, ChapterID: 1, LineIndex: 0}, want: "lineIndex"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertValidationErrorContains(t, validateBookReadRecord(tt.item), tt.want) + }) + } +} + +func assertValidationErrorContains(t *testing.T, err error, want string) { + t.Helper() + if err == nil { + t.Fatalf("validation error = nil, want %q", want) + } + if !strings.Contains(err.Error(), want) { + t.Fatalf("validation error = %q, want contains %q", err.Error(), want) + } +} diff --git a/server/service/enter.go b/server/service/enter.go index 4dc990e..85842d8 100644 --- a/server/service/enter.go +++ b/server/service/enter.go @@ -1,6 +1,7 @@ package service import ( + "github.com/flipped-aurora/gin-vue-admin/server/service/book" "github.com/flipped-aurora/gin-vue-admin/server/service/example" "github.com/flipped-aurora/gin-vue-admin/server/service/system" ) @@ -10,4 +11,5 @@ var ServiceGroupApp = new(ServiceGroup) type ServiceGroup struct { SystemServiceGroup system.ServiceGroup ExampleServiceGroup example.ServiceGroup + BookServiceGroup book.ServiceGroup } diff --git a/server/service/system/auto_code_package_test.go b/server/service/system/auto_code_package_test.go index d2a5473..c99883a 100644 --- a/server/service/system/auto_code_package_test.go +++ b/server/service/system/auto_code_package_test.go @@ -95,8 +95,8 @@ func Test_autoCodePackage_templates(t *testing.T) { } for key, value := range gotCode { t.Logf("\n") - t.Logf(key) - t.Logf(value) + t.Log(key) + t.Log(value) t.Logf("\n") } t.Log(gotCreates) diff --git a/server/service/system/sys_api.go b/server/service/system/sys_api.go index a77d1d5..20a647e 100644 --- a/server/service/system/sys_api.go +++ b/server/service/system/sys_api.go @@ -134,6 +134,9 @@ func (apiService *ApiService) IgnoreApi(ignoreApi system.SysIgnoreApi) (err erro } func (apiService *ApiService) EnterSyncApi(syncApis systemRes.SysSyncApis) (err error) { + if len(syncApis.NewApis) == 0 && len(syncApis.DeleteApis) == 0 { + return apiService.SyncApiToDB() + } return global.GVA_DB.Transaction(func(tx *gorm.DB) error { var txErr error if len(syncApis.NewApis) > 0 { @@ -153,6 +156,87 @@ func (apiService *ApiService) EnterSyncApi(syncApis systemRes.SysSyncApis) (err }) } +func (apiService *ApiService) SyncApiToDB() (err error) { + return global.GVA_DB.Transaction(func(tx *gorm.DB) error { + var dbApis []system.SysApi + if err := tx.Find(&dbApis).Error; err != nil { + return err + } + + var ignores []system.SysIgnoreApi + if err := tx.Find(&ignores).Error; err != nil { + return err + } + + ignoreMap := make(map[string]bool, len(ignores)) + for i := range ignores { + ignoreMap[apiRouteKey(ignores[i].Path, ignores[i].Method)] = true + } + + dbMap := make(map[string]system.SysApi, len(dbApis)) + for i := range dbApis { + dbMap[apiRouteKey(dbApis[i].Path, dbApis[i].Method)] = dbApis[i] + } + + routeMap := make(map[string]system.SysApi, len(global.GVA_ROUTERS)) + for i := range global.GVA_ROUTERS { + path := global.GVA_ROUTERS[i].Path + method := global.GVA_ROUTERS[i].Method + key := apiRouteKey(path, method) + if ignoreMap[key] { + continue + } + routeMap[key] = system.SysApi{ + Path: path, + Method: method, + ApiGroup: defaultApiGroup(path), + Description: defaultApiDescription(method, path), + } + } + + newApis := make([]system.SysApi, 0) + for key, routeApi := range routeMap { + if _, ok := dbMap[key]; !ok { + newApis = append(newApis, routeApi) + } + } + if len(newApis) > 0 { + if err := tx.Create(&newApis).Error; err != nil { + return err + } + } + + for i := range dbApis { + key := apiRouteKey(dbApis[i].Path, dbApis[i].Method) + if _, ok := routeMap[key]; ok { + continue + } + CasbinServiceApp.ClearCasbin(1, dbApis[i].Path, dbApis[i].Method) + if err := tx.Delete(&system.SysApi{}, "path = ? AND method = ?", dbApis[i].Path, dbApis[i].Method).Error; err != nil { + return err + } + } + + return nil + }) +} + +func apiRouteKey(path, method string) string { + return method + " " + path +} + +func defaultApiGroup(path string) string { + pathArr := strings.Split(strings.Trim(path, "/"), "/") + if len(pathArr) == 0 || pathArr[0] == "" { + return "default" + } + return pathArr[0] +} + +func defaultApiDescription(method, path string) string { + return method + " " + path +} + //@author: [piexlmax](https://github.com/piexlmax) //@function: DeleteApi //@description: 删除基础api diff --git a/server/service/system/sys_api_test.go b/server/service/system/sys_api_test.go new file mode 100644 index 0000000..f8d58b7 --- /dev/null +++ b/server/service/system/sys_api_test.go @@ -0,0 +1,84 @@ +package system + +import ( + "testing" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + systemModel "github.com/flipped-aurora/gin-vue-admin/server/model/system" + systemRes "github.com/flipped-aurora/gin-vue-admin/server/model/system/response" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupApiServiceTestDB(t *testing.T) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&systemModel.SysApi{}, &systemModel.SysIgnoreApi{})) + + global.GVA_DB = db + global.GVA_ROUTERS = gin.RoutesInfo{ + { + Method: "POST", + Path: "/book/createBook", + }, + } + + t.Cleanup(func() { + global.GVA_DB = nil + global.GVA_ROUTERS = nil + }) +} + +func TestApiService_EnterSyncApiSyncsRoutesWhenPayloadIsEmpty(t *testing.T) { + setupApiServiceTestDB(t) + + err := ApiServiceApp.EnterSyncApi(systemRes.SysSyncApis{}) + require.NoError(t, err) + + var api systemModel.SysApi + err = global.GVA_DB.Where("path = ? AND method = ?", "/book/createBook", "POST").First(&api).Error + require.NoError(t, err) + require.Equal(t, "/book/createBook", api.Path) + require.Equal(t, "POST", api.Method) +} + +func TestApiService_EnterSyncApiKeepsExistingApiMetadata(t *testing.T) { + setupApiServiceTestDB(t) + err := global.GVA_DB.Create(&systemModel.SysApi{ + Path: "/book/createBook", + Method: "POST", + ApiGroup: "book-admin", + Description: "创建书籍", + }).Error + require.NoError(t, err) + + err = ApiServiceApp.EnterSyncApi(systemRes.SysSyncApis{}) + require.NoError(t, err) + + var api systemModel.SysApi + err = global.GVA_DB.Where("path = ? AND method = ?", "/book/createBook", "POST").First(&api).Error + require.NoError(t, err) + require.Equal(t, "book-admin", api.ApiGroup) + require.Equal(t, "创建书籍", api.Description) +} + +func TestApiService_EnterSyncApiSkipsIgnoredRoutes(t *testing.T) { + setupApiServiceTestDB(t) + err := global.GVA_DB.Create(&systemModel.SysIgnoreApi{ + Path: "/book/createBook", + Method: "POST", + }).Error + require.NoError(t, err) + + err = ApiServiceApp.EnterSyncApi(systemRes.SysSyncApis{}) + require.NoError(t, err) + + var count int64 + err = global.GVA_DB.Model(&systemModel.SysApi{}).Where("path = ? AND method = ?", "/book/createBook", "POST").Count(&count).Error + require.NoError(t, err) + require.Zero(t, count) +} diff --git a/server/source/system/api.go b/server/source/system/api.go index e1b466a..71b7a9e 100644 --- a/server/source/system/api.go +++ b/server/source/system/api.go @@ -73,6 +73,8 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) { {ApiGroup: "api", Method: "POST", Path: "/api/getApiList", Description: "获取api列表"}, {ApiGroup: "api", Method: "POST", Path: "/api/getAllApis", Description: "获取所有api"}, {ApiGroup: "api", Method: "POST", Path: "/api/getApiById", Description: "获取api详细信息"}, + {ApiGroup: "api", Method: "GET", Path: "/api/getApiRoles", Description: "获取API关联角色ID列表"}, + {ApiGroup: "api", Method: "POST", Path: "/api/setApiRoles", Description: "全量覆盖API关联角色"}, {ApiGroup: "api", Method: "DELETE", Path: "/api/deleteApisByIds", Description: "批量删除api"}, {ApiGroup: "api", Method: "GET", Path: "/api/syncApi", Description: "获取待同步API"}, {ApiGroup: "api", Method: "GET", Path: "/api/getApiGroups", Description: "获取路由组"}, diff --git a/server/source/system/api_test.go b/server/source/system/api_test.go new file mode 100644 index 0000000..aae75bb --- /dev/null +++ b/server/source/system/api_test.go @@ -0,0 +1,30 @@ +package system + +import ( + "context" + "testing" + + sysModel "github.com/flipped-aurora/gin-vue-admin/server/model/system" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestInitApiIncludesApiRoleManagementEndpoints(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&sysModel.SysApi{})) + + _, err = (&initApi{}).InitializeData(context.WithValue(context.Background(), "db", db)) + require.NoError(t, err) + + requiredApis := []sysModel.SysApi{ + {Path: "/api/getApiRoles", Method: "GET"}, + {Path: "/api/setApiRoles", Method: "POST"}, + } + for _, api := range requiredApis { + var count int64 + require.NoError(t, db.Model(&sysModel.SysApi{}).Where("path = ? AND method = ?", api.Path, api.Method).Count(&count).Error) + require.Equalf(t, int64(1), count, "missing api: %+v", api) + } +} diff --git a/server/source/system/casbin.go b/server/source/system/casbin.go index 805d3c4..01a4f4b 100644 --- a/server/source/system/casbin.go +++ b/server/source/system/casbin.go @@ -62,6 +62,8 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error {Ptype: "p", V0: "888", V1: "/api/deleteApi", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/api/updateApi", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/api/getAllApis", V2: "POST"}, + {Ptype: "p", V0: "888", V1: "/api/getApiRoles", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/api/setApiRoles", V2: "POST"}, {Ptype: "p", V0: "888", V1: "/api/deleteApisByIds", V2: "DELETE"}, {Ptype: "p", V0: "888", V1: "/api/syncApi", V2: "GET"}, {Ptype: "p", V0: "888", V1: "/api/getApiGroups", V2: "GET"}, diff --git a/server/source/system/casbin_test.go b/server/source/system/casbin_test.go new file mode 100644 index 0000000..6776896 --- /dev/null +++ b/server/source/system/casbin_test.go @@ -0,0 +1,30 @@ +package system + +import ( + "context" + "testing" + + adapter "github.com/casbin/gorm-adapter/v3" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestInitCasbinIncludesApiRoleManagementPolicies(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&adapter.CasbinRule{})) + + _, err = (&initCasbin{}).InitializeData(context.WithValue(context.Background(), "db", db)) + require.NoError(t, err) + + requiredPolicies := []adapter.CasbinRule{ + {Ptype: "p", V0: "888", V1: "/api/getApiRoles", V2: "GET"}, + {Ptype: "p", V0: "888", V1: "/api/setApiRoles", V2: "POST"}, + } + for _, policy := range requiredPolicies { + var count int64 + require.NoError(t, db.Model(&adapter.CasbinRule{}).Where(policy).Count(&count).Error) + require.Equalf(t, int64(1), count, "missing policy: %+v", policy) + } +} diff --git a/server/source/system/dictionary.go b/server/source/system/dictionary.go index a0cf8e0..8d498db 100644 --- a/server/source/system/dictionary.go +++ b/server/source/system/dictionary.go @@ -50,6 +50,12 @@ func (i *initDict) InitializeData(ctx context.Context) (next context.Context, er {Name: "数据库浮点型", Type: "float64", Status: &True, Desc: "数据库浮点型"}, {Name: "数据库字符串", Type: "string", Status: &True, Desc: "数据库字符串"}, {Name: "数据库bool类型", Type: "bool", Status: &True, Desc: "数据库bool类型"}, + {Name: "作者状态", Type: "book_author_status", Status: &True, Desc: "作者状态字典"}, + {Name: "书籍评论状态", Type: "book_comment_status", Status: &True, Desc: "书籍评论状态字典"}, + {Name: "书籍完结状态", Type: "book_completion_status", Status: &True, Desc: "书籍完结状态字典"}, + {Name: "书籍时代标签", Type: "book_era_tag", Status: &True, Desc: "书籍时代标签字典"}, + {Name: "书籍上下架状态", Type: "book_publish_status", Status: &True, Desc: "书籍上下架状态字典"}, + {Name: "书籍类型", Type: "book_type", Status: &True, Desc: "书籍类型动态字典"}, } if err = db.Create(&entities).Error; err != nil { diff --git a/server/source/system/dictionary_detail.go b/server/source/system/dictionary_detail.go index 8a2bd2b..3e98932 100644 --- a/server/source/system/dictionary_detail.go +++ b/server/source/system/dictionary_detail.go @@ -98,6 +98,35 @@ func (i *initDictDetail) InitializeData(ctx context.Context) (context.Context, e {Label: "tinyint", Value: "1", Extend: "mysql", Status: &True}, {Label: "bool", Value: "2", Extend: "pgsql", Status: &True}, } + dicts[6].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ + {Label: "启用", Value: "enabled", Status: &True, Sort: 10}, + {Label: "禁用", Value: "disabled", Status: &True, Sort: 20}, + } + dicts[7].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ + {Label: "正常", Value: "normal", Status: &True, Sort: 10}, + {Label: "隐藏", Value: "hidden", Status: &True, Sort: 20}, + } + dicts[8].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ + {Label: "完结", Value: "completed", Status: &True, Sort: 10}, + {Label: "连载", Value: "serializing", Status: &True, Sort: 20}, + } + dicts[9].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ + {Label: "未知时代", Value: "unknown", Status: &True, Sort: 10}, + {Label: "远古", Value: "ancient", Status: &True, Sort: 20}, + {Label: "汉", Value: "han", Status: &True, Sort: 30}, + {Label: "唐", Value: "tang", Status: &True, Sort: 40}, + {Label: "宋", Value: "song", Status: &True, Sort: 50}, + {Label: "元", Value: "yuan", Status: &True, Sort: 60}, + {Label: "明", Value: "ming", Status: &True, Sort: 70}, + {Label: "清", Value: "qing", Status: &True, Sort: 80}, + {Label: "近代", Value: "modern", Status: &True, Sort: 90}, + {Label: "现代", Value: "contemporary", Status: &True, Sort: 100}, + } + dicts[10].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ + {Label: "草稿", Value: "draft", Status: &True, Sort: 10}, + {Label: "下架", Value: "off_shelf", Status: &True, Sort: 20}, + {Label: "上架", Value: "on_shelf", Status: &True, Sort: 30}, + } for _, dict := range dicts { if err := db.Model(&dict).Association("SysDictionaryDetails"). Replace(dict.SysDictionaryDetails); err != nil {