- 导出统一行数上限:xlsx 超限在拉取阶段截停并报错,CSV 到限截断并在文件末尾写入可见标记 - 全部导出接口 OFFSET 循环翻页改为 keyset 游标(用量日志/计费记录/成员/渠道/钱包流水) - 用量日志导出强制时间窗(开始时间必填、跨度≤31天),校验下沉 common 供管理后台与租户端共用 - 管理后台仪表盘趋势/租户排行/模型分布/小时费用聚合加 5 分钟双层缓存 - 交易流水 Username 筛选改 EXISTS 子查询解除对 JOIN 别名的依赖,COUNT 与列表拆分免背联表 - 管理后台成员列表 COUNT 免联表(筛选条件只引用主表列) - 租户端用量日志列表/统计/导出共用筛选构造器,COUNT 与 SUM 全程剥离联表,钱包流水 COUNT 拆分
218 lines
6.8 KiB
Go
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)
|
|
}
|