This commit is contained in:
2026-08-17 13:19:15 +08:00
parent 2b7c1c790b
commit 0e93c4f0a9
314 changed files with 2151 additions and 17468 deletions
+12 -3
View File
@@ -24,6 +24,7 @@ func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, e
if err != nil {
return 0, err
}
CacheClear(ctx, g.DB(), table)
if r == nil {
return 0, nil
}
@@ -32,7 +33,7 @@ func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, e
func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err error) {
r, err := g.DB().Model(table).Ctx(ctx).
Cache(gdb.CacheOption{Duration: CacheTTL(), Name: table + "_GetOneByPk_" + gconv.String(pk)}).
Cache(gdb.CacheOption{Duration: CacheTTL(), Name: CacheName(table, "GetOneByPk", pk)}).
Where("id", pk).One()
if err != nil {
return nil, err
@@ -46,10 +47,18 @@ func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err
func UpdateByPk(ctx context.Context, table string, pk int64, data any) error {
_, err := g.DB().Model(table).Ctx(ctx).Data(data).Where("id", pk).Update()
return err
if err != nil {
return err
}
CacheClear(ctx, g.DB(), table)
return nil
}
func DeleteByPk(ctx context.Context, table string, pk int64) error {
_, err := g.DB().Model(table).Ctx(ctx).Unscoped().Where("id", pk).Delete()
return err
if err != nil {
return err
}
CacheClear(ctx, g.DB(), table)
return nil
}
+35
View File
@@ -2,10 +2,13 @@ package common
import (
"context"
"strings"
"sync"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
var (
@@ -20,3 +23,35 @@ func CacheTTL() time.Duration {
})
return cacheTTL
}
// CacheName 生成 dao 查询缓存名:统一以 "table@" 开头,
// CacheClear 按表前缀精确清理的前提(gdb 缓存键 = "SelectCache:" + name
func CacheName(table, op string, params ...any) string {
s := table + "@" + op
for _, p := range params {
s += "_" + gconv.String(p)
}
return s
}
// CacheClear 清理某表全部查询缓存,dao 写操作成功后必须调用(否则"库里已改、查询还是旧值")。
// gdb.DB 接口未暴露 Core.ClearCache,此处等价实现:遍历缓存键,删除 "SelectCache:<table>@" 前缀条目。
func CacheClear(ctx context.Context, db gdb.DB, table string) {
keys, err := db.GetCache().KeyStrings(ctx)
if err != nil {
g.Log().Warningf(ctx, "清理 %s 查询缓存失败(读取键): %v", table, err)
return
}
prefix := "SelectCache:" + table + "@"
var toRemove []any
for _, k := range keys {
if strings.HasPrefix(k, prefix) {
toRemove = append(toRemove, k)
}
}
if len(toRemove) > 0 {
if err := db.GetCache().Removes(ctx, toRemove); err != nil {
g.Log().Warningf(ctx, "清理 %s 查询缓存失败: %v", table, err)
}
}
}
+12
View File
@@ -0,0 +1,12 @@
package common
import (
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// 数据库组访问器:DAO 按业务域拆分到独立 SQLite 文件(config.yml database.*),经所属组访问。
// 归属 common(非表基础设施),禁止在业务分层目录出现非表文件。
func DbPlan() gdb.DB { return g.DB("plan") }
func DbPay() gdb.DB { return g.DB("pay") }
func DbCps() gdb.DB { return g.DB("cps") }
+34
View File
@@ -0,0 +1,34 @@
package common
import (
"context"
"sync"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/grpool"
)
// 协程池封装(grpool):异步任务一律经 Submit 提交,禁止裸 go 启动并行工作负载。
// 并发度来源:config.yml pool.<name>(缺失或非法回退调用方传入的业务默认值,定义在 styleagent/consts)。
// 防死锁:等待链单向(主 → 池),池内任务不得再等待其他池。
type taskPool struct {
size int
once sync.Once
pool *grpool.Pool
}
var pools sync.Map // name → *taskPool
// Submit 提交任务到命名池。ctx 建议传 gctx.New()(请求结束后任务不中断)。
func Submit(ctx context.Context, name string, defaultSize int, fn func(ctx context.Context)) error {
v, _ := pools.LoadOrStore(name, &taskPool{size: defaultSize})
tp := v.(*taskPool)
tp.once.Do(func() {
if n := g.Cfg().MustGet(ctx, "pool."+name, defaultSize).Int(); n > 0 {
tp.size = n
}
tp.pool = grpool.New(tp.size)
})
return tp.pool.Add(ctx, fn)
}
+14
View File
@@ -1,13 +1,27 @@
package common
import (
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math"
"os"
"strings"
)
// RoundInt 浮点数量(克)× 单价(分)等金额计算的四舍五入到整数分
func RoundInt(f float64) int64 {
return int64(math.Round(f))
}
// IsNotFound GoFrame Scan/One 无匹配行时返回 sql.ErrNoRows
// dao 层统一归一为「无记录」(返回 nil 实体),不作为系统错误向上抛
func IsNotFound(err error) bool {
return err != nil && errors.Is(err, sql.ErrNoRows)
}
// ImageFileToBase64 reads an image file and returns a data:image/...;base64 string.
func ImageFileToBase64(path string) (string, error) {
data, err := os.ReadFile(path)
+2 -2
View File
@@ -6,10 +6,10 @@ import (
)
// webStaticDirs 候选前端静态目录(相对 server 工作目录,按序取第一个存在者):
// 1. 本地开发:直接服务 Flutter 构建产物 app/build/webscripts/build_web.sh 或 dev.sh 构建
// 1. 本地开发:uni-app H5 构建产物 app-uni/dist/build/h5npm run build:h5
// 2. Docker 镜像:Dockerfile web-builder 阶段产物(/app/web
var webStaticDirs = []string{
"../app/build/web",
"../app-uni/dist/build/h5",
"web",
}
+125
View File
@@ -0,0 +1,125 @@
package common
import (
"context"
"errors"
"fmt"
"time"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
)
// ErrLockHeld 锁被他人持有(重试耗尽仍拿不到时返回)
var ErrLockHeld = errors.New("lock held")
// WithLock 互斥临界区唯一入口(泛型):业务返回值经 T 原样透出。
// 内部按 config.yml 自动选择锁实现:配置了 redis 节点 → redis 锁(跨实例互斥,
// SET NX EX + token 对比删除防误删他人锁);未配置 → gcache 内存锁(单实例互斥)。
// 拿不到锁最多重试 retries 次、每次间隔 retryIntervalretries=0 立即失败;
// ctx 取消/超时同样终止);中间件故障不重试直接返回。defer 自动释放:无论 fn
// 成功、失败还是 panic。expire 必须 > 0(进程崩溃兜底不死锁),fn 耗时须在 expire 前完成,
// fn 内禁止长耗时 IO(LLM/DB 调用);锁粒度按业务唯一键尽量小。
func WithLock[T any](ctx context.Context, key string, expire time.Duration, retries int, retryInterval time.Duration, fn func() (T, error)) (T, error) {
var zero T
if expire <= 0 {
return zero, errors.New("lock expire must be positive")
}
lock, err := newLock(ctx, key, expire)
if err != nil {
return zero, err
}
var ok bool
for attempt := 0; ; attempt++ {
ok, err = lock.TryAcquire(ctx)
if err != nil {
return zero, err
}
if ok {
break
}
if attempt >= retries {
return zero, ErrLockHeld
}
select {
case <-ctx.Done():
return zero, ctx.Err()
case <-time.After(retryInterval):
}
}
defer lock.Release(ctx)
return fn()
}
type lock interface {
TryAcquire(ctx context.Context) (bool, error)
Release(ctx context.Context)
}
func newLock(ctx context.Context, key string, expire time.Duration) (lock, error) {
token := fmt.Sprintf("%d-%d", time.Now().UnixNano(), time.Now().UnixMilli()%1e9)
if g.Cfg().MustGet(ctx, "redis.default.address", "").String() != "" {
return &redisLock{key: "lock:" + key, token: token, expire: expire, client: g.Redis()}, nil
}
return &memoryLock{key: "lock:" + key, token: token, expire: expire}, nil
}
type redisLock struct {
key string
token string
expire time.Duration
client *gredis.Redis
}
func (l *redisLock) TryAcquire(ctx context.Context) (bool, error) {
// SetNX 无 TTL 参数,SET NX 与 TTL 分两步;当前项目未配置 redis 节点此路径不可达,
// 若崩溃于两步之间仅残留无 TTL 锁(token 归属明确,可手工清除),可接受
ok, err := l.client.SetNX(ctx, l.key, l.token)
if err != nil || !ok {
return ok, err
}
if _, err := l.client.PExpire(ctx, l.key, l.expire.Milliseconds()); err != nil {
if _, derr := l.client.Del(ctx, l.key); derr != nil {
g.Log().Warningf(ctx, "清理未设 TTL 的锁失败: %v", derr)
}
return false, err
}
return true, nil
}
func (l *redisLock) Release(ctx context.Context) {
v, err := l.client.Get(ctx, l.key)
if err != nil {
g.Log().Warningf(ctx, "释放锁失败(读取): %v", err)
return
}
if v.String() == l.token {
if _, err := l.client.Del(ctx, l.key); err != nil {
g.Log().Warningf(ctx, "释放锁失败(删除): %v", err)
}
}
}
type memoryLock struct {
key string
token string
expire time.Duration
}
func (l *memoryLock) TryAcquire(ctx context.Context) (bool, error) {
return gcache.SetIfNotExist(ctx, l.key, l.token, l.expire)
}
func (l *memoryLock) Release(ctx context.Context) {
v, err := gcache.Get(ctx, l.key)
if err != nil {
g.Log().Warningf(ctx, "释放锁失败(读取): %v", err)
return
}
if v.String() == l.token {
if _, err := gcache.Remove(ctx, l.key); err != nil {
g.Log().Warningf(ctx, "释放锁失败(删除): %v", err)
}
}
}