七月迭代短暂完结,还有很多后端的关键东西没有弄,这是一版赶时间做的东西
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m26s

This commit is contained in:
2026-07-25 17:06:58 +08:00
parent ad9f613dd6
commit 73f5125d3d
249 changed files with 17137 additions and 877 deletions

View File

@@ -7,6 +7,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -57,10 +58,26 @@ func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dt
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败")
}
submitterIDs := make([]uint, 0, len(orders))
for _, order := range orders {
if order.Creator > 0 {
submitterIDs = append(submitterIDs, order.Creator)
}
}
accounts, err := postgres.NewAccountStore(q.db, nil).GetDisplayAccountsByIDs(ctx, submitterIDs)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货提交人失败")
}
submitterNames := make(map[uint]string, len(accounts))
for _, account := range accounts {
submitterNames[account.ID] = account.Username
}
items := make([]*dto.ExchangeOrderResponse, 0, len(orders))
for _, order := range orders {
items = append(items, projectExchangeOrder(order))
item := projectExchangeOrder(order)
item.SubmitterName = submitterNames[order.Creator]
items = append(items, item)
}
return &dto.ExchangeListResponse{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
@@ -121,7 +138,7 @@ func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse
ExchangeReason: order.ExchangeReason, Remark: order.Remark,
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), StatusText: constants.GetExchangeStatusName(order.Status),
ShopID: order.ShopID, CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, DeletedAt: deletedAt,
Creator: order.Creator, Updater: order.Updater,
SubmitterID: order.Creator, Creator: order.Creator, Updater: order.Updater,
}
}

View File

@@ -115,6 +115,9 @@ func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.P
return nil, err
}
base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC())
if request.IsRead != nil {
base = base.Where("is_read = ?", *request.IsRead)
}
var total int64
if err := base.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知总数失败")
@@ -194,8 +197,8 @@ func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gor
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
constants.NotificationRecipientKindPersonalCustomer,
customerID,
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry},
[]string{constants.NotificationTypePackageExpiring},
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem},
[]string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated},
now,
)
}

View File

@@ -0,0 +1,372 @@
package packageexpiry
import (
"context"
"sort"
"strings"
"time"
"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"
"gorm.io/gorm"
)
const expiryWindowDays = 15
// ListResult 是临期资产 Query 的分页结果。
type ListResult struct {
Items []dto.ExpiringAssetItem
Total int64
Page int
Size int
Summary dto.ExpiringAssetSummary
}
type assetCandidate struct {
AssetType string
AssetID uint
Identifier string
ShopID *uint
}
// List 查询当前权限范围内的临期资产,并返回同口径数量汇总。
func (q *Query) List(ctx context.Context, request dto.ExpiringAssetListRequest) (ListResult, error) {
if q == nil || q.db == nil {
return ListResult{}, errors.New(errors.CodeInternalError, "套餐临期查询未配置")
}
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
return ListResult{}, errors.New(errors.CodeForbidden, "企业账号无权查看临期资产列表")
}
request = normalizeListRequest(request)
if err := validateListRequest(request); err != nil {
return ListResult{}, err
}
items, err := q.collect(ctx, request)
if err != nil {
return ListResult{}, err
}
summary := summarize(items)
total := int64(len(items))
start := (request.Page - 1) * request.PageSize
if start > len(items) {
start = len(items)
}
end := start + request.PageSize
if end > len(items) {
end = len(items)
}
return ListResult{Items: items[start:end], Total: total, Page: request.Page, Size: request.PageSize, Summary: summary}, nil
}
// ReminderCandidates 查询当天精确命中 15、7、3 天节点的全部资产,供每日通知任务复用。
func (q *Query) ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error) {
items, err := q.collect(ctx, normalizeListRequest(dto.ExpiringAssetListRequest{}))
if err != nil {
return nil, err
}
result := make([]dto.ExpiringAssetItem, 0)
for _, item := range items {
if item.DaysUntilFinalExpiry == nil {
continue
}
switch *item.DaysUntilFinalExpiry {
case 15, 7, 3:
result = append(result, item)
}
}
return result, nil
}
func (q *Query) collect(ctx context.Context, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) {
candidates := make([]assetCandidate, 0)
if request.AssetType == "" || request.AssetType == constants.AssetTypeIotCard {
cards, err := q.findCardCandidates(ctx, request)
if err != nil {
return nil, err
}
candidates = append(candidates, cards...)
}
if request.AssetType == "" || request.AssetType == constants.AssetTypeDevice {
devices, err := q.findDeviceCandidates(ctx, request)
if err != nil {
return nil, err
}
candidates = append(candidates, devices...)
}
items, err := q.resolveCandidates(ctx, candidates, request)
if err != nil {
return nil, err
}
if err := q.fillShopNames(ctx, items); err != nil {
return nil, err
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].IsPriority != items[j].IsPriority {
return items[i].IsPriority
}
left, right := items[i].EstimatedFinalExpiresAt, items[j].EstimatedFinalExpiresAt
if left != nil && right != nil && !left.Equal(*right) {
return left.Before(*right)
}
if items[i].AssetType != items[j].AssetType {
return items[i].AssetType < items[j].AssetType
}
return items[i].AssetID < items[j].AssetID
})
return items, nil
}
func (q *Query) findCardCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) {
var rows []model.IotCard
query := q.db.WithContext(ctx).Model(&model.IotCard{}).
Select("id, iccid, shop_id")
query = applyStrictShopScope(ctx, query)
if request.ShopID != nil {
query = query.Where("shop_id = ?", *request.ShopID)
}
if request.Keyword != "" {
keyword := "%" + strings.TrimSpace(request.Keyword) + "%"
query = query.Where("iccid ILIKE ? OR msisdn ILIKE ? OR virtual_no ILIKE ?", keyword, keyword, keyword)
}
query = query.Where(`EXISTS (
SELECT 1 FROM tb_package_usage pu
WHERE pu.iot_card_id = tb_iot_card.id AND pu.deleted_at IS NULL
AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL
AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ?
)`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1))
if err := query.Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期卡候选失败")
}
result := make([]assetCandidate, 0, len(rows))
for _, row := range rows {
result = append(result, assetCandidate{AssetType: constants.AssetTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID})
}
return result, nil
}
func (q *Query) findDeviceCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) {
var rows []model.Device
query := q.db.WithContext(ctx).Model(&model.Device{}).
Select("id, virtual_no, imei, shop_id")
query = applyStrictShopScope(ctx, query)
if request.ShopID != nil {
query = query.Where("shop_id = ?", *request.ShopID)
}
if request.Keyword != "" {
keyword := "%" + strings.TrimSpace(request.Keyword) + "%"
query = query.Where("virtual_no ILIKE ? OR imei ILIKE ?", keyword, keyword)
}
query = query.Where(`EXISTS (
SELECT 1 FROM tb_package_usage pu
WHERE pu.device_id = tb_device.id AND pu.deleted_at IS NULL
AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL
AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ?
)`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1))
if err := query.Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期设备候选失败")
}
result := make([]assetCandidate, 0, len(rows))
for _, row := range rows {
identifier := row.VirtualNo
if identifier == "" {
identifier = row.IMEI
}
result = append(result, assetCandidate{AssetType: constants.AssetTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID})
}
return result, nil
}
func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandidate, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) {
groupedIDs := map[string][]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}}
for _, candidate := range candidates {
groupedIDs[candidate.AssetType] = append(groupedIDs[candidate.AssetType], candidate.AssetID)
}
estimates := make(map[string]map[uint]dto.PackageExpiryEstimate, 2)
finalUsages := make(map[string]map[uint]*model.PackageUsage, 2)
for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} {
var err error
estimates[assetType], err = q.ResolveBatch(ctx, assetType, groupedIDs[assetType])
if err != nil {
return nil, err
}
finalUsages[assetType], err = q.loadFinalUsages(ctx, assetType, groupedIDs[assetType])
if err != nil {
return nil, err
}
}
from, to, err := parseExpiryRange(request)
if err != nil {
return nil, err
}
items := make([]dto.ExpiringAssetItem, 0, len(candidates))
for _, candidate := range candidates {
estimate := estimates[candidate.AssetType][candidate.AssetID]
usage := finalUsages[candidate.AssetType][candidate.AssetID]
if usage == nil || estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil {
continue
}
days := *estimate.DaysUntilFinalExpiry
if days < 0 || days > expiryWindowDays || request.DaysMin != nil && days < *request.DaysMin || request.DaysMax != nil && days > *request.DaysMax {
continue
}
expiryDate := dateInShanghai(*estimate.EstimatedFinalExpiresAt)
if from != nil && expiryDate.Before(*from) || to != nil && expiryDate.After(*to) {
continue
}
if request.PackageID != nil && usage.PackageID != *request.PackageID {
continue
}
level, levelName := expiryLevel(days)
items = append(items, dto.ExpiringAssetItem{
AssetType: candidate.AssetType, AssetID: candidate.AssetID, Identifier: candidate.Identifier, ShopID: candidate.ShopID,
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: usage.PackageName,
PackageExpiryEstimate: estimate, ExpiryLevel: level, ExpiryLevelName: levelName, IsPriority: days <= 3,
})
}
return items, nil
}
func (q *Query) loadFinalUsages(ctx context.Context, assetType string, assetIDs []uint) (map[uint]*model.PackageUsage, error) {
result := make(map[uint]*model.PackageUsage, len(assetIDs))
if len(assetIDs) == 0 {
return result, nil
}
column, ok := packageUsageAssetColumn(assetType)
if !ok {
return nil, errors.New(errors.CodeInvalidParam, "资产类型无效")
}
var usages []*model.PackageUsage
if err := q.db.WithContext(ctx).Where(column+" IN ?", assetIDs).
Where("master_usage_id IS NULL AND refund_id IS NULL").
Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Order("priority ASC, created_at ASC, id ASC").Find(&usages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产最终套餐失败")
}
for _, usage := range usages {
assetID := usage.IotCardID
if assetType == constants.AssetTypeDevice {
assetID = usage.DeviceID
}
result[assetID] = usage
}
return result, nil
}
func (q *Query) fillShopNames(ctx context.Context, items []dto.ExpiringAssetItem) error {
ids := make([]uint, 0)
seen := make(map[uint]struct{})
for _, item := range items {
if item.ShopID == nil {
continue
}
if _, exists := seen[*item.ShopID]; !exists {
seen[*item.ShopID] = struct{}{}
ids = append(ids, *item.ShopID)
}
}
if len(ids) == 0 {
return nil
}
var shops []model.Shop
if err := q.db.WithContext(ctx).Select("id, shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产店铺名称失败")
}
names := make(map[uint]string, len(shops))
for _, shop := range shops {
names[shop.ID] = shop.ShopName
}
for index := range items {
if items[index].ShopID != nil {
items[index].ShopName = names[*items[index].ShopID]
}
}
return nil
}
func applyStrictShopScope(ctx context.Context, query *gorm.DB) *gorm.DB {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent {
return query
}
shopIDs := middleware.GetSubordinateShopIDs(ctx)
if len(shopIDs) == 0 {
return query.Where("1 = 0")
}
return query.Where("shop_id IN ?", shopIDs)
}
func normalizeListRequest(request dto.ExpiringAssetListRequest) dto.ExpiringAssetListRequest {
if request.Page <= 0 {
request.Page = 1
}
if request.PageSize <= 0 {
request.PageSize = constants.DefaultPageSize
}
return request
}
func validateListRequest(request dto.ExpiringAssetListRequest) error {
if request.PageSize > constants.MaxPageSize || request.Page < 1 {
return errors.New(errors.CodeInvalidParam)
}
if request.AssetType != "" && request.AssetType != constants.AssetTypeIotCard && request.AssetType != constants.AssetTypeDevice {
return errors.New(errors.CodeInvalidParam)
}
if request.DaysMin != nil && (*request.DaysMin < 0 || *request.DaysMin > expiryWindowDays) || request.DaysMax != nil && (*request.DaysMax < 0 || *request.DaysMax > expiryWindowDays) {
return errors.New(errors.CodeInvalidParam)
}
if request.DaysMin != nil && request.DaysMax != nil && *request.DaysMin > *request.DaysMax {
return errors.New(errors.CodeInvalidParam, "最小剩余天数不能大于最大剩余天数")
}
_, _, err := parseExpiryRange(request)
return err
}
func parseExpiryRange(request dto.ExpiringAssetListRequest) (*time.Time, *time.Time, error) {
parse := func(value string) (*time.Time, error) {
if value == "" {
return nil, nil
}
result, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "到期日期格式无效")
}
return &result, nil
}
from, err := parse(request.ExpiresFrom)
if err != nil {
return nil, nil, err
}
to, err := parse(request.ExpiresTo)
if err != nil {
return nil, nil, err
}
if from != nil && to != nil && from.After(*to) {
return nil, nil, errors.New(errors.CodeInvalidParam, "到期开始日期不能晚于结束日期")
}
return from, to, nil
}
func expiryLevel(days int) (string, string) {
if days <= 3 {
return "red", "红色"
}
if days <= 7 {
return "purple", "紫色"
}
return "pink", "粉红色"
}
func summarize(items []dto.ExpiringAssetItem) dto.ExpiringAssetSummary {
result := dto.ExpiringAssetSummary{WindowDays: expiryWindowDays, TotalCount: int64(len(items))}
for _, item := range items {
if item.AssetType == constants.AssetTypeIotCard {
result.CardCount++
} else if item.AssetType == constants.AssetTypeDevice {
result.DeviceCount++
}
}
return result
}

View File

@@ -139,7 +139,8 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
BusinessOwnerAccountID: shop.BusinessOwnerAccountID, Level: shop.Level,
ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province,
City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status,
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
ClientLoginDisabled: shop.ClientLoginDisabled,
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if shop.ParentID != nil {