refactor(api): 公共接口改名 open 并重构契约层为 tools/<子功能>/index.go

- api/common/v1 → api/open/v1,路由前缀 /api/common/v1 → /api/open/v1
- 契约层按子功能拆目录:api/open/v1/tools/{uuid,md5,random,time,ip}/index.go
- 控制器同步拆文件:internal/controller/open/{uuid,md5,random,time,ip}.go
- 命名规则(open 优于 common)与 tools 目录规则记入 AGENTS.md
- 冒烟测试:旧路由 404,新路由 5 端点全部通过
This commit is contained in:
夏犀麟 2026-08-24 18:03:30 +08:00
parent 1486bb903f
commit 82b1ad2b5c
18 changed files with 248 additions and 143 deletions

View File

@ -25,13 +25,14 @@ service.xpcool.com/
api/ # HTTP 契约与 Swagger 元数据
user/v1/ # /api/v1 用户端 API登录公开其余走 UserAuth
admin/v1/ # /admin/v1 管理端 APIAdminAuth + X-Permission
common/v1/ # /api/common/v1 公共接口给前端调用公开无鉴权
open/v1/ # /api/open/v1 开放接口给前端调用公开无鉴权
tools/ # 工具子功能契约每子功能一目录tools/<name>/index.go
common/ # 公共可复用模块不依赖 internal可独立抽取成库
tools/ # 工具模块按子功能分包见下文
internal/
cmd/ # 启动引导路由分组注册在此
consts/ # 错误码与常量
controller/ # APIservice 适配层不写业务 common/ 公共接口实现
controller/ # APIservice 适配层不写业务 open/ 开放接口实现
service/ # 领域用例业务逻辑直接写这里不用 logic/
dao/ # gf gen dao 生成禁止手改
model/ # entity/ do/ dto/ vo/entitydo 生成禁止手改
@ -43,11 +44,13 @@ service.xpcool.com/
utility/ # 预留跨切面辅助
```
## 公共接口api/common/v1前端调用
## 开放接口api/open/v1前端调用与命名规则
- 路由前缀 `/api/common/v1`**公开无鉴权**登录接口外的通用能力实现于 `internal/controller/common`
- **命名决策**公共接口前缀用 **open**不用 common理由`common` 语义偏"内部公共代码"`open` 是开放接口业界惯例支付宝 /open/api 更能表达"对外暴露、无鉴权"同属"公开"语义的备选还有 `public`
- 路由前缀 `/api/open/v1`**公开无鉴权**实现于 `internal/controller/open`
- **tools 子功能目录规则**`api/open/v1/tools/<子功能名>/index.go` 定义该子功能的 Req/Res 契约index.go `package <子功能名>`控制器 `internal/controller/open/<子功能名>.go` 放对应方法controller 统一 `package open`
- 当前端点`GET/POST /tools/*`uuidmd5randomtimeip底层复用 `common/tools` Go
- 新增公共接口 `api/common/v1` Req/Resg.Meta path/method `internal/controller/common` 加方法`internal/cmd/cmd.go` `/api/common/v1` 分组会自动绑定
- **新增子功能三步** `api/open/v1/tools/<name>/index.go` Req/Resg.Meta path/method `internal/controller/open/<name>.go` 加方法 路由自动绑定无需改 cmd.go
## 公共工具模块 common/tools

View File

@ -5,14 +5,15 @@ service.xpcool.com/
api/ # HTTP contracts and Swagger metadata
user/v1/ # /api/v1 - client-facing API
admin/v1/ # /admin/v1 - administration API
common/v1/ # /api/common/v1 - public API for frontends (no auth)
open/v1/ # /api/open/v1 - open API for frontends (no auth)
tools/ # one dir per sub-feature: tools/<name>/index.go
common/ # public reusable module (no internal/ deps)
tools/ # utility toolbox: md5, cryptox, uuid, random,
# timex, convertx, strx, slicex, ip, filex
internal/
cmd/ # application bootstrap and route isolation
consts/ # application error codes and constants
controller/ # API-to-service adapters only (incl. common/ public API)
controller/ # API-to-service adapters only (incl. open/ open API)
service/ # domain use cases and provider interfaces
dao/ # generated by gf gen dao; never hand edited
model/

View File

@ -1,65 +0,0 @@
// Package v1 defines the public common API contracts served at /api/common/v1.
//
// These endpoints are open to any frontend client (mini/h5/app) and require
// no authentication. Implementations live in internal/controller/common and
// reuse the common/tools Go packages as their backend.
package v1
import "github.com/gogf/gf/v2/frame/g"
// UUIDReq generates a unique ID.
type UUIDReq struct {
g.Meta `path:"/tools/uuid" method:"get" tags:"Common/Tools" summary:"Generate a unique ID"`
Short bool `json:"short"` // true: 8-char short code; false: 32-char ID
}
// UUIDRes is the response of UUIDReq.
type UUIDRes struct {
UUID string `json:"uuid"`
}
// MD5Req computes an MD5 digest.
type MD5Req struct {
g.Meta `path:"/tools/md5" method:"post" tags:"Common/Tools" summary:"Compute MD5 digest"`
Text string `json:"text" v:"required#text required"`
}
// MD5Res is the response of MD5Req.
type MD5Res struct {
MD5 string `json:"md5"`
}
// RandomReq generates a random string.
type RandomReq struct {
g.Meta `path:"/tools/random" method:"get" tags:"Common/Tools" summary:"Generate a random string"`
Length int `json:"length" d:"16" v:"min:1|max:128#length must be 1-128"`
Type string `json:"type" d:"alnum" v:"in:alnum,digits,letters#unsupported type"`
}
// RandomRes is the response of RandomReq.
type RandomRes struct {
Value string `json:"value"`
}
// TimeReq returns the current server time.
type TimeReq struct {
g.Meta `path:"/tools/time" method:"get" tags:"Common/Tools" summary:"Current server time"`
}
// TimeRes is the response of TimeReq.
type TimeRes struct {
Timestamp int64 `json:"timestamp"` // unix seconds
DateTime string `json:"dateTime"` // 2006-01-02 15:04:05
Date string `json:"date"` // 2006-01-02
}
// IPReq returns the caller's IP information.
type IPReq struct {
g.Meta `path:"/tools/ip" method:"get" tags:"Common/Tools" summary:"Client IP info"`
}
// IPRes is the response of IPReq.
type IPRes struct {
IP string `json:"ip"`
Internal bool `json:"internal"` // whether the IP is a private/internal address
}

14
api/open/v1/tools/doc.go Normal file
View File

@ -0,0 +1,14 @@
// Package tools hosts the open API tool endpoints (GET/POST /tools/*).
//
// Naming rule: every sub-feature gets its own directory under tools/, and the
// Req/Res contracts live in an index.go inside that directory, e.g.
//
// api/open/v1/tools/uuid/index.go - GET /tools/uuid
// api/open/v1/tools/md5/index.go - POST /tools/md5
// api/open/v1/tools/random/index.go - GET /tools/random
// api/open/v1/tools/time/index.go - GET /tools/time
// api/open/v1/tools/ip/index.go - GET /tools/ip
//
// Add a new feature by creating tools/<name>/index.go and a matching method
// in internal/controller/open/<name>.go; the router binds it automatically.
package tools

View File

@ -0,0 +1,15 @@
// Package ip defines the GET /tools/ip endpoint contract.
package ip
import "github.com/gogf/gf/v2/frame/g"
// IPReq returns the caller's IP information.
type IPReq struct {
g.Meta `path:"/tools/ip" method:"get" tags:"Open/Tools" summary:"Client IP info"`
}
// IPRes is the response of IPReq.
type IPRes struct {
IP string `json:"ip"`
Internal bool `json:"internal"` // whether the IP is a private/internal address
}

View File

@ -0,0 +1,15 @@
// Package md5 defines the POST /tools/md5 endpoint contract.
package md5
import "github.com/gogf/gf/v2/frame/g"
// MD5Req computes an MD5 digest.
type MD5Req struct {
g.Meta `path:"/tools/md5" method:"post" tags:"Open/Tools" summary:"Compute MD5 digest"`
Text string `json:"text" v:"required#text required"`
}
// MD5Res is the response of MD5Req.
type MD5Res struct {
MD5 string `json:"md5"`
}

View File

@ -0,0 +1,16 @@
// Package random defines the GET /tools/random endpoint contract.
package random
import "github.com/gogf/gf/v2/frame/g"
// RandomReq generates a random string.
type RandomReq struct {
g.Meta `path:"/tools/random" method:"get" tags:"Open/Tools" summary:"Generate a random string"`
Length int `json:"length" d:"16" v:"min:1|max:128#length must be 1-128"`
Type string `json:"type" d:"alnum" v:"in:alnum,digits,letters#unsupported type"`
}
// RandomRes is the response of RandomReq.
type RandomRes struct {
Value string `json:"value"`
}

View File

@ -0,0 +1,16 @@
// Package time defines the GET /tools/time endpoint contract.
package time
import "github.com/gogf/gf/v2/frame/g"
// TimeReq returns the current server time.
type TimeReq struct {
g.Meta `path:"/tools/time" method:"get" tags:"Open/Tools" summary:"Current server time"`
}
// TimeRes is the response of TimeReq.
type TimeRes struct {
Timestamp int64 `json:"timestamp"` // unix seconds
DateTime string `json:"dateTime"` // 2006-01-02 15:04:05
Date string `json:"date"` // 2006-01-02
}

View File

@ -0,0 +1,15 @@
// Package uuid defines the GET /tools/uuid endpoint contract.
package uuid
import "github.com/gogf/gf/v2/frame/g"
// UUIDReq generates a unique ID.
type UUIDReq struct {
g.Meta `path:"/tools/uuid" method:"get" tags:"Open/Tools" summary:"Generate a unique ID"`
Short bool `json:"short"` // true: 8-char short code; false: 32-char ID
}
// UUIDRes is the response of UUIDReq.
type UUIDRes struct {
UUID string `json:"uuid"`
}

View File

@ -83,3 +83,48 @@
- 本次追加变更同样未提交 git与上文合并为一次 commit
- 公共接口已开放无鉴权能力md5/random/uuid 后续新增接口时需评审是否应限流/加签名避免被滥用
---
## 追加18:05开放接口命名决策 + API 目录重构
### 请求
1. 变更 API 目录设计契约层改为 `tools/<子功能>/index.go` 结构示例 `/api/common/v1/tools/ocr/index.go`
2. 同步修改所有涉及处控制器路由
3. 把命名规则记入项目记忆
4. 咨询公共接口一般用 common 命名吗有没有更好的
### 变更
新增文件契约层按子功能拆目录
- `api/open/v1/tools/doc.go` tools 目录结构规则说明
- `api/open/v1/tools/uuid/index.go``md5/index.go``random/index.go``time/index.go``ip/index.go` 每子功能一个目录index.go `package <子功能名>`g.Meta tags `Open/Tools`
- `internal/controller/open/controller.go` Controller 结构 + New()
- `internal/controller/open/uuid.go``md5.go``random.go``time.go``ip.go` 按子功能拆文件import 对应契约子包
删除文件
- `api/common/v1/tools.go``api/common/` 目录
- `internal/controller/common/tools.go``internal/controller/common/` 目录
修改文件
- `internal/cmd/cmd.go` import `commonctl``openctl`路由 `/api/common/v1``/api/open/v1`注释改 Open tools API
- `AGENTS.md` 目录树更新公共接口小节改为开放接口api/open/v1与命名规则记录 open 命名决策与 tools 子功能目录规则
- `PROJECT_STRUCTURE.md` 目录树同步
验证`go build ./...``go vet` 通过冒烟测试旧路由 `/api/common/v1/tools/time` 返回 404新路由 `/api/open/v1/tools/*` 5 端点全部正确
### 决策与理由
- **命名 common open**用户询问"公共接口一般用 common 吗"对比后选 **open**业界开放接口惯例如支付宝 /open/api语义强调对外暴露无鉴权`public` 为并列备选更强调"公开"`common` 偏内部通用语义弃用命名规则已记入 AGENTS.md开放接口小节
- **契约层目录规则**`api/open/v1/tools/<子功能名>/index.go` 每子功能一目录 GoFrame 惯例api 下按功能分包一致用户示例 ocr 即为后续子功能如未来加 OCR 识别接口即建 `tools/ocr/index.go`控制器 `internal/controller/open/<子功能名>.go` 对应拆文件 package open
- **路由绑定不受目录重构影响**GoFrame 通过 controller 方法参数反射定位 g.Meta契约包拆成多个子包uuid/md5/random/time/ip不影响 `group.Bind(openctl.New())` 自动注册cmd.go 只需改前缀
- 包名 `time`api/open/v1/tools/time与标准库 time 潜在同名controller 中统一用 import 别名 `timeapi` 规避
### 待办与风险
- 前端若已联调旧 `/api/common/v1` 路径需同步改 `/api/open/v1`当前无线上前端风险低
- 新增子功能记得更新 `api/open/v1/tools/doc.go` 的示例清单

View File

@ -8,8 +8,8 @@ import (
"github.com/gogf/gf/v2/os/gcmd"
adminctl "service.xpcool.com/internal/controller/admin"
commonctl "service.xpcool.com/internal/controller/common"
"service.xpcool.com/internal/controller/hello"
openctl "service.xpcool.com/internal/controller/open"
userctl "service.xpcool.com/internal/controller/user"
"service.xpcool.com/internal/library/jwt"
"service.xpcool.com/internal/middleware"
@ -34,9 +34,9 @@ var (
hello.NewV1(),
)
})
s.Group("/api/common/v1", func(group *ghttp.RouterGroup) {
s.Group("/api/open/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
group.Bind(commonctl.New()) // Public common/tools API, no auth required.
group.Bind(openctl.New()) // Open tools API for frontends, no auth required.
})
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)

View File

@ -1,66 +0,0 @@
// Package common implements the public common API (/api/common/v1).
// These controllers are thin adapters over the common/tools Go packages and
// require no authentication.
package common
import (
"context"
"github.com/gogf/gf/v2/frame/g"
commonv1 "service.xpcool.com/api/common/v1"
"service.xpcool.com/common/tools/ip"
"service.xpcool.com/common/tools/md5"
"service.xpcool.com/common/tools/random"
"service.xpcool.com/common/tools/timex"
"service.xpcool.com/common/tools/uuid"
)
// Controller implements the /api/common/v1 endpoints.
type Controller struct{}
// New creates a common API controller.
func New() *Controller { return &Controller{} }
// UUID generates a unique ID (32-char by default, 8-char when short=true).
func (c *Controller) UUID(ctx context.Context, req *commonv1.UUIDReq) (res *commonv1.UUIDRes, err error) {
if req.Short {
return &commonv1.UUIDRes{UUID: uuid.Short(8)}, nil
}
return &commonv1.UUIDRes{UUID: uuid.New()}, nil
}
// MD5 computes the MD5 digest of the given text.
func (c *Controller) MD5(ctx context.Context, req *commonv1.MD5Req) (res *commonv1.MD5Res, err error) {
return &commonv1.MD5Res{MD5: md5.Md5Hex(req.Text)}, nil
}
// Random generates a random string of the requested type and length.
func (c *Controller) Random(ctx context.Context, req *commonv1.RandomReq) (res *commonv1.RandomRes, err error) {
var value string
switch req.Type {
case "digits":
value = random.Digits(req.Length)
case "letters":
value = random.Letters(req.Length)
default:
value = random.String(req.Length)
}
return &commonv1.RandomRes{Value: value}, nil
}
// Time returns the current server timestamp and formatted time.
func (c *Controller) Time(ctx context.Context, req *commonv1.TimeReq) (res *commonv1.TimeRes, err error) {
now := timex.Now()
return &commonv1.TimeRes{
Timestamp: now.Timestamp(),
DateTime: now.Layout(timex.LayoutDateTime),
Date: now.Layout(timex.LayoutDate),
}, nil
}
// IP returns the caller's IP and whether it is an internal address.
func (c *Controller) IP(ctx context.Context, req *commonv1.IPReq) (res *commonv1.IPRes, err error) {
clientIP := g.RequestFromCtx(ctx).GetClientIp()
return &commonv1.IPRes{IP: clientIP, Internal: ip.IsInternal(clientIP)}, nil
}

View File

@ -0,0 +1,11 @@
// Package open implements the public open API (/api/open/v1).
// These controllers are thin adapters over the common/tools Go packages and
// require no authentication. Each sub-feature lives in its own file here,
// mirroring api/open/v1/tools/<name>/index.go.
package open
// Controller implements the /api/open/v1 endpoints.
type Controller struct{}
// New creates an open API controller.
func New() *Controller { return &Controller{} }

View File

@ -0,0 +1,16 @@
package open
import (
"context"
"github.com/gogf/gf/v2/frame/g"
ipapi "service.xpcool.com/api/open/v1/tools/ip"
"service.xpcool.com/common/tools/ip"
)
// IP returns the caller's IP and whether it is an internal address.
func (c *Controller) IP(ctx context.Context, req *ipapi.IPReq) (res *ipapi.IPRes, err error) {
clientIP := g.RequestFromCtx(ctx).GetClientIp()
return &ipapi.IPRes{IP: clientIP, Internal: ip.IsInternal(clientIP)}, nil
}

View File

@ -0,0 +1,13 @@
package open
import (
"context"
md5api "service.xpcool.com/api/open/v1/tools/md5"
"service.xpcool.com/common/tools/md5"
)
// MD5 computes the MD5 digest of the given text.
func (c *Controller) MD5(ctx context.Context, req *md5api.MD5Req) (res *md5api.MD5Res, err error) {
return &md5api.MD5Res{MD5: md5.Md5Hex(req.Text)}, nil
}

View File

@ -0,0 +1,22 @@
package open
import (
"context"
randomapi "service.xpcool.com/api/open/v1/tools/random"
"service.xpcool.com/common/tools/random"
)
// Random generates a random string of the requested type and length.
func (c *Controller) Random(ctx context.Context, req *randomapi.RandomReq) (res *randomapi.RandomRes, err error) {
var value string
switch req.Type {
case "digits":
value = random.Digits(req.Length)
case "letters":
value = random.Letters(req.Length)
default:
value = random.String(req.Length)
}
return &randomapi.RandomRes{Value: value}, nil
}

View File

@ -0,0 +1,18 @@
package open
import (
"context"
timeapi "service.xpcool.com/api/open/v1/tools/time"
"service.xpcool.com/common/tools/timex"
)
// Time returns the current server timestamp and formatted time.
func (c *Controller) Time(ctx context.Context, req *timeapi.TimeReq) (res *timeapi.TimeRes, err error) {
now := timex.Now()
return &timeapi.TimeRes{
Timestamp: now.Timestamp(),
DateTime: now.Layout(timex.LayoutDateTime),
Date: now.Layout(timex.LayoutDate),
}, nil
}

View File

@ -0,0 +1,16 @@
package open
import (
"context"
uuidapi "service.xpcool.com/api/open/v1/tools/uuid"
"service.xpcool.com/common/tools/uuid"
)
// UUID generates a unique ID (32-char by default, 8-char when short=true).
func (c *Controller) UUID(ctx context.Context, req *uuidapi.UUIDReq) (res *uuidapi.UUIDRes, err error) {
if req.Short {
return &uuidapi.UUIDRes{UUID: uuid.Short(8)}, nil
}
return &uuidapi.UUIDRes{UUID: uuid.New()}, nil
}