feat(套餐真流量预警): AUG26-004 真流量预警规则、达量扫描通知与导出

新增 000228 迁移:规则表 tb_package_traffic_alert_rule(每套餐商品至多一条,无软删除,package_id
非部分唯一约束)、达量预警快照表 tb_package_traffic_alert(以主套餐使用记录 + 阈值快照为唯一键,
触发时冻结用量、额度、比例、阈值、到期时间、归属与资产快照),并为 tb_package_usage 新增扫描
范围部分索引 idx_package_usage_alert_scope;down 在预警表存在数据时阻断回滚。

新增规则维护接口 GET/POST/PUT /api/admin/package-traffic-alert-rules(仅超级管理员与平台账号):
创建校验套餐存在且真流量额度大于零,阈值为 1%~100% 的两位小数;修改只影响后续扫描,不回填也
不改写既有预警快照;全部写操作记录操作者、前后值与时间。

新增每日 06:00(Asia/Shanghai)扫描任务 package:traffic:alert:scan,与套餐临期扫描共用 data_cleanup
队列:按资产汇总当前有效套餐的真流量,分子取使用记录真已用量、分母取使用记录真总量快照,命中
主套餐规则阈值时在同一事务创建预警与可靠通知事件;重复执行以唯一冲突视为已处理,不重复投递,
不建停机锁、不调用运营商。

新增预警列表、详情与异步导出 GET /api/admin/package-traffic-alerts、GET /api/admin/package-traffic-alerts/:id、
POST /api/admin/package-traffic-alerts/export,列表与详情一律读冻结快照;新增通知类型
package.traffic.alert 与受控目标 package_traffic_alert_detail,目标解析仅对超级管理员与平台账号
返回可跳转,越权与不存在统一按资源不可见处理。

同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)、审计动作与资源注册、上下文
健康检查证据;归档变更并同步 package-traffic-alert 主 Spec。
This commit is contained in:
2026-09-16 17:05:55 +08:00
parent ef4d3696d4
commit d5bcda94fe
46 changed files with 3679 additions and 100 deletions

View File

@@ -0,0 +1,546 @@
// Package packagetrafficalert 提供套餐真流量预警规则与达量预警的只读投影。
// Query 只做筛选、分页与 DTO 投影,不修改任何状态;越权与不存在统一按资源不可见处理。
package packagetrafficalert
import (
"context"
"strings"
"time"
"gorm.io/gorm"
domainpackagetrafficalert "github.com/break/junhong_cmp_fiber/internal/domain/packagetrafficalert"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// shanghaiLocation 是剩余天数推算使用的上海时区。
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// Query 查询套餐真流量预警规则与达量预警。
type Query struct {
db *gorm.DB
// now 可在验证时替换,默认使用系统时间。
now func() time.Time
}
// NewQuery 创建套餐真流量预警查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db, now: func() time.Time { return time.Now().UTC() }}
}
// ListRules 分页查询套餐真流量预警规则。
// 规则列表返回套餐名称与商品当前真流量额度,供维护页核对配置合法性;商品额度不作为预警分母。
func (q *Query) ListRules(ctx context.Context, request dto.ListPackageTrafficAlertRuleRequest) (*dto.PackageTrafficAlertRuleListResponse, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置")
}
if err := requirePlatformOperator(ctx); err != nil {
return nil, err
}
page, pageSize := normalizePage(request.Page, request.PageSize)
var rows []ruleRow
query := q.db.WithContext(ctx).Table("tb_package_traffic_alert_rule AS r").
Joins("LEFT JOIN tb_package AS p ON p.id = r.package_id AND p.deleted_at IS NULL")
if request.PackageID != nil {
query = query.Where("r.package_id = ?", *request.PackageID)
}
if request.Enabled != nil {
query = query.Where("r.enabled = ?", boolToStatus(*request.Enabled))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则总数失败")
}
if err := query.Select("r.id, r.package_id, r.threshold_percent, r.enabled, r.remark, r.updated_at, " +
"COALESCE(p.package_name, '') AS package_name, COALESCE(p.real_data_mb, 0) AS real_data_mb").
Order("r.updated_at DESC, r.id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量预警规则列表失败")
}
items := make([]dto.PackageTrafficAlertRuleItem, 0, len(rows))
for _, row := range rows {
items = append(items, dto.PackageTrafficAlertRuleItem{
ID: row.ID,
PackageID: row.PackageID,
PackageName: row.PackageName,
RealDataMB: row.RealDataMB,
ThresholdPercent: row.ThresholdPercent,
Enabled: row.Enabled == constants.StatusEnabled,
EnabledName: enabledStatusName(row.Enabled),
Remark: row.Remark,
UpdatedAt: row.UpdatedAt,
})
}
return &dto.PackageTrafficAlertRuleListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
type ruleRow struct {
ID uint `gorm:"column:id"`
PackageID uint `gorm:"column:package_id"`
PackageName string `gorm:"column:package_name"`
RealDataMB int64 `gorm:"column:real_data_mb"`
ThresholdPercent float64 `gorm:"column:threshold_percent"`
Enabled int `gorm:"column:enabled"`
Remark string `gorm:"column:remark"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
// ListAlerts 分页查询套餐真流量达量预警。
// 先应用既有资产数据范围(当前对超级管理员与平台无实际过滤,保留为冻结语义与未来放开的前置),
// 再按套餐、店铺、业务员、资产/卡标识、阈值、触发时间与通知投递结果筛选。
func (q *Query) ListAlerts(ctx context.Context, request dto.ListPackageTrafficAlertRequest) (*dto.PackageTrafficAlertListResponse, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置")
}
if err := requirePlatformOperator(ctx); err != nil {
return nil, err
}
page, pageSize := normalizePage(request.Page, request.PageSize)
query := q.applyAlertFilters(ctx, q.db.WithContext(ctx).Model(&model.PackageTrafficAlert{}), request)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警总数失败")
}
var alerts []*model.PackageTrafficAlert
if err := query.Order("triggered_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).
Find(&alerts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警列表失败")
}
items, err := q.projectAlerts(ctx, alerts, false)
if err != nil {
return nil, err
}
return &dto.PackageTrafficAlertListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
// GetAlert 查询单条达量预警详情。
// 越权与不存在统一返回资源不可见错误,不形成可枚举差异。
func (q *Query) GetAlert(ctx context.Context, alertID uint) (*dto.PackageTrafficAlertDetailResponse, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "套餐真流量预警查询尚未配置")
}
if alertID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
if err := requirePlatformOperator(ctx); err != nil {
return nil, err
}
var alert model.PackageTrafficAlert
err := q.db.WithContext(ctx).Model(&model.PackageTrafficAlert{}).
Where("id = ?", alertID).
Scopes(func(scopeQuery *gorm.DB) *gorm.DB {
return applyAssetDataScope(ctx, scopeQuery, "tb_package_traffic_alert.shop_id_snapshot")
}).
First(&alert).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, invisibleAlertError()
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐真流量达量预警详情失败")
}
items, projectErr := q.projectAlerts(ctx, []*model.PackageTrafficAlert{&alert}, true)
if projectErr != nil {
return nil, projectErr
}
detail := &dto.PackageTrafficAlertDetailResponse{
PackageTrafficAlertItem: items[0],
NotificationEventID: alert.NotificationEventID,
}
detail.NotificationSummary = notificationSummary(items[0].NotificationStatus)
detail.ShopChangedSinceTrigger, detail.OwnerChangedSinceTrigger = q.detectOwnershipDrift(ctx, &alert)
return detail, nil
}
// applyAlertFilters 应用数据范围与筛选条件。
func (q *Query) applyAlertFilters(ctx context.Context, query *gorm.DB, request dto.ListPackageTrafficAlertRequest) *gorm.DB {
query = applyAssetDataScope(ctx, query, "tb_package_traffic_alert.shop_id_snapshot")
if request.PackageID != nil {
query = query.Where("tb_package_traffic_alert.package_id = ?", *request.PackageID)
}
if request.ShopID != nil {
query = query.Where("tb_package_traffic_alert.shop_id_snapshot = ?", *request.ShopID)
}
if request.BusinessOwnerAccountID != nil {
query = query.Where("tb_package_traffic_alert.business_owner_account_id_snapshot = ?", *request.BusinessOwnerAccountID)
}
if request.AssetType != "" {
query = query.Where("tb_package_traffic_alert.asset_type = ?", request.AssetType)
}
if keyword := strings.TrimSpace(request.AssetIdentifier); keyword != "" {
pattern := "%" + keyword + "%"
query = query.Where("(tb_package_traffic_alert.asset_identifier_snapshot ILIKE ? "+
"OR tb_package_traffic_alert.card_identifier_snapshot ILIKE ? "+
"OR tb_package_traffic_alert.counterpart_identifier_snapshot ILIKE ?)", pattern, pattern, pattern)
}
if request.ThresholdPercent != nil {
query = query.Where("tb_package_traffic_alert.threshold_percent_snapshot = ?",
domainpackagetrafficalert.NormalizeThresholdPercent(*request.ThresholdPercent))
}
if request.StartTime != nil {
query = query.Where("tb_package_traffic_alert.triggered_at >= ?", request.StartTime.UTC())
}
if request.EndTime != nil {
query = query.Where("tb_package_traffic_alert.triggered_at <= ?", request.EndTime.UTC())
}
return applyNotificationStatusFilter(query, request.NotificationStatus)
}
// projectAlerts 批量投影预警列表项。
// 冻结快照直接读预警行通知投递结果由可靠通知事件、Outbox 状态与站内通知事实交叉推导;
// 用户组按冻结的业务员账号实时推导(账号不变则稳定),不写入店铺表。
func (q *Query) projectAlerts(ctx context.Context, alerts []*model.PackageTrafficAlert, withReadState bool) ([]dto.PackageTrafficAlertItem, error) {
items := make([]dto.PackageTrafficAlertItem, 0, len(alerts))
if len(alerts) == 0 {
return items, nil
}
eventIDs := make([]string, 0, len(alerts))
ownerIDs := make([]uint, 0, len(alerts))
for _, alert := range alerts {
if alert.NotificationEventID != "" {
eventIDs = append(eventIDs, alert.NotificationEventID)
}
if alert.BusinessOwnerAccountIDSnapshot != nil && *alert.BusinessOwnerAccountIDSnapshot > 0 {
ownerIDs = append(ownerIDs, *alert.BusinessOwnerAccountIDSnapshot)
}
}
outboxStates, err := q.loadOutboxStates(ctx, eventIDs)
if err != nil {
return nil, err
}
notificationStates, err := q.loadNotificationStates(ctx, eventIDs)
if err != nil {
return nil, err
}
groupNames, err := q.loadBusinessUserGroupNames(ctx, ownerIDs)
if err != nil {
return nil, err
}
now := q.now()
for _, alert := range alerts {
item := dto.PackageTrafficAlertItem{
ID: alert.ID,
PackageUsageID: alert.PackageUsageID,
PackageID: alert.PackageID,
PackageName: alert.PackageNameSnapshot,
AssetType: alert.AssetType,
AssetID: alert.AssetID,
AssetIdentifier: alert.AssetIdentifierSnapshot,
CardIdentifier: alert.CardIdentifierSnapshot,
CounterpartIdentifier: alert.CounterpartIdentifierSnapshot,
DeviceType: alert.DeviceTypeSnapshot,
DeviceModel: alert.DeviceModelSnapshot,
UsedMB: alert.UsedMBSnapshot,
LimitMB: alert.LimitMBSnapshot,
UsagePercent: alert.UsagePercentSnapshot,
ThresholdPercent: alert.ThresholdPercentSnapshot,
ExpiresAt: alert.ExpiresAtSnapshot,
TriggeredAt: alert.TriggeredAt,
ShopName: alert.ShopNameSnapshot,
BusinessOwnerName: alert.BusinessOwnerNameSnapshot,
BusinessUserGroupNames: []string{},
}
if alert.ShopIDSnapshot > 0 {
shopID := alert.ShopIDSnapshot
item.ShopID = &shopID
}
if alert.BusinessOwnerAccountIDSnapshot != nil && *alert.BusinessOwnerAccountIDSnapshot > 0 {
ownerID := *alert.BusinessOwnerAccountIDSnapshot
item.BusinessOwnerAccountID = &ownerID
if names, ok := groupNames[ownerID]; ok {
item.BusinessUserGroupNames = names
}
}
if alert.ExpiresAtSnapshot != nil {
days := daysUntil(*alert.ExpiresAtSnapshot, now)
item.DaysRemaining = &days
}
status, deliveredAt, readAt, expiresAt := resolveNotificationState(alert, outboxStates, notificationStates)
item.NotificationStatus = status
item.NotificationStatusName = constants.GetPackageTrafficAlertNotifyStatusName(status)
item.NotificationDeliveredAt = deliveredAt
if withReadState {
item.NotificationReadAt = readAt
item.NotificationExpiresAt = expiresAt
}
items = append(items, item)
}
return items, nil
}
type outboxState struct {
Status int `gorm:"column:status"`
DeliveredAt *time.Time `gorm:"column:delivered_at"`
}
// loadOutboxStates 按事件ID批量读取 Outbox 状态。
func (q *Query) loadOutboxStates(ctx context.Context, eventIDs []string) (map[string]outboxState, error) {
result := make(map[string]outboxState, len(eventIDs))
if len(eventIDs) == 0 {
return result, nil
}
var rows []struct {
EventID string `gorm:"column:event_id"`
Status int `gorm:"column:status"`
DeliveredAt *time.Time `gorm:"column:delivered_at"`
}
if err := q.db.WithContext(ctx).Table("tb_outbox_event").
Select("event_id, status, delivered_at").
Where("event_id IN ?", eventIDs).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询预警通知事件状态失败")
}
for _, row := range rows {
result[row.EventID] = outboxState{Status: row.Status, DeliveredAt: row.DeliveredAt}
}
return result, nil
}
type notificationState struct {
ReadAt *time.Time `gorm:"column:read_at"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
}
// loadNotificationStates 按事件ID批量读取站内通知事实。
func (q *Query) loadNotificationStates(ctx context.Context, eventIDs []string) (map[string]notificationState, error) {
result := make(map[string]notificationState, len(eventIDs))
if len(eventIDs) == 0 {
return result, nil
}
var rows []struct {
EventID string `gorm:"column:event_id"`
ReadAt *time.Time `gorm:"column:read_at"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
}
if err := q.db.WithContext(ctx).Table("tb_notification").
Select("event_id, read_at, expires_at").
Where("event_id IN ?", eventIDs).
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询预警站内通知失败")
}
for _, row := range rows {
result[row.EventID] = notificationState{ReadAt: row.ReadAt, ExpiresAt: row.ExpiresAt}
}
return result, nil
}
// loadBusinessUserGroupNames 按业务员账号批量推导当前所属业务用户组名称。
func (q *Query) loadBusinessUserGroupNames(ctx context.Context, accountIDs []uint) (map[uint][]string, error) {
result := make(map[uint][]string, len(accountIDs))
if len(accountIDs) == 0 {
return result, nil
}
var rows []struct {
AccountID uint `gorm:"column:account_id"`
GroupName string `gorm:"column:group_name"`
}
if err := q.db.WithContext(ctx).Table("tb_business_user_group_member AS m").
Select("m.account_id, g.name AS group_name").
Joins("JOIN tb_business_user_group AS g ON g.id = m.business_user_group_id AND g.deleted_at IS NULL").
Where("m.account_id IN ? AND m.deleted_at IS NULL", accountIDs).
Order("g.sort_order ASC, g.id ASC").
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员业务用户组失败")
}
for _, row := range rows {
result[row.AccountID] = append(result[row.AccountID], row.GroupName)
}
return result, nil
}
// detectOwnershipDrift 判断资产当前归属店铺与店铺当前业务员是否已偏离触发快照。
func (q *Query) detectOwnershipDrift(ctx context.Context, alert *model.PackageTrafficAlert) (bool, bool) {
currentShopID, err := q.currentAssetShopID(ctx, alert.AssetType, alert.AssetID)
if err != nil || currentShopID == 0 {
return false, false
}
shopChanged := currentShopID != alert.ShopIDSnapshot
var shopOwner struct {
BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id"`
}
if err := q.db.WithContext(ctx).Table("tb_shop").
Select("business_owner_account_id").
Where("id = ?", currentShopID).
Scan(&shopOwner).Error; err != nil {
return shopChanged, false
}
ownerID := shopOwner.BusinessOwnerAccountID
ownerChanged := !sameOptionalID(ownerID, alert.BusinessOwnerAccountIDSnapshot)
return shopChanged, ownerChanged
}
// currentAssetShopID 查询资产当前所属店铺ID。
func (q *Query) currentAssetShopID(ctx context.Context, assetType string, assetID uint) (uint, error) {
if assetID == 0 {
return 0, nil
}
table := "tb_iot_card"
if assetType == constants.AssetTypeDevice {
table = "tb_device"
}
var shopID *uint
if err := q.db.WithContext(ctx).Table(table).
Select("shop_id").
Where("id = ? AND deleted_at IS NULL", assetID).
Scan(&shopID).Error; err != nil {
return 0, err
}
if shopID == nil {
return 0, nil
}
return *shopID, nil
}
// applyNotificationStatusFilter 按通知投递结果筛选。
// 结果由通知事件、Outbox 状态与站内通知事实推导,因此筛选必须与投影同口径。
func applyNotificationStatusFilter(query *gorm.DB, status *int) *gorm.DB {
if status == nil {
return query
}
const hasNotification = "EXISTS (SELECT 1 FROM tb_notification AS n WHERE n.event_id = tb_package_traffic_alert.notification_event_id)"
const outboxStatusExpr = `(SELECT oe.status FROM tb_outbox_event AS oe WHERE oe.event_id = tb_package_traffic_alert.notification_event_id)`
switch *status {
case constants.PackageTrafficAlertNotifyNoBusinessOwner:
return query.Where("tb_package_traffic_alert.notification_event_id = ''")
case constants.PackageTrafficAlertNotifyNotified:
return query.Where("tb_package_traffic_alert.notification_event_id <> ''").Where(hasNotification)
case constants.PackageTrafficAlertNotifyPending:
return query.Where("tb_package_traffic_alert.notification_event_id <> ''").
Where("NOT "+hasNotification).
Where(outboxStatusExpr+" IN ?", []int{constants.OutboxStatusPending, constants.OutboxStatusDelivering})
case constants.PackageTrafficAlertNotifyFailed:
return query.Where("tb_package_traffic_alert.notification_event_id <> ''").
Where("NOT "+hasNotification).
Where(outboxStatusExpr+" = ?", constants.OutboxStatusFailed)
case constants.PackageTrafficAlertNotifyRecipientGone:
return query.Where("tb_package_traffic_alert.notification_event_id <> ''").
Where("NOT "+hasNotification).
Where(outboxStatusExpr+" = ?", constants.OutboxStatusDelivered)
default:
return query
}
}
// resolveNotificationState 推导单条预警的通知投递结果,口径由 constants 统一定义。
func resolveNotificationState(alert *model.PackageTrafficAlert, outboxStates map[string]outboxState,
notificationStates map[string]notificationState) (int, *time.Time, *time.Time, *time.Time) {
if alert.NotificationEventID == "" {
return constants.ResolvePackageTrafficAlertNotifyStatus(false, nil, false), nil, nil, nil
}
state, hasNotification := notificationStates[alert.NotificationEventID]
var outboxStatus *int
var deliveredAt *time.Time
if outbox, ok := outboxStates[alert.NotificationEventID]; ok {
status := outbox.Status
outboxStatus = &status
deliveredAt = outbox.DeliveredAt
}
status := constants.ResolvePackageTrafficAlertNotifyStatus(true, outboxStatus, hasNotification)
if status == constants.PackageTrafficAlertNotifyNotified {
return status, deliveredAt, state.ReadAt, state.ExpiresAt
}
if status == constants.PackageTrafficAlertNotifyRecipientGone {
return status, deliveredAt, nil, nil
}
return status, nil, nil, nil
}
// notificationSummary 返回详情页的通知补充说明。
func notificationSummary(status int) string {
switch status {
case constants.PackageTrafficAlertNotifyNoBusinessOwner:
return "触发时店铺无有效业务员,只保存预警且不补发通知"
case constants.PackageTrafficAlertNotifyRecipientGone:
return "通知事件已投递,但接收人账号在投递时已失效,未生成站内通知"
case constants.PackageTrafficAlertNotifyFailed:
return "通知事件投递失败,已进入既有可靠投递恢复"
default:
return ""
}
}
// requirePlatformOperator 要求调用者为超级管理员或平台账号;其他账号统一按资源不可见处理。
func requirePlatformOperator(ctx context.Context) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return invisibleAlertError()
}
return nil
}
// applyAssetDataScope 应用既有资产数据范围。
// 范围为空表示不受限(超级管理员与平台账号当前无实际过滤),保留为冻结语义与未来放开的前置;
// 列名必须显式给出,因为预警行冻结的是 shop_id_snapshot而不是通用的 shop_id 列。
func applyAssetDataScope(ctx context.Context, query *gorm.DB, column string) *gorm.DB {
shopIDs := middleware.GetSubordinateShopIDs(ctx)
if len(shopIDs) == 0 {
return query
}
return query.Where(column+" IN ?", shopIDs)
}
// invisibleAlertError 返回与既有资源不可见一致的统一错误,避免越权与不存在形成可枚举差异。
func invisibleAlertError() error {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
// daysUntil 按上海自然日计算剩余天数,负数表示已过期。
func daysUntil(expiresAt time.Time, now time.Time) int {
days := dateInShanghai(expiresAt).Sub(dateInShanghai(now))
return int(days.Hours() / 24)
}
// dateInShanghai 归一化到上海时区的自然日零点。
func dateInShanghai(value time.Time) time.Time {
local := value.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
}
// normalizePage 归一化分页参数并执行默认值与上限。
func normalizePage(page, pageSize int) (int, int) {
if page <= 0 {
page = constants.DefaultPage
}
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}
// boolToStatus 把对外启停布尔映射为既有整型状态。
func boolToStatus(enabled bool) int {
if enabled {
return constants.StatusEnabled
}
return constants.StatusDisabled
}
// enabledStatusName 返回启停状态的中文名称。
func enabledStatusName(status int) string {
if status == constants.StatusEnabled {
return "启用"
}
return "停用"
}
// sameOptionalID 判断两个可空账号ID是否指向同一非空账号。
func sameOptionalID(left, right *uint) bool {
leftID, rightID := uint(0), uint(0)
if left != nil {
leftID = *left
}
if right != nil {
rightID = *right
}
return leftID == rightID
}