feat(api): 新增公共接口 api/common/v1(tools 工具端点)并修复 gtime 格式化
- api/common/v1/tools.go 契约 + internal/controller/common 实现:uuid/md5/random/time/ip - cmd.go 注册 /api/common/v1 公开分组(无鉴权),复用 common/tools Go 包 - 修复 gtime v2.10.2 坑:Format 为 PHP 风格,Go layout 须用 Layout(),timex 封装为 Layout 语义 - 冒烟测试 5 端点全部通过;AGENTS.md / PROJECT_STRUCTURE.md / change-log 同步更新
This commit is contained in:
parent
8d5f2c65ae
commit
1486bb903f
17
AGENTS.md
17
AGENTS.md
@ -22,13 +22,16 @@
|
||||
|
||||
```
|
||||
service.xpcool.com/
|
||||
├── api/ # HTTP 契约与 Swagger 元数据(api/user/v1, api/admin/v1)
|
||||
├── api/ # HTTP 契约与 Swagger 元数据
|
||||
│ ├── user/v1/ # /api/v1 用户端 API(登录公开,其余走 UserAuth)
|
||||
│ ├── admin/v1/ # /admin/v1 管理端 API(AdminAuth + X-Permission)
|
||||
│ └── common/v1/ # /api/common/v1 公共接口(给前端调用,公开无鉴权)
|
||||
├── common/ # 公共可复用模块(不依赖 internal,可独立抽取成库)
|
||||
│ └── tools/ # 工具模块,按子功能分包(见下文)
|
||||
├── internal/
|
||||
│ ├── cmd/ # 启动引导
|
||||
│ ├── cmd/ # 启动引导(路由分组注册在此)
|
||||
│ ├── consts/ # 错误码与常量
|
||||
│ ├── controller/ # API→service 适配层(不写业务)
|
||||
│ ├── controller/ # API→service 适配层(不写业务;含 common/ 公共接口实现)
|
||||
│ ├── service/ # 领域用例(业务逻辑直接写这里,不用 logic/)
|
||||
│ ├── dao/ # gf gen dao 生成,禁止手改
|
||||
│ ├── model/ # entity/ do/ dto/ vo/(entity、do 生成,禁止手改)
|
||||
@ -40,6 +43,12 @@ service.xpcool.com/
|
||||
└── utility/ # (预留)跨切面辅助
|
||||
```
|
||||
|
||||
## 公共接口(api/common/v1,前端调用)
|
||||
|
||||
- 路由前缀 `/api/common/v1`,**公开、无鉴权**(登录接口外的通用能力),实现于 `internal/controller/common`。
|
||||
- 当前端点:`GET/POST /tools/*`(uuid、md5、random、time、ip),底层复用 `common/tools` Go 包。
|
||||
- 新增公共接口:在 `api/common/v1` 加 Req/Res(g.Meta 带 path/method),在 `internal/controller/common` 加方法,`internal/cmd/cmd.go` 的 `/api/common/v1` 分组会自动绑定。
|
||||
|
||||
## 公共工具模块 common/tools
|
||||
|
||||
- 规则:**不依赖 internal/**,只薄封装 GoFrame 内置组件;新工具优先复用内置(gmd5/gaes/gdes/guid/grand/gtime/gconv/gstr/gfile...),避免重复造轮子。
|
||||
@ -84,6 +93,8 @@ go test ./...
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ⚠️ **gtime v2.10.2 格式化**:`Time.Format("Y-m-d H:i:s")` 是 PHP 风格;传 Go layout(`2006-01-02`)要用 `Time.Layout(...)`。工具包 `common/tools/timex` 已统一封装。
|
||||
- ⚠️ **gf v2.10.2 包名与旧版不同**:AES/DES 在 `crypto/gaes`、`crypto/gdes`(无 gcrypto);UUID 在 `util/guid`(无 guuid);无 gslicer(用标准库 slices)。
|
||||
- 配置文件按 `GF_GCFG_FILE` 切换;`manifest/config/config.yaml` 不入库。
|
||||
- 生产环境强密码:`JWT_SECRET`、数据库口令。
|
||||
- 变更涉及 API 时同步更新 `api/` 下的 Swagger 元数据注释。
|
||||
|
||||
@ -4,14 +4,15 @@
|
||||
service.xpcool.com/
|
||||
├── api/ # HTTP contracts and Swagger metadata
|
||||
│ ├── user/v1/ # /api/v1 - client-facing API
|
||||
│ └── admin/v1/ # /admin/v1 - administration API
|
||||
│ ├── admin/v1/ # /admin/v1 - administration API
|
||||
│ └── common/v1/ # /api/common/v1 - public API for frontends (no auth)
|
||||
├── 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
|
||||
│ ├── controller/ # API-to-service adapters only (incl. common/ public API)
|
||||
│ ├── service/ # domain use cases and provider interfaces
|
||||
│ ├── dao/ # generated by gf gen dao; never hand edited
|
||||
│ ├── model/
|
||||
|
||||
65
api/common/v1/tools.go
Normal file
65
api/common/v1/tools.go
Normal file
@ -0,0 +1,65 @@
|
||||
// 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
|
||||
}
|
||||
@ -17,14 +17,17 @@ func Now() *gtime.Time {
|
||||
return gtime.Now()
|
||||
}
|
||||
|
||||
// Format returns t formatted with the given layout.
|
||||
// Format returns t formatted with the given Go layout.
|
||||
// When layout is empty, LayoutDateTime is used.
|
||||
//
|
||||
// Note: gtime v2.10.2's Format() takes PHP-style format ("Y-m-d H:i:s"),
|
||||
// so this helper uses the Layout() method which accepts Go layouts.
|
||||
func Format(t *gtime.Time, layout ...string) string {
|
||||
ly := LayoutDateTime
|
||||
if len(layout) > 0 && layout[0] != "" {
|
||||
ly = layout[0]
|
||||
}
|
||||
return t.Format(ly)
|
||||
return t.Layout(ly)
|
||||
}
|
||||
|
||||
// Timestamp returns the current Unix timestamp in seconds.
|
||||
@ -34,10 +37,10 @@ func Timestamp() int64 {
|
||||
|
||||
// StartOfDay returns the beginning (00:00:00) of the day containing t.
|
||||
func StartOfDay(t *gtime.Time) *gtime.Time {
|
||||
return gtime.NewFromStr(t.Format(LayoutDate) + " 00:00:00")
|
||||
return gtime.NewFromStr(t.Layout(LayoutDate) + " 00:00:00")
|
||||
}
|
||||
|
||||
// EndOfDay returns the end (23:59:59) of the day containing t.
|
||||
func EndOfDay(t *gtime.Time) *gtime.Time {
|
||||
return gtime.NewFromStr(t.Format(LayoutDate) + " 23:59:59")
|
||||
return gtime.NewFromStr(t.Layout(LayoutDate) + " 23:59:59")
|
||||
}
|
||||
|
||||
@ -46,3 +46,40 @@
|
||||
- 后续每次任务完成后:更新 `docs/change-log/`(当日文件追加)+ 提交 git,确保其他账号 clone 即恢复上下文。
|
||||
- `convertx` 依赖 gconv 的"转换失败返回零值"行为(无法区分"0"与非法输入),需要严格转换的场景应在 service 层先校验。
|
||||
- 本次变更尚未 git 提交,建议尽快 commit。
|
||||
|
||||
---
|
||||
|
||||
## 追加(17:30):方向纠正 — 「公共模块」实为公共接口
|
||||
|
||||
### 请求
|
||||
|
||||
用户澄清:要新增的是**给前端调用的公共 HTTP 接口**(此前误建成 Go 工具包),确认应规划到 `api/` 契约层。
|
||||
|
||||
### 变更
|
||||
|
||||
新增文件:
|
||||
|
||||
- `api/common/v1/tools.go` — 公共接口契约:`UUIDReq/Res`、`MD5Req/Res`、`RandomReq/Res`、`TimeReq/Res`、`IPReq/Res`(g.Meta 路由元数据)
|
||||
- `internal/controller/common/tools.go` — 控制器实现,薄适配层,复用 `common/tools/*` Go 包
|
||||
|
||||
修改文件:
|
||||
|
||||
- `internal/cmd/cmd.go` — 新增公开分组 `s.Group("/api/common/v1", ...)`(Recover+CORS+HandlerResponse,**无鉴权**)
|
||||
- `common/tools/timex/timex.go` — 修复:`Format` 内部改用 `Layout()` 方法(见决策)
|
||||
- `internal/controller/common/tools.go` — `Time` 用 `now.Layout(...)`
|
||||
- `AGENTS.md` — 目录树与新增「公共接口」小节、注意事项补 gtime 与包名差异
|
||||
- `PROJECT_STRUCTURE.md` — 目录树补充 `api/common/v1`
|
||||
|
||||
验证:`go build ./...`、`go vet` 通过;**实际启动服务冒烟测试** 5 个端点全部返回正确(含修复后 time 格式化)。
|
||||
|
||||
### 决策与理由
|
||||
|
||||
- **路由前缀 `/api/common/v1`**:与 `/api/v1`(user)、`/admin/v1`(admin) 平级的独立公开前缀,天然不套登录鉴权;前端三个端(mini/h5/app)通用。
|
||||
- **契约层放 `api/common/v1`,实现放 `internal/controller/common`**:与 user/admin 完全同构;公共接口不需要 service 层(纯工具计算),controller 直接复用 `common/tools` 包,避免过度分层。
|
||||
- **🐛 gtime v2.10.2 大坑(已修复)**:`Time.Format()` 参数是 **PHP 风格**(`"Y-m-d H:i:s"`),传 Go layout(`"2006-01-02 15:04:05"`)会原样输出!Go layout 必须用 `Time.Layout()`。`common/tools/timex` 已统一封装为 `Layout` 语义,调用方直接 `timex.Format(t, layout...)` 即可。
|
||||
- **冒烟测试教训**:`go run` 会 spawn 子进程,`kill %1` 只杀包装进程,残留的 `main.exe` 会继续占用 8000 端口导致后续测试打到旧代码——杀进程需 `netstat -ano | grep :8000` 找 PID 后 `Stop-Process`。
|
||||
|
||||
### 待办与风险
|
||||
|
||||
- 本次追加变更同样未提交 git,与上文合并为一次 commit。
|
||||
- 公共接口已开放无鉴权能力(md5/random/uuid 等),后续新增接口时需评审是否应限流/加签名,避免被滥用。
|
||||
|
||||
@ -8,6 +8,7 @@ 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"
|
||||
userctl "service.xpcool.com/internal/controller/user"
|
||||
"service.xpcool.com/internal/library/jwt"
|
||||
@ -33,6 +34,10 @@ var (
|
||||
hello.NewV1(),
|
||||
)
|
||||
})
|
||||
s.Group("/api/common/v1", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||
group.Bind(commonctl.New()) // Public common/tools API, no auth required.
|
||||
})
|
||||
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
|
||||
group.Bind(userctl.New()) // Login and refresh routes are public.
|
||||
|
||||
66
internal/controller/common/tools.go
Normal file
66
internal/controller/common/tools.go
Normal file
@ -0,0 +1,66 @@
|
||||
// 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user