Files
junhong_cmp_fiber/internal/store/postgres/device_store.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

461 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package postgres
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
type DeviceStore struct {
db *gorm.DB
redis *redis.Client
}
func NewDeviceStore(db *gorm.DB, redis *redis.Client) *DeviceStore {
return &DeviceStore{
db: db,
redis: redis,
}
}
func (s *DeviceStore) Create(ctx context.Context, device *model.Device) error {
return s.db.WithContext(ctx).Create(device).Error
}
func (s *DeviceStore) CreateBatch(ctx context.Context, devices []*model.Device) error {
if len(devices) == 0 {
return nil
}
return s.db.WithContext(ctx).CreateInBatches(devices, 100).Error
}
func (s *DeviceStore) GetByID(ctx context.Context, id uint) (*model.Device, error) {
var device model.Device
query := s.db.WithContext(ctx).Where("id = ?", id)
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err != nil {
return nil, err
}
return &device, nil
}
func (s *DeviceStore) GetByDeviceNo(ctx context.Context, deviceNo string) (*model.Device, error) {
var device model.Device
query := s.db.WithContext(ctx).Where("virtual_no = ?", deviceNo)
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err != nil {
return nil, err
}
return &device, nil
}
// GetByIdentifier 通过任意标识符查找设备
// 支持 virtual_no(虚拟号)、imei、sn 三个字段的自动匹配
func (s *DeviceStore) GetByIdentifier(ctx context.Context, identifier string) (*model.Device, error) {
var device model.Device
query := s.db.WithContext(ctx).Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier)
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.First(&device).Error; err != nil {
return nil, err
}
return &device, nil
}
// GetByIdentifiers 批量按 VirtualNo、IMEI 或 SN 查询设备,并应用当前数据权限。
func (s *DeviceStore) GetByIdentifiers(ctx context.Context, identifiers []string) ([]*model.Device, error) {
devices := make([]*model.Device, 0)
if len(identifiers) == 0 {
return devices, nil
}
query := s.db.WithContext(ctx).
Where("virtual_no IN ? OR imei IN ? OR sn IN ?", identifiers, identifiers, identifiers)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Find(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}
func (s *DeviceStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Device, error) {
var devices []*model.Device
if len(ids) == 0 {
return devices, nil
}
query := s.db.WithContext(ctx).Where("id IN ?", ids)
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Find(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}
func (s *DeviceStore) Update(ctx context.Context, device *model.Device) error {
return s.db.WithContext(ctx).Save(device).Error
}
func (s *DeviceStore) Delete(ctx context.Context, id uint) error {
return s.db.WithContext(ctx).Delete(&model.Device{}, id).Error
}
func (s *DeviceStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Device, int64, error) {
var devices []*model.Device
var total int64
query := s.db.WithContext(ctx).Model(&model.Device{})
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
query = s.applyDeviceFilters(ctx, query, filters)
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize)
if opts.OrderBy != "" {
query = query.Order(opts.OrderBy)
} else {
query = query.Order("created_at DESC")
}
if err := query.Find(&devices).Error; err != nil {
return nil, 0, err
}
return devices, total, nil
}
// ListDeviceTypes 查询未删除设备的去重设备类型。
func (s *DeviceStore) ListDeviceTypes(ctx context.Context) ([]string, error) {
types := make([]string, 0)
trimmedType := "TRIM(device_type)"
err := s.db.WithContext(ctx).
Model(&model.Device{}).
Where("NULLIF("+trimmedType+", '') IS NOT NULL").
Select(trimmedType).
Distinct().
Order(trimmedType+" ASC").
Pluck(trimmedType, &types).Error
if err != nil {
return nil, err
}
return types, nil
}
func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, filters map[string]any) *gorm.DB {
if virtualNo, ok := filters["virtual_no"].(string); ok && virtualNo != "" {
query = query.Where("virtual_no LIKE ?", "%"+virtualNo+"%")
}
if imei, ok := filters["imei"].(string); ok && imei != "" {
query = query.Where("imei LIKE ?", "%"+imei+"%")
}
if keyword, ok := filters["keyword"].(string); ok && keyword != "" {
query = query.Where("virtual_no LIKE ? OR imei LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
if virtualNoStart, ok := filters["virtual_no_start"].(string); ok && virtualNoStart != "" {
query = query.Where("virtual_no >= ?", virtualNoStart)
}
if virtualNoEnd, ok := filters["virtual_no_end"].(string); ok && virtualNoEnd != "" {
query = query.Where("virtual_no <= ?", virtualNoEnd)
}
if deviceName, ok := filters["device_name"].(string); ok && deviceName != "" {
query = query.Where("device_name LIKE ?", "%"+deviceName+"%")
}
if status, ok := filters["status"].(int); ok && status > 0 {
query = query.Where("status = ?", status)
}
if activationStatus, ok := filters["activation_status"].(int); ok {
query = s.applyActivationStatusFilter(query, activationStatus)
}
if realNameStatus, ok := filters["real_name_status"].(int); ok {
query = s.applyRealNameStatusFilter(query, realNameStatus)
}
if shopIDs, ok := filters["shop_ids"].([]uint); ok {
if len(shopIDs) == 0 {
query = query.Where("1 = 0")
} else {
query = query.Where("shop_id IN ?", shopIDs)
}
} else if shopID, ok := filters["shop_id"].(*uint); ok {
if shopID == nil {
query = query.Where("shop_id IS NULL")
} else {
query = query.Where("shop_id = ?", *shopID)
}
}
if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" {
query = query.Where("batch_no = ?", batchNo)
}
if deviceType, ok := filters["device_type"].(string); ok && deviceType != "" {
query = query.Where("device_type = ?", deviceType)
}
if manufacturer, ok := filters["manufacturer"].(string); ok && manufacturer != "" {
query = query.Where("manufacturer LIKE ?", "%"+manufacturer+"%")
}
if createdAtStart, ok := filters["created_at_start"].(time.Time); ok && !createdAtStart.IsZero() {
query = query.Where("created_at >= ?", createdAtStart)
}
if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok && !createdAtEnd.IsZero() {
query = query.Where("created_at <= ?", createdAtEnd)
}
if seriesID, ok := filters["series_id"].(uint); ok && seriesID > 0 {
query = query.Where("series_id = ?", seriesID)
}
if hasActive, ok := filters["has_active_package"].(bool); ok {
query = s.applyHasActivePackageFilter(query, hasActive)
}
if enablePolling, ok := filters["enable_polling"].(bool); ok {
query = query.Where("enable_polling = ?", enablePolling)
}
if enterpriseID, ok := filters["authorized_enterprise_id"].(uint); ok && enterpriseID > 0 {
// 只返回当前有效授权给该企业的设备
query = query.Where("id IN (?)",
s.db.WithContext(ctx).Table("tb_enterprise_device_authorization").
Select("device_id").
Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID))
}
if start, ok := filters["outbound_start"].(time.Time); ok {
query = query.Where("outbound_at >= ?", start)
}
if end, ok := filters["outbound_end"].(time.Time); ok {
query = query.Where("outbound_at <= ?", end)
}
if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok {
subQ := s.db.WithContext(ctx).Table("tb_enterprise_device_authorization").
Select("device_id").
Where("revoked_at IS NULL AND deleted_at IS NULL")
if isAuthorized {
query = query.Where("id IN (?)", subQ)
} else {
query = query.Where("id NOT IN (?)", subQ)
}
}
return query
}
// applyRealNameStatusFilter 按设备当前有效绑定卡的实名状态过滤。
// 任意有效绑定卡已实名时设备视为已实名,否则视为未实名。
func (s *DeviceStore) applyRealNameStatusFilter(query *gorm.DB, realNameStatus int) *gorm.DB {
verifiedBindingCondition := `EXISTS (
SELECT 1
FROM tb_device_sim_binding AS b
JOIN tb_iot_card AS c
ON c.id = b.iot_card_id
AND c.real_name_status = ?
AND c.deleted_at IS NULL
WHERE b.device_id = tb_device.id
AND b.bind_status = ?
AND b.deleted_at IS NULL
)`
args := []any{constants.RealNameStatusVerified, constants.BindStatusBound}
if realNameStatus == constants.RealNameStatusVerified {
return query.Where(verifiedBindingCondition, args...)
}
return query.Where("NOT "+verifiedBindingCondition, args...)
}
// applyHasActivePackageFilter 按"是否有生效中主套餐"过滤设备。
// true: 设备在 tb_package_usage 中存在生效中(status=PackageUsageStatusActive)的主套餐记录;
// false: 不存在上述记录。
func (s *DeviceStore) applyHasActivePackageFilter(query *gorm.DB, hasActive bool) *gorm.DB {
subQuery := `SELECT 1 FROM tb_package_usage AS pu
WHERE pu.device_id = tb_device.id
AND pu.status = ?
AND pu.master_usage_id IS NULL
AND pu.deleted_at IS NULL`
if hasActive {
return query.Where("EXISTS ("+subQuery+")", constants.PackageUsageStatusActive)
}
return query.Where("NOT EXISTS ("+subQuery+")", constants.PackageUsageStatusActive)
}
func (s *DeviceStore) applyActivationStatusFilter(query *gorm.DB, activationStatus int) *gorm.DB {
activeCondition := `EXISTS (
SELECT 1
FROM tb_package_usage AS pu
JOIN tb_device_sim_binding AS b
ON b.device_id = pu.device_id
AND b.bind_status = ?
AND b.deleted_at IS NULL
JOIN tb_iot_card AS c
ON c.id = b.iot_card_id
AND c.real_name_status = ?
AND c.deleted_at IS NULL
WHERE pu.device_id = tb_device.id
AND pu.status = ?
AND pu.master_usage_id IS NULL
AND pu.deleted_at IS NULL
)`
args := []any{
constants.BindStatusBound,
constants.RealNameStatusVerified,
constants.PackageUsageStatusActive,
}
if activationStatus == constants.ActivationStatusActive {
return query.Where(activeCondition, args...)
}
if activationStatus == constants.ActivationStatusInactive {
return query.Where("NOT "+activeCondition, args...)
}
return query
}
// GetByFilters 根据筛选条件查询设备
// 用于批量设置套餐系列时选择完整筛选结果,不受列表分页限制。
func (s *DeviceStore) GetByFilters(ctx context.Context, filters map[string]any) ([]*model.Device, error) {
var devices []*model.Device
query := s.db.WithContext(ctx).Model(&model.Device{})
query = middleware.ApplyShopFilter(ctx, query)
query = s.applyDeviceFilters(ctx, query, filters)
if err := query.Find(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}
func (s *DeviceStore) UpdateShopID(ctx context.Context, id uint, shopID *uint) error {
return s.db.WithContext(ctx).Model(&model.Device{}).Where("id = ?", id).Update("shop_id", shopID).Error
}
func (s *DeviceStore) BatchUpdateShopIDAndStatus(ctx context.Context, ids []uint, shopID *uint, status int) error {
if len(ids) == 0 {
return nil
}
updates := map[string]any{
"shop_id": shopID,
"status": status,
"updated_at": time.Now(),
}
return s.db.WithContext(ctx).Model(&model.Device{}).Where("id IN ?", ids).Updates(updates).Error
}
// GetActivationStatusMap 批量查询设备激活状态。
// 设备激活规则:存在生效中主套餐,且任意已绑定卡已实名。
func (s *DeviceStore) GetActivationStatusMap(ctx context.Context, ids []uint) (map[uint]int, error) {
result := make(map[uint]int, len(ids))
if len(ids) == 0 {
return result, nil
}
for _, id := range ids {
result[id] = constants.ActivationStatusInactive
}
var activeDeviceIDs []uint
err := s.db.WithContext(ctx).
Table("tb_package_usage AS pu").
Distinct("pu.device_id").
Joins("JOIN tb_device_sim_binding AS b ON b.device_id = pu.device_id AND b.bind_status = ? AND b.deleted_at IS NULL", constants.BindStatusBound).
Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.real_name_status = ? AND c.deleted_at IS NULL", constants.RealNameStatusVerified).
Where("pu.device_id IN ?", ids).
Where("pu.status = ?", constants.PackageUsageStatusActive).
Where("pu.master_usage_id IS NULL").
Where("pu.deleted_at IS NULL").
Pluck("pu.device_id", &activeDeviceIDs).Error
if err != nil {
return nil, err
}
for _, id := range activeDeviceIDs {
result[id] = constants.ActivationStatusActive
}
return result, nil
}
func (s *DeviceStore) ExistsByDeviceNoBatch(ctx context.Context, deviceNos []string) (map[string]bool, error) {
result := make(map[string]bool)
if len(deviceNos) == 0 {
return result, nil
}
var existingDevices []struct {
VirtualNo string
}
if err := s.db.WithContext(ctx).Model(&model.Device{}).
Select("virtual_no").
Where("virtual_no IN ?", deviceNos).
Find(&existingDevices).Error; err != nil {
return nil, err
}
for _, d := range existingDevices {
result[d.VirtualNo] = true
}
return result, nil
}
func (s *DeviceStore) GetByDeviceNos(ctx context.Context, deviceNos []string) ([]*model.Device, error) {
var devices []*model.Device
if len(deviceNos) == 0 {
return devices, nil
}
query := s.db.WithContext(ctx).Where("virtual_no IN ?", deviceNos)
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Find(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}
// BatchUpdateSeriesID 批量更新设备的套餐系列ID
func (s *DeviceStore) BatchUpdateSeriesID(ctx context.Context, deviceIDs []uint, seriesID *uint) error {
if len(deviceIDs) == 0 {
return nil
}
return s.db.WithContext(ctx).Model(&model.Device{}).
Where("id IN ?", deviceIDs).
Update("series_id", seriesID).Error
}
// ListBySeriesID 根据套餐系列ID查询设备列表
func (s *DeviceStore) ListBySeriesID(ctx context.Context, seriesID uint) ([]*model.Device, error) {
var devices []*model.Device
query := s.db.WithContext(ctx).Where("series_id = ?", seriesID)
// 应用数据权限过滤(NULL shop_id 对代理用户不可见)
query = middleware.ApplyShopFilter(ctx, query)
if err := query.Find(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}
func (s *DeviceStore) UpdateRechargeTrackingFields(ctx context.Context, deviceID uint, accumulatedJSON, triggeredJSON string) error {
return s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ?", deviceID).
Updates(map[string]interface{}{
"accumulated_recharge_by_series": accumulatedJSON,
"first_recharge_triggered_by_series": triggeredJSON,
}).Error
}
// UpdatePollingStatus 更新设备的轮询启用状态
func (s *DeviceStore) UpdatePollingStatus(ctx context.Context, deviceID uint, enablePolling bool) error {
return s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ?", deviceID).
Update("enable_polling", enablePolling).Error
}
// UpdateRealnamePolicy 更新设备的实名认证策略
func (s *DeviceStore) UpdateRealnamePolicy(ctx context.Context, deviceID uint, realnamePolicy string) error {
return s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ?", deviceID).
Update("realname_policy", realnamePolicy).Error
}