Files
team-api/internal/logic/common/validation.go
T
chengqian 62a23325c7 improve:导出接口护栏与两端控制台查询性能优化
- 导出统一行数上限:xlsx 超限在拉取阶段截停并报错,CSV 到限截断并在文件末尾写入可见标记
- 全部导出接口 OFFSET 循环翻页改为 keyset 游标(用量日志/计费记录/成员/渠道/钱包流水)
- 用量日志导出强制时间窗(开始时间必填、跨度≤31天),校验下沉 common 供管理后台与租户端共用
- 管理后台仪表盘趋势/租户排行/模型分布/小时费用聚合加 5 分钟双层缓存
- 交易流水 Username 筛选改 EXISTS 子查询解除对 JOIN 别名的依赖,COUNT 与列表拆分免背联表
- 管理后台成员列表 COUNT 免联表(筛选条件只引用主表列)
- 租户端用量日志列表/统计/导出共用筛选构造器,COUNT 与 SUM 全程剥离联表,钱包流水 COUNT 拆分
2026-09-18 15:24:09 +08:00

218 lines
6.8 KiB
Go

package common
import (
"context"
"fmt"
"strings"
"time"
"unicode"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/consts"
)
// ValidateUsername 校验用户名格式:仅允许英文字母和数字,不能为纯数字,长度3-50
func ValidateUsername(username string) error {
if len(username) < 3 || len(username) > 50 {
return gerror.New("用户名长度为3-50位")
}
allDigit := true
for _, c := range username {
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' {
allDigit = false
} else if c >= '0' && c <= '9' {
// digits are ok
} else {
return gerror.New("用户名仅支持英文字母和数字,不能包含特殊字符或中文")
}
}
if allDigit {
return gerror.New("用户名不能为纯数字")
}
return nil
}
// ValidatePassword 校验密码强度:至少8位,且同时包含字母(不区分大小写)和数字
func ValidatePassword(password string) error {
if len(password) < 8 {
return gerror.New("密码长度不能少于8位")
}
var hasLetter, hasDigit bool
for _, c := range password {
switch {
case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'):
hasLetter = true
case c >= '0' && c <= '9':
hasDigit = true
}
}
if !hasLetter || !hasDigit {
return gerror.New("密码必须同时包含字母和数字")
}
return nil
}
// ValidateTenantRole 校验租户角色是否合法
func ValidateTenantRole(role string) error {
if role != "admin" && role != "member" {
return gerror.New("角色无效")
}
return nil
}
// ValidateAdminRole 校验管理后台角色是否合法
func ValidateAdminRole(role string) error {
if role != "admin" && role != "super_admin" {
return gerror.New("角色无效")
}
return nil
}
// ValidateDateParam 校验日期参数格式是否为 YYYY-MM-DD
func ValidateDateParam(date string, fieldName string) error {
if date == "" {
return nil
}
_, err := time.Parse("2006-01-02", date)
if err != nil {
return gerror.NewCode(gcode.New(consts.CodeBadRequest, "", nil), "%s格式无效,应为 YYYY-MM-DD", fieldName)
}
return nil
}
// ValidateDateTimeParam 校验时间筛选参数,兼容 YYYY-MM-DD 与 YYYY-MM-DD HH:mm:ss
// (前端时间范围选择器精确到分钟时传完整时间戳,仅选日期时传日期)。
func ValidateDateTimeParam(value string, fieldName string) error {
if value == "" {
return nil
}
for _, layout := range []string{"2006-01-02", "2006-01-02 15:04:05"} {
if _, err := time.Parse(layout, value); err == nil {
return nil
}
}
return gerror.NewCode(gcode.New(consts.CodeBadRequest, "", nil), "%s格式无效,应为 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss", fieldName)
}
// StartOfRange 返回时间范围过滤起点:仅日期(YYYY-MM-DD)时补齐当天 00:00:00,带时间则原样使用
func StartOfRange(value string) string {
if len(value) == 10 {
return value + " 00:00:00"
}
return value
}
// EndOfRange 返回时间范围过滤终点:仅日期(YYYY-MM-DD)时补齐当天 23:59:59,带时间则原样使用
func EndOfRange(value string) string {
if len(value) == 10 {
return value + " 23:59:59"
}
return value
}
// UsageLogExportMaxWindowDays 用量日志类导出的时间窗上限(天),管理后台与租户控制台共用。
const UsageLogExportMaxWindowDays = 31
// ValidateExportTimeWindow 校验导出必须限定时间窗:开始时间必填,结束时间缺省按
// 「现在」计(与前端「截止留空 = 到现在」的口径一致),窗口不得超过 maxDays 天。
//
// 列表接口天然有分页 LIMIT 兜底,导出没有 —— 不限窗口等于允许一次请求扫全量大表。
// 更长跨度的取数应按窗口分多次导出。
func ValidateExportTimeWindow(start, end string, maxDays int) error {
if start == "" {
return NewBadRequestError(fmt.Sprintf("导出必须指定开始时间,且时间跨度不超过 %d 天", maxDays))
}
startAt := gtime.NewFromStr(StartOfRange(start))
if startAt == nil {
return NewBadRequestError("开始时间格式不正确")
}
endAt := gtime.Now()
if end != "" {
if endAt = gtime.NewFromStr(EndOfRange(end)); endAt == nil {
return NewBadRequestError("结束时间格式不正确")
}
}
if endAt.Before(startAt) {
return NewBadRequestError("结束时间不能早于开始时间")
}
if endAt.Sub(startAt) > time.Duration(maxDays)*24*time.Hour {
return NewBadRequestError(fmt.Sprintf("导出时间跨度不能超过 %d 天,请缩小范围后分批导出", maxDays))
}
return nil
}
// ValidateForbiddenWords 校验名称是否包含系统禁用词
// 从系统配置 register_forbidden_words 读取逗号分隔的禁用词列表,对 value 做大小写不敏感的包含检查
func ValidateForbiddenWords(ctx context.Context, value, fieldName string) error {
raw := Config().GetString(ctx, "register_forbidden_words")
if raw == "" {
return nil
}
lowerVal := strings.ToLower(value)
for _, word := range strings.Split(raw, ",") {
word = strings.TrimSpace(word)
if word == "" {
continue
}
if strings.Contains(lowerVal, strings.ToLower(word)) {
return gerror.NewCodef(gcode.New(consts.CodeForbiddenWord, consts.MsgForbiddenWord, nil),
"%s包含禁用词「%s」,请修改后重试", fieldName, word)
}
}
return nil
}
// TenantNameMaxDisplayWidth 组织名称最大显示宽度。
// 采用“显示宽度”模型:1 个汉字算 2、其余字符算 1,总宽度上限 16。
// 等价于“汉字最多 8 个(8×2=16)”或“字母最多 16 个(16×1=16)”,中英文混排按比例折算。
const TenantNameMaxDisplayWidth = 16
// displayWidth 计算“显示宽度”:CJK 汉字(unicode.Han)每个算 2,其余字符每个算 1。
// 用于组织名称等中英文混排场景的长度衡量,使 8 个汉字与 16 个字母视觉等宽。
func displayWidth(s string) int {
width := 0
for _, r := range s {
if unicode.Is(unicode.Han, r) {
width += 2
} else {
width += 1
}
}
return width
}
// ValidateTenantName 校验组织名称:显示宽度需在 2~TenantNameMaxDisplayWidth 之间。
// 配合 API 层使用,规则为“汉字最多 8 个、字母最多 16 个”。
func ValidateTenantName(name string) error {
w := displayWidth(name)
if w < 2 {
return gerror.New("组织名称长度不能少于 2 个字符")
}
if w > TenantNameMaxDisplayWidth {
return gerror.New(consts.MsgInvalidTenantName)
}
return nil
}
// TruncateToDisplayWidth 按显示宽度截断字符串,使结果宽度不超过 max。
// 用于自动生成的组织名称兜底(如“xxx 的组织”),避免用户名过长导致存入超长数据。
func TruncateToDisplayWidth(s string, max int) string {
width := 0
out := make([]rune, 0, len(s))
for _, r := range s {
rw := 1
if unicode.Is(unicode.Han, r) {
rw = 2
}
if width+rw > max {
break
}
width += rw
out = append(out, r)
}
return string(out)
}