diff --git a/server/.ai-specs/coding-specs/vo-model-request-response.md b/server/.ai-specs/coding-specs/vo-model-request-response.md index 112a243..52f3732 100644 --- a/server/.ai-specs/coding-specs/vo-model-request-response.md +++ b/server/.ai-specs/coding-specs/vo-model-request-response.md @@ -2,53 +2,57 @@ ## 适用范围 -- 判断项目中的 `vo`、实体、接口入参、接口出参应该落在哪一层时,使用本文。 -- 新增或修改业务模块的 `model/`、`model//request`、`model//response` 时,使用本文。 -- 判断某个 API 是直接复用实体,还是新增 `request/response` 结构时,使用本文。 +- 判断实体、API 入参、API 出参、`vo` 的落点时,使用本文。 +- 新增或修改 `model/`、`model//request`、`model//response` 时,使用本文。 ## 结论基线 -- 本项目不单独维护顶层 `vo` 目录。 -- 项目里的数据载体统一归到 `model` 体系,不额外拆一套平行 `vo` 层。 -- 标准落点如下: +- 不建顶层 `vo` 目录;所有数据载体统一归入 `model` 体系。 +- 实体只承载表结构和业务实体字段;禁止承载分页、筛选、排序、展示聚合字段。 -| 场景 | 落点 | 说明 | +| 类型 | 固定落点 | 用途 | |:---|:---|:---| -| 数据库实体 / 业务实体 | `model/` | 承载表结构映射、业务实体字段 | -| API 入参 | `model//request` | 承载查询条件、分页条件、保存参数等 | -| API 出参 | `model//response` | 承载详情包装、列表项包装、聚合展示结构等 | -| 跨模块通用入参 | `model/common/request` | 例如 `PageInfo`、`GetById`、`IdsReq` | -| 跨模块通用出参 | `model/common/response` | 例如统一响应壳、分页结果 | +| 实体 | `model/` | GORM 映射、业务实体 | +| 入参 | `model//request` | 查询、分页、排序、保存、动作参数 | +| 出参 | `model//response` | 详情包装、列表项、聚合字段、展示转换 | +| 通用结构 | `model/common/request`、`model/common/response` | 跨模块稳定复用 | ## 判定规则 -- 请求参数如果就是业务实体本身,且不会引入多余字段、敏感字段或语义歧义,可以直接复用 `model/` 实体。 -- 请求参数如果只是“分页 + 条件筛选 + 排序”这类接口 contract,应定义到 `model//request`。 -- 返回结果如果只是直接返回实体本身,可以直接返回实体或列表,不强制为了“像 VO”再包一层空结构。 -- 返回结果如果需要额外包装、聚合字段、嵌套结构、展示字段转换,应定义到 `model//response`。 -- 多个 API 只要 contract 一致,可以共用同一个 `request` 或 `response` 结构;不是每个 API 都必须单独建一份。 +| 场景 | 结论 | +|:---|:---| +| 入参 = 可公开写入的实体字段,且无额外语义 | 可复用实体 | +| 入参只开放部分字段,或需要屏蔽敏感/系统字段 | 必须使用 `request` | +| 入参包含分页、筛选、排序、动作语义、组合参数 | 必须使用 `request` | +| 返回实体原样或实体列表 | 可直接返回实体 | +| 返回包含展示、聚合、联表、树、嵌套结构 | 必须使用 `response` | +| 多个接口 contract 完全一致 | 复用同一个 `request/response` | +| `admin/app` 字段、校验、分页、展示口径不同 | 按端拆分 `request/response` | ## 强制规则 -- 新增业务模块时,实体统一放 `model/`;禁止把实体落到 `api`、`service`、`router`。 -- `API` 层入参、出参需要独立结构时,统一放 `model//request`、`model//response`;禁止在 `API` 文件里长期维护临时匿名结构体当正式 contract。 -- 跨模块都能稳定复用的分页、主键、统一响应、分页结果等结构,统一复用 `model/common/request`、`model/common/response`;不要每个模块各复制一份。 -- 同一业务模块如果同时存在 `admin/app` 两套接口,实体仍统一放 `model/`;只有 `request/response` 按 `admin` / `app` 分文件区分。 -- 新增 `request/response` 前,先检查同模块现有结构是否可复用;只有接口字段、校验语义、返回语义明显不同,才新增结构。 -- 改实体字段时,必须同步检查 `service` 查询/写入、`API` 绑定/返回、`doc-sql` 是否仍一致。 -- 改 `request/response` 时,必须同步检查 `API` 绑定、`Service` 方法签名、`doc-api` 是否仍一致。 +- 新增实体必须放 `model/`;正式 API contract 必须放 `request` 或 `response`。 +- 禁止在 `API` 文件中长期维护正式 contract 的匿名结构体。 +- 新增 `request/response` 前,必须先检查同模块现有结构是否可复用。 +- `request/response` 只有字段、校验语义、返回语义明显不同,才允许新增。 +- 同一业务模块存在 `admin/app` 两套接口时,实体只保留一份;仅 `request/response` 按端拆分。 +- 改实体字段时,必须同步检查 `doc-sql`、`Service` 查询/写入、`API` 绑定/返回。 +- 改 `request/response` 或接口 contract 时,必须同步检查并更新 `doc-api`、`API`、`Service`。 ## 推荐做法 -- `Create`、`Update` 这类保存接口,如果直接面向实体字段,可优先复用实体。 -- `GetList`、搜索、筛选、排序接口,优先使用 `request` 结构,不要把分页筛选字段硬塞进实体。 -- `Find`、详情、聚合展示、树结构、联表结果等返回,优先使用 `response` 结构,不要把纯展示字段反向塞进数据库实体。 -- `response` 结构命名优先体现业务语义,例如 `BookDetailResponse`、`BookListItem`、`AuthorOption`;不要机械统一叫 `XxxVO`。 +| 对象 | 模板 | 示例 | +|:---|:---|:---| +| 列表查询入参 | `Search` | `BookSearch` | +| 动作入参 | `Req` | `ChangePasswordReq` | +| 详情出参 | `Response` | `BookResponse` | +| 列表项出参 | `ListItem` | `BookListItem` | +| 选项出参 | `Option` | `AuthorOption` | +| 文件 | `model//.go`、`model//request/.go`、`model//response/.go` | `model/book/book.go` | ## 禁止事项 -- 禁止单独新建顶层 `vo` 目录,与 `model` 并行维护两套数据结构体系。 -- 禁止为了“每个 API 都有专属 VO”而机械性给每个接口复制一份几乎相同的 `request/response`。 -- 禁止把分页、排序、筛选字段直接加进数据库实体,只为了省掉 `request` 结构。 -- 禁止把纯展示字段、聚合字段、临时返回字段长期塞进实体,只为了省掉 `response` 结构。 -- 禁止 `API`、`Service` 长期返回 `map[string]interface{}`、`gin.H` 充当正式业务出参,导致 contract 漂移。 +- 禁止新建顶层 `vo` 目录,与 `model` 并行维护数据结构。 +- 禁止为每个 API 机械复制字段几乎相同的 `request/response`。 +- 禁止把分页、排序、筛选、展示、聚合、临时返回字段塞进实体。 +- 禁止用 `map[string]interface{}`、`gin.H` 作为正式业务出参。 diff --git a/server/.ai-specs/doc-api/admin/book_author.md b/server/.ai-specs/doc-api/admin/book_author.md index c926f9b..a3ba9ee 100644 --- a/server/.ai-specs/doc-api/admin/book_author.md +++ b/server/.ai-specs/doc-api/admin/book_author.md @@ -30,7 +30,7 @@ | 字段 | 类型 | 必填 | 规则 | 说明 | |:---|:---|:---:|:---|:---| | name | string | 是 | 非空,最大 `128` 字符,唯一 | 作者名称 | -| authorStatus | string | 否 | `common_enabled_status`:`enabled`/`disabled`;默认 `enabled` | 作者启用状态 | +| isEnabled | bool | 否 | 默认 `true` | 作者是否启用 | | intro | string | 否 | 可为空 | 作者简介 | | coverUrl | string | 否 | 可为空,最大 `500` 字符 | 作者头像或封面 URL | @@ -42,7 +42,7 @@ - 创建请求禁止传服务端生成字段:`id/createdAt/updatedAt`。 - `name` 必须唯一,对应唯一索引 `uk_book_author_name`。 -- `authorStatus` 为空时按数据库默认值 `enabled` 落库;传值时必须符合 `common_enabled_status`。 +- `isEnabled` 未传时按数据库默认值 `true` 落库。 ### 删除书籍作者 @@ -104,7 +104,7 @@ |:---|:---|:---:|:---|:---| | id | uint | 是 | `> 0` | 作者 ID | | name | string | 是 | 非空,最大 `128` 字符,唯一 | 作者名称 | -| authorStatus | string | 否 | `common_enabled_status`:`enabled`/`disabled` | 作者启用状态 | +| isEnabled | bool | 否 | `true/false` | 作者是否启用 | | intro | string | 否 | 可为空 | 作者简介 | | coverUrl | string | 否 | 可为空,最大 `500` 字符 | 作者头像或封面 URL | @@ -117,7 +117,6 @@ - 更新请求禁止传服务端维护字段:`createdAt/updatedAt`。 - 更新为实体全量保存,前端应传完整可编辑实体,避免字段被零值覆盖。 - `name` 必须唯一,对应唯一索引 `uk_book_author_name`。 -- `authorStatus` 为空时会按空值保存;传值时必须符合 `common_enabled_status`。 ### 查询书籍作者详情 @@ -142,7 +141,7 @@ | bookAuthor.createdAt | time | 创建时间 | | bookAuthor.updatedAt | time | 更新时间 | | bookAuthor.name | string | 作者名称 | -| bookAuthor.authorStatus | string | 作者启用状态,见 `common_enabled_status` | +| bookAuthor.isEnabled | bool | 作者是否启用 | | bookAuthor.intro | string | 作者简介 | | bookAuthor.coverUrl | string | 作者头像或封面 URL | @@ -162,7 +161,7 @@ | pageSize | int | 否 | 默认 `10`,最大 `100` | 每页数量 | | keyword | string | 否 | 模糊匹配 `name` | 通用关键字 | | name | string | 否 | 模糊匹配 `name` | 作者名称 | -| authorStatus | string | 否 | `common_enabled_status`:`enabled`/`disabled` | 作者启用状态 | +| isEnabled | bool | 否 | `true/false` | 作者是否启用 | #### Response `response.PageResult` @@ -173,7 +172,7 @@ | list[].createdAt | time | 创建时间 | | list[].updatedAt | time | 更新时间 | | list[].name | string | 作者名称 | -| list[].authorStatus | string | 作者启用状态,见 `common_enabled_status` | +| list[].isEnabled | bool | 作者是否启用 | | list[].intro | string | 作者简介 | | list[].coverUrl | string | 作者头像或封面 URL | | list[].authorName | string | 作者名称展示字段,来源于 `book_author.name AS author_name` | @@ -190,5 +189,5 @@ - admin 端书籍作者 CRUD 挂载到 `PrivateGroup`,需要 `JWT + Casbin`。 - 写操作挂载 `middleware.OperationRecord()`;读操作不挂操作审计。 -- `authorStatus` 固定值域来自 `common_enabled_status`:`enabled`、`disabled`。 +- “是否启用”统一使用 `isEnabled` 布尔字段:`true` 表示启用,`false` 表示禁用。 - 表结构、字段长度、唯一约束和索引以 `.ai-specs/doc-sql/book_author.sql` 为准。 diff --git a/server/.ai-specs/doc-dict/common_enabled_status.md b/server/.ai-specs/doc-dict/common_enabled_status.md deleted file mode 100644 index c252059..0000000 --- a/server/.ai-specs/doc-dict/common_enabled_status.md +++ /dev/null @@ -1,10 +0,0 @@ -# 通用启用状态 - -- 模块:common -- 字典编码:`common_enabled_status` -- 字典类型:`固定值域字典` - -| Label | Value | Sort | Status | Desc | -|:---|:---|:---|:---|:---| -| 启用 | `enabled` | 10 | true | 业务对象处于启用状态,可按对应业务规则正常使用 | -| 禁用 | `disabled` | 20 | true | 业务对象处于禁用状态,不应再被新增业务关系使用 | diff --git a/server/.ai-specs/doc-sql/book_author.sql b/server/.ai-specs/doc-sql/book_author.sql index 39d0152..74a52b4 100644 --- a/server/.ai-specs/doc-sql/book_author.sql +++ b/server/.ai-specs/doc-sql/book_author.sql @@ -7,7 +7,6 @@ -- 模型:model/book/book_author.go -- 迁移接入:initialize/gorm_biz.go -- 删除策略:硬删表 --- 启用状态字典:common_enabled_status -- 职责:承载书籍作者主体信息,用于作者资料展示、书籍作者关联和后台作者管理。 CREATE TABLE book_author ( @@ -15,7 +14,7 @@ CREATE TABLE book_author ( 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, - author_status varchar(32) NOT NULL DEFAULT 'enabled', + is_enabled boolean NOT NULL DEFAULT true, intro text, cover_url varchar(500) ); @@ -25,10 +24,10 @@ COMMENT ON COLUMN book_author.id IS '主键'; COMMENT ON COLUMN book_author.created_at IS '创建时间'; COMMENT ON COLUMN book_author.updated_at IS '更新时间'; COMMENT ON COLUMN book_author.name IS '作者名称'; -COMMENT ON COLUMN book_author.author_status IS '作者启用状态字典值,对应 common_enabled_status'; +COMMENT ON COLUMN book_author.is_enabled IS '作者是否启用'; COMMENT ON COLUMN book_author.intro IS '作者简介'; 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_is_enabled ON book_author (is_enabled); CREATE INDEX idx_book_author_created_at ON book_author (created_at); diff --git a/server/.ai-specs/prd-draft/book.md b/server/.ai-specs/prd-draft/book.md index 1d3d09c..f59ee46 100644 --- a/server/.ai-specs/prd-draft/book.md +++ b/server/.ai-specs/prd-draft/book.md @@ -41,7 +41,7 @@ ### 书籍作者表 - 名称:作者名称 -- 状态:字典 `common_enabled_status` +- 是否启用:作者启停状态 - 简介:作者简介 - 封面图片URL:作者封面地址 diff --git a/server/.ai-specs/sys-specs/business-table-spec.md b/server/.ai-specs/sys-specs/business-table-spec.md index 9adc925..aedb939 100644 --- a/server/.ai-specs/sys-specs/business-table-spec.md +++ b/server/.ai-specs/sys-specs/business-table-spec.md @@ -80,7 +80,7 @@ CREATE INDEX idx__ ON (); - `硬删表` 按模板直接输出,不追加 `deleted_at` - 没有字典字段,就删除对应“字典”注释行 - 没有唯一索引或普通索引,就删除对应 SQL 行 -- 如果字段默认值来自 `固定值域字典`,直接写字典项 `Value`,例如 `DEFAULT 'enabled'` +- 如果字段默认值来自 `固定值域字典`,直接写字典项 `Value`,例如 `DEFAULT 'draft'` - 如果字段引用 `动态值域字典`,就删除默认值示例,按业务真实规则只保留结构约束和索引 ## 复刻目标 diff --git a/server/.ai-transition/database-upgrade-doc/v1.sql b/server/.ai-transition/database-upgrade-doc/v1.sql index 9b95196..7ce4f91 100644 --- a/server/.ai-transition/database-upgrade-doc/v1.sql +++ b/server/.ai-transition/database-upgrade-doc/v1.sql @@ -38,7 +38,6 @@ WHERE type = 'book_author_status'; WITH dict_seed(name, type, status, description) AS ( VALUES - ('通用启用状态', 'common_enabled_status', true, '通用启用禁用状态字典'), ('书籍评论状态', 'book_comment_status', true, '书籍评论状态字典'), ('书籍完结状态', 'book_completion_status', true, '书籍完结状态字典'), ('书籍时代标签', 'book_era_tag', true, '书籍时代标签字典'), @@ -56,7 +55,6 @@ WHERE d.type = s.type; WITH dict_seed(name, type, status, description) AS ( VALUES - ('通用启用状态', 'common_enabled_status', true, '通用启用禁用状态字典'), ('书籍评论状态', 'book_comment_status', true, '书籍评论状态字典'), ('书籍完结状态', 'book_completion_status', true, '书籍完结状态字典'), ('书籍时代标签', 'book_era_tag', true, '书籍时代标签字典'), @@ -72,8 +70,6 @@ WHERE NOT EXISTS ( WITH detail_seed(dict_type, label, value, sort, status) AS ( VALUES - ('common_enabled_status', '启用', 'enabled', 10, true), - ('common_enabled_status', '禁用', 'disabled', 20, true), ('book_comment_status', '正常', 'normal', 10, true), ('book_comment_status', '隐藏', 'hidden', 20, true), ('book_completion_status', '完结', 'completed', 10, true), @@ -108,8 +104,6 @@ WHERE d.sys_dictionary_id = dict.id WITH detail_seed(dict_type, label, value, sort, status) AS ( VALUES - ('common_enabled_status', '启用', 'enabled', 10, true), - ('common_enabled_status', '禁用', 'disabled', 20, true), ('book_comment_status', '正常', 'normal', 10, true), ('book_comment_status', '隐藏', 'hidden', 20, true), ('book_completion_status', '完结', 'completed', 10, true), @@ -138,3 +132,55 @@ WHERE NOT EXISTS ( WHERE d.sys_dictionary_id = dict.id AND d.value = s.value ); + +-- 作者启停字段统一为 is_enabled bool / book_author, sys_dictionaries, sys_dictionary_details / 2026-04-27 + +DO $$ +BEGIN + IF to_regclass('public.book_author') IS NOT NULL THEN + ALTER TABLE book_author + ADD COLUMN IF NOT EXISTS is_enabled boolean; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'book_author' + AND column_name = 'author_status' + ) THEN + UPDATE book_author + SET is_enabled = CASE WHEN author_status = 'disabled' THEN false ELSE true END + WHERE is_enabled IS NULL; + ELSE + UPDATE book_author + SET is_enabled = true + WHERE is_enabled IS NULL; + END IF; + + ALTER TABLE book_author + ALTER COLUMN is_enabled SET DEFAULT true; + + ALTER TABLE book_author + ALTER COLUMN is_enabled SET NOT NULL; + + COMMENT ON COLUMN book_author.is_enabled IS '作者是否启用'; + + DROP INDEX IF EXISTS idx_book_author_author_status; + CREATE INDEX IF NOT EXISTS idx_book_author_is_enabled ON book_author (is_enabled); + + ALTER TABLE book_author + DROP COLUMN IF EXISTS author_status; + END IF; +END $$; + +UPDATE sys_dictionary_details d +SET status = false, + updated_at = NOW() +FROM sys_dictionaries dict +WHERE d.sys_dictionary_id = dict.id + AND dict.type = 'common_enabled_status'; + +UPDATE sys_dictionaries +SET status = false, + updated_at = NOW() +WHERE type = 'common_enabled_status'; diff --git a/server/AGENTS.md b/server/AGENTS.md index 09ae27f..eee292f 100644 --- a/server/AGENTS.md +++ b/server/AGENTS.md @@ -170,7 +170,6 @@ flowchart LR | 路径 | 用途 | 说明 | |:---|:---|:---| -| `.ai-specs\doc-dict\common_enabled_status.md` | 定义通用启用/禁用状态的标准值域 | 涉及业务对象启用禁用状态的存储、校验、展示和接口出入参时必读 | | `.ai-specs\doc-dict\book_comment_status.md` | 定义书籍评论状态的标准值域 | 涉及评论状态存储、评论隐藏和评论出入参展示时必读 | | `.ai-specs\doc-dict\book_completion_status.md` | 定义书籍完结状态的标准值域 | 涉及书籍完结状态的存储、校验、展示和接口出入参时必读 | | `.ai-specs\doc-dict\book_era_tag.md` | 定义书籍时代标签的标准值域 | 涉及时代标签存储、筛选聚合和接口出入参时必读 | diff --git a/server/api/v1/book/book_author.go b/server/api/v1/book/book_author.go index 5c0fd5d..122bab6 100644 --- a/server/api/v1/book/book_author.go +++ b/server/api/v1/book/book_author.go @@ -13,12 +13,12 @@ import ( type BookAuthorApi struct{} func (a *BookAuthorApi) CreateBookAuthor(c *gin.Context) { - var item book.BookAuthor - if err := c.ShouldBindJSON(&item); err != nil { + var req bookReq.CreateBookAuthorReq + if err := c.ShouldBindJSON(&req); err != nil { response.FailWithMessage(err.Error(), c) return } - if err := bookAuthorService.CreateBookAuthor(item); err != nil { + if err := bookAuthorService.CreateBookAuthor(req.ToModel()); err != nil { global.GVA_LOG.Error("创建失败!", zap.Error(err)) response.FailWithMessage("创建失败", c) return diff --git a/server/api/v1/book/book_chapter.go b/server/api/v1/book/book_chapter.go index ef4762e..76f2081 100644 --- a/server/api/v1/book/book_chapter.go +++ b/server/api/v1/book/book_chapter.go @@ -13,12 +13,12 @@ import ( type BookChapterApi struct{} func (a *BookChapterApi) CreateBookChapter(c *gin.Context) { - var item book.BookChapter - if err := c.ShouldBindJSON(&item); err != nil { + var req bookReq.CreateBookChapterReq + if err := c.ShouldBindJSON(&req); err != nil { response.FailWithMessage(err.Error(), c) return } - if err := bookChapterService.CreateBookChapter(item); err != nil { + if err := bookChapterService.CreateBookChapter(req.ToModel()); err != nil { global.GVA_LOG.Error("创建失败!", zap.Error(err)) response.FailWithMessage("创建失败", c) return diff --git a/server/api/v1/book/book_series.go b/server/api/v1/book/book_series.go index f54dd59..b861b19 100644 --- a/server/api/v1/book/book_series.go +++ b/server/api/v1/book/book_series.go @@ -13,12 +13,12 @@ import ( type BookSeriesApi struct{} func (a *BookSeriesApi) CreateBookSeries(c *gin.Context) { - var item book.BookSeries - if err := c.ShouldBindJSON(&item); err != nil { + var req bookReq.CreateBookSeriesReq + if err := c.ShouldBindJSON(&req); err != nil { response.FailWithMessage(err.Error(), c) return } - if err := bookSeriesService.CreateBookSeries(item); err != nil { + if err := bookSeriesService.CreateBookSeries(req.ToModel()); err != nil { global.GVA_LOG.Error("创建失败!", zap.Error(err)) response.FailWithMessage("创建失败", c) return diff --git a/server/api/v1/book/is_enabled_create_test.go b/server/api/v1/book/is_enabled_create_test.go new file mode 100644 index 0000000..b449360 --- /dev/null +++ b/server/api/v1/book/is_enabled_create_test.go @@ -0,0 +1,76 @@ +package book + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupIsEnabledCreateAPITestDB(t *testing.T) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&book.BookAuthor{}, &book.BookSeries{}, &book.BookChapter{})) + + global.GVA_DB = db + t.Cleanup(func() { + global.GVA_DB = nil + }) +} + +func postJSON(t *testing.T, body string, handler gin.HandlerFunc) { + t.Helper() + + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(w) + ctx.Request = httptest.NewRequest(http.MethodPost, "/book/create", bytes.NewBufferString(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + + handler(ctx) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestCreateAPIDefaultsIsEnabledTrueAndPersistsExplicitFalse(t *testing.T) { + setupIsEnabledCreateAPITestDB(t) + + postJSON(t, `{"name":"默认启用作者"}`, (&BookAuthorApi{}).CreateBookAuthor) + postJSON(t, `{"name":"显式禁用作者","isEnabled":false}`, (&BookAuthorApi{}).CreateBookAuthor) + postJSON(t, `{"name":"默认启用系列"}`, (&BookSeriesApi{}).CreateBookSeries) + postJSON(t, `{"name":"显式禁用系列","isEnabled":false}`, (&BookSeriesApi{}).CreateBookSeries) + postJSON(t, `{"bookId":1,"title":"默认启用章节","chapterNo":1,"contentFileUrl":"chapters/default.txt"}`, (&BookChapterApi{}).CreateBookChapter) + postJSON(t, `{"bookId":1,"title":"显式禁用章节","chapterNo":2,"contentFileUrl":"chapters/disabled.txt","isEnabled":false}`, (&BookChapterApi{}).CreateBookChapter) + + var defaultAuthor book.BookAuthor + require.NoError(t, global.GVA_DB.Where("name = ?", "默认启用作者").First(&defaultAuthor).Error) + require.True(t, defaultAuthor.IsEnabled) + + var disabledAuthor book.BookAuthor + require.NoError(t, global.GVA_DB.Where("name = ?", "显式禁用作者").First(&disabledAuthor).Error) + require.False(t, disabledAuthor.IsEnabled) + + var defaultSeries book.BookSeries + require.NoError(t, global.GVA_DB.Where("name = ?", "默认启用系列").First(&defaultSeries).Error) + require.True(t, defaultSeries.IsEnabled) + + var disabledSeries book.BookSeries + require.NoError(t, global.GVA_DB.Where("name = ?", "显式禁用系列").First(&disabledSeries).Error) + require.False(t, disabledSeries.IsEnabled) + + var defaultChapter book.BookChapter + require.NoError(t, global.GVA_DB.Where("title = ?", "默认启用章节").First(&defaultChapter).Error) + require.True(t, defaultChapter.IsEnabled) + + var disabledChapter book.BookChapter + require.NoError(t, global.GVA_DB.Where("title = ?", "显式禁用章节").First(&disabledChapter).Error) + require.False(t, disabledChapter.IsEnabled) +} diff --git a/server/initialize/gorm_biz.go b/server/initialize/gorm_biz.go index b96d593..b786440 100644 --- a/server/initialize/gorm_biz.go +++ b/server/initialize/gorm_biz.go @@ -3,8 +3,11 @@ package initialize import ( "github.com/flipped-aurora/gin-vue-admin/server/global" "github.com/flipped-aurora/gin-vue-admin/server/model/book" + "github.com/flipped-aurora/gin-vue-admin/server/model/example" ) +var _ = example.ExaFile{} + func bizModel() error { db := global.GVA_DB err := db.AutoMigrate( diff --git a/server/model/book/book_author.go b/server/model/book/book_author.go index fef30e8..9c18a4c 100644 --- a/server/model/book/book_author.go +++ b/server/model/book/book_author.go @@ -2,10 +2,10 @@ 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:作者启用状态字典值,对应 common_enabled_status"` - Intro string `json:"intro" form:"intro" gorm:"type:text;comment:作者简介"` - CoverUrl string `json:"coverUrl" form:"coverUrl" gorm:"type:varchar(500);comment:作者头像或封面 URL"` + Name string `json:"name" form:"name" gorm:"type:varchar(128);not null;uniqueIndex:uk_book_author_name;comment:作者名称"` + IsEnabled bool `json:"isEnabled" form:"isEnabled" gorm:"not null;default:true;index;comment:作者是否启用"` + 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 { diff --git a/server/model/book/request/book.go b/server/model/book/request/book.go index 562aff7..1d00556 100644 --- a/server/model/book/request/book.go +++ b/server/model/book/request/book.go @@ -1,6 +1,9 @@ package request -import commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +import ( + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + commonReq "github.com/flipped-aurora/gin-vue-admin/server/model/common/request" +) type BookSearch struct { commonReq.PageInfo @@ -14,8 +17,15 @@ type BookSearch struct { type BookAuthorSearch struct { commonReq.PageInfo - Name string `json:"name" form:"name"` - AuthorStatus string `json:"authorStatus" form:"authorStatus"` + Name string `json:"name" form:"name"` + IsEnabled *bool `json:"isEnabled" form:"isEnabled"` +} + +type CreateBookAuthorReq struct { + Name string `json:"name" form:"name"` + IsEnabled *bool `json:"isEnabled" form:"isEnabled"` + Intro string `json:"intro" form:"intro"` + CoverUrl string `json:"coverUrl" form:"coverUrl"` } type BookAuthorRelationSearch struct { @@ -31,6 +41,16 @@ type BookChapterSearch struct { IsEnabled *bool `json:"isEnabled" form:"isEnabled"` } +type CreateBookChapterReq struct { + BookID uint `json:"bookId" form:"bookId"` + Title string `json:"title" form:"title"` + ChapterNo int `json:"chapterNo" form:"chapterNo"` + IsReadable bool `json:"isReadable" form:"isReadable"` + ContentFileUrl string `json:"contentFileUrl" form:"contentFileUrl"` + TotalLines int `json:"totalLines" form:"totalLines"` + IsEnabled *bool `json:"isEnabled" form:"isEnabled"` +} + type BookCommentSearch struct { commonReq.PageInfo MemberUserID *uint `json:"memberUserId" form:"memberUserId"` @@ -62,3 +82,47 @@ type BookSeriesSearch struct { Name string `json:"name" form:"name"` IsEnabled *bool `json:"isEnabled" form:"isEnabled"` } + +type CreateBookSeriesReq struct { + Name string `json:"name" form:"name"` + CoverUrl string `json:"coverUrl" form:"coverUrl"` + Intro string `json:"intro" form:"intro"` + IsEnabled *bool `json:"isEnabled" form:"isEnabled"` +} + +func (req CreateBookAuthorReq) ToModel() book.BookAuthor { + return book.BookAuthor{ + Name: req.Name, + IsEnabled: boolValueOrDefault(req.IsEnabled, true), + Intro: req.Intro, + CoverUrl: req.CoverUrl, + } +} + +func (req CreateBookChapterReq) ToModel() book.BookChapter { + return book.BookChapter{ + BookID: req.BookID, + Title: req.Title, + ChapterNo: req.ChapterNo, + IsReadable: req.IsReadable, + ContentFileUrl: req.ContentFileUrl, + TotalLines: req.TotalLines, + IsEnabled: boolValueOrDefault(req.IsEnabled, true), + } +} + +func (req CreateBookSeriesReq) ToModel() book.BookSeries { + return book.BookSeries{ + Name: req.Name, + CoverUrl: req.CoverUrl, + Intro: req.Intro, + IsEnabled: boolValueOrDefault(req.IsEnabled, true), + } +} + +func boolValueOrDefault(value *bool, defaultValue bool) bool { + if value == nil { + return defaultValue + } + return *value +} diff --git a/server/model/common/status.go b/server/model/common/status.go deleted file mode 100644 index 4759b7d..0000000 --- a/server/model/common/status.go +++ /dev/null @@ -1,6 +0,0 @@ -package common - -const ( - CommonEnabledStatusEnabled = "enabled" - CommonEnabledStatusDisabled = "disabled" -) diff --git a/server/service/book/book_author.go b/server/service/book/book_author.go index bc63b3a..bbd885c 100644 --- a/server/service/book/book_author.go +++ b/server/service/book/book_author.go @@ -14,7 +14,7 @@ func (s *BookAuthorService) CreateBookAuthor(item book.BookAuthor) error { if err := validateBookAuthor(item); err != nil { return err } - return global.GVA_DB.Create(&item).Error + return global.GVA_DB.Save(&item).Error } func (s *BookAuthorService) DeleteBookAuthor(item book.BookAuthor) error { @@ -45,8 +45,8 @@ func (s *BookAuthorService) GetBookAuthorInfoList(info bookReq.BookAuthorSearch) if info.Keyword != "" { db = db.Where("name LIKE ?", "%"+info.Keyword+"%") } - if info.AuthorStatus != "" { - db = db.Where("author_status = ?", info.AuthorStatus) + if info.IsEnabled != nil { + db = db.Where("is_enabled = ?", *info.IsEnabled) } err = db.Count(&total).Error if err != nil { diff --git a/server/service/book/book_author_list_test.go b/server/service/book/book_author_list_test.go index df4ddc4..0a6382c 100644 --- a/server/service/book/book_author_list_test.go +++ b/server/service/book/book_author_list_test.go @@ -29,8 +29,8 @@ func TestBookAuthorService_GetBookAuthorInfoListReturnsAuthorName(t *testing.T) setupBookAuthorListTestDB(t) require.NoError(t, global.GVA_DB.Create(&book.BookAuthor{ - Name: "鲁迅", - AuthorStatus: "enabled", + Name: "鲁迅", + IsEnabled: true, }).Error) list, total, err := (&BookAuthorService{}).GetBookAuthorInfoList(bookReq.BookAuthorSearch{ @@ -46,7 +46,7 @@ func TestBookAuthorService_GetBookAuthorInfoListReturnsAuthorName(t *testing.T) func TestBookAuthorRelationService_GetBookAuthorRelationInfoListReturnsAuthorName(t *testing.T) { setupBookAuthorListTestDB(t) - author := book.BookAuthor{Name: "沈从文", AuthorStatus: "enabled"} + author := book.BookAuthor{Name: "沈从文", IsEnabled: true} require.NoError(t, global.GVA_DB.Create(&author).Error) require.NoError(t, global.GVA_DB.Create(&book.BookAuthorRelation{ BookID: 11, diff --git a/server/service/book/book_chapter.go b/server/service/book/book_chapter.go index 7435631..abc5173 100644 --- a/server/service/book/book_chapter.go +++ b/server/service/book/book_chapter.go @@ -13,7 +13,7 @@ func (s *BookChapterService) CreateBookChapter(item book.BookChapter) error { if err := validateBookChapter(item); err != nil { return err } - return global.GVA_DB.Create(&item).Error + return global.GVA_DB.Save(&item).Error } func (s *BookChapterService) DeleteBookChapter(item book.BookChapter) error { diff --git a/server/service/book/book_series.go b/server/service/book/book_series.go index b7c78f4..bcab575 100644 --- a/server/service/book/book_series.go +++ b/server/service/book/book_series.go @@ -10,7 +10,7 @@ import ( type BookSeriesService struct{} func (s *BookSeriesService) CreateBookSeries(item book.BookSeries) error { - return global.GVA_DB.Create(&item).Error + return global.GVA_DB.Save(&item).Error } func (s *BookSeriesService) DeleteBookSeries(item book.BookSeries) error { diff --git a/server/service/book/is_enabled_create_test.go b/server/service/book/is_enabled_create_test.go new file mode 100644 index 0000000..b98ea9a --- /dev/null +++ b/server/service/book/is_enabled_create_test.go @@ -0,0 +1,50 @@ +package book + +import ( + "testing" + + "github.com/flipped-aurora/gin-vue-admin/server/global" + "github.com/flipped-aurora/gin-vue-admin/server/model/book" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupIsEnabledCreateTestDB(t *testing.T) { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&book.BookAuthor{}, &book.BookSeries{}, &book.BookChapter{})) + + global.GVA_DB = db + t.Cleanup(func() { + global.GVA_DB = nil + }) +} + +func TestCreateIsEnabledFalsePersistsForEnabledModels(t *testing.T) { + setupIsEnabledCreateTestDB(t) + + require.NoError(t, (&BookAuthorService{}).CreateBookAuthor(book.BookAuthor{Name: "禁用作者", IsEnabled: false})) + require.NoError(t, (&BookSeriesService{}).CreateBookSeries(book.BookSeries{Name: "禁用系列", IsEnabled: false})) + require.NoError(t, (&BookChapterService{}).CreateBookChapter(book.BookChapter{ + BookID: 1, + Title: "禁用章节", + ChapterNo: 1, + ContentFileUrl: "chapters/disabled.txt", + IsEnabled: false, + })) + + var author book.BookAuthor + require.NoError(t, global.GVA_DB.Where("name = ?", "禁用作者").First(&author).Error) + require.False(t, author.IsEnabled) + + var series book.BookSeries + require.NoError(t, global.GVA_DB.Where("name = ?", "禁用系列").First(&series).Error) + require.False(t, series.IsEnabled) + + var chapter book.BookChapter + require.NoError(t, global.GVA_DB.Where("title = ?", "禁用章节").First(&chapter).Error) + require.False(t, chapter.IsEnabled) +} diff --git a/server/service/book/validation.go b/server/service/book/validation.go index 3b5558e..cbe97ea 100644 --- a/server/service/book/validation.go +++ b/server/service/book/validation.go @@ -4,7 +4,6 @@ import ( "errors" "github.com/flipped-aurora/gin-vue-admin/server/model/book" - commonModel "github.com/flipped-aurora/gin-vue-admin/server/model/common" ) func validateBook(item book.Book) error { @@ -59,20 +58,12 @@ var validBookPublishStatuses = map[string]bool{ book.BookPublishStatusOnShelf: true, } -var validBookAuthorStatuses = map[string]bool{ - commonModel.CommonEnabledStatusEnabled: true, - commonModel.CommonEnabledStatusDisabled: 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 } diff --git a/server/service/book/validation_test.go b/server/service/book/validation_test.go index f9856eb..af4adb0 100644 --- a/server/service/book/validation_test.go +++ b/server/service/book/validation_test.go @@ -1,11 +1,11 @@ package book import ( + "reflect" "strings" "testing" "github.com/flipped-aurora/gin-vue-admin/server/model/book" - commonModel "github.com/flipped-aurora/gin-vue-admin/server/model/common" ) func TestValidateBookRejectsOutOfRangeAggregates(t *testing.T) { @@ -47,17 +47,21 @@ func TestValidateBookRejectsInvalidFixedDictionaryValues(t *testing.T) { } } -func TestValidateBookAuthorRejectsInvalidStatus(t *testing.T) { - err := validateBookAuthor(book.BookAuthor{AuthorStatus: "bad"}) - assertValidationErrorContains(t, err, "authorStatus") -} - -func TestValidateBookAuthorAcceptsCommonEnabledStatus(t *testing.T) { - if err := validateBookAuthor(book.BookAuthor{AuthorStatus: commonModel.CommonEnabledStatusEnabled}); err != nil { - t.Fatalf("validateBookAuthor enabled error = %v", err) +func TestBookAuthorUsesIsEnabledBooleanContract(t *testing.T) { + authorType := reflect.TypeOf(book.BookAuthor{}) + legacyFieldName := "Author" + "Status" + if _, ok := authorType.FieldByName(legacyFieldName); ok { + t.Fatal("BookAuthor still exposes legacy status field, want IsEnabled boolean only") } - if err := validateBookAuthor(book.BookAuthor{AuthorStatus: commonModel.CommonEnabledStatusDisabled}); err != nil { - t.Fatalf("validateBookAuthor disabled error = %v", err) + field, ok := authorType.FieldByName("IsEnabled") + if !ok { + t.Fatal("BookAuthor missing IsEnabled field") + } + if field.Type.Kind() != reflect.Bool { + t.Fatalf("BookAuthor.IsEnabled kind = %s, want bool", field.Type.Kind()) + } + if got := field.Tag.Get("json"); got != "isEnabled" { + t.Fatalf("BookAuthor.IsEnabled json tag = %q, want isEnabled", got) } } diff --git a/server/source/system/dictionary.go b/server/source/system/dictionary.go index ee99b71..3db5811 100644 --- a/server/source/system/dictionary.go +++ b/server/source/system/dictionary.go @@ -50,7 +50,6 @@ 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: "common_enabled_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: "书籍时代标签字典"}, diff --git a/server/source/system/dictionary_detail.go b/server/source/system/dictionary_detail.go index 3e98932..b684d46 100644 --- a/server/source/system/dictionary_detail.go +++ b/server/source/system/dictionary_detail.go @@ -99,18 +99,14 @@ func (i *initDictDetail) InitializeData(ctx context.Context) (context.Context, e {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{ + dicts[7].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ {Label: "完结", Value: "completed", Status: &True, Sort: 10}, {Label: "连载", Value: "serializing", Status: &True, Sort: 20}, } - dicts[9].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ + dicts[8].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ {Label: "未知时代", Value: "unknown", Status: &True, Sort: 10}, {Label: "远古", Value: "ancient", Status: &True, Sort: 20}, {Label: "汉", Value: "han", Status: &True, Sort: 30}, @@ -122,7 +118,7 @@ func (i *initDictDetail) InitializeData(ctx context.Context) (context.Context, e {Label: "近代", Value: "modern", Status: &True, Sort: 90}, {Label: "现代", Value: "contemporary", Status: &True, Sort: 100}, } - dicts[10].SysDictionaryDetails = []sysModel.SysDictionaryDetail{ + dicts[9].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},