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:
夏犀麟 2026-08-24 17:29:30 +08:00
parent 8d5f2c65ae
commit 1486bb903f
7 changed files with 197 additions and 9 deletions

View File

@ -22,13 +22,16 @@
``` ```
service.xpcool.com/ 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 管理端 APIAdminAuth + X-Permission
common/v1/ # /api/common/v1 公共接口给前端调用公开无鉴权
common/ # 公共可复用模块不依赖 internal可独立抽取成库 common/ # 公共可复用模块不依赖 internal可独立抽取成库
tools/ # 工具模块按子功能分包见下文 tools/ # 工具模块按子功能分包见下文
internal/ internal/
cmd/ # 启动引导 cmd/ # 启动引导路由分组注册在此
consts/ # 错误码与常量 consts/ # 错误码与常量
controller/ # APIservice 适配层不写业务 controller/ # APIservice 适配层不写业务 common/ 公共接口实现
service/ # 领域用例业务逻辑直接写这里不用 logic/ service/ # 领域用例业务逻辑直接写这里不用 logic/
dao/ # gf gen dao 生成禁止手改 dao/ # gf gen dao 生成禁止手改
model/ # entity/ do/ dto/ vo/entitydo 生成禁止手改 model/ # entity/ do/ dto/ vo/entitydo 生成禁止手改
@ -40,6 +43,12 @@ service.xpcool.com/
utility/ # 预留跨切面辅助 utility/ # 预留跨切面辅助
``` ```
## 公共接口api/common/v1前端调用
- 路由前缀 `/api/common/v1`**公开无鉴权**登录接口外的通用能力实现于 `internal/controller/common`
- 当前端点`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` 分组会自动绑定
## 公共工具模块 common/tools ## 公共工具模块 common/tools
- 规则**不依赖 internal/**只薄封装 GoFrame 内置组件新工具优先复用内置gmd5/gaes/gdes/guid/grand/gtime/gconv/gstr/gfile...避免重复造轮子 - 规则**不依赖 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` gcryptoUUID `util/guid` guuid gslicer用标准库 slices
- 配置文件按 `GF_GCFG_FILE` 切换`manifest/config/config.yaml` 不入库 - 配置文件按 `GF_GCFG_FILE` 切换`manifest/config/config.yaml` 不入库
- 生产环境强密码`JWT_SECRET`数据库口令 - 生产环境强密码`JWT_SECRET`数据库口令
- 变更涉及 API 时同步更新 `api/` 下的 Swagger 元数据注释 - 变更涉及 API 时同步更新 `api/` 下的 Swagger 元数据注释

View File

@ -4,14 +4,15 @@
service.xpcool.com/ service.xpcool.com/
api/ # HTTP contracts and Swagger metadata api/ # HTTP contracts and Swagger metadata
user/v1/ # /api/v1 - client-facing API 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) common/ # public reusable module (no internal/ deps)
tools/ # utility toolbox: md5, cryptox, uuid, random, tools/ # utility toolbox: md5, cryptox, uuid, random,
# timex, convertx, strx, slicex, ip, filex # timex, convertx, strx, slicex, ip, filex
internal/ internal/
cmd/ # application bootstrap and route isolation cmd/ # application bootstrap and route isolation
consts/ # application error codes and constants 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 service/ # domain use cases and provider interfaces
dao/ # generated by gf gen dao; never hand edited dao/ # generated by gf gen dao; never hand edited
model/ model/

65
api/common/v1/tools.go Normal file
View 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
}

View File

@ -17,14 +17,17 @@ func Now() *gtime.Time {
return gtime.Now() 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. // 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 { func Format(t *gtime.Time, layout ...string) string {
ly := LayoutDateTime ly := LayoutDateTime
if len(layout) > 0 && layout[0] != "" { if len(layout) > 0 && layout[0] != "" {
ly = layout[0] ly = layout[0]
} }
return t.Format(ly) return t.Layout(ly)
} }
// Timestamp returns the current Unix timestamp in seconds. // 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. // StartOfDay returns the beginning (00:00:00) of the day containing t.
func StartOfDay(t *gtime.Time) *gtime.Time { 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. // EndOfDay returns the end (23:59:59) of the day containing t.
func EndOfDay(t *gtime.Time) *gtime.Time { 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")
} }

View File

@ -46,3 +46,40 @@
- 后续每次任务完成后更新 `docs/change-log/`当日文件追加+ 提交 git确保其他账号 clone 即恢复上下文 - 后续每次任务完成后更新 `docs/change-log/`当日文件追加+ 提交 git确保其他账号 clone 即恢复上下文
- `convertx` 依赖 gconv "转换失败返回零值"行为无法区分"0"与非法输入需要严格转换的场景应在 service 层先校验 - `convertx` 依赖 gconv "转换失败返回零值"行为无法区分"0"与非法输入需要严格转换的场景应在 service 层先校验
- 本次变更尚未 git 提交建议尽快 commit - 本次变更尚未 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 后续新增接口时需评审是否应限流/加签名避免被滥用

View File

@ -8,6 +8,7 @@ import (
"github.com/gogf/gf/v2/os/gcmd" "github.com/gogf/gf/v2/os/gcmd"
adminctl "service.xpcool.com/internal/controller/admin" adminctl "service.xpcool.com/internal/controller/admin"
commonctl "service.xpcool.com/internal/controller/common"
"service.xpcool.com/internal/controller/hello" "service.xpcool.com/internal/controller/hello"
userctl "service.xpcool.com/internal/controller/user" userctl "service.xpcool.com/internal/controller/user"
"service.xpcool.com/internal/library/jwt" "service.xpcool.com/internal/library/jwt"
@ -33,6 +34,10 @@ var (
hello.NewV1(), 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) { s.Group("/api/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse) group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
group.Bind(userctl.New()) // Login and refresh routes are public. group.Bind(userctl.New()) // Login and refresh routes are public.

View 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
}