feat(common): 新增公共模块 common/tools 及项目上下文记忆体系

- common/tools 工具模块,10 个子功能:md5/cryptox/uuid/random/timex/convertx/strx/slicex/ip/filex(薄封装 GoFrame 内置组件)
- AGENTS.md 项目智能体说明书(架构、规范、命令、记忆体系索引)
- docs/change-log/2026-08-24.md 本次请求与变更记录
- .gitignore 排除 .workbuddy/;PROJECT_STRUCTURE.md 补充目录树
This commit is contained in:
夏犀麟 2026-08-24 17:18:10 +08:00
parent 54135d2be5
commit 8d5f2c65ae
16 changed files with 646 additions and 1 deletions

5
.gitignore vendored
View File

@ -16,4 +16,7 @@ manifest/output/
temp/ temp/
temp.yaml temp.yaml
bin bin
**/config/config.yaml **/config/config.yaml
# WorkBuddy 本地记忆不入库跨账号上下文见 AGENTS.md docs/change-log/
.workbuddy/

89
AGENTS.md Normal file
View File

@ -0,0 +1,89 @@
# AGENTS.md 项目智能体说明书
> 本文件是给所有 AI 编程助手CodeBuddy / WorkBuddy / Claude Code / Codex / Cursor 看的项目级上下文
> 任何账号 clone 本仓库后助手都应先读本文件与 `docs/change-log/` 下最近的记录即可无缝衔接
> 请保持本文件**长期稳定**只写架构规范约定不要写一次性事项
## 项目简介
`service.xpcool.com`个人多客户端mini / h5 / app后端服务GoFrame v2 单体应用
核心业务用户认证JWT内容收藏站内消息后台管理RBAC + 操作审计
## 技术栈
| | |
|---|---|
| 语言 | Go 1.23.0 |
| 框架 | github.com/gogf/gf/v2 v2.10.2 |
| 数据库 | MySQLORM gf 生成 dao/do/entity |
| 认证 | JWT`internal/library/jwt`admin 另有 `X-Permission` 校验 |
## 目录结构
```
service.xpcool.com/
api/ # HTTP 契约与 Swagger 元数据api/user/v1, api/admin/v1
common/ # 公共可复用模块不依赖 internal可独立抽取成库
tools/ # 工具模块按子功能分包见下文
internal/
cmd/ # 启动引导
consts/ # 错误码与常量
controller/ # APIservice 适配层不写业务
service/ # 领域用例业务逻辑直接写这里不用 logic/
dao/ # gf gen dao 生成禁止手改
model/ # entity/ do/ dto/ vo/entitydo 生成禁止手改
middleware/ # 路由中间件
library/ # jwt / page / response 等内部基础件
table/ # 表列名常量
manifest/ # config.dev/test/prod.yamlsql 迁移
docs/change-log/ # 每次请求与变更的记录重要上下文记忆
utility/ # 预留跨切面辅助
```
## 公共工具模块 common/tools
- 规则**不依赖 internal/**只薄封装 GoFrame 内置组件新工具优先复用内置gmd5/gaes/gdes/guid/grand/gtime/gconv/gstr/gfile...避免重复造轮子
- 子功能`md5``cryptox`(AES/DES)`uuid``random``timex``convertx``strx``slicex``ip``filex`
- 新增子功能 `common/tools/` 下建子包更新 `common/tools/doc.go` 的布局清单
## 分层与调用规范必须遵守
1. 调用链`controller → service → dao → model(do)`controller 不碰 dao
2. DTO/VO 边界跨层出入参走 `internal/model/dto` `internal/model/vo`API 类型与 entity 不得越界
3. **数据库操作必须用 DO 对象**`internal/model/do`禁止 `g.Map`未赋值字段保持 nil 自动忽略
```go
dao.Users.Ctx(ctx).Where(cols.Id, id).Data(do.User{Uid: uid}).Update()
```
4. **时间字段自动维护**`created_at/updated_at/deleted_at` ORM 自动处理禁止手动赋值软删除用 `Delete()`禁止手写 `WhereNull(cols.DeletedAt)`
5. **错误处理一律用 gerror**保留堆栈响应统一走 `internal/library/response`
6. 生成代码dao/do/entity**禁止手改**改表后跑 `gf gen dao` 重新生成
7. 声明 3 个相关变量时 `var (...)` 块对齐
## 常用命令
```bash
# 运行dev
GF_GCFG_FILE=config.dev.yaml DB_DSN="user:pass@tcp(127.0.0.1:3306)/db?loc=Local" JWT_SECRET=xxx go run main.go
# 数据库模型生成唯一来源
gf gen dao -p internal -g default -gt -c
# 构建 / 测试
go build ./...
go test ./...
```
## 上下文记忆重要
三层配合保证换个账号/换台机器也能无缝衔接
1. **本文件AGENTS.md**长期稳定的架构与规范
2. **`docs/change-log/YYYY-MM-DD.md`**每次对话的请求 + 变更 + 决策记录 git 提交
- 每次完成任务后 `docs/change-log/` 已有当日文件则**追加**否则新建
- 格式固定`## 请求` / `## 变更`含文件清单/ `## 决策与理由` / `## 待办与风险`
- 助手开工前先读最近 1-2 快速恢复上下文
3. **`.workbuddy/memory/`**WorkBuddy 桌面端本机记忆每日日志 + MEMORY.md**已加入 .gitignore不入库**仅本机增强
## 注意事项
- 配置文件按 `GF_GCFG_FILE` 切换`manifest/config/config.yaml` 不入库
- 生产环境强密码`JWT_SECRET`数据库口令
- 变更涉及 API 时同步更新 `api/` 下的 Swagger 元数据注释

View File

@ -5,6 +5,9 @@ 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/ # public reusable module (no internal/ deps)
tools/ # utility toolbox: md5, cryptox, uuid, random,
# 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
@ -18,6 +21,7 @@ service.xpcool.com/
vo/ # API view models vo/ # API view models
middleware/ # configurable route-group middleware middleware/ # configurable route-group middleware
library/ # JWT, response, pagination primitives library/ # JWT, response, pagination primitives
docs/change-log/ # per-request change records (agent context)
manifest/ manifest/
config/ # config.dev/test/prod.yaml config/ # config.dev/test/prod.yaml
sql/ # ordered MySQL migrations sql/ # ordered MySQL migrations
@ -25,3 +29,5 @@ service.xpcool.com/
``` ```
`dao`, `model/do` and `model/entity` are generated after migration, so their schema never drifts from MySQL. Controllers do not access DAO; only services do. `dao`, `model/do` and `model/entity` are generated after migration, so their schema never drifts from MySQL. Controllers do not access DAO; only services do.
See `AGENTS.md` for the agent-facing conventions and the context-memory system.

10
common/doc.go Normal file
View File

@ -0,0 +1,10 @@
// Package common is the public, reusable module of service.xpcool.com.
//
// It contains only code that is independent of internal/ — anything here can
// be shared across services inside the repository, or extracted into a
// standalone library later without touching business code.
//
// Current layout:
//
// common/tools shared utility toolbox (md5, cryptox, uuid, ...)
package common

View File

@ -0,0 +1,64 @@
// Package convertx provides type conversion helpers with default-value
// fallback, built on top of gconv.
//
// Note: gconv silently returns the zero value when conversion fails, so the
// helpers here only fall back to the default for nil / blank-string inputs.
// Pass pre-validated data when strict conversion is required.
package convertx
import (
"strings"
"github.com/gogf/gf/v2/util/gconv"
)
// ToInt converts v to int, returning def when v is nil or a blank string.
func ToInt(v any, def int) int {
if isEmpty(v) {
return def
}
return gconv.Int(v)
}
// ToInt64 converts v to int64, returning def when v is nil or a blank string.
func ToInt64(v any, def int64) int64 {
if isEmpty(v) {
return def
}
return gconv.Int64(v)
}
// ToFloat64 converts v to float64, returning def when v is nil or a blank string.
func ToFloat64(v any, def float64) float64 {
if isEmpty(v) {
return def
}
return gconv.Float64(v)
}
// ToString converts v to string, returning def when v is nil.
func ToString(v any, def string) string {
if v == nil {
return def
}
return gconv.String(v)
}
// ToBool converts v to bool, returning def when v is nil or a blank string.
func ToBool(v any, def bool) bool {
if isEmpty(v) {
return def
}
return gconv.Bool(v)
}
// isEmpty reports whether v is nil or a blank string.
func isEmpty(v any) bool {
if v == nil {
return true
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s) == ""
}
return false
}

View File

@ -0,0 +1,80 @@
// Package cryptox provides AES/DES encryption helpers with base64 output,
// built on top of gaes and gdes.
//
// Keys: any-length secrets are accepted — they are normalized to the exact
// key size internally, so callers do not need to manage key lengths.
package cryptox
import (
"crypto/md5"
"encoding/base64"
"github.com/gogf/gf/v2/crypto/gaes"
"github.com/gogf/gf/v2/crypto/gdes"
"github.com/gogf/gf/v2/errors/gerror"
)
const (
aesKeySize = 16 // AES-128 key size in bytes
desKeySize = 8 // DES key size in bytes
)
// normalizeKey derives a fixed-size key from an arbitrary-length secret.
func normalizeKey(secret string, size int) []byte {
key := []byte(secret)
if len(key) == size {
return key
}
sum := md5.Sum([]byte(secret))
out := make([]byte, size)
for i := 0; i < size; i++ {
out[i] = sum[i%len(sum)]
}
return out
}
// AesEncrypt encrypts plainText with AES-128-CBC using a key derived from
// secret, and returns the ciphertext encoded in base64.
func AesEncrypt(plainText, secret string) (string, error) {
out, err := gaes.Encrypt([]byte(plainText), normalizeKey(secret, aesKeySize))
if err != nil {
return "", gerror.Wrap(err, `AesEncrypt failed`)
}
return base64.StdEncoding.EncodeToString(out), nil
}
// AesDecrypt decrypts the base64-encoded cipherText produced by AesEncrypt.
func AesDecrypt(cipherText, secret string) (string, error) {
data, err := base64.StdEncoding.DecodeString(cipherText)
if err != nil {
return "", gerror.Wrap(err, `base64 decode failed`)
}
out, err := gaes.Decrypt(data, normalizeKey(secret, aesKeySize))
if err != nil {
return "", gerror.Wrap(err, `AesDecrypt failed`)
}
return string(out), nil
}
// DesEncrypt encrypts plainText with DES-ECB (PKCS5 padding) using a key
// derived from secret, and returns the ciphertext encoded in base64.
func DesEncrypt(plainText, secret string) (string, error) {
out, err := gdes.EncryptECB([]byte(plainText), normalizeKey(secret, desKeySize), gdes.PKCS5PADDING)
if err != nil {
return "", gerror.Wrap(err, `DesEncrypt failed`)
}
return base64.StdEncoding.EncodeToString(out), nil
}
// DesDecrypt decrypts the base64-encoded cipherText produced by DesEncrypt.
func DesDecrypt(cipherText, secret string) (string, error) {
data, err := base64.StdEncoding.DecodeString(cipherText)
if err != nil {
return "", gerror.Wrap(err, `base64 decode failed`)
}
out, err := gdes.DecryptECB(data, normalizeKey(secret, desKeySize), gdes.PKCS5PADDING)
if err != nil {
return "", gerror.Wrap(err, `DesDecrypt failed`)
}
return string(out), nil
}

24
common/tools/doc.go Normal file
View File

@ -0,0 +1,24 @@
// Package tools is the shared utility toolbox of the project.
//
// Every sub-package is a thin, documented wrapper around GoFrame built-in
// components (gmd5, gaes, gdes, guid, grand, gtime, gconv, gstr, gfile, ...),
// so it stays small and consistent with the framework.
//
// Layout:
//
// common/tools/md5 MD5 digest helpers
// common/tools/cryptox AES/DES encryption with base64 output
// common/tools/uuid unique ID generation
// common/tools/random random numbers and strings
// common/tools/timex time formatting and computation
// common/tools/convertx type conversion with default fallback
// common/tools/strx string / naming / masking helpers
// common/tools/slicex generic slice utilities
// common/tools/ip IP address helpers
// common/tools/filex file system helpers
//
// Rules:
// - Never depend on internal/ — this module must stay self-contained.
// - Prefer reusing GoFrame built-in components over re-implementing.
// - Keep each helper small and add a Chinese doc comment.
package tools

View File

@ -0,0 +1,28 @@
// Package filex provides common file system helpers on top of gfile.
package filex
import (
"github.com/gogf/gf/v2/os/gfile"
)
// Exists reports whether the file or directory at path exists.
func Exists(path string) bool {
return gfile.Exists(path)
}
// IsDir reports whether path is a directory.
func IsDir(path string) bool {
return gfile.IsDir(path)
}
// ReadString returns the full content of the file at path as a string.
// Returns an empty string when the file does not exist.
func ReadString(path string) string {
return gfile.GetContents(path)
}
// WriteString writes content to the file at path, creating intermediate
// directories if needed.
func WriteString(path, content string) error {
return gfile.PutContents(path, content)
}

59
common/tools/ip/ip.go Normal file
View File

@ -0,0 +1,59 @@
// Package ip provides IP address helpers.
package ip
import (
"net"
"strconv"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/net/gipv4"
)
// IsValid reports whether s is a valid IPv4 address.
func IsValid(s string) bool {
return gipv4.Validate(s)
}
// LocalIP returns the first non-loopback IPv4 address of this host.
func LocalIP() (string, error) {
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", gerror.Wrap(err, `net.InterfaceAddrs failed`)
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok {
if ipv4 := ipNet.IP.To4(); ipv4 != nil && !ipv4.IsLoopback() {
return ipv4.String(), nil
}
}
}
return "", nil
}
// IsInternal reports whether s is a private/internal IPv4 address
// (private ranges, loopback or link-local).
func IsInternal(s string) bool {
parsed := net.ParseIP(s)
if parsed == nil || parsed.To4() == nil {
return false
}
return parsed.IsPrivate() || parsed.IsLoopback() || parsed.IsLinkLocalUnicast()
}
// ToLong converts an IPv4 string to its uint32 representation
// (big-endian, same as inet_aton).
func ToLong(s string) (uint32, error) {
ipv4 := net.ParseIP(s).To4()
if ipv4 == nil {
return 0, gerror.Newf(`invalid IPv4 address: %s`, s)
}
return uint32(ipv4[0])<<24 | uint32(ipv4[1])<<16 | uint32(ipv4[2])<<8 | uint32(ipv4[3]), nil
}
// ToString converts a uint32 IPv4 value to its dotted-decimal string.
func ToString(v uint32) string {
return strconv.Itoa(int(v>>24)) + "." +
strconv.Itoa(int(v>>16&0xFF)) + "." +
strconv.Itoa(int(v>>8&0xFF)) + "." +
strconv.Itoa(int(v&0xFF))
}

23
common/tools/md5/md5.go Normal file
View File

@ -0,0 +1,23 @@
// Package md5 provides MD5 digest helpers.
package md5
import (
"github.com/gogf/gf/v2/crypto/gmd5"
)
// Md5Hex returns the MD5 digest of s as a lowercase hex string.
// The underlying error is ignored because it never fails for in-memory input.
func Md5Hex(s string) string {
h, _ := gmd5.EncryptString(s)
return h
}
// Md5Bytes returns the MD5 digest of data as a lowercase hex string.
func Md5Bytes(data []byte) (string, error) {
return gmd5.Encrypt(data)
}
// Md5File returns the MD5 digest of the local file at path as a hex string.
func Md5File(path string) (string, error) {
return gmd5.EncryptFile(path)
}

View File

@ -0,0 +1,26 @@
// Package random provides random number and string generation helpers.
package random
import (
"github.com/gogf/gf/v2/util/grand"
)
// Int returns a random integer in [min, max].
func Int(min, max int) int {
return grand.N(min, max)
}
// String returns a random alphanumeric string of length n.
func String(n int) string {
return grand.S(n)
}
// Digits returns a random numeric-only string of length n, e.g. SMS codes.
func Digits(n int) string {
return grand.Digits(n)
}
// Letters returns a random letter-only string of length n.
func Letters(n int) string {
return grand.Letters(n)
}

View File

@ -0,0 +1,64 @@
// Package slicex provides generic slice utilities built on the standard
// library (Go 1.23+).
package slicex
import (
"slices"
)
// Contains reports whether v is present in items.
func Contains[T comparable](items []T, v T) bool {
return slices.Contains(items, v)
}
// Unique returns items with duplicates removed, preserving first-seen order.
func Unique[T comparable](items []T) []T {
seen := make(map[T]struct{}, len(items))
out := make([]T, 0, len(items))
for _, v := range items {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
// Chunk splits items into sub-slices of at most size elements.
// Returns nil when size <= 0 or items is empty.
func Chunk[T any](items []T, size int) [][]T {
if size <= 0 || len(items) == 0 {
return nil
}
out := make([][]T, 0, (len(items)+size-1)/size)
for len(items) > 0 {
n := size
if len(items) < n {
n = len(items)
}
out = append(out, items[:n])
items = items[n:]
}
return out
}
// Map applies fn to every element and returns the results.
func Map[T, R any](items []T, fn func(T) R) []R {
out := make([]R, len(items))
for i, v := range items {
out[i] = fn(v)
}
return out
}
// Filter returns the elements for which fn returns true, preserving order.
func Filter[T any](items []T, fn func(T) bool) []T {
out := make([]T, 0, len(items))
for _, v := range items {
if fn(v) {
out = append(out, v)
}
}
return out
}

57
common/tools/strx/strx.go Normal file
View File

@ -0,0 +1,57 @@
// Package strx provides string helpers on top of gstr, including naming
// conversion and sensitive-data masking.
package strx
import (
"strings"
"github.com/gogf/gf/v2/text/gstr"
)
// SnakeCase converts s to snake_case, e.g. "UserName" -> "user_name".
func SnakeCase(s string) string {
return gstr.CaseSnake(s)
}
// CamelCase converts s to CamelCase, e.g. "user_name" -> "UserName".
func CamelCase(s string) string {
return gstr.CaseCamel(s)
}
// LowerCamelCase converts s to lowerCamelCase, e.g. "user_name" -> "userName".
func LowerCamelCase(s string) string {
return gstr.CaseCamelLower(s)
}
// IsEmpty reports whether s is empty or whitespace-only.
func IsEmpty(s string) bool {
return strings.TrimSpace(s) == ""
}
// MaskPhone masks a phone number, keeping the first 3 and last 4 characters.
// e.g. "13812345678" -> "138****5678".
func MaskPhone(s string) string {
if len(s) < 7 {
return s
}
return s[:3] + "****" + s[len(s)-4:]
}
// MaskIDCard masks a Chinese ID card number, keeping the first 6 and last 4
// characters. e.g. "110101199003074512" -> "110101********4512".
func MaskIDCard(s string) string {
if len(s) < 10 {
return s
}
return s[:6] + "********" + s[len(s)-4:]
}
// MaskName masks a Chinese name, keeping only the first character.
// e.g. "张三丰" -> "张**".
func MaskName(s string) string {
r := []rune(s)
if len(r) <= 1 {
return s
}
return string(r[0]) + strings.Repeat("*", len(r)-1)
}

View File

@ -0,0 +1,43 @@
// Package timex provides time formatting and computation helpers on top of gtime.
package timex
import (
"github.com/gogf/gf/v2/os/gtime"
)
const (
// LayoutDateTime is the conventional datetime layout: 2006-01-02 15:04:05.
LayoutDateTime = "2006-01-02 15:04:05"
// LayoutDate is the conventional date layout: 2006-01-02.
LayoutDate = "2006-01-02"
)
// Now returns the current time.
func Now() *gtime.Time {
return gtime.Now()
}
// Format returns t formatted with the given layout.
// When layout is empty, LayoutDateTime is used.
func Format(t *gtime.Time, layout ...string) string {
ly := LayoutDateTime
if len(layout) > 0 && layout[0] != "" {
ly = layout[0]
}
return t.Format(ly)
}
// Timestamp returns the current Unix timestamp in seconds.
func Timestamp() int64 {
return gtime.Now().Timestamp()
}
// 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")
}
// 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")
}

21
common/tools/uuid/uuid.go Normal file
View File

@ -0,0 +1,21 @@
// Package uuid provides unique ID generation helpers.
package uuid
import (
"github.com/gogf/gf/v2/util/grand"
"github.com/gogf/gf/v2/util/guid"
)
// New returns a 32-character unique ID without dashes.
func New() string {
return guid.S()
}
// Short returns a random alphanumeric ID of length n (defaults to 8 when n <= 0).
// Suitable for short invite codes / trace ids.
func Short(n int) string {
if n <= 0 {
n = 8
}
return grand.S(n)
}

View File

@ -0,0 +1,48 @@
# Change Log 2026-08-24
## 请求
1. 新增一个公共模块模块下存放 `tools` 功能模块`tools` 下再分各种子功能
2. 建立项目上下文记忆体系记录每次请求与更改方案对比后落地要求后续使用其他 CodeBuddy 账号也能无缝衔接
## 变更
新增文件
- `common/doc.go` 公共模块说明不依赖 internal可独立抽取
- `common/tools/doc.go` tools 模块布局清单与封装规则
- `common/tools/md5/md5.go` MD5 摘要Md5Hex / Md5Bytes / Md5File
- `common/tools/cryptox/cryptox.go` AES-128-CBC / DES-ECB 加解密base64 输出密钥任意长度自动规范化
- `common/tools/uuid/uuid.go` 唯一 IDNew 32 / Short 短随机码
- `common/tools/random/random.go` 随机数/随机串Int / String / Digits / Letters
- `common/tools/timex/timex.go` 时间工具Format / Timestamp / StartOfDay / EndOfDay
- `common/tools/convertx/convertx.go` 类型转换带默认值ToInt / ToInt64 / ToFloat64 / ToString / ToBool
- `common/tools/strx/strx.go` 字符串工具命名转换 SnakeCase/CamelCase + 脱敏 MaskPhone/MaskIDCard/MaskName
- `common/tools/slicex/slicex.go` 泛型切片工具Contains / Unique / Chunk / Map / Filter
- `common/tools/ip/ip.go` IP 工具IsValid / LocalIP / IsInternal / ToLong / ToString
- `common/tools/filex/filex.go` 文件工具Exists / IsDir / ReadString / WriteString
- `AGENTS.md` 项目智能体说明书架构规范命令记忆体系索引
- `docs/change-log/2026-08-24.md` 本文档
修改文件
- `.gitignore` 追加 `.workbuddy/`本机记忆不入库
- `PROJECT_STRUCTURE.md` 目录树补充 `common/` `docs/change-log/`见后续提交
验证`go build ./...` `go vet ./common/...` 全部通过
## 决策与理由
- **公共模块放顶层 `common/` 而非 `internal/common/`**Go internal 包无法被外部模块引用放顶层便于未来抽取为独立库/被同仓库其他服务复用
- **tools 一律薄封装 GoFrame 内置组件**v2.10.2 AES/DES 已拆为 `crypto/gaes``crypto/gdes`UUID `util/guid` `gslicer`用标准库 `slices` 替代避免重复造轮子保持与框架一致
- **记忆体系三层方案**对比见下
1. `AGENTS.md`根目录 长期稳定规范跨工具标准Claude Code/CodeBuddy/Codex 等均识别 git
2. `docs/change-log/YYYY-MM-DD.md` 每次请求变更的结构化记录 git **这是跨账号衔接的关键**
3. `.workbuddy/memory/` WorkBuddy 本机增强不入库
- 对比过 `CLAUDE.md`Claude Code 专属已建议统一为 AGENTS.md`.cursor/rules`Cursor 专属`.codebuddy/` CodeBuddy 读取它们都不是最大公约数故不采用
## 待办与风险
- 后续每次任务完成后更新 `docs/change-log/`当日文件追加+ 提交 git确保其他账号 clone 即恢复上下文
- `convertx` 依赖 gconv "转换失败返回零值"行为无法区分"0"与非法输入需要严格转换的场景应在 service 层先校验
- 本次变更尚未 git 提交建议尽快 commit