diff --git a/server/internal/shared/swagger/descriptions.go b/server/internal/shared/swagger/descriptions.go new file mode 100644 index 0000000..5159108 --- /dev/null +++ b/server/internal/shared/swagger/descriptions.go @@ -0,0 +1,247 @@ +package swagger + +import "strings" + +type routeDoc struct { + Summary string + Description string +} + +// routeDocs maps "METHOD path" (Gin syntax, e.g. "GET /api/tenant/articles/:id") +// to a human-readable summary and longer description shown in Swagger UI. +// +// Keep keys in sync with the Gin routes registered in +// internal/tenant/transport/router.go (and bootstrap health routes). +// Missing entries fall back to " " automatically. +var routeDocs = map[string]routeDoc{ + // --- Health --- + "GET /api/health/live": {"存活探针", "Kubernetes liveness 探针,永远返回 200。"}, + "GET /api/health/ready": {"就绪探针", "Kubernetes readiness 探针,依赖(数据库等)就绪后返回 200。"}, + + // --- Auth --- + "POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"}, + "POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"}, + "GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"}, + "POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"}, + + // --- Public 资源 --- + "GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"}, + + // --- Desktop 客户端:注册 / 心跳 / 鉴权 --- + "POST /api/desktop/clients/register": {"桌面客户端注册", "由租户用户登录后调用,为本机桌面客户端申请客户端凭证。"}, + "POST /api/desktop/clients/rotate": {"轮换客户端密钥", "客户端使用旧凭证调用,换取新的客户端密钥。"}, + "POST /api/desktop/clients/heartbeat": {"客户端心跳上报", "桌面客户端定时上报存活与版本,服务端用于在线状态展示。"}, + "POST /api/desktop/clients/offline": {"客户端主动离线", "桌面客户端退出/休眠时上报,立即将本机标记为离线。"}, + "POST /api/desktop/clients/revoke": {"吊销客户端", "由桌面端调用,立即注销当前客户端凭证。"}, + + // --- Desktop:派单与拉取 --- + "GET /api/desktop/dispatch": {"派单 WebSocket", "桌面客户端通过 WebSocket 长连接订阅派单事件,断线后自动重连。"}, + "GET /api/desktop/accounts": {"拉取账号列表(桌面)", "桌面客户端拉取本机绑定/可用的媒体账号,支持增量同步。"}, + "GET /api/desktop/content/articles/:id": {"拉取文章正文(桌面)", "桌面客户端在执行发布任务时拉取目标文章的最新内容。"}, + "GET /api/desktop/content/assets/:token": {"桌面端访问素材", "桌面客户端使用签名 token 拉取文章中的图片/附件。"}, + "GET /api/desktop/publish-tasks": {"拉取发布任务(桌面)", "桌面客户端分页查询当前账号需要执行的发布任务。"}, + "POST /api/desktop/accounts/health-reports": {"上报账号健康状态", "桌面客户端定期上报媒体账号的登录态、风控信号等。"}, + + // --- Desktop:账号增删改 --- + "POST /api/desktop/accounts": {"上报/绑定媒体账号", "桌面客户端登录媒体平台后,将账号信息上报到云端。"}, + "PATCH /api/desktop/accounts/:id": {"更新媒体账号", "桌面客户端更新已绑定的媒体账号字段(昵称、头像、Cookie 等)。"}, + "DELETE /api/desktop/accounts/:id": {"删除媒体账号(桌面)", "桌面客户端解绑账号,仅清理本地侧记录。"}, + + // --- Desktop:发布任务执行 --- + "POST /api/desktop/tasks/lease": {"领取发布任务", "桌面客户端从队列领取一批可执行任务,原子加锁。"}, + "POST /api/desktop/tasks/:id/lease": {"续锁/重领任务", "对指定任务再次加锁,常用于异常重试场景。"}, + "POST /api/desktop/tasks/:id/extend": {"延长任务锁", "执行较慢时延长锁定时间,避免任务被其他客户端抢走。"}, + "POST /api/desktop/tasks/:id/cancel": {"取消任务(桌面)", "桌面客户端在执行失败/用户中止时通知服务端取消。"}, + "POST /api/desktop/tasks/:id/result": {"上报任务结果", "桌面客户端将发布结果(成功/失败/截图/链接)回传服务端。"}, + "POST /api/desktop/publish-tasks/:id/retry": {"重试发布任务", "对失败的发布任务发起重试。"}, + + // --- Desktop:监控采集任务 --- + "POST /api/desktop/monitoring/tasks/lease": {"领取监控任务", "桌面客户端领取 GEO 监控的爬取任务(AI 平台问答抓取)。"}, + "POST /api/desktop/monitoring/tasks/resume": {"恢复监控任务", "客户端断线重连后恢复未完成的监控任务。"}, + "POST /api/desktop/monitoring/tasks/:id/result": {"上报监控结果", "上报某条监控任务在 AI 平台的抓取结果。"}, + "POST /api/desktop/monitoring/tasks/:id/skip": {"跳过监控任务", "无法完成时跳过该条任务,并附带原因。"}, + "POST /api/desktop/monitoring/tasks/:id/cancel": {"取消监控任务", "桌面端主动取消正在执行的监控任务。"}, + + // --- Tenant:账号 / 任务统一视图(管理员视角) --- + "GET /api/tenant/accounts": {"租户媒体账号列表", "运营人员在管理后台查看本租户下所有媒体账号。"}, + "POST /api/tenant/accounts/:id/request-delete": {"申请删除媒体账号", "由后台发起异步删除,桌面端确认后真正解绑。"}, + "POST /api/tenant/tasks/:id/reconcile": {"对账发布任务", "管理后台对发布任务做对账:状态校正、结果回填。"}, + "POST /api/tenant/tasks/:id/cancel": {"取消发布任务(后台)", "管理员在后台取消还未派发或还在执行的发布任务。"}, + "POST /api/tenant/publish-jobs": {"批量创建发布任务", "为一批文章/账号批量生成发布任务,进入派发队列。"}, + + // --- Tenant:媒体 --- + "GET /api/tenant/media/platforms": {"媒体平台列表", "返回当前租户可用的媒体平台元数据(图标、能力开关等)。"}, + + // --- Tenant:工作台 --- + "GET /api/tenant/workspace/overview": {"工作台概览", "首页顶部统计:文章数、发布数、KOL 订阅数等。"}, + "GET /api/tenant/workspace/recent-articles": {"最近文章", "工作台展示最近创建/更新的若干篇文章。"}, + "GET /api/tenant/workspace/quota-summary": {"额度概览", "返回当前 Workspace 的 AI 点数、存储、发布次数等额度。"}, + "GET /api/tenant/workspace/ai-point-usage": {"AI 点数消耗明细", "分页查询 AI 点数的扣减流水,可按时间筛选。"}, + "GET /api/tenant/workspace/template-cards": {"模板入口卡片", "工作台首页的模板推荐卡片。"}, + "GET /api/tenant/workspace/kol-cards": {"KOL 入口卡片", "工作台首页的 KOL 订阅推荐卡片。"}, + + // --- Tenant:模板 --- + "GET /api/tenant/templates": {"模板列表", "返回当前 Workspace 可见的内容模板。"}, + "GET /api/tenant/templates/:id": {"模板详情", "返回模板字段定义、prompt 结构、示例等。"}, + "POST /api/tenant/templates/:id/drafts": {"保存模板草稿", "保存用户在模板表单上的临时输入,便于稍后续填。"}, + "POST /api/tenant/templates/:id/analyze-tasks": {"创建主题分析任务", "基于用户输入创建主题分析异步任务。"}, + "GET /api/tenant/templates/:id/analyze_task_result": {"获取主题分析结果", "轮询主题分析任务结果,需要带上 task_id。"}, + "POST /api/tenant/templates/:id/title-tasks": {"创建标题生成任务", "基于主题创建标题候选生成任务。"}, + "GET /api/tenant/templates/:id/gen_title_task_result": {"获取标题生成结果", "轮询标题生成任务结果。"}, + "POST /api/tenant/templates/:id/outline-tasks": {"创建大纲生成任务", "基于选定标题创建大纲生成任务。"}, + "GET /api/tenant/templates/:id/gen_outline_task_result": {"获取大纲生成结果", "轮询大纲生成任务结果。"}, + "POST /api/tenant/templates/:id/generate": {"生成正文", "基于模板入参直接生成完整文章并落库。"}, + + // --- Tenant:KOL 管理(创作者侧) --- + "GET /api/tenant/kol/manage/profile": {"KOL 主页信息", "获取当前用户作为 KOL 的资料卡。"}, + "PUT /api/tenant/kol/manage/profile": {"更新 KOL 主页", "更新 KOL 名称、简介、行业、联系方式等。"}, + "POST /api/tenant/kol/manage/profile/avatar": {"上传 KOL 头像", "Multipart 上传 KOL 头像,返回访问 URL。"}, + + "GET /api/tenant/kol/manage/packages": {"KOL 套餐列表", "KOL 自己管理名下的套餐(产品包)列表。"}, + "POST /api/tenant/kol/manage/packages": {"新建 KOL 套餐", "创建新的 KOL 套餐草稿。"}, + "PUT /api/tenant/kol/manage/packages/:id": {"更新 KOL 套餐", "修改套餐基础信息(名称、价格、描述、封面等)。"}, + "DELETE /api/tenant/kol/manage/packages/:id": {"删除 KOL 套餐", "仅未发布或已归档的套餐可删除。"}, + "PUT /api/tenant/kol/manage/packages/:id/publish": {"发布 KOL 套餐", "把草稿态套餐发布到市场,对所有租户可见。"}, + "PUT /api/tenant/kol/manage/packages/:id/archive": {"下架 KOL 套餐", "将已发布的套餐归档,不再对外展示。"}, + "POST /api/tenant/kol/manage/packages/:id/self-subscription": {"KOL 自订阅套餐", "KOL 自己订阅自己的套餐用于体验/测试。"}, + "DELETE /api/tenant/kol/manage/packages/:id/self-subscription": {"取消 KOL 自订阅", "取消 KOL 对自己套餐的自订阅。"}, + + "GET /api/tenant/kol/manage/packages/:id/prompts": {"套餐下的 Prompt 列表", "查看某个 KOL 套餐内挂载的 Prompt 列表。"}, + "POST /api/tenant/kol/manage/packages/:id/prompts": {"新增套餐 Prompt", "在套餐下创建新的 Prompt 配置。"}, + "GET /api/tenant/kol/manage/prompts/:id": {"获取 Prompt 最新版", "返回该 Prompt 的最新版本(含变量、模板正文)。"}, + "PUT /api/tenant/kol/manage/prompts/:id": {"更新 Prompt 元信息", "更新 Prompt 名称、说明、可见性等元数据。"}, + "DELETE /api/tenant/kol/manage/prompts/:id": {"删除 Prompt", "彻底删除该 Prompt 及其历史版本(若已发布且有订阅则禁止)。"}, + "POST /api/tenant/kol/manage/prompts/:id/save": {"保存 Prompt 草稿", "保存 Prompt 正文与变量,作为新的草稿版本。"}, + "POST /api/tenant/kol/manage/prompts/:id/publish": {"发布 Prompt 版本", "将当前草稿发布为新版本,订阅者可使用。"}, + "PUT /api/tenant/kol/manage/prompts/:id/activate": {"激活 Prompt", "把已发布的 Prompt 切换为当前激活版本。"}, + "PUT /api/tenant/kol/manage/prompts/:id/archive": {"归档 Prompt", "归档某个 Prompt,新订阅不再可见。"}, + + "POST /api/tenant/kol/manage/assist": {"KOL 创作助手(同步)", "基于上下文一次性生成 Prompt 优化建议。"}, + "POST /api/tenant/kol/manage/assist/stream": {"KOL 创作助手(SSE 流式)", "通过 Server-Sent Events 流式返回创作助手输出。"}, + "GET /api/tenant/kol/manage/assist/:id": {"查询创作助手任务", "按任务 id 查询此前的助手生成结果。"}, + + // --- Tenant:KOL Dashboard(创作者数据看板) --- + "GET /api/tenant/kol/dashboard/overview": {"KOL 看板概览", "KOL 自己的看板:订阅数、生成数、收益等。"}, + "GET /api/tenant/kol/dashboard/packages": {"KOL 看板分套餐数据", "按套餐维度的订阅与使用统计。"}, + "GET /api/tenant/kol/dashboard/trend": {"KOL 看板趋势", "按日聚合的订阅、生成、收益趋势曲线。"}, + + // --- Tenant:KOL Marketplace(订阅方视角) --- + "GET /api/tenant/kol/marketplace/packages": {"KOL 市场套餐列表", "面向订阅方的 KOL 市场列表,可按行业/关键词筛选。"}, + "GET /api/tenant/kol/marketplace/packages/:id": {"KOL 市场套餐详情", "套餐详情页:包含价格、Prompt 列表、案例等。"}, + "POST /api/tenant/kol/marketplace/packages/:id/subscribe": {"订阅 KOL 套餐", "当前 Workspace 订阅指定套餐,进入计费/试用流程。"}, + + "GET /api/tenant/kol/subscriptions": {"我的 KOL 订阅", "返回当前 Workspace 已订阅的 KOL 套餐列表。"}, + "GET /api/tenant/kol/subscription-prompts": {"我的订阅 Prompt 列表", "已订阅套餐下可用的 Prompt 集合。"}, + "GET /api/tenant/kol/subscription-prompts/:id/schema": {"订阅 Prompt 输入 Schema", "返回该 Prompt 的入参字段定义,用于前端渲染表单。"}, + "POST /api/tenant/kol/subscription-prompts/:id/generate": {"用订阅 Prompt 生成内容", "基于订阅 Prompt 生成一篇文章,自动扣 KOL 计费点数。"}, + + // --- Tenant:文章 --- + "GET /api/tenant/articles": {"文章列表", "支持按生成/发布状态、来源、关键词、时间等筛选。"}, + "POST /api/tenant/articles": {"创建文章", "手动创建一篇空文章草稿。"}, + "POST /api/tenant/articles/generate-from-rule": {"根据 Prompt 规则生成文章", "基于已配置的 Prompt 规则生成一篇文章。"}, + "POST /api/tenant/articles/imitations/generate": {"仿写生成文章", "基于参考文链接/原文进行仿写并生成新文章。"}, + "POST /api/tenant/articles/:id/images": {"上传文章配图", "Multipart 上传文章正文使用的图片。"}, + "POST /api/tenant/articles/:id/selection-optimize/stream": {"选区优化(SSE 流式)", "针对用户选中的段落做润色/改写,结果流式返回。"}, + "GET /api/tenant/articles/:id": {"文章详情", "返回文章的完整结构(标题、大纲、正文、版本号等)。"}, + "PUT /api/tenant/articles/:id": {"保存文章", "全量更新文章内容(标题、正文、媒体附件等)。"}, + "GET /api/tenant/articles/:id/stream": {"文章生成 SSE 流", "订阅文章正文生成过程的流式输出。"}, + "GET /api/tenant/articles/:id/versions": {"文章版本历史", "返回该文章的历史版本列表,可用于回滚。"}, + "GET /api/tenant/articles/:id/publish-records": {"文章发布记录", "返回该文章在各媒体平台的发布历史。"}, + "DELETE /api/tenant/articles/:id": {"删除文章", "软删除文章;可在回收站撤回(需带 undo 参数)。"}, + + // --- Tenant:品牌库 --- + "GET /api/tenant/brands": {"品牌列表", "返回当前 Workspace 下的品牌列表。"}, + "GET /api/tenant/brands/library-summary": {"品牌库概览", "品牌、关键词、问题、竞品的总数与最近变更。"}, + "POST /api/tenant/brands": {"新建品牌", "创建一个新品牌。"}, + "GET /api/tenant/brands/:id": {"品牌详情", "返回品牌基础信息及统计。"}, + "PUT /api/tenant/brands/:id": {"更新品牌", "更新品牌名称、行业、官网等。"}, + "DELETE /api/tenant/brands/:id": {"删除品牌", "删除品牌及其下属关键词、问题、竞品。"}, + + "GET /api/tenant/brands/:id/keywords": {"品牌关键词列表", "返回某品牌下的关键词。"}, + "POST /api/tenant/brands/:id/keywords": {"新增关键词", "为某品牌添加关键词。"}, + "PUT /api/tenant/brands/:id/keywords/:kid": {"更新关键词", "修改关键词文本/分组。"}, + "DELETE /api/tenant/brands/:id/keywords/:kid": {"删除关键词", "删除某条关键词。"}, + + "GET /api/tenant/brands/:id/questions": {"品牌问题列表", "返回品牌下的监控问题,可按 keyword_id 过滤。"}, + "POST /api/tenant/brands/:id/questions": {"新增监控问题", "为品牌添加一条 GEO 监控问题。"}, + "PUT /api/tenant/brands/:id/questions/:qid": {"更新监控问题", "修改问题文本或所属关键词。"}, + "DELETE /api/tenant/brands/:id/questions/:qid": {"删除监控问题", "删除某条监控问题。"}, + + "GET /api/tenant/brands/:id/competitors": {"竞品列表", "返回品牌下登记的竞品。"}, + "POST /api/tenant/brands/:id/competitors": {"新增竞品", "为品牌添加竞品记录。"}, + "PUT /api/tenant/brands/:id/competitors/:cid": {"更新竞品", "修改竞品名称/官网/备注。"}, + "DELETE /api/tenant/brands/:id/competitors/:cid": {"删除竞品", "删除某条竞品记录。"}, + + // --- Tenant:监控(GEO 仪表盘) --- + "GET /api/tenant/monitoring/dashboard/composite": {"监控仪表盘综合视图", "组合返回品牌曝光、引用率、问答覆盖等监控指标。"}, + "GET /api/tenant/monitoring/citation-summary": {"引用情况汇总", "按 AI 平台维度返回品牌的引用频次与变化。"}, + "GET /api/tenant/monitoring/brands/:brand_id/questions/:question_id/detail": {"问题级监控详情", "某问题在各 AI 平台的回答样本与引用拆解。"}, + "POST /api/tenant/monitoring/brands/:brand_id/collect-now": {"立即触发采集", "对某品牌立即下发一次监控采集任务,跳过定时计划。"}, + + // --- Tenant:知识库 --- + "GET /api/tenant/knowledge/groups": {"知识库分组列表", "返回当前 Workspace 的知识库分组。"}, + "POST /api/tenant/knowledge/groups": {"新建知识库分组", "创建新的知识库分组。"}, + "PUT /api/tenant/knowledge/groups/:gid": {"更新知识库分组", "重命名/移动分组。"}, + "DELETE /api/tenant/knowledge/groups/:gid": {"删除知识库分组", "删除分组及其下条目(按业务规则限制)。"}, + + "GET /api/tenant/knowledge/items": {"知识条目列表", "按分组返回知识库条目,可分页。"}, + "GET /api/tenant/knowledge/items/:id": {"知识条目详情", "返回条目的解析后正文、来源、向量化状态。"}, + "POST /api/tenant/knowledge/items/text": {"新增文本条目", "粘贴一段文本作为知识条目入库。"}, + "POST /api/tenant/knowledge/items/url": {"新增链接条目", "提交 URL,由后台抓取并解析后入库。"}, + "POST /api/tenant/knowledge/items/file": {"上传文件条目", "Multipart 上传 PDF/Word/MD 文件作为知识条目。"}, + "POST /api/tenant/knowledge/items/retry/:id": {"重试解析", "对解析失败的条目重新触发解析与向量化。"}, + "DELETE /api/tenant/knowledge/items/:id": {"删除知识条目", "彻底删除某条知识条目。"}, + + // --- Tenant:Prompt 规则 --- + "GET /api/tenant/prompt-rules": {"Prompt 规则列表", "查询 Prompt 规则,支持分组、状态、关键字筛选。"}, + "GET /api/tenant/prompt-rules/simple": {"Prompt 规则下拉项", "为下拉框/选择器提供精简结构(id + 名称)。"}, + "POST /api/tenant/prompt-rules": {"新建 Prompt 规则", "创建新的 Prompt 生成规则。"}, + "GET /api/tenant/prompt-rules/:id": {"Prompt 规则详情", "返回规则完整配置(变量、模板、约束)。"}, + "PUT /api/tenant/prompt-rules/:id": {"更新 Prompt 规则", "修改规则配置。"}, + "DELETE /api/tenant/prompt-rules/:id": {"删除 Prompt 规则", "删除规则;若有定时任务在引用则禁止。"}, + "PUT /api/tenant/prompt-rules/:id/status": {"切换 Prompt 规则启停", "启用或禁用 Prompt 规则。"}, + "GET /api/tenant/prompt-rules/groups": {"Prompt 规则分组列表", "返回 Prompt 规则的分组用于左侧导航。"}, + "POST /api/tenant/prompt-rules/groups": {"新建分组", "创建 Prompt 规则分组。"}, + "PUT /api/tenant/prompt-rules/groups/:gid": {"更新分组", "重命名分组或调整顺序。"}, + "DELETE /api/tenant/prompt-rules/groups/:gid": {"删除分组", "删除空分组。"}, + + // --- Tenant:定时任务 --- + "GET /api/tenant/schedules": {"定时任务列表", "查询定时生成任务,支持按 Prompt 规则、状态、时间筛选。"}, + "POST /api/tenant/schedules": {"创建定时任务", "为某 Prompt 规则配置 cron 周期任务。"}, + "GET /api/tenant/schedules/:id": {"定时任务详情", "返回任务配置与最近一次执行结果。"}, + "PUT /api/tenant/schedules/:id": {"更新定时任务", "修改 cron、参数、并发等。"}, + "DELETE /api/tenant/schedules/:id": {"删除定时任务", "彻底删除该定时任务。"}, + "PUT /api/tenant/schedules/:id/status": {"切换定时任务启停", "启动或暂停某个定时任务。"}, + + // --- Tenant:即时任务 --- + "GET /api/tenant/instant-tasks": {"即时任务列表", "查询用户手动触发的即时生成任务。"}, + + // --- Tenant:图片库 --- + "GET /api/tenant/images": {"图片列表", "分页查询图片,可按分组、关键词筛选。"}, + "POST /api/tenant/images": {"上传图片", "Multipart 上传单张图片到指定分组。"}, + "PUT /api/tenant/images/:id": {"更新图片", "重命名、移动分组或修改 alt。"}, + "GET /api/tenant/images/:id/references": {"图片引用情况", "查询图片被哪些文章/草稿引用。"}, + "DELETE /api/tenant/images/:id": {"删除图片", "软删除图片,可强制(force=1)跳过引用校验。"}, + "GET /api/tenant/images/storage-usage": {"图片存储用量", "返回当前 Workspace 已使用的图片存储与配额。"}, + + "GET /api/tenant/images/folders": {"图片分组列表", "返回图片分组树。"}, + "POST /api/tenant/images/folders": {"创建图片分组", "新建图片分组。"}, + "PUT /api/tenant/images/folders/:id": {"更新图片分组", "重命名或移动分组。"}, + "GET /api/tenant/images/folders/:id/delete-preview": {"删除分组预览", "返回删除该分组将影响的图片数量。"}, + "DELETE /api/tenant/images/folders/:id": {"删除图片分组", "删除空分组。"}, + + // --- Swagger 自身 --- + "GET /swagger": {"Swagger 入口", "重定向至 /swagger/index.html。"}, + "GET /swagger/": {"Swagger 入口", "重定向至 /swagger/index.html。"}, + "GET /swagger/index.html": {"Swagger UI", "提供本服务的 Swagger UI 页面。"}, + "GET /swagger/openapi.json": {"OpenAPI 规范", "返回当前服务自动生成的 OpenAPI 3 文档。"}, +} + +// docFor returns the documented summary/description for a route, falling back +// to " " when no entry is registered. +func docFor(method, path string) routeDoc { + if doc, ok := routeDocs[strings.ToUpper(method)+" "+path]; ok { + return doc + } + return routeDoc{Summary: strings.ToUpper(method) + " " + path} +} diff --git a/server/internal/shared/swagger/descriptions_parity_test.go b/server/internal/shared/swagger/descriptions_parity_test.go new file mode 100644 index 0000000..ae899c6 --- /dev/null +++ b/server/internal/shared/swagger/descriptions_parity_test.go @@ -0,0 +1,147 @@ +package swagger + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" +) + +// TestRouteDocs_CoversEveryRouterEntry parses the tenant-api router source and +// asserts that every registered route has a corresponding entry in routeDocs. +// This is a static-analysis safety net: when a new handler is wired up in +// internal/tenant/transport/router.go, this test fails until a Chinese +// summary is added. +func TestRouteDocs_CoversEveryRouterEntry(t *testing.T) { + routerPath, bootstrapPath := findRepoSources(t) + + routes := append(parseRoutes(t, routerPath), parseEngineRoutes(t, bootstrapPath)...) + if len(routes) == 0 { + t.Fatalf("could not extract any routes from %s", routerPath) + } + + missing := make([]string, 0) + for _, r := range routes { + if shouldSkipRoute(r.path) { + continue + } + if _, ok := routeDocs[r.method+" "+r.path]; !ok { + missing = append(missing, r.method+" "+r.path) + } + } + sort.Strings(missing) + if len(missing) > 0 { + t.Fatalf("routeDocs is missing %d route(s):\n %s", len(missing), strings.Join(missing, "\n ")) + } + + registered := map[string]struct{}{} + for _, r := range routes { + registered[r.method+" "+r.path] = struct{}{} + } + extras := make([]string, 0) + for k := range routeDocs { + if strings.HasPrefix(k, "GET /swagger") { + continue + } + if _, ok := registered[k]; !ok { + extras = append(extras, k) + } + } + sort.Strings(extras) + if len(extras) > 0 { + t.Fatalf("routeDocs has %d entry/entries with no matching route (renamed or removed):\n %s", + len(extras), strings.Join(extras, "\n ")) + } +} + +type parsedRoute struct { + method string + path string +} + +var ( + groupRe = regexp.MustCompile(`(\w+)\s*:?=\s*([\w.]+)\.Group\("([^"]*)"\)`) + methodVarRe = regexp.MustCompile(`\b(\w+)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"`) + methodEngRe = regexp.MustCompile(`\b(?:app\.Engine|engine)\.(GET|POST|PUT|PATCH|DELETE)\("([^"]*)"`) +) + +func parseRoutes(t *testing.T, path string) []parsedRoute { + t.Helper() + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + text := string(src) + + prefixes := map[string][2]string{} // var -> [parent, prefix] + for _, line := range strings.Split(text, "\n") { + if m := groupRe.FindStringSubmatch(line); m != nil { + prefixes[m[1]] = [2]string{m[2], m[3]} + } + } + resolve := func(v string) string { + var parts []string + for { + pp, ok := prefixes[v] + if !ok { + break + } + parts = append([]string{pp[1]}, parts...) + v = pp[0] + } + return strings.Join(parts, "") + } + + routes := make([]parsedRoute, 0) + seen := map[string]struct{}{} + add := func(method, fullPath string) { + key := method + " " + fullPath + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + routes = append(routes, parsedRoute{method: method, path: fullPath}) + } + for _, line := range strings.Split(text, "\n") { + for _, m := range methodVarRe.FindAllStringSubmatch(line, -1) { + v, meth, p := m[1], m[2], m[3] + if _, ok := prefixes[v]; !ok { + continue + } + add(meth, resolve(v)+p) + } + for _, m := range methodEngRe.FindAllStringSubmatch(line, -1) { + add(m[1], m[2]) + } + } + return routes +} + +func parseEngineRoutes(t *testing.T, path string) []parsedRoute { + t.Helper() + src, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + routes := make([]parsedRoute, 0) + for _, m := range methodEngRe.FindAllStringSubmatch(string(src), -1) { + routes = append(routes, parsedRoute{method: m[1], path: m[2]}) + } + return routes +} + +func findRepoSources(t *testing.T) (router, bootstrap string) { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatalf("runtime.Caller failed") + } + // .../server/internal/shared/swagger/descriptions_parity_test.go -> + // .../server is three levels up from this file's parent dir. + serverRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) + return filepath.Join(serverRoot, "internal", "tenant", "transport", "router.go"), + filepath.Join(serverRoot, "internal", "bootstrap", "bootstrap.go") +} diff --git a/server/internal/shared/swagger/swagger.go b/server/internal/shared/swagger/swagger.go index bf5b499..98acef9 100644 --- a/server/internal/shared/swagger/swagger.go +++ b/server/internal/shared/swagger/swagger.go @@ -72,12 +72,16 @@ func BuildOpenAPI(routes gin.RoutesInfo, cfg Config) map[string]any { tagSet[tag] = struct{}{} operationID := uniqueOperationID(method+"_"+sanitizeIdentifier(path), operationIDs) + doc := docFor(route.Method, route.Path) operation := map[string]any{ "tags": []string{tag}, - "summary": route.Method + " " + path, + "summary": doc.Summary, "operationId": operationID, "responses": responsesForRoute(route), } + if doc.Description != "" { + operation["description"] = doc.Description + } if params := parametersForRoute(route); len(params) > 0 { operation["parameters"] = params diff --git a/server/internal/shared/swagger/swagger_test.go b/server/internal/shared/swagger/swagger_test.go index bafd587..84c0865 100644 --- a/server/internal/shared/swagger/swagger_test.go +++ b/server/internal/shared/swagger/swagger_test.go @@ -31,6 +31,12 @@ func TestBuildOpenAPI_GeneratesUsableSpecFromGinRoutes(t *testing.T) { articlePath := paths["/api/tenant/articles/{id}"].(map[string]any) getArticle := articlePath["get"].(map[string]any) require.Equal(t, []map[string][]string{{"bearerAuth": []string{}}}, getArticle["security"]) + require.Equal(t, "文章详情", getArticle["summary"]) + require.NotEmpty(t, getArticle["description"]) + + loginPath := paths["/api/auth/login"].(map[string]any) + loginPost := loginPath["post"].(map[string]any) + require.Equal(t, "账号密码登录", loginPost["summary"]) params := getArticle["parameters"].([]map[string]any) require.Contains(t, parameterNames(params), "id") @@ -50,6 +56,18 @@ func TestEnabledForMode_DisablesReleaseOnly(t *testing.T) { require.True(t, EnabledForMode("debug")) } +func TestDocFor_FallsBackWhenMissing(t *testing.T) { + doc := docFor("GET", "/api/some/unregistered/path") + require.Equal(t, "GET /api/some/unregistered/path", doc.Summary) + require.Empty(t, doc.Description) +} + +func TestDocFor_KnownRoute(t *testing.T) { + doc := docFor("POST", "/api/auth/login") + require.Equal(t, "账号密码登录", doc.Summary) + require.NotEmpty(t, doc.Description) +} + func parameterNames(params []map[string]any) []string { names := make([]string, 0, len(params)) for _, param := range params {