# APP 端开发统一规范 ## 1. 目标 统一 APP 端接口的目录结构、编码范式、错误处理与文档注释,确保与管理端(admin 模块)保持一致的风格与契约。 ## 2. 目录结构 - 模块放置:`internal/api/activity/` - 每个端点单文件: - `activity_create.go` - `activity_issue_add.go` - `activity_list.go` - `activity_detail.go` - `activity_issues_list.go` - `issue_rewards_list.go` ## 3. Handler 初始化 - 在模块根定义 `handler` 与 `New(logger, db)`,注入 `writeDB`/`readDB` - 路由绑定示例:`appHandler := app.New(logger, db)`,方法统一返回 `core.HandlerFunc` ## 4. 请求/响应范式 - 所有接口必须定义 `Request` 与 `Response` 结构体 - 成功统一:`ctx.Payload(res)`,`res.Message` 使用统一文案 `操作成功` ## 5. 参数绑定与校验 - JSON:`ctx.ShouldBindJSON(req)` - 表单/查询:`ctx.ShouldBindForm(req)` - 绑定错误:`ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))` ## 6. 业务错误处理 - 统一:`ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))` - 业务码:在 `internal/code/` 中约定并使用具体业务码 ## 7. 分页与过滤规范 - 分页:`page` 默认 1、`page_size` 默认 20、最大 100,超过返回参数错误 - 过滤:字符串使用 `Like("%xxx%")`,数值/枚举使用 `Eq(...)`,`is_boss` 仅允许 `0/1` ## 8. 路径参数规范 - 使用 `strconv.Atoi` 并校验 `> 0`,非法返回 `ParamBindError` ## 9. 时间规范 - 入参解析:`timeutil.ParseCSTInLocation` - 响应时间:`FriendlyTime` 或固定格式化 `2006-01-02 15:04` ## 10. Swagger 注释规范 - 标签:APP 端统一使用 `@Tags APP端.活动`(或模块名) - 必填注释:`@Summary/@Description/@Accept/@Produce/@Param/@Success/@Failure/@Router` ## 11. 统一 Handler 模板(示例) 以下示例来自 `internal/api/activity/activity_issues_list.go:46-127`,作为 APP 端列表接口标准参考: ```go // ListActivityIssues 活动期列表 // @Summary 活动期列表 // @Description 获取指定活动的期列表,支持分页 // @Tags APP端.活动 // @Accept json // @Produce json // @Param activity_id path int true "活动ID" // @Param page query int true "页码" default(1) // @Param page_size query int true "每页数量,最多100" default(20) // @Success 200 {object} listIssuesResponse // @Failure 400 {object} code.Failure // @Router /api/app/activities/{activity_id}/issues [get] func (h *handler) ListActivityIssues() core.HandlerFunc { return func(ctx core.Context) { idStr := ctx.Param("activity_id") id, err := strconv.Atoi(idStr) if err != nil || id <= 0 { ctx.AbortWithError(core.Error( http.StatusBadRequest, code.ParamBindError, "活动ID无效"), ) return } req := new(listIssuesRequest) res := new(listIssuesResponse) if err := ctx.ShouldBindForm(req); err != nil { ctx.AbortWithError(core.Error( http.StatusBadRequest, code.ParamBindError, validation.Error(err)), ) return } if req.Page == 0 { req.Page = 1 } if req.PageSize == 0 { req.PageSize = 20 } if req.PageSize > 100 { ctx.AbortWithError(core.Error( http.StatusBadRequest, code.ParamBindError, "每页最多100条"), ) return } query := h.readDB.ActivityIssues.WithContext(ctx.RequestContext()). Where(h.readDB.ActivityIssues.ActivityID.Eq(int64(id))) listQuery := query countQuery := query issues, err := listQuery.Order(h.readDB.ActivityIssues.ID.Desc()). Limit(req.PageSize).Offset((req.Page-1)*req.PageSize).Find() if err != nil { ctx.AbortWithError(core.Error( http.StatusBadRequest, code.ServerError, err.Error()), ) return } total, err := countQuery.Count() if err != nil { ctx.AbortWithError(core.Error( http.StatusBadRequest, code.ServerError, err.Error()), ) return } res.Page = req.Page res.PageSize = req.PageSize res.Total = total res.List = make([]issueListItem, len(issues)) for i, v := range issues { res.List[i] = issueListItem{ ID: v.ID, IssueNumber: v.IssueNumber, Status: v.Status, Sort: v.Sort, CreatedAt: timeutil.FriendlyTime(v.CreatedAt), UpdatedAt: timeutil.FriendlyTime(v.UpdatedAt), } } ctx.Payload(res) } } ``` ## 12. 文档生成与预览 - 生成:`make gen-swagger` - 预览:`make serve-swagger`(默认端口 `36666`,访问 `http://localhost:36666/docs`)