277 lines
8.8 KiB
Go
277 lines
8.8 KiB
Go
// Package common provides shared HTTP and persistence helpers for business logic modules.
|
||
package common
|
||
|
||
import (
|
||
"errors"
|
||
"reflect"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"git.apinb.com/bsm-sdk/core/errcode"
|
||
"git.apinb.com/bsm-sdk/core/infra"
|
||
"git.apinb.com/bsm-sdk/core/utils"
|
||
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
|
||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||
"github.com/gin-gonic/gin"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
// AccountPasswordMinLength 是账号密码允许的最低字符数。
|
||
const AccountPasswordMinLength = 6
|
||
|
||
// IsValidAccountPassword 仅校验账号密码的最低字符长度。
|
||
func IsValidAccountPassword(password string) bool {
|
||
return utf8.RuneCountInString(password) >= AccountPasswordMinLength
|
||
}
|
||
|
||
// PasswordHash creates the shared password representation used by account modules.
|
||
func PasswordHash(password string) (string, error) {
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
return string(hash), err
|
||
}
|
||
|
||
// FilterFields keeps only explicitly allowed persistence fields.
|
||
func FilterFields(values map[string]any, allowedFields []string) gin.H {
|
||
allowed := make(map[string]struct{}, len(allowedFields))
|
||
for _, field := range allowedFields {
|
||
allowed[field] = struct{}{}
|
||
}
|
||
filtered := make(gin.H, len(allowed))
|
||
for field, value := range values {
|
||
if _, ok := allowed[field]; ok {
|
||
filtered[field] = value
|
||
}
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
// UpdateRecordStatus 更新主表状态,停用和归档均保留历史记录。
|
||
func UpdateRecordStatus(ctx *gin.Context, model any) {
|
||
var request struct {
|
||
Status int `json:"status" binding:"required"`
|
||
}
|
||
if err := ctx.ShouldBindJSON(&request); err != nil || !IsGenericRecordStatus(request.Status) {
|
||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||
return
|
||
}
|
||
UpdateAllowedByIdentity(ctx, model, gin.H{"status": request.Status}, []string{"status"})
|
||
}
|
||
|
||
// ArchiveRecord 通过 archived 状态实现逻辑删除,不物理删除主数据。
|
||
func ArchiveRecord(ctx *gin.Context, model any) {
|
||
UpdateAllowedByIdentity(ctx, model, gin.H{"status": StatusArchived}, []string{"status"})
|
||
}
|
||
|
||
func NewEntity(status int) models.Entity {
|
||
return models.Entity{Identity: models.NewIdentity(), Status: status}
|
||
}
|
||
|
||
// IsGenericRecordStatus reports whether status belongs to the shared record lifecycle.
|
||
func IsGenericRecordStatus(status int) bool {
|
||
switch status {
|
||
case StatusDraft, StatusEnable, StatusDisable, StatusArchived, StatusFrozen:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// ActiveRecords excludes logically archived records from operational queries.
|
||
func ActiveRecords(query *gorm.DB) *gorm.DB {
|
||
return query.Where(clause.Neq{
|
||
Column: clause.Column{Table: clause.CurrentTable, Name: "status"},
|
||
Value: StatusArchived,
|
||
})
|
||
}
|
||
|
||
func ListPage[T any](ctx *gin.Context) {
|
||
ListPageFiltered[T](ctx, nil)
|
||
}
|
||
|
||
// ListPageFiltered applies a resource-specific exact filter before pagination.
|
||
func ListPageFiltered[T any](ctx *gin.Context, filter func(*gorm.DB) *gorm.DB) {
|
||
page, size := PageSize(ctx)
|
||
var list []T
|
||
var total int64
|
||
model := new(T)
|
||
databaseQuery := ApplyKeywordFilter(ctx, ActiveRecords(impl.DBService.Model(model)), model)
|
||
if filter != nil {
|
||
databaseQuery = filter(databaseQuery)
|
||
}
|
||
if err := databaseQuery.Count(&total).Error; err != nil {
|
||
infra.Response.Error(ctx, err)
|
||
return
|
||
}
|
||
if err := databaseQuery.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||
infra.Response.Error(ctx, err)
|
||
return
|
||
}
|
||
response, err := PublicResourceResponse(list)
|
||
if err != nil {
|
||
infra.Response.Error(ctx, err)
|
||
return
|
||
}
|
||
infra.Response.Success(ctx, gin.H{"total": total, "list": ProtectPreciseLocation(ctx, model, response)})
|
||
}
|
||
|
||
var keywordSafeColumns = map[string]bool{
|
||
"code": true, "name": true, "username": true, "display_name": true,
|
||
"role_code": true, "delivery_code": true, "work_status": true,
|
||
"credential_type": true, "device_no": true, "model": true,
|
||
"online_status": true, "rule_code": true, "action": true,
|
||
"event_code": true, "title": true, "result": true,
|
||
"product_code": true, "value": true, "order_no": true,
|
||
"channel": true, "settlement_no": true, "subject_type": true,
|
||
"content_type": true, "publish_status": true, "template_code": true,
|
||
"ticket_no": true, "category": true, "priority": true,
|
||
"platform_role_code": true, "location_scope": true, "group_code": true,
|
||
"path": true, "resource_type": true,
|
||
"owner_type": true, "owner_identity": true, "payment_no": true,
|
||
"record_no": true, "request_no": true, "refund_no": true,
|
||
"cash_no": true, "trade_no": true, "trade_type": true,
|
||
"pay_channel": true, "payment_type": true,
|
||
"contract_no": true, "creator_type": true,
|
||
"confirm_type": true, "product_type_name": true,
|
||
}
|
||
|
||
func ApplyKeywordFilter(ctx *gin.Context, query *gorm.DB, model any) *gorm.DB {
|
||
keyword := strings.ToLower(strings.TrimSpace(ctx.Query("keyword")))
|
||
if keyword == "" {
|
||
return query
|
||
}
|
||
columns := keywordColumns(model)
|
||
if len(columns) == 0 {
|
||
return query
|
||
}
|
||
conditions := make([]string, 0, len(columns))
|
||
arguments := make([]any, 0, len(columns))
|
||
for _, column := range columns {
|
||
conditions = append(conditions, `LOWER("`+column+`") LIKE ?`)
|
||
arguments = append(arguments, "%"+keyword+"%")
|
||
}
|
||
return query.Where("("+strings.Join(conditions, " OR ")+")", arguments...)
|
||
}
|
||
|
||
func keywordColumns(model any) []string {
|
||
modelType := reflect.TypeOf(model)
|
||
for modelType.Kind() == reflect.Pointer {
|
||
modelType = modelType.Elem()
|
||
}
|
||
columns := make([]string, 0)
|
||
for index := 0; index < modelType.NumField(); index++ {
|
||
field := modelType.Field(index)
|
||
if field.Anonymous || field.Type.Kind() != reflect.String {
|
||
continue
|
||
}
|
||
column := gormColumn(field.Tag.Get("gorm"))
|
||
if keywordSafeColumns[column] && !isSensitiveKeywordColumn(model, column) {
|
||
columns = append(columns, column)
|
||
}
|
||
}
|
||
return columns
|
||
}
|
||
|
||
func isSensitiveKeywordColumn(model any, column string) bool {
|
||
switch model.(type) {
|
||
case *models.UserAccount, *models.StaffAccount:
|
||
return column == "name"
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func gormColumn(tag string) string {
|
||
for _, part := range strings.Split(tag, ";") {
|
||
if strings.HasPrefix(part, "column:") {
|
||
return strings.TrimPrefix(part, "column:")
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func GetByIdentity[T any](ctx *gin.Context) {
|
||
var data T
|
||
if err := ActiveRecords(impl.DBService).Where("identity = ?", ctx.Param("identity")).First(&data).Error; err != nil {
|
||
RespondRecordError(ctx, err)
|
||
return
|
||
}
|
||
response, err := PublicResourceResponse(data)
|
||
if err != nil {
|
||
infra.Response.Error(ctx, err)
|
||
return
|
||
}
|
||
infra.Response.Success(ctx, ProtectPreciseLocation(ctx, new(T), response))
|
||
}
|
||
|
||
// UpdateAllowedByIdentity 按白名单更新资源,并保持原有数据库错误响应行为。
|
||
func UpdateAllowedByIdentity(ctx *gin.Context, model any, values map[string]any, allowedFields []string) {
|
||
UpdateAllowedByIdentityWithError(ctx, model, values, allowedFields, nil)
|
||
}
|
||
|
||
// UpdateAllowedByIdentityWithError 按白名单更新资源,并允许调用方转换数据库写入错误。
|
||
// 参数:transformError 为空时保持原有错误响应,非空时仅转换数据库更新错误。
|
||
// 返回值:无,处理结果通过统一 HTTP 响应写入。
|
||
func UpdateAllowedByIdentityWithError(ctx *gin.Context, model any, values map[string]any, allowedFields []string, transformError func(error) error) {
|
||
values = FilterFields(values, allowedFields)
|
||
if len(values) == 0 {
|
||
infra.Response.Success(ctx, gin.H{"updated": false})
|
||
return
|
||
}
|
||
|
||
result := ActiveRecords(impl.DBService.Model(model)).Where("identity = ?", ctx.Param("identity")).Updates(values)
|
||
if result.Error != nil {
|
||
if transformError != nil {
|
||
result.Error = transformError(result.Error)
|
||
}
|
||
infra.Response.Error(ctx, result.Error)
|
||
return
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||
return
|
||
}
|
||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||
}
|
||
|
||
func UpdateByIdentity(ctx *gin.Context, model any, values map[string]any) {
|
||
result := ActiveRecords(impl.DBService.Model(model)).Where("identity = ?", ctx.Param("identity")).Updates(values)
|
||
if result.Error != nil {
|
||
infra.Response.Error(ctx, result.Error)
|
||
return
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||
return
|
||
}
|
||
infra.Response.Success(ctx, gin.H{"updated": true})
|
||
}
|
||
|
||
func RespondRecordError(ctx *gin.Context, err error) {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
infra.Response.Error(ctx, errcode.ErrRecordNotFound)
|
||
return
|
||
}
|
||
infra.Response.Error(ctx, err)
|
||
}
|
||
|
||
func PageSize(ctx *gin.Context) (int, int) {
|
||
page := utils.String2Int(ctx.DefaultQuery("page", "1"))
|
||
size := utils.String2Int(ctx.DefaultQuery("size", "20"))
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if size < 1 || size > 100 {
|
||
size = 20
|
||
}
|
||
return page, size
|
||
}
|
||
|
||
func MaskPhone(phone string) string {
|
||
if len(phone) < 7 {
|
||
return "***"
|
||
}
|
||
return phone[:3] + "****" + phone[len(phone)-4:]
|
||
}
|