chore: 初始开源发布
iotlcm-worker-svr:异步执行中枢(RabbitMQ 消费者 + 全量同步编排)。 - Go module:iotlcm-worker-svr → git.5iot.com/iotlcm-public/iotlcm-worker-svr(214 文件) - 凭据与内部标识脱敏:MySQL/Redis 凭据、私网与生产 IP、内网域名、 库名 xiujie_admin → iotlcm、哨兵名占位 - 159 处真实手机号 → 合法 11 位编造号(保留号段与后 4 位,维持互异, 不破坏「校验 MSISDN 格式」等测试断言) - 移除内部资产:CI 触发器、Jenkins 触发测试文档、预演门禁与镜像 digest 记录 - Dockerfile 改用公共基础镜像(golang:1.25-alpine → alpine:3.23), 移除写死的 git 凭据 - README 重写(架构/全量同步模型/配置/已知失败),补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes 已知:3 个测试包在源仓同一提交上同样失败(client/upstream、 service/allocate、service/inbound),属既有缺陷,本仓未引入亦未修复。
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
)
|
||||
|
||||
// ChangeBindHistoryDO is the repository-local shape of the history table.
|
||||
// It deliberately lives outside the generated model because Worker does not
|
||||
// generate the card-package domain tables.
|
||||
type ChangeBindHistoryDO struct {
|
||||
g.Meta `orm:"table:t_sim_bind_package_logs, do:true"`
|
||||
SubscriptionNo any `orm:"subscription_no"`
|
||||
BatchNo any `orm:"batch_no"`
|
||||
Iccid any `orm:"iccid"`
|
||||
Msisdn any `orm:"msisdn"`
|
||||
DeptId any `orm:"dept_id"`
|
||||
PackageType any `orm:"package_type"`
|
||||
PackageCode any `orm:"package_code"`
|
||||
PreviousPackageCode any `orm:"previous_package_code"`
|
||||
BusinessType any `orm:"business_type"`
|
||||
EffectiveStatus any `orm:"effective_status"`
|
||||
HandleTime *gtime.Time `orm:"handle_time"`
|
||||
EffectiveTime *gtime.Time `orm:"effective_time"`
|
||||
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
|
||||
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
|
||||
DurationMonths any `orm:"duration_months"`
|
||||
GiftDurationMonths any `orm:"gift_duration_months"`
|
||||
OrderCode any `orm:"order_code"`
|
||||
Remark any `orm:"remark"`
|
||||
TenantId any `orm:"tenant_id"`
|
||||
CreatedDept any `orm:"created_dept"`
|
||||
CreatedBy any `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy any `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type changeOrderUpdateDO struct {
|
||||
g.Meta `orm:"table:t_order, do:true"`
|
||||
ReturnStatus any `orm:"return_status"`
|
||||
UpdatedBy any `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type changeOrderItemRow struct {
|
||||
ItemId int64 `orm:"item_id"`
|
||||
ExtraSkuData string `orm:"extra_sku_data"`
|
||||
}
|
||||
|
||||
type changeOrderItemUpdateDO struct {
|
||||
g.Meta `orm:"table:t_order_item, do:true"`
|
||||
ExtraSkuData any `orm:"extra_sku_data"`
|
||||
UpdatedBy any `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type changeCardUpdateDO struct {
|
||||
g.Meta `orm:"table:t_sim_card_info, do:true"`
|
||||
CurrentPackageCode any `orm:"current_package_code"`
|
||||
PackageStatus any `orm:"package_status"`
|
||||
FlowPkgBeginAt any `orm:"flow_pkg_begin_at"`
|
||||
FlowPkgPresentAt any `orm:"flow_pkg_present_at"`
|
||||
FlowPkgRealPresentAt any `orm:"flow_pkg_real_present_at"`
|
||||
FlowPkgEndAt any `orm:"flow_pkg_end_at"`
|
||||
DataServiceStatus any `orm:"data_service_status"`
|
||||
PackageTotalFlowMb any `orm:"package_total_flow_mb"`
|
||||
UsedFlowMb any `orm:"used_flow_mb"`
|
||||
TrafficCutoffStatus any `orm:"traffic_cutoff_status"`
|
||||
TrafficCutoffThreshold any `orm:"traffic_cutoff_threshold"`
|
||||
SmsServiceStatus any `orm:"sms_service_status"`
|
||||
PackageTotalSms any `orm:"package_total_sms"`
|
||||
UsedSms any `orm:"used_sms"`
|
||||
VoiceServiceStatus any `orm:"voice_service_status"`
|
||||
PackageTotalVoice any `orm:"package_total_voice"`
|
||||
UsedVoice any `orm:"used_voice"`
|
||||
RoamServiceStatus any `orm:"roam_service_status"`
|
||||
PackageTotalRoamFlowMb any `orm:"package_total_roam_flow_mb"`
|
||||
UsedRoamFlowMb any `orm:"used_roam_flow_mb"`
|
||||
UpdatedBy any `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type ChangeDeptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
}
|
||||
|
||||
// LockChangeBatch locks the formal change batch so a duplicate delivery
|
||||
// cannot create a second order after the first transaction commits.
|
||||
func (r *Repository) LockChangeBatch(ctx context.Context, tx gdb.TX, batchNo string) (*BatchMainRow, error) {
|
||||
var main BatchMainRow
|
||||
if err := tx.Ctx(ctx).Model(TableBatchMain).
|
||||
Where("batch_no", strings.TrimSpace(batchNo)).
|
||||
Where("batch_type", consts.BatchTypeChange).
|
||||
LockUpdate().
|
||||
Scan(&main); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock change batch failed")
|
||||
}
|
||||
if main.Id == 0 {
|
||||
return nil, gerror.New("change batch does not exist")
|
||||
}
|
||||
return &main, nil
|
||||
}
|
||||
|
||||
// LockChangeDetails locks all details, allowing the executor to verify that
|
||||
// the claimed batch still has exactly the expected running rows.
|
||||
func (r *Repository) LockChangeDetails(ctx context.Context, tx gdb.TX, batchNo string) ([]*BatchDetailRow, error) {
|
||||
var rows []*BatchDetailRow
|
||||
if err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", strings.TrimSpace(batchNo)).
|
||||
Where("batch_type", consts.BatchTypeChange).
|
||||
OrderAsc("row_no").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock change details failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LockChangeBinds returns every pending/effective bind for the requested
|
||||
// cards. The service chooses the single current bind and treats equal-priority
|
||||
// duplicates as an integrity error.
|
||||
func (r *Repository) LockChangeBinds(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
return r.LockActiveBindRowsByIccids(ctx, tx, iccids)
|
||||
}
|
||||
|
||||
func (r *Repository) LoadChangePackage(ctx context.Context, tx gdb.TX, tenantID, packageCode string) (*FlowPackageInfo, error) {
|
||||
var row FlowPackageInfo
|
||||
if err := tx.Ctx(ctx).Model(TableSimFlowPackageInfo).
|
||||
Where("tenant_id", strings.TrimSpace(tenantID)).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
LockUpdate().
|
||||
Scan(&row); err != nil {
|
||||
return nil, gerror.Wrap(err, "load change package failed")
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, gerror.Newf("package not found: %s", packageCode)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LoadChangeDept(ctx context.Context, tx gdb.TX, tenantID string, deptID int64) (*ChangeDeptRow, error) {
|
||||
var row ChangeDeptRow
|
||||
if err := tx.Ctx(ctx).Model(TableSysDept).
|
||||
Where("tenant_id", strings.TrimSpace(tenantID)).
|
||||
Where("dept_id", deptID).
|
||||
Scan(&row); err != nil {
|
||||
return nil, gerror.Wrap(err, "load change customer failed")
|
||||
}
|
||||
if row.DeptId == 0 {
|
||||
return nil, gerror.New("change customer does not exist")
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ArchiveChangeBinds(ctx context.Context, tx gdb.TX, binds []*SimCardBindPkg, status int, actualExpireAt *gtime.Time, changeBatchNo string, userID int64) error {
|
||||
if len(binds) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := make([]ChangeBindHistoryDO, 0, len(binds))
|
||||
for _, bind := range binds {
|
||||
if bind == nil || strings.TrimSpace(bind.SubscriptionNo) == "" {
|
||||
return gerror.New("change subscription history is incomplete")
|
||||
}
|
||||
rows = append(rows, ChangeBindHistoryDO{
|
||||
SubscriptionNo: bind.SubscriptionNo,
|
||||
BatchNo: bind.BatchNo,
|
||||
Iccid: bind.Iccid,
|
||||
Msisdn: bind.Msisdn,
|
||||
DeptId: bind.DeptId,
|
||||
PackageType: bind.PackageType,
|
||||
PackageCode: bind.PackageCode,
|
||||
PreviousPackageCode: bind.PreviousPackageCode,
|
||||
BusinessType: bind.BusinessType,
|
||||
EffectiveStatus: status,
|
||||
HandleTime: bind.HandleTime,
|
||||
EffectiveTime: bind.EffectiveTime,
|
||||
ExpectedExpireTime: bind.ExpectedExpireTime,
|
||||
ActualExpireTime: actualExpireAt,
|
||||
DurationMonths: bind.DurationMonths,
|
||||
GiftDurationMonths: bind.GiftDurationMonths,
|
||||
OrderCode: bind.OrderCode,
|
||||
Remark: appendChangeRemark(bind.Remark, changeBatchNo),
|
||||
TenantId: bind.TenantId,
|
||||
CreatedDept: bind.CreatedDept,
|
||||
CreatedBy: bind.CreatedBy,
|
||||
CreatedAt: bind.CreatedAt,
|
||||
UpdatedBy: userID,
|
||||
UpdatedAt: actualExpireAt,
|
||||
})
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(TableSimBindPackageLogs).
|
||||
Data(rows).
|
||||
OnConflict("subscription_no").
|
||||
Save(); err != nil {
|
||||
return gerror.Wrap(err, "archive change subscriptions failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteChangeBinds(ctx context.Context, tx gdb.TX, subscriptionNos []string) (int64, error) {
|
||||
unique := uniqueStrings(subscriptionNos)
|
||||
if len(unique) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("subscription_no", unique).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "delete change subscriptions failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "read deleted change subscriptions count failed")
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateChangeOrdersAfterRemoval(ctx context.Context, tx gdb.TX, orderCodes []string, batchNo, batchKey string, quantityByOrder map[string]int, eventTime *gtime.Time, userID int64) error {
|
||||
orderCodes = uniqueStrings(orderCodes)
|
||||
sort.Strings(orderCodes)
|
||||
if len(orderCodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
if eventTime == nil {
|
||||
eventTime = gtime.Now()
|
||||
}
|
||||
activeCounts, err := countChangeBindsByOrder(ctx, tx, orderCodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, orderCode := range orderCodes {
|
||||
returnStatus := consts.OrderReturnStatusSuccess
|
||||
if activeCounts[orderCode] > 0 {
|
||||
returnStatus = consts.OrderReturnStatusPartial
|
||||
}
|
||||
result, updateErr := tx.Ctx(ctx).Model(TableOrder).
|
||||
Where("order_sn", orderCode).
|
||||
Data(buildChangeOrderUpdateDO(returnStatus, userID, eventTime)).
|
||||
Update()
|
||||
if updateErr != nil {
|
||||
return gerror.Wrap(updateErr, "update changed order return status failed")
|
||||
}
|
||||
affected, affectedErr := result.RowsAffected()
|
||||
if affectedErr != nil {
|
||||
return gerror.Wrap(affectedErr, "read changed order update count failed")
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.Newf("changed source order does not exist: %s", orderCode)
|
||||
}
|
||||
if err = r.updateChangeOrderItemExtra(ctx, tx, orderCode, batchNo, batchKey, quantityByOrder[orderCode], eventTime, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) updateChangeOrderItemExtra(ctx context.Context, tx gdb.TX, orderCode, batchNo, batchKey string, quantity int, eventTime *gtime.Time, userID int64) error {
|
||||
var item changeOrderItemRow
|
||||
if err := tx.Ctx(ctx).Model(TableOrderItem).
|
||||
Where("order_sn", orderCode).
|
||||
Limit(1).
|
||||
Scan(&item); err != nil {
|
||||
return gerror.Wrap(err, "load changed order item failed")
|
||||
}
|
||||
if item.ItemId == 0 {
|
||||
return gerror.Newf("changed order item does not exist: %s", orderCode)
|
||||
}
|
||||
extra := map[string]any{}
|
||||
if strings.TrimSpace(item.ExtraSkuData) != "" {
|
||||
if err := json.Unmarshal([]byte(item.ExtraSkuData), &extra); err != nil {
|
||||
return gerror.Wrap(err, "parse changed order item extra failed")
|
||||
}
|
||||
}
|
||||
extra[batchKey+"BatchNo"] = batchNo
|
||||
extra[batchKey+"Time"] = eventTime.Format("Y-m-d H:i:s")
|
||||
if batchKey == "lastUnsubscribe" {
|
||||
extra["unsubscribedQuantity"] = quantity
|
||||
} else {
|
||||
extra["voidedByChangeQuantity"] = quantity
|
||||
}
|
||||
data, err := json.Marshal(extra)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "marshal changed order item extra failed")
|
||||
}
|
||||
_, err = tx.Ctx(ctx).Model(TableOrderItem).
|
||||
Where("item_id", item.ItemId).
|
||||
Data(buildChangeOrderItemUpdateDO(string(data), userID, eventTime)).
|
||||
Update()
|
||||
return gerror.Wrap(err, "update changed order item extra failed")
|
||||
}
|
||||
|
||||
func buildChangeOrderUpdateDO(returnStatus int, userID int64, updatedAt *gtime.Time) changeOrderUpdateDO {
|
||||
return changeOrderUpdateDO{ReturnStatus: returnStatus, UpdatedBy: userID, UpdatedAt: updatedAt}
|
||||
}
|
||||
|
||||
func buildChangeOrderItemUpdateDO(extraSKUData string, userID int64, updatedAt *gtime.Time) changeOrderItemUpdateDO {
|
||||
return changeOrderItemUpdateDO{ExtraSkuData: extraSKUData, UpdatedBy: userID, UpdatedAt: updatedAt}
|
||||
}
|
||||
|
||||
func countChangeBindsByOrder(ctx context.Context, tx gdb.TX, orderCodes []string) (map[string]int, error) {
|
||||
type orderCountRow struct {
|
||||
OrderCode string `orm:"order_code"`
|
||||
Count int `orm:"count"`
|
||||
}
|
||||
var rows []orderCountRow
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
Fields("order_code", "COUNT(1) AS count").
|
||||
WhereIn("order_code", orderCodes).
|
||||
Group("order_code").
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "count changed order subscriptions failed")
|
||||
}
|
||||
counts := make(map[string]int, len(rows))
|
||||
for _, row := range rows {
|
||||
counts[strings.TrimSpace(row.OrderCode)] = row.Count
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateChangeCard(ctx context.Context, tx gdb.TX, iccid, packageCode string, packageStatus int, packageFlowMb float64, beginAt, endAt *gtime.Time, userID int64) error {
|
||||
result, err := tx.Ctx(ctx).Model(TableSimCardInfo).
|
||||
Where("iccid", strings.TrimSpace(iccid)).
|
||||
Data(changeCardUpdateDO{
|
||||
CurrentPackageCode: packageCode,
|
||||
PackageStatus: packageStatus,
|
||||
FlowPkgBeginAt: beginAt,
|
||||
FlowPkgPresentAt: gdb.Raw("NULL"),
|
||||
FlowPkgRealPresentAt: gdb.Raw("NULL"),
|
||||
FlowPkgEndAt: endAt,
|
||||
DataServiceStatus: 1,
|
||||
PackageTotalFlowMb: packageFlowMb,
|
||||
UsedFlowMb: 0,
|
||||
TrafficCutoffStatus: 0,
|
||||
TrafficCutoffThreshold: 0,
|
||||
SmsServiceStatus: 0,
|
||||
PackageTotalSms: 0,
|
||||
UsedSms: 0,
|
||||
VoiceServiceStatus: 0,
|
||||
PackageTotalVoice: 0,
|
||||
UsedVoice: 0,
|
||||
RoamServiceStatus: 0,
|
||||
PackageTotalRoamFlowMb: 0,
|
||||
UsedRoamFlowMb: 0,
|
||||
UpdatedBy: userID,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update changed card package failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "read changed card update count failed")
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.Newf("changed card was modified concurrently: %s", iccid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkChangeDetailsSuccess(ctx context.Context, tx gdb.TX, batchNo string, details []*BatchDetailRow, snapshots map[int]*gjson.Json, userID int64) error {
|
||||
for _, detail := range details {
|
||||
if detail == nil {
|
||||
continue
|
||||
}
|
||||
data := do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusSuccess,
|
||||
ErrorCode: "",
|
||||
ErrorReason: "",
|
||||
Snapshot: snapshots[detail.RowNo],
|
||||
UpdatedBy: userID,
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", consts.BatchTypeChange).
|
||||
Where("row_no", detail.RowNo).
|
||||
Data(data).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "mark change detail success failed")
|
||||
}
|
||||
affected, affectedErr := result.RowsAffected()
|
||||
if affectedErr != nil || affected != 1 {
|
||||
return gerror.Newf("change detail disappeared during success update: row %d", detail.RowNo)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendChangeRemark(old, batchNo string) string {
|
||||
addition := "changeBatchNo=" + strings.TrimSpace(batchNo)
|
||||
old = strings.TrimSpace(old)
|
||||
if old == "" {
|
||||
return addition
|
||||
}
|
||||
return old + "; " + addition
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestChangeOrderRemovalUpdatesCarryAuditTimestamp(t *testing.T) {
|
||||
updatedAt := gtime.NewFromTime(time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local))
|
||||
orderUpdate := buildChangeOrderUpdateDO(2, 7, updatedAt)
|
||||
itemUpdate := buildChangeOrderItemUpdateDO(`{"lastChangeBatchNo":"CHG-1"}`, 7, updatedAt)
|
||||
|
||||
if orderUpdate.UpdatedAt != updatedAt || itemUpdate.UpdatedAt != updatedAt {
|
||||
t.Fatalf("source order audit timestamps = %v/%v, want shared %v", orderUpdate.UpdatedAt, itemUpdate.UpdatedAt, updatedAt)
|
||||
}
|
||||
if orderUpdate.ReturnStatus != 2 || orderUpdate.UpdatedBy != int64(7) {
|
||||
t.Fatalf("source order update = %+v", orderUpdate)
|
||||
}
|
||||
if itemUpdate.ExtraSkuData == nil || itemUpdate.UpdatedBy != int64(7) {
|
||||
t.Fatalf("source order item update = %+v", itemUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeCardUpdateHasExplicitUpdatedAtField(t *testing.T) {
|
||||
updatedAt := gtime.NewFromTime(time.Date(2026, 8, 3, 10, 2, 0, 0, time.Local))
|
||||
update := changeCardUpdateDO{UpdatedBy: int64(7), UpdatedAt: updatedAt}
|
||||
if update.UpdatedAt != updatedAt {
|
||||
t.Fatalf("card updated_at = %v, want %v", update.UpdatedAt, updatedAt)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package renewchange
|
||||
|
||||
import "github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
var (
|
||||
errRenewDurationEmpty = gerror.New("续费/变更套餐未配置可续费时长")
|
||||
errPkgNotFound = gerror.New("套餐不存在")
|
||||
errPkgDisabled = gerror.New("套餐未启用或不存在")
|
||||
errDeptNotFound = gerror.New("客户不存在")
|
||||
errNoChangeablePackages = gerror.New("当前套餐未配置可变更套餐范围")
|
||||
errCardNotFound = gerror.New("ICCID不存在正式卡记录")
|
||||
errLatestBindMissing = gerror.New("ICCID最新订购记录不存在")
|
||||
errCardNotOwned = gerror.New("ICCID不属于当前机构")
|
||||
errCardNoCurrentPackage = gerror.New("ICCID没有当前套餐")
|
||||
errCardPackageMismatch = gerror.New("ICCID当前套餐与最新订购记录不一致")
|
||||
errCardExpireMissing = gerror.New("ICCID最新订购没有预计到期时间")
|
||||
errCardSourceBatchMismatch = gerror.New("ICCID不属于来源出库批次")
|
||||
errDuplicateICCID = gerror.New("ICCID重复")
|
||||
errPrepareIncomplete = gerror.New("预处理结果不完整")
|
||||
errMultiplePackages = gerror.New("当前批次下存在多个套餐,不能统一处理")
|
||||
errMultipleCustomers = gerror.New("当前批次下存在多个客户,不能统一处理")
|
||||
errSameBatchInvalid = gerror.New("同批存在不满足条件的卡,本次未执行")
|
||||
errSameBatchChangeInvalid = gerror.New("同批存在不满足变更条件的卡,本行未执行")
|
||||
errNoPendingBinds = gerror.New("ICCID没有有效订购记录")
|
||||
errActiveBindNotFound = gerror.New("ICCID当前有效订购不存在")
|
||||
errActiveBindMismatch = gerror.New("ICCID当前有效订购与当前套餐不一致")
|
||||
errActiveBindSourceBatch = gerror.New("ICCID当前有效订购不属于来源批次")
|
||||
errMsisdnMismatch = gerror.New("MSISDN与SIM卡记录不一致")
|
||||
errImsiMismatch = gerror.New("IMSI与SIM卡记录不一致")
|
||||
errRenewableDurationMiss = gerror.New("续费时长不在当前套餐可续费范围内")
|
||||
errPriceChanged = gerror.New("当前续费价格已变化,请重新校验")
|
||||
errPackageTypeMismatch = gerror.New("目标套餐类型与当前套餐类型不一致")
|
||||
errNewPackageNotInChange = gerror.New("新套餐不在当前套餐可变更范围内")
|
||||
errNewPackageSameAsOld = gerror.New("新套餐不能与当前套餐相同")
|
||||
errTargetPkgNotFound = gerror.New("目标套餐不存在")
|
||||
errPrepareBatchNotFound = gerror.New("预处理批次不存在或已执行")
|
||||
errPrepareBatchTypeWrong = gerror.New("批次不是续费/变更预处理批次")
|
||||
errPrepareInfoMissing = gerror.New("预处理批次缺少关键快照")
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
const businessNoAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
// NextOrderSN returns a production order number before any INSERT is issued.
|
||||
func NextOrderSN(now time.Time) (string, error) {
|
||||
suffix, err := randomBusinessNoSuffix(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("SO%s%s", now.Format("20060102150405"), suffix), nil
|
||||
}
|
||||
|
||||
func randomBusinessNoSuffix(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", gerror.New("business number suffix length must be greater than zero")
|
||||
}
|
||||
randomBytes := make([]byte, length)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", gerror.Wrap(err, "generate business number suffix failed")
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
for i, value := range randomBytes {
|
||||
buf[i] = businessNoAlphabet[int(value)%len(businessNoAlphabet)]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
// GenerateOrderSn 形如 "yyyyMMddHHmmssfff"+6位随机后缀
|
||||
func GenerateOrderSn(t *time.Time) string {
|
||||
now := time.Now()
|
||||
if t != nil {
|
||||
now = *t
|
||||
}
|
||||
return fmt.Sprintf("%s%06d", now.UTC().Format("20060102150405.000")[0:14], now.Nanosecond()/1000%1000000)
|
||||
}
|
||||
|
||||
// GenerateSubscriptionNo 形如 "SUB" + yyyyMMddHHmmss + 6位后缀
|
||||
func GenerateSubscriptionNo(prefix string) string {
|
||||
now := time.Now()
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
prefix = "SUB"
|
||||
}
|
||||
return fmt.Sprintf("%s%s%06d", prefix, now.UTC().Format("20060102150405"), now.Nanosecond()/1000%1000000)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// OrderDO mirrors the production t_order table. It is repository-local
|
||||
// because Worker does not generate DAO/DO/entity code for the order domain.
|
||||
type OrderDO struct {
|
||||
g.Meta `orm:"table:t_order, do:true"`
|
||||
Id any `orm:"id"`
|
||||
OrderSn any `orm:"order_sn"`
|
||||
ParentOrderId any `orm:"parent_order_id"`
|
||||
ParentOrderSn any `orm:"parent_order_sn"`
|
||||
CustomerType any `orm:"customer_type"`
|
||||
CustomerAgentId any `orm:"customer_agent_id"`
|
||||
EndUserId any `orm:"end_user_id"`
|
||||
AgentId any `orm:"agent_id"`
|
||||
OrderType any `orm:"order_type"`
|
||||
OrderStatus any `orm:"order_status"`
|
||||
ShippingStatus any `orm:"shipping_status"`
|
||||
ReturnStatus any `orm:"return_status"`
|
||||
Consignee any `orm:"consignee"`
|
||||
Mobile any `orm:"mobile"`
|
||||
Email any `orm:"email"`
|
||||
Address any `orm:"address"`
|
||||
AreaCode any `orm:"area_code"`
|
||||
BuyerNote any `orm:"buyer_note"`
|
||||
AdminNote any `orm:"admin_note"`
|
||||
Remark any `orm:"remark"`
|
||||
ShippingMethod any `orm:"shipping_method"`
|
||||
LogisticsId any `orm:"logistics_id"`
|
||||
LogisticsName any `orm:"logistics_name"`
|
||||
ShippingTypeId any `orm:"shipping_type_id"`
|
||||
ShippingTypeName any `orm:"shipping_type_name"`
|
||||
TrackingNo any `orm:"tracking_no"`
|
||||
ShippingTime *gtime.Time `orm:"shipping_time"`
|
||||
ReceivedTime *gtime.Time `orm:"received_time"`
|
||||
PayType any `orm:"pay_type"`
|
||||
PayStatus any `orm:"pay_status"`
|
||||
PayOrderSn any `orm:"pay_order_sn"`
|
||||
PayTime *gtime.Time `orm:"pay_time"`
|
||||
TotalAmount any `orm:"total_amount"`
|
||||
ProductAmount any `orm:"product_amount"`
|
||||
ShippingFee any `orm:"shipping_fee"`
|
||||
ServiceFee any `orm:"service_fee"`
|
||||
InvoiceFee any `orm:"invoice_fee"`
|
||||
CouponAmount any `orm:"coupon_amount"`
|
||||
DiscountAmount any `orm:"discount_amount"`
|
||||
Balance any `orm:"balance"`
|
||||
PaidAmount any `orm:"paid_amount"`
|
||||
OnlinePaidAmount any `orm:"online_paid_amount"`
|
||||
OfflinePaidAmount any `orm:"offline_paid_amount"`
|
||||
UnpaidAmount any `orm:"unpaid_amount"`
|
||||
UnrefundAmount any `orm:"unrefund_amount"`
|
||||
IsNeedCommission any `orm:"is_need_commission"`
|
||||
DistributionStatus any `orm:"distribution_status"`
|
||||
TenantId any `orm:"tenant_id"`
|
||||
CreatedDept any `orm:"created_dept"`
|
||||
CreatedBy any `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy any `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
// OrderItemDO mirrors the production t_order_item table.
|
||||
type OrderItemDO struct {
|
||||
g.Meta `orm:"table:t_order_item, do:true"`
|
||||
ItemId any `orm:"item_id"`
|
||||
OrderId any `orm:"order_id"`
|
||||
OrderSn any `orm:"order_sn"`
|
||||
CustomerType any `orm:"customer_type"`
|
||||
CustomerAgentId any `orm:"customer_agent_id"`
|
||||
EndUserId any `orm:"end_user_id"`
|
||||
Price any `orm:"price"`
|
||||
Quantity any `orm:"quantity"`
|
||||
ProductId any `orm:"product_id"`
|
||||
ProductName any `orm:"product_name"`
|
||||
ProductSn any `orm:"product_sn"`
|
||||
PicThumb any `orm:"pic_thumb"`
|
||||
SkuId any `orm:"sku_id"`
|
||||
SkuData any `orm:"sku_data"`
|
||||
DeliveryQuantity any `orm:"delivery_quantity"`
|
||||
ProductType any `orm:"product_type"`
|
||||
IsGift any `orm:"is_gift"`
|
||||
AgentId any `orm:"agent_id"`
|
||||
PrepayPrice any `orm:"prepay_price"`
|
||||
Commission any `orm:"commission"`
|
||||
OriginPrice any `orm:"origin_price"`
|
||||
PromotionData any `orm:"promotion_data"`
|
||||
ExtraSkuData any `orm:"extra_sku_data"`
|
||||
PayMode any `orm:"pay_mode"`
|
||||
ServiceBeginTime *gtime.Time `orm:"service_begin_time"`
|
||||
ServiceEndTime *gtime.Time `orm:"service_end_time"`
|
||||
TenantId any `orm:"tenant_id"`
|
||||
CreatedDept any `orm:"created_dept"`
|
||||
CreatedBy any `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy any `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
// InsertContractOrder inserts a fully built order and returns its primary key.
|
||||
// The order number must exist before the INSERT is issued.
|
||||
func (r *Repository) InsertContractOrder(ctx context.Context, tx gdb.TX, order *OrderDO) (int64, error) {
|
||||
if err := ValidateOrderDO(order); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := tx.Ctx(ctx).Model(TableOrder).Data(order).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "insert order failed")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// InsertContractOrderItem inserts the aggregate order item for an order.
|
||||
func (r *Repository) InsertContractOrderItem(ctx context.Context, tx gdb.TX, item *OrderItemDO) error {
|
||||
if err := ValidateOrderItemDO(item); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(TableOrderItem).Data(item).Insert()
|
||||
return gerror.Wrap(err, "insert order item failed")
|
||||
}
|
||||
|
||||
func ValidateOrderDO(order *OrderDO) error {
|
||||
if order == nil {
|
||||
return gerror.New("order cannot be nil")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(order.OrderSn)) == "" {
|
||||
return gerror.New("order number cannot be empty")
|
||||
}
|
||||
if gconv.Int64(order.CustomerAgentId) <= 0 {
|
||||
return gerror.New("order customer agent id must be greater than zero")
|
||||
}
|
||||
if gconv.Int64(order.AgentId) <= 0 {
|
||||
return gerror.New("order seller agent id must be greater than zero")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(order.TenantId)) == "" {
|
||||
return gerror.New("order tenant id cannot be empty")
|
||||
}
|
||||
if gconv.Int64(order.CreatedDept) <= 0 || gconv.Int64(order.CreatedBy) <= 0 || gconv.Int64(order.UpdatedBy) <= 0 {
|
||||
return gerror.New("order creator organization is incomplete")
|
||||
}
|
||||
if order.CreatedAt == nil || order.UpdatedAt == nil {
|
||||
return gerror.New("order audit time cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateOrderItemDO(item *OrderItemDO) error {
|
||||
if item == nil {
|
||||
return gerror.New("order item cannot be nil")
|
||||
}
|
||||
if gconv.Int64(item.OrderId) <= 0 {
|
||||
return gerror.New("order item order id must be greater than zero")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(item.OrderSn)) == "" {
|
||||
return gerror.New("order item order number cannot be empty")
|
||||
}
|
||||
if gconv.Int64(item.CustomerAgentId) <= 0 {
|
||||
return gerror.New("order item customer agent id must be greater than zero")
|
||||
}
|
||||
if gconv.Int64(item.AgentId) <= 0 {
|
||||
return gerror.New("order item seller agent id must be greater than zero")
|
||||
}
|
||||
if gconv.Int(item.Quantity) <= 0 {
|
||||
return gerror.New("order item quantity must be greater than zero")
|
||||
}
|
||||
if gconv.Int64(item.ProductId) <= 0 {
|
||||
return gerror.New("order item product id must be greater than zero")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(item.ProductName)) == "" || strings.TrimSpace(gconv.String(item.ProductSn)) == "" {
|
||||
return gerror.New("order item product contract is incomplete")
|
||||
}
|
||||
if item.ServiceBeginTime == nil || item.ServiceEndTime == nil || !item.ServiceEndTime.After(item.ServiceBeginTime) {
|
||||
return gerror.New("order item service period is invalid")
|
||||
}
|
||||
if strings.TrimSpace(gconv.String(item.TenantId)) == "" {
|
||||
return gerror.New("order item tenant id cannot be empty")
|
||||
}
|
||||
if gconv.Int64(item.CreatedDept) <= 0 || gconv.Int64(item.CreatedBy) <= 0 || gconv.Int64(item.UpdatedBy) <= 0 {
|
||||
return gerror.New("order item creator organization is incomplete")
|
||||
}
|
||||
if item.CreatedAt == nil || item.UpdatedAt == nil {
|
||||
return gerror.New("order item audit time cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestOrderDOLocalSchemaMatchesProductionContract(t *testing.T) {
|
||||
want := []string{
|
||||
"Id", "OrderSn", "ParentOrderId", "ParentOrderSn", "CustomerType",
|
||||
"CustomerAgentId", "EndUserId", "AgentId", "OrderType", "OrderStatus",
|
||||
"ShippingStatus", "ReturnStatus", "Consignee", "Mobile", "Email", "Address",
|
||||
"AreaCode", "BuyerNote", "AdminNote", "Remark", "ShippingMethod", "LogisticsId",
|
||||
"LogisticsName", "ShippingTypeId", "ShippingTypeName", "TrackingNo", "ShippingTime",
|
||||
"ReceivedTime", "PayType", "PayStatus", "PayOrderSn", "PayTime", "TotalAmount",
|
||||
"ProductAmount", "ShippingFee", "ServiceFee", "InvoiceFee", "CouponAmount",
|
||||
"DiscountAmount", "Balance", "PaidAmount", "OnlinePaidAmount", "OfflinePaidAmount",
|
||||
"UnpaidAmount", "UnrefundAmount", "IsNeedCommission", "DistributionStatus", "TenantId",
|
||||
"CreatedDept", "CreatedBy", "CreatedAt", "UpdatedBy", "UpdatedAt",
|
||||
}
|
||||
assertDOFields(t, reflect.TypeOf(OrderDO{}), want)
|
||||
}
|
||||
|
||||
func TestOrderItemDOLocalSchemaMatchesProductionContract(t *testing.T) {
|
||||
want := []string{
|
||||
"ItemId", "OrderId", "OrderSn", "CustomerType", "CustomerAgentId", "EndUserId",
|
||||
"Price", "Quantity", "ProductId", "ProductName", "ProductSn", "PicThumb", "SkuId",
|
||||
"SkuData", "DeliveryQuantity", "ProductType", "IsGift", "AgentId", "PrepayPrice",
|
||||
"Commission", "OriginPrice", "PromotionData", "ExtraSkuData", "PayMode",
|
||||
"ServiceBeginTime", "ServiceEndTime", "TenantId", "CreatedDept", "CreatedBy",
|
||||
"CreatedAt", "UpdatedBy", "UpdatedAt",
|
||||
}
|
||||
assertDOFields(t, reflect.TypeOf(OrderItemDO{}), want)
|
||||
}
|
||||
|
||||
func TestOrderDOsHaveExplicitORMColumnMappings(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
typ reflect.Type
|
||||
}{
|
||||
{name: "order", typ: reflect.TypeOf(OrderDO{})},
|
||||
{name: "order item", typ: reflect.TypeOf(OrderItemDO{})},
|
||||
{name: "change history", typ: reflect.TypeOf(ChangeBindHistoryDO{})},
|
||||
{name: "change order update", typ: reflect.TypeOf(changeOrderUpdateDO{})},
|
||||
{name: "change order item update", typ: reflect.TypeOf(changeOrderItemUpdateDO{})},
|
||||
{name: "change card update", typ: reflect.TypeOf(changeCardUpdateDO{})},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
for i := 0; i < testCase.typ.NumField(); i++ {
|
||||
field := testCase.typ.Field(i)
|
||||
if field.Name == "Meta" {
|
||||
continue
|
||||
}
|
||||
if got := field.Tag.Get("orm"); got == "" {
|
||||
t.Errorf("%s has no explicit orm column mapping", field.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderDOTagsMatchProductionColumns(t *testing.T) {
|
||||
assertDOColumnTags(t, reflect.TypeOf(OrderDO{}), map[string]string{
|
||||
"OrderSn": "order_sn", "CustomerAgentId": "customer_agent_id", "EndUserId": "end_user_id", "AgentId": "agent_id",
|
||||
"OrderType": "order_type", "OrderStatus": "order_status", "ShippingStatus": "shipping_status", "ReturnStatus": "return_status",
|
||||
"BuyerNote": "buyer_note", "AdminNote": "admin_note", "Remark": "remark", "PayType": "pay_type", "PayStatus": "pay_status",
|
||||
"PayTime": "pay_time", "TotalAmount": "total_amount", "ProductAmount": "product_amount", "PaidAmount": "paid_amount",
|
||||
"OnlinePaidAmount": "online_paid_amount", "OfflinePaidAmount": "offline_paid_amount", "TenantId": "tenant_id",
|
||||
"CreatedDept": "created_dept", "CreatedBy": "created_by", "CreatedAt": "created_at", "UpdatedBy": "updated_by", "UpdatedAt": "updated_at",
|
||||
})
|
||||
assertDOColumnTags(t, reflect.TypeOf(OrderItemDO{}), map[string]string{
|
||||
"ItemId": "item_id", "OrderId": "order_id", "OrderSn": "order_sn", "CustomerAgentId": "customer_agent_id", "EndUserId": "end_user_id",
|
||||
"Price": "price", "Quantity": "quantity", "ProductId": "product_id", "ProductName": "product_name", "ProductSn": "product_sn",
|
||||
"SkuData": "sku_data", "ProductType": "product_type", "IsGift": "is_gift", "AgentId": "agent_id", "OriginPrice": "origin_price",
|
||||
"ExtraSkuData": "extra_sku_data", "PayMode": "pay_mode", "ServiceBeginTime": "service_begin_time", "ServiceEndTime": "service_end_time",
|
||||
"TenantId": "tenant_id", "CreatedDept": "created_dept", "CreatedBy": "created_by", "CreatedAt": "created_at", "UpdatedBy": "updated_by", "UpdatedAt": "updated_at",
|
||||
})
|
||||
}
|
||||
|
||||
func TestNextOrderSNIsGeneratedBeforeInsert(t *testing.T) {
|
||||
got, err := NextOrderSN(time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
|
||||
if err != nil {
|
||||
t.Fatalf("NextOrderSN returned error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got, "SO20260803100250") {
|
||||
t.Fatalf("NextOrderSN = %q, want SO prefix and timestamp", got)
|
||||
}
|
||||
if len(got) != len("SO20260803100250")+8 {
|
||||
t.Fatalf("NextOrderSN length = %d, want %d", len(got), len("SO20260803100250")+8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContractOrderRequiredFields(t *testing.T) {
|
||||
now := gtime.NewFromTime(time.Date(2026, 8, 3, 10, 2, 50, 0, time.Local))
|
||||
order := &OrderDO{
|
||||
OrderSn: "SO20260803100250ABCDEFGH",
|
||||
CustomerAgentId: int64(200),
|
||||
AgentId: int64(100),
|
||||
TenantId: "000000",
|
||||
CreatedDept: int64(100),
|
||||
CreatedBy: int64(1),
|
||||
CreatedAt: now,
|
||||
UpdatedBy: int64(1),
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := ValidateOrderDO(order); err != nil {
|
||||
t.Fatalf("ValidateOrderDO returned error: %v", err)
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
clear func(*OrderDO)
|
||||
}{
|
||||
{name: "order number", clear: func(order *OrderDO) { order.OrderSn = "" }},
|
||||
{name: "customer agent", clear: func(order *OrderDO) { order.CustomerAgentId = int64(0) }},
|
||||
{name: "seller agent", clear: func(order *OrderDO) { order.AgentId = int64(0) }},
|
||||
{name: "tenant", clear: func(order *OrderDO) { order.TenantId = "" }},
|
||||
{name: "creator department", clear: func(order *OrderDO) { order.CreatedDept = int64(0) }},
|
||||
{name: "creator", clear: func(order *OrderDO) { order.CreatedBy = int64(0) }},
|
||||
{name: "updater", clear: func(order *OrderDO) { order.UpdatedBy = int64(0) }},
|
||||
{name: "created time", clear: func(order *OrderDO) { order.CreatedAt = nil }},
|
||||
{name: "updated time", clear: func(order *OrderDO) { order.UpdatedAt = nil }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
invalid := *order
|
||||
testCase.clear(&invalid)
|
||||
if err := ValidateOrderDO(&invalid); err == nil {
|
||||
t.Fatalf("ValidateOrderDO accepted missing %s", testCase.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateContractOrderItemRequiredFields(t *testing.T) {
|
||||
beginAt := gtime.NewFromTime(time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local))
|
||||
endAt := gtime.NewFromTime(time.Date(2027, 8, 3, 0, 0, 0, 0, time.Local))
|
||||
item := &OrderItemDO{
|
||||
OrderId: int64(1),
|
||||
OrderSn: "SO20260803100250ABCDEFGH",
|
||||
CustomerAgentId: int64(200),
|
||||
AgentId: int64(100),
|
||||
Quantity: 2,
|
||||
ProductId: int64(300),
|
||||
ProductName: "package",
|
||||
ProductSn: "PKG001",
|
||||
ServiceBeginTime: beginAt,
|
||||
ServiceEndTime: endAt,
|
||||
TenantId: "000000",
|
||||
CreatedDept: int64(100),
|
||||
CreatedBy: int64(1),
|
||||
CreatedAt: beginAt,
|
||||
UpdatedBy: int64(1),
|
||||
UpdatedAt: beginAt,
|
||||
}
|
||||
if err := ValidateOrderItemDO(item); err != nil {
|
||||
t.Fatalf("ValidateOrderItemDO returned error: %v", err)
|
||||
}
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
clear func(*OrderItemDO)
|
||||
}{
|
||||
{name: "order id", clear: func(item *OrderItemDO) { item.OrderId = int64(0) }},
|
||||
{name: "order number", clear: func(item *OrderItemDO) { item.OrderSn = "" }},
|
||||
{name: "customer agent", clear: func(item *OrderItemDO) { item.CustomerAgentId = int64(0) }},
|
||||
{name: "seller agent", clear: func(item *OrderItemDO) { item.AgentId = int64(0) }},
|
||||
{name: "quantity", clear: func(item *OrderItemDO) { item.Quantity = 0 }},
|
||||
{name: "product id", clear: func(item *OrderItemDO) { item.ProductId = int64(0) }},
|
||||
{name: "product name", clear: func(item *OrderItemDO) { item.ProductName = "" }},
|
||||
{name: "product code", clear: func(item *OrderItemDO) { item.ProductSn = "" }},
|
||||
{name: "service start", clear: func(item *OrderItemDO) { item.ServiceBeginTime = nil }},
|
||||
{name: "service end", clear: func(item *OrderItemDO) { item.ServiceEndTime = nil }},
|
||||
{name: "tenant", clear: func(item *OrderItemDO) { item.TenantId = "" }},
|
||||
{name: "creator department", clear: func(item *OrderItemDO) { item.CreatedDept = int64(0) }},
|
||||
{name: "creator", clear: func(item *OrderItemDO) { item.CreatedBy = int64(0) }},
|
||||
{name: "updater", clear: func(item *OrderItemDO) { item.UpdatedBy = int64(0) }},
|
||||
{name: "created time", clear: func(item *OrderItemDO) { item.CreatedAt = nil }},
|
||||
{name: "updated time", clear: func(item *OrderItemDO) { item.UpdatedAt = nil }},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
invalid := *item
|
||||
testCase.clear(&invalid)
|
||||
if err := ValidateOrderItemDO(&invalid); err == nil {
|
||||
t.Fatalf("ValidateOrderItemDO accepted missing %s", testCase.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertDOFields(t *testing.T, typ reflect.Type, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, typ.NumField()-1)
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if field.Name == "Meta" {
|
||||
continue
|
||||
}
|
||||
got = append(got, field.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s fields mismatch\n got: %v\nwant: %v", typ.Name(), got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDOColumnTags(t *testing.T, typ reflect.Type, want map[string]string) {
|
||||
t.Helper()
|
||||
for fieldName, column := range want {
|
||||
field, ok := typ.FieldByName(fieldName)
|
||||
if !ok {
|
||||
t.Fatalf("%s missing field %s", typ.Name(), fieldName)
|
||||
}
|
||||
if got := field.Tag.Get("orm"); got != column {
|
||||
t.Fatalf("%s.%s orm tag = %q, want %q", typ.Name(), fieldName, got, column)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePriceOverrideResultTreatsNoRowsAsMissing(t *testing.T) {
|
||||
got, err := normalizePriceOverrideResult(&FlowPackagePrice{}, sql.ErrNoRows)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePriceOverrideResult returned error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("normalizePriceOverrideResult returned %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePriceOverrideResultReturnsOtherErrors(t *testing.T) {
|
||||
wantErr := errors.New("database unavailable")
|
||||
got, err := normalizePriceOverrideResult(nil, wantErr)
|
||||
if got != nil {
|
||||
t.Fatalf("normalizePriceOverrideResult returned %+v on error", got)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v, want wrapped %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePriceOverrideResultKeepsPriceRow(t *testing.T) {
|
||||
want := &FlowPackagePrice{Id: 7, AgentCostProfit: 1.25}
|
||||
got, err := normalizePriceOverrideResult(want, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizePriceOverrideResult returned error: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("row pointer = %p, want %p", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/model/do"
|
||||
)
|
||||
|
||||
// LockRenewBatchMain locks the business batch before the renew transaction
|
||||
// starts. This closes the gap between runtime claiming and business writes
|
||||
// when a duplicate message is delivered concurrently.
|
||||
func (r *Repository) LockRenewBatchMain(ctx context.Context, tx gdb.TX, batchNo string, batchType int) (*BatchMainRow, error) {
|
||||
var main BatchMainRow
|
||||
err := tx.Ctx(ctx).Model(TableBatchMain).
|
||||
Where("batch_no", strings.TrimSpace(batchNo)).
|
||||
Where("batch_type", batchType).
|
||||
LockUpdate().
|
||||
Scan(&main)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "lock renew batch main failed")
|
||||
}
|
||||
if main.Id == 0 {
|
||||
return nil, gerror.New("renew batch main not found")
|
||||
}
|
||||
return &main, nil
|
||||
}
|
||||
|
||||
// LockRenewDetails reloads every detail row inside the business transaction.
|
||||
// The initial consumer read is only a routing hint; the transaction must lock
|
||||
// the exact rows and let the service reject any row that is no longer running.
|
||||
func (r *Repository) LockRenewDetails(ctx context.Context, tx gdb.TX, batchNo string, batchType int) ([]*BatchDetailRow, error) {
|
||||
var rows []*BatchDetailRow
|
||||
if err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", strings.TrimSpace(batchNo)).
|
||||
Where("batch_type", batchType).
|
||||
OrderAsc("row_no").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock renew details failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LoadRenewPackage reads the package inside the business transaction so the
|
||||
// order item is built from a real package row, not a hand-written snapshot.
|
||||
func (r *Repository) LoadRenewPackage(ctx context.Context, tx gdb.TX, tenantID, packageCode string) (*FlowPackageInfo, error) {
|
||||
var pkg FlowPackageInfo
|
||||
query := tx.Ctx(ctx).Model(TableSimFlowPackageInfo).
|
||||
Where("package_code", strings.TrimSpace(packageCode))
|
||||
if strings.TrimSpace(tenantID) != "" {
|
||||
query = query.Where("tenant_id", strings.TrimSpace(tenantID))
|
||||
}
|
||||
query = query.LockUpdate()
|
||||
if err := query.Scan(&pkg); err != nil {
|
||||
return nil, gerror.Wrap(err, "load renew package failed")
|
||||
}
|
||||
if pkg.Id == 0 {
|
||||
return nil, gerror.New("renew package not found")
|
||||
}
|
||||
return &pkg, nil
|
||||
}
|
||||
|
||||
// UpdateRenewCard writes only the fields allowed by the renew contract.
|
||||
func (r *Repository) UpdateRenewCard(ctx context.Context, tx gdb.TX, iccid string, update *do.TSimCardInfo) error {
|
||||
if strings.TrimSpace(iccid) == "" {
|
||||
return gerror.New("renew card ICCID is empty")
|
||||
}
|
||||
if update == nil {
|
||||
return gerror.New("renew card update is empty")
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(TableSimCardInfo).
|
||||
Where("iccid", strings.TrimSpace(iccid)).
|
||||
Data(update).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update renew card failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "read renew card update count failed")
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.Newf("renew card changed during execution: %s", iccid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/consts"
|
||||
)
|
||||
|
||||
// Repository 提供对续费/变更相关表的 typed 访问。
|
||||
// 表名/列名都是常量(见 tables.go),与 Admin 同步链路保持一致。
|
||||
type Repository struct{}
|
||||
|
||||
func New() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
const simCardRenewChangeFields = "id,iccid,msisdn,imsi,dept_id,current_package_code,package_status,outbound_batch_no,flow_pkg_begin_at,flow_pkg_end_at,tenant_id,created_dept,updated_by,updated_at"
|
||||
|
||||
func (r *Repository) database() gdb.DB {
|
||||
return db()
|
||||
}
|
||||
|
||||
// DB 返回底层 gdb.DB,供 service 层发起事务。
|
||||
func (r *Repository) DB() gdb.DB {
|
||||
return r.database()
|
||||
}
|
||||
|
||||
// LoadBatchMain 按 batchNo+batchType 加载。
|
||||
func (r *Repository) LoadBatchMain(ctx context.Context, batchNo string, batchType int) (*BatchMainRow, error) {
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("batch number is empty")
|
||||
}
|
||||
m := r.database().Model(TableBatchMain).Safe().Ctx(ctx).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", batchType)
|
||||
var out BatchMainRow
|
||||
if err := m.Scan(&out); err != nil {
|
||||
return nil, gerror.Wrap(err, "query batch main failed")
|
||||
}
|
||||
if out.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// LoadBatchDetails 加载指定批次下指定状态集合的明细。
|
||||
func (r *Repository) LoadBatchDetails(ctx context.Context, batchNo string, batchType int, statuses []int) ([]*BatchDetailRow, error) {
|
||||
if batchNo == "" {
|
||||
return nil, nil
|
||||
}
|
||||
m := r.database().Model(TableBatchDetail).Safe().Ctx(ctx).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", batchType).
|
||||
OrderAsc("row_no")
|
||||
if len(statuses) > 0 {
|
||||
m = m.WhereIn("detail_status", statuses)
|
||||
}
|
||||
var out []*BatchDetailRow
|
||||
if err := m.Scan(&out); err != nil {
|
||||
return nil, gerror.Wrap(err, "query batch details failed")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) LoadCardsByIdentityValues(ctx context.Context, tenantID string, values []string) ([]*SimCardInfo, error) {
|
||||
uniq := uniqueStrings(values)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
tenantID = strings.TrimSpace(tenantID)
|
||||
byID := map[int64]*SimCardInfo{}
|
||||
for _, column := range []string{"iccid", "msisdn", "imsi"} {
|
||||
var rows []*SimCardInfo
|
||||
query := r.database().Model(TableSimCardInfo).Safe().Ctx(ctx).
|
||||
Fields(simCardRenewChangeFields).
|
||||
WhereIn(column, uniq)
|
||||
if tenantID != "" {
|
||||
query = query.Where("tenant_id", tenantID)
|
||||
}
|
||||
if err := query.Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "query sim cards by identity failed")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Id > 0 {
|
||||
byID[row.Id] = row
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]*SimCardInfo, 0, len(byID))
|
||||
for _, row := range byID {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateBatchMainStatus 在事务内更新主表。
|
||||
func (r *Repository) UpdateBatchMainStatus(ctx context.Context, tx gdb.TX, batchNo string, batchType int, data *BatchMainRow) error {
|
||||
updateData := map[string]any{
|
||||
"batch_type": data.BatchType,
|
||||
"batch_async_status": data.BatchAsyncStatus,
|
||||
"run_result": data.RunResult,
|
||||
"total_count": data.TotalCount,
|
||||
"valid_count": data.ValidCount,
|
||||
"success_count": data.SuccessCount,
|
||||
"fail_count": data.FailCount,
|
||||
"fail_reason": data.FailReason,
|
||||
"batch_info": data.BatchInfo,
|
||||
"end_time": data.EndTime,
|
||||
"updated_by": data.UpdatedBy,
|
||||
"updated_at": gtime.Now(),
|
||||
"order_sn": data.OrderSn,
|
||||
"target_dept_id": data.TargetDeptId,
|
||||
}
|
||||
if data.StartTime != nil && !data.StartTime.IsZero() {
|
||||
updateData["start_time"] = data.StartTime
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(TableBatchMain).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", batchType).
|
||||
Data(updateData).
|
||||
Update()
|
||||
return gerror.Wrap(err, "update batch main failed")
|
||||
}
|
||||
|
||||
// ConvertBatchDetails 将指定状态的明细转换为目标类型与状态。
|
||||
func (r *Repository) ConvertBatchDetails(ctx context.Context, tx gdb.TX, batchNo string, fromTypes []int, toType, toStatus int, data map[string]any) (int64, error) {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["batch_type"] = toType
|
||||
data["detail_status"] = toStatus
|
||||
data["updated_at"] = gtime.Now()
|
||||
result, err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", batchNo).
|
||||
WhereIn("batch_type", fromTypes).
|
||||
Data(data).
|
||||
Update()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "convert batch details failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// UpdateBatchDetailSnapshot 覆盖指定明细的 snapshot 与状态。
|
||||
func (r *Repository) UpdateBatchDetailSnapshot(ctx context.Context, tx gdb.TX, batchNo string, batchType, rowNo int, status int, snapshot *gjson.Json, reason string, userID int64) error {
|
||||
data := map[string]any{
|
||||
"detail_status": status,
|
||||
"snapshot": snapshot,
|
||||
"updated_at": gtime.Now(),
|
||||
"updated_by": userID,
|
||||
}
|
||||
if reason != "" {
|
||||
data["error_reason"] = reason
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", batchNo).
|
||||
Where("batch_type", batchType).
|
||||
Where("row_no", rowNo).
|
||||
Data(data).
|
||||
Update()
|
||||
return gerror.Wrap(err, "update batch detail snapshot failed")
|
||||
}
|
||||
|
||||
// UpsertBatchDetail 按 (batch_no, batch_type, row_no) 写入明细(覆盖式)。
|
||||
func (r *Repository) UpsertBatchDetail(ctx context.Context, tx gdb.TX, detail *BatchDetailRow) error {
|
||||
_, err := tx.Ctx(ctx).Model(TableBatchDetail).
|
||||
Where("batch_no", detail.BatchNo).
|
||||
Where("batch_type", detail.BatchType).
|
||||
Where("row_no", detail.RowNo).
|
||||
Data(map[string]any{
|
||||
"detail_status": detail.DetailStatus,
|
||||
"blank_out_status": detail.BlankOutStatus,
|
||||
"item_id": detail.ItemId,
|
||||
"error_code": detail.ErrorCode,
|
||||
"error_reason": detail.ErrorReason,
|
||||
"snapshot": detail.Snapshot,
|
||||
"updated_at": gtime.Now(),
|
||||
"updated_by": detail.UpdatedBy,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "update batch detail failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCardsByIccids 加载正式卡。
|
||||
func (r *Repository) LoadCardsByIccids(ctx context.Context, iccids []string) (map[string]*SimCardInfo, error) {
|
||||
out := make(map[string]*SimCardInfo, len(iccids))
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []*SimCardInfo
|
||||
if err := r.database().Model(TableSimCardInfo).Safe().Ctx(ctx).
|
||||
Fields(simCardRenewChangeFields).
|
||||
WhereIn("iccid", uniq).
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "query sim cards failed")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
if row.Iccid == "" {
|
||||
continue
|
||||
}
|
||||
out[row.Iccid] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LockCardsByIccids 在事务内按 ICCID 锁行加载正式卡。
|
||||
func (r *Repository) LockCardsByIccids(ctx context.Context, tx gdb.TX, iccids []string) (map[string]*SimCardInfo, error) {
|
||||
out := make(map[string]*SimCardInfo, len(iccids))
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
var rows []*SimCardInfo
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardInfo).
|
||||
Fields(simCardRenewChangeFields).
|
||||
WhereIn("iccid", uniq).
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock sim cards failed")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
if row.Iccid == "" {
|
||||
continue
|
||||
}
|
||||
out[row.Iccid] = row
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LoadActiveBindRowsByIccids loads every pending/effective formal package
|
||||
// subscription for prepare validation. Add-on package rows (103/104) are
|
||||
// intentionally excluded from current-subscription selection.
|
||||
func (r *Repository) LoadActiveBindRowsByIccids(ctx context.Context, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := r.database().Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
WhereIn("effective_status", []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
|
||||
OrderDesc("effective_status").
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "load active binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LoadRenewBindRowsByIccids includes expired formal-package history so renewal
|
||||
// can restart after a card has naturally expired. Add-on package rows (103/104)
|
||||
// never participate in renewal selection.
|
||||
func (r *Repository) LoadRenewBindRowsByIccids(ctx context.Context, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := r.database().Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
WhereIn("effective_status", []int{
|
||||
consts.EffectiveStatusPending,
|
||||
consts.EffectiveStatusEffective,
|
||||
consts.EffectiveStatusExpired,
|
||||
}).
|
||||
OrderDesc("effective_status").
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "load renew binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LockActiveBindRowsByIccids is the transactional counterpart used by change
|
||||
// execution before any business write. It locks formal package rows only.
|
||||
func (r *Repository) LockActiveBindRowsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
WhereIn("effective_status", []int{consts.EffectiveStatusPending, consts.EffectiveStatusEffective}).
|
||||
OrderDesc("effective_status").
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock active binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LockRenewBindRowsByIccids includes expired formal-package history for renewal
|
||||
// execution. Change execution keeps using LockActiveBindRowsByIccids.
|
||||
func (r *Repository) LockRenewBindRowsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
WhereIn("effective_status", []int{
|
||||
consts.EffectiveStatusPending,
|
||||
consts.EffectiveStatusEffective,
|
||||
consts.EffectiveStatusExpired,
|
||||
}).
|
||||
OrderDesc("effective_status").
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock renew binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// LockPendingBindsByIccids 锁行加载 pending 状态的未来订购。
|
||||
func (r *Repository) LockPendingBindsByIccids(ctx context.Context, tx gdb.TX, iccids []string) ([]*SimCardBindPkg, error) {
|
||||
uniq := uniqueStrings(iccids)
|
||||
if len(uniq) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []*SimCardBindPkg
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("iccid", uniq).
|
||||
WhereIn("package_type", consts.FormalPackageTypes()).
|
||||
Where("effective_status", consts.EffectiveStatusPending).
|
||||
OrderAsc("effective_time").
|
||||
OrderAsc("id").
|
||||
LockUpdate().
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "lock pending binds failed")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DeletePendingBindsBySubscriptionNos 删除(作废)一批订阅号。
|
||||
func (r *Repository) DeletePendingBindsBySubscriptionNos(ctx context.Context, tx gdb.TX, subscriptionNos []string) (int64, error) {
|
||||
uniq := uniqueStrings(subscriptionNos)
|
||||
if len(uniq) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
WhereIn("subscription_no", uniq).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "delete pending binds failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// LoadPackageByCode 按 packageCode 读取套餐。
|
||||
func (r *Repository) LoadPackageByCode(ctx context.Context, tenantID, packageCode string) (*FlowPackageInfo, error) {
|
||||
if packageCode == "" {
|
||||
return nil, errPkgNotFound
|
||||
}
|
||||
var row FlowPackageInfo
|
||||
err := r.database().Model(TableSimFlowPackageInfo).Safe().Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "query package failed")
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, errPkgNotFound
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// LoadDeptName 按 deptId 取客户名。
|
||||
func (r *Repository) LoadDeptName(ctx context.Context, tenantID string, deptID int64) (string, error) {
|
||||
row, err := r.LoadDept(ctx, tenantID, deptID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(row.DeptName), nil
|
||||
}
|
||||
|
||||
// LoadDept returns the current department and its parent. Renewals use the
|
||||
// parent department as the price owner for the current card owner.
|
||||
func (r *Repository) LoadDept(ctx context.Context, tenantID string, deptID int64) (*SysDeptRow, error) {
|
||||
if deptID <= 0 {
|
||||
return nil, errDeptNotFound
|
||||
}
|
||||
var row SysDeptRow
|
||||
err := r.database().Model(TableSysDept).Safe().Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
Scan(&row)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "query dept failed")
|
||||
}
|
||||
if row.DeptId == 0 {
|
||||
return nil, errDeptNotFound
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// LoadPriceOverride 读取代理续费/变更价格覆盖。
|
||||
func (r *Repository) LoadPriceOverride(ctx context.Context, tenantID string, deptID, targetAgentID int64, scopeType int, packageCode string) (*FlowPackagePrice, error) {
|
||||
var row FlowPackagePrice
|
||||
err := r.database().Model(TableSimFlowPackagePrice).Safe().Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("dept_id", deptID).
|
||||
Where("agent_scope_type", scopeType).
|
||||
Where("target_agent_id", targetAgentID).
|
||||
Where("package_code", strings.TrimSpace(packageCode)).
|
||||
Where("enabled", 1).
|
||||
Scan(&row)
|
||||
return normalizePriceOverrideResult(&row, err)
|
||||
}
|
||||
|
||||
func normalizePriceOverrideResult(row *FlowPackagePrice, err error) (*FlowPackagePrice, error) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "query price override failed")
|
||||
}
|
||||
if row == nil || row.Id == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// UpdateCardPackageFields 续费/变更后更新卡的当前套餐与流量状态。
|
||||
func (r *Repository) UpdateCardPackageFields(ctx context.Context, tx gdb.TX, iccid string, fields map[string]any, userID int64) error {
|
||||
if iccid == "" {
|
||||
return gerror.New("iccid is empty")
|
||||
}
|
||||
fields["updated_by"] = userID
|
||||
fields["updated_at"] = gtime.Now()
|
||||
_, err := tx.Ctx(ctx).Model(TableSimCardInfo).
|
||||
Where("iccid", iccid).
|
||||
Data(fields).
|
||||
Update()
|
||||
return gerror.Wrap(err, "update sim card failed")
|
||||
}
|
||||
|
||||
// InsertOrder 插入订单(返回 orderSn)。
|
||||
// InsertOrderItem 插入订单项。
|
||||
// UpdateOrderReturnStatus 更新订单退订状态。
|
||||
// UpdateOrderItemExtra 合并并写入订单项 extraSkuData(JSON 字符串)。
|
||||
// ActiveBindCountByOrderCodes 统计订单当前未作废的订购数。
|
||||
func (r *Repository) ActiveBindCountByOrderCodes(ctx context.Context, tx gdb.TX, orderSNs []string) (map[string]int, error) {
|
||||
out := make(map[string]int, len(orderSNs))
|
||||
uniq := uniqueStrings(orderSNs)
|
||||
if len(uniq) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
type row struct {
|
||||
OrderCode string `orm:"order_code"`
|
||||
Count int `orm:"count"`
|
||||
}
|
||||
var rows []row
|
||||
if err := tx.Ctx(ctx).Model(TableSimCardBindPkg).
|
||||
Fields("order_code", "COUNT(1) AS count").
|
||||
WhereIn("order_code", uniq).
|
||||
Group("order_code").
|
||||
Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "count active binds failed")
|
||||
}
|
||||
for _, r := range rows {
|
||||
out[strings.TrimSpace(r.OrderCode)] = r.Count
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertBind 插入新的订购记录。
|
||||
func (r *Repository) InsertBind(ctx context.Context, tx gdb.TX, bind *SimCardBindPkg) error {
|
||||
_, err := tx.Ctx(ctx).Model(TableSimCardBindPkg).Data(bind).Insert()
|
||||
return gerror.Wrap(err, "insert bind failed")
|
||||
}
|
||||
|
||||
// InsertSysOss 插入 OSS 记录,返回 ID。
|
||||
func (r *Repository) InsertSysOss(ctx context.Context, tx gdb.TX, row *SysOssRow) (int64, error) {
|
||||
id, err := tx.Ctx(ctx).Model(TableSysOss).Data(row).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "insert sys oss failed")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// InsertBatchDownload 插入 t_resource_batch_download 记录,返回 ID。
|
||||
func (r *Repository) InsertBatchDownload(ctx context.Context, tx gdb.TX, row *ResourceBatchDownloadRow) (int64, error) {
|
||||
id, err := tx.Ctx(ctx).Model(TableTResourceBatchDl).Data(row).InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "insert batch download failed")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
const (
|
||||
TableSimCardInfo = "t_sim_card_info"
|
||||
TableSimCardBindPkg = "t_sim_card_bind_pkg"
|
||||
TableSimPoolBindPackage = "t_sim_pool_bind_package"
|
||||
TableSimBindPackageLogs = "t_sim_bind_package_logs"
|
||||
TableOrder = "t_order"
|
||||
TableOrderItem = "t_order_item"
|
||||
TableSimFlowPackageInfo = "t_sim_flow_package_info"
|
||||
TableSimFlowPackagePrice = "t_sim_flow_package_price"
|
||||
TableSysDept = "sys_dept"
|
||||
TableBatchMain = "batch_main"
|
||||
TableBatchDetail = "batch_detail"
|
||||
TableAgentBalance = "t_agent_balance"
|
||||
TableBalanceDetail = "t_balance_detail"
|
||||
TableSysOss = "sys_oss"
|
||||
TableTResourceBatchDl = "t_resource_batch_download"
|
||||
)
|
||||
|
||||
// typed row structs(worker 端未生成 DAO/DO/entity,故使用本地 typed struct)。
|
||||
type SimCardInfo struct {
|
||||
Id int64 `orm:"id"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
Imsi string `orm:"imsi"`
|
||||
RegionCode string `orm:"region_code"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
AllocationBatchNo string `orm:"allocation_batch_no"`
|
||||
CurrentPackageCode string `orm:"current_package_code"`
|
||||
PackageStatus int `orm:"package_status"`
|
||||
ChanKey string `orm:"chan_key"`
|
||||
OutboundBatchNo string `orm:"outbound_batch_no"`
|
||||
InboundBatchNo string `orm:"inbound_batch_no"`
|
||||
FlowPkgBeginAt *gtime.Time `orm:"flow_pkg_begin_at"`
|
||||
FlowPkgPresentAt *gtime.Time `orm:"flow_pkg_present_at"`
|
||||
FlowPkgRealPresent *gtime.Time `orm:"flow_pkg_real_present_at"`
|
||||
FlowPkgEndAt *gtime.Time `orm:"flow_pkg_end_at"`
|
||||
DataServiceStatus int `orm:"data_service_status"`
|
||||
PackageTotalFlowMb float64 `orm:"package_total_flow_mb"`
|
||||
UsedFlowMb float64 `orm:"used_flow_mb"`
|
||||
TrafficCutoffEn int `orm:"traffic_cutoff_status"`
|
||||
TrafficCutoffThr float64 `orm:"traffic_cutoff_threshold"`
|
||||
SmsServiceStatus int `orm:"sms_service_status"`
|
||||
PackageTotalSms int `orm:"package_total_sms"`
|
||||
UsedSms int `orm:"used_sms"`
|
||||
VoiceServiceStatus int `orm:"voice_service_status"`
|
||||
PackageTotalVoice int `orm:"package_total_voice"`
|
||||
UsedVoice int `orm:"used_voice"`
|
||||
RoamServiceStatus int `orm:"roam_service_status"`
|
||||
PkgTotalRoamFlowMb float64 `orm:"package_total_roam_flow_mb"`
|
||||
UsedRoamFlowMb float64 `orm:"used_roam_flow_mb"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type SimCardBindPkg struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
PackageType int `orm:"package_type"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
PreviousPackageCode string `orm:"previous_package_code"`
|
||||
BusinessType int `orm:"business_type"`
|
||||
EffectiveStatus int `orm:"effective_status"`
|
||||
HandleTime *gtime.Time `orm:"handle_time"`
|
||||
EffectiveTime *gtime.Time `orm:"effective_time"`
|
||||
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
|
||||
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
|
||||
DurationMonths int `orm:"duration_months"`
|
||||
GiftDurationMonths int `orm:"gift_duration_months"`
|
||||
OrderCode string `orm:"order_code"`
|
||||
SubscriptionNo string `orm:"subscription_no"`
|
||||
Remark string `orm:"remark"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type SimBindPackageLogs struct {
|
||||
Id int64 `orm:"id"`
|
||||
SubscriptionNo string `orm:"subscription_no"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
PackageType int `orm:"package_type"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
PreviousPackageCod string `orm:"previous_package_code"`
|
||||
BusinessType int `orm:"business_type"`
|
||||
EffectiveStatus int `orm:"effective_status"`
|
||||
HandleTime *gtime.Time `orm:"handle_time"`
|
||||
EffectiveTime *gtime.Time `orm:"effective_time"`
|
||||
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
|
||||
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
|
||||
DurationMonths int `orm:"duration_months"`
|
||||
GiftDurationMonths int `orm:"gift_duration_months"`
|
||||
OrderCode string `orm:"order_code"`
|
||||
Remark string `orm:"remark"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type FlowPackageInfo struct {
|
||||
Id int64 `orm:"id"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
PoolPackageCode int64 `orm:"pool_package_code"`
|
||||
PackageName string `orm:"package_name"`
|
||||
PackageType int `orm:"package_type"`
|
||||
BillingMethod int `orm:"billing_method"`
|
||||
FlowMb float64 `orm:"flow_mb"`
|
||||
CostPrice float64 `orm:"cost_price"`
|
||||
SysDefaultRatio float64 `orm:"sys_default_ratio"`
|
||||
SysDefaultProfit float64 `orm:"sys_default_profit"`
|
||||
RenewableDurationConfig *gjson.Json `orm:"renewable_duration_config"`
|
||||
RenewablePackageCodes *gjson.Json `orm:"renewable_package_codes"`
|
||||
ChangeablePackageCodes *gjson.Json `orm:"changeable_package_codes"`
|
||||
TopupPackageCodes *gjson.Json `orm:"topup_package_codes"`
|
||||
Enabled int `orm:"enabled"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
}
|
||||
|
||||
type PoolBindPackageLogs struct {
|
||||
Id int64 `orm:"id"`
|
||||
SubscriptionNo string `orm:"subscription_no"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
PoolNo string `orm:"pool_no"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
PackageType int `orm:"package_type"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
PreviousPackageCode string `orm:"previous_package_code"`
|
||||
BusinessType int `orm:"business_type"`
|
||||
EffectiveStatus int `orm:"effective_status"`
|
||||
HandleTime *gtime.Time `orm:"handle_time"`
|
||||
EffectiveTime *gtime.Time `orm:"effective_time"`
|
||||
ExpectedExpireTime *gtime.Time `orm:"expected_expire_time"`
|
||||
ActualExpireTime *gtime.Time `orm:"actual_expire_time"`
|
||||
DurationMonths int `orm:"duration_months"`
|
||||
GiftDurationMonths int `orm:"gift_duration_months"`
|
||||
OrderCode string `orm:"order_code"`
|
||||
Remark string `orm:"remark"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type FlowPackagePrice struct {
|
||||
Id int64 `orm:"id"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
AgentScopeType int `orm:"agent_scope_type"`
|
||||
TargetAgentId int64 `orm:"target_agent_id"`
|
||||
PackageCode string `orm:"package_code"`
|
||||
AgentCostRatio float64 `orm:"agent_cost_ratio"`
|
||||
AgentCostProfit float64 `orm:"agent_cost_profit"`
|
||||
}
|
||||
|
||||
type SysDeptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
}
|
||||
|
||||
type BatchMainRow struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
OrderSn string `orm:"order_sn"`
|
||||
BatchType int `orm:"batch_type"`
|
||||
BatchStatus int `orm:"batch_status"`
|
||||
BatchAsyncStatus int `orm:"batch_async_status"`
|
||||
SourceType int `orm:"source_type"`
|
||||
SourceBatchNo string `orm:"source_batch_no"`
|
||||
IsCrossDept int `orm:"is_cross_dept"`
|
||||
TargetDeptId int64 `orm:"target_dept_id"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
TotalCount int `orm:"total_count"`
|
||||
ValidCount int `orm:"valid_count"`
|
||||
SuccessCount int `orm:"success_count"`
|
||||
FailCount int `orm:"fail_count"`
|
||||
RunResult int `orm:"run_result"`
|
||||
FailReason string `orm:"fail_reason"`
|
||||
BatchInfo *gjson.Json `orm:"batch_info"`
|
||||
StartTime *gtime.Time `orm:"start_time"`
|
||||
EndTime *gtime.Time `orm:"end_time"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type BatchDetailRow struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
BatchType int `orm:"batch_type"`
|
||||
DetailStatus int `orm:"detail_status"`
|
||||
BlankOutStatus int `orm:"blank_out_status"`
|
||||
RowNo int `orm:"row_no"`
|
||||
ItemId string `orm:"item_id"`
|
||||
ErrorCode string `orm:"error_code"`
|
||||
ErrorReason string `orm:"error_reason"`
|
||||
Snapshot *gjson.Json `orm:"snapshot"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
CreatedAt *gtime.Time `orm:"created_at"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
UpdatedAt *gtime.Time `orm:"updated_at"`
|
||||
}
|
||||
|
||||
type SysOssRow struct {
|
||||
Id int64 `orm:"id"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
FileName string `orm:"file_name"`
|
||||
OriginalName string `orm:"original_name"`
|
||||
FileSuffix string `orm:"file_suffix"`
|
||||
Path string `orm:"path"`
|
||||
Url string `orm:"url"`
|
||||
Service string `orm:"service"`
|
||||
Md5 string `orm:"md5"`
|
||||
FileSize int64 `orm:"file_size"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
}
|
||||
|
||||
type ResourceBatchDownloadRow struct {
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
TaskName string `orm:"task_name"`
|
||||
ResourceType int `orm:"resource_type"`
|
||||
ExportScene int `orm:"export_scene"`
|
||||
TotalCount int `orm:"total_count"`
|
||||
SuccessCount int `orm:"success_count"`
|
||||
FailCount int `orm:"fail_count"`
|
||||
DownloadStatus int `orm:"download_status"`
|
||||
OssId int64 `orm:"oss_id"`
|
||||
FileName string `orm:"file_name"`
|
||||
FileExt string `orm:"file_ext"`
|
||||
FileUrl string `orm:"file_url"`
|
||||
FilePath string `orm:"file_path"`
|
||||
FileSize int64 `orm:"file_size"`
|
||||
FileMd5 string `orm:"file_md5"`
|
||||
DownloadCount int `orm:"download_count"`
|
||||
MaxDownloadCount int `orm:"max_download_count"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
StartTime *gtime.Time `orm:"start_time"`
|
||||
FinishTime *gtime.Time `orm:"finish_time"`
|
||||
TenantId string `orm:"tenant_id"`
|
||||
CreatedDept int64 `orm:"created_dept"`
|
||||
CreatedBy int64 `orm:"created_by"`
|
||||
UpdatedBy int64 `orm:"updated_by"`
|
||||
}
|
||||
|
||||
// DB 默认从 g.DB() 取,避免和现有 repository 紧耦合。
|
||||
func DB() gdb.DB {
|
||||
return db()
|
||||
}
|
||||
|
||||
func db() gdb.DB {
|
||||
return g.DB()
|
||||
}
|
||||
|
||||
func uniqueStrings(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RoundAmount 四舍五入到分(导出以便 service 层复用)。
|
||||
func RoundAmount(value float64) float64 {
|
||||
return roundAmount(value)
|
||||
}
|
||||
|
||||
func roundAmount(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(int(value*100+0.5)) / 100
|
||||
}
|
||||
|
||||
// RenewableDurationOptions 解析套餐的可续费时长配置。
|
||||
func RenewableDurationOptions(jsonValue *gjson.Json) ([]int, error) {
|
||||
return renewableDurationOptions(jsonValue)
|
||||
}
|
||||
|
||||
func renewableDurationOptions(jsonValue *gjson.Json) ([]int, error) {
|
||||
if jsonValue == nil || jsonValue.IsNil() {
|
||||
return nil, errRenewDurationEmpty
|
||||
}
|
||||
values := jsonValue.Interfaces()
|
||||
out := make([]int, 0, len(values))
|
||||
seen := make(map[int]struct{}, len(values))
|
||||
for _, raw := range values {
|
||||
var month int
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
month = v
|
||||
case int32:
|
||||
month = int(v)
|
||||
case int64:
|
||||
month = int(v)
|
||||
case float32:
|
||||
month = int(v)
|
||||
case float64:
|
||||
month = int(v)
|
||||
case json.Number:
|
||||
n, err := v.Int64()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
month = int(n)
|
||||
case string:
|
||||
if t, err := time.Parse("2006-01-02", strings.TrimSpace(v)); err == nil {
|
||||
// tolerate date strings, fall back to ignoring
|
||||
_ = t
|
||||
}
|
||||
continue
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if month <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[month]; ok {
|
||||
continue
|
||||
}
|
||||
seen[month] = struct{}{}
|
||||
out = append(out, month)
|
||||
}
|
||||
sort.Ints(out)
|
||||
if len(out) == 0 {
|
||||
return nil, errRenewDurationEmpty
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ContainsInt 检查切片中是否包含目标值。
|
||||
func ContainsInt(values []int, target int) bool {
|
||||
return containsInt(values, target)
|
||||
}
|
||||
|
||||
func containsInt(values []int, target int) bool {
|
||||
for _, v := range values {
|
||||
if v == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package renewchange
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
)
|
||||
|
||||
func TestRenewableDurationOptionsAcceptsJsonNumber(t *testing.T) {
|
||||
got, err := RenewableDurationOptions(gjson.New([]any{
|
||||
json.Number("3"),
|
||||
json.Number("1"),
|
||||
json.Number("2"),
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("RenewableDurationOptions returned error: %v", err)
|
||||
}
|
||||
|
||||
want := []int{1, 2, 3}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("options = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("options = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user