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:
MiFaZhan
2026-10-22 16:37:56 +08:00
commit 2bec257289
361 changed files with 71524 additions and 0 deletions
@@ -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)
}
}
+39
View File
@@ -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("预处理批次缺少关键快照")
)
+54
View File
@@ -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)
}
+195
View File
@@ -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
}
+392
View File
@@ -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)
}
}
}