chore: 并入 iotlcm-worker-svr 历史到 worker-svr/(原提交 96f0f4e)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:03 +08:00
422 changed files with 80993 additions and 0 deletions
@@ -0,0 +1,576 @@
package renewchange
import (
"context"
"errors"
"strings"
"time"
"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"
rc "git.5iot.com/iotlcm-public/iotlcm-worker-svr/internal/repository/renewchange"
)
var errRenewAlreadyHandled = errors.New("renew batch was already handled")
// RenewExecutor executes a confirmed type=101 batch. All business writes and
// the success state are committed by one database transaction.
type RenewExecutor struct {
repo *Repository
nowFunc NowFunc
}
func NewRenewExecutor(repo *Repository) *RenewExecutor {
if repo == nil {
repo = NewRepository()
}
return &RenewExecutor{repo: repo, nowFunc: timeNow}
}
func (r *RenewExecutor) SetNowFunc(now NowFunc) { r.nowFunc = now }
func (r *RenewExecutor) Execute(ctx context.Context, batchNo string) error {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return gerror.New("renew batch number is empty")
}
main, err := r.repo.LoadProcessingBatch(ctx, batchNo, consts.BatchTypeRenew)
if err != nil {
return err
}
if strings.TrimSpace(main.OrderSn) != "" {
g.Log().Warningf(ctx, "renew batch already has order, skip duplicate: batchNo=%s orderSn=%s", batchNo, main.OrderSn)
return nil
}
details, err := r.repo.LoadRunningDetails(ctx, batchNo, consts.BatchTypeRenew)
if err != nil {
return gerror.Wrap(err, "load renew details failed")
}
if len(details) == 0 {
return r.finalizeFailure(ctx, main, "renew batch has no runnable details")
}
now := pnow(r.nowFunc)
orderSN, err := r.apply(ctx, main, details, now)
if errors.Is(err, errRenewAlreadyHandled) {
return nil
}
if err != nil {
return r.finalizeFailure(ctx, main, err.Error())
}
snap, _ := r.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
_ = r.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, snap)
g.Log().Infof(ctx, "renew batch success, batchNo=%s, orderSn=%s, tenant=%s", main.BatchNo, orderSN, main.TenantId)
return nil
}
type renewExecution struct {
detail *rc.BatchDetailRow
card *rc.SimCardInfo
bind *rc.SimCardBindPkg
begin time.Time
end time.Time
}
func (r *RenewExecutor) apply(ctx context.Context, main *rc.BatchMainRow, details []*rc.BatchDetailRow, now time.Time) (string, error) {
iccids, err := strictIccidsFromDetails(details)
if err != nil {
return "", err
}
var orderSN string
err = r.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
lockedMain, err := r.repo.Tables().LockRenewBatchMain(ctx, tx, main.BatchNo, consts.BatchTypeRenew)
if err != nil {
return err
}
if strings.TrimSpace(lockedMain.OrderSn) != "" || lockedMain.BatchAsyncStatus != consts.BatchAsyncStatusProcessing {
return errRenewAlreadyHandled
}
lockedDetails, err := r.repo.Tables().LockRenewDetails(ctx, tx, main.BatchNo, consts.BatchTypeRenew)
if err != nil {
return err
}
if err = validateRunningRenewDetails(lockedDetails, details); err != nil {
return err
}
details = lockedDetails
cards, err := r.repo.Tables().LockCardsByIccids(ctx, tx, iccids)
if err != nil {
return err
}
bindRows, err := r.repo.Tables().LockRenewBindRowsByIccids(ctx, tx, iccids)
if err != nil {
return err
}
binds, futureBinds, err := resolveCurrentSubscriptions(bindRows, iccids)
if err != nil {
return err
}
contract, err := r.ensureRenewExecutionContract(ctx, tx, lockedMain, details, cards, binds, futureBinds, now)
if err != nil {
return err
}
executions, err := validateRenewExecutions(contract, details, cards, binds, futureBinds, now)
if err != nil {
return err
}
pkg, err := r.repo.Tables().LoadRenewPackage(ctx, tx, contract.TenantID, contract.PackageCode)
if err != nil {
return err
}
orderSN, err = rc.NextOrderSN(now)
if err != nil {
return err
}
order, err := BuildOrderDO(contract, orderSN, now)
if err != nil {
return err
}
orderID, err := r.repo.Tables().InsertContractOrder(ctx, tx, order)
if err != nil {
return gerror.Wrap(err, "insert renew order failed")
}
minBegin, maxEnd := renewServiceRange(executions)
item, err := BuildOrderItemDO(contract, OrderProductContract{
ID: pkg.Id,
Code: pkg.PackageCode,
Name: pkg.PackageName,
Type: pkg.PackageType,
}, orderID, orderSN, minBegin, maxEnd, now)
if err != nil {
return err
}
if err = r.repo.Tables().InsertContractOrderItem(ctx, tx, item); err != nil {
return gerror.Wrap(err, "insert renew order item failed")
}
for _, execution := range executions {
subscriptionNo, err := generateRenewSubscriptionNo(now)
if err != nil {
return err
}
newBind := buildRenewBind(contract, pkg, execution, orderSN, subscriptionNo, now)
if err = r.repo.Tables().InsertBind(ctx, tx, newBind); err != nil {
return err
}
packageStatus := renewPackageStatus(now, execution.card.FlowPkgBeginAt, newBind.ExpectedExpireTime)
cardUpdate, err := BuildRenewCardUpdate(contract, execution.end, packageStatus, now)
if err != nil {
return err
}
if err = r.repo.Tables().UpdateRenewCard(ctx, tx, execution.card.Iccid, cardUpdate); err != nil {
return err
}
snapshot := buildRenewDetailSnapshot(execution, contract, pkg, orderSN, subscriptionNo)
if err = r.repo.MarkDetailSuccess(ctx, tx, main.BatchNo, consts.BatchTypeRenew, execution.detail.RowNo, snapshot, contract.OperatorUserID); err != nil {
return err
}
}
info := mergeBatchInfo(lockedMain.BatchInfo, map[string]any{
"orderSn": orderSN,
"packageCode": contract.PackageCode,
"packageName": pkg.PackageName,
"packageType": pkg.PackageType,
"billingMethod": contract.BillingMethod,
"renewDurationUnit": contract.RenewDurationUnit,
"renewDurationUnitText": contract.RenewDurationUnitText,
"durationValue": contract.DisplayDurationValue(),
"actualDurationMonths": contract.DurationMonths,
"durationMonths": contract.DurationMonths,
"giftDurationMonths": contract.GiftDurationMonths,
"amount": contract.TotalAmount(),
"quantity": contract.Quantity,
"renewTime": now.Format("2006-01-02 15:04:05"),
})
if err = r.repo.UpdateBatchMainInfo(ctx, tx, main.BatchNo, consts.BatchTypeRenew, &rc.BatchMainRow{
BatchType: consts.BatchTypeRenew,
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
RunResult: consts.BatchRunResultSuccess,
TotalCount: lockedMain.TotalCount,
ValidCount: len(executions),
SuccessCount: len(executions),
FailCount: 0,
BatchInfo: gjson.New(info),
OrderSn: orderSN,
StartTime: lockedMain.StartTime,
EndTime: gtime.NewFromTime(now),
TargetDeptId: contract.CustomerAgentID,
UpdatedBy: contract.OperatorUserID,
}); err != nil {
return err
}
return nil
})
return orderSN, err
}
func validateRenewExecutions(contract *ExecutionContract, details []*rc.BatchDetailRow, cards map[string]*rc.SimCardInfo, binds map[string]*rc.SimCardBindPkg, futureBinds map[string][]*rc.SimCardBindPkg, now time.Time) ([]renewExecution, error) {
if contract == nil {
return nil, gerror.New("renew execution contract is missing")
}
if contract.Quantity != len(details) {
return nil, gerror.New("renew detail quantity does not match confirmed contract")
}
seen := make(map[string]struct{}, len(details))
result := make([]renewExecution, 0, len(details))
for _, detail := range details {
if detail == nil {
return nil, gerror.New("renew detail is nil")
}
iccid := strings.TrimSpace(detail.ItemId)
if iccid == "" {
return nil, gerror.New("renew detail ICCID is empty")
}
if _, exists := seen[iccid]; exists {
return nil, gerror.Newf("renew detail ICCID is duplicated: %s", iccid)
}
seen[iccid] = struct{}{}
card := cards[iccid]
if card == nil {
return nil, gerror.Newf("renew card not found: %s", iccid)
}
bind := binds[iccid]
if bind == nil {
return nil, gerror.Newf("renew latest subscription not found: %s", iccid)
}
var expectedMSISDN, expectedIMSI string
if detail.Snapshot != nil {
expectedMSISDN = strings.TrimSpace(detail.Snapshot.Get("msisdn").String())
expectedIMSI = strings.TrimSpace(detail.Snapshot.Get("imsi").String())
}
if err := validateCurrentSubscription(card, bind, subscriptionValidation{
ICCID: iccid,
MSISDN: expectedMSISDN,
IMSI: expectedIMSI,
TenantID: contract.TenantID,
CustomerDeptID: contract.CustomerAgentID,
PackageCode: contract.PackageCode,
SourceBatchNo: contract.SourceBatchNo,
}); err != nil {
return nil, gerror.Wrapf(err, "validate renew current subscription failed, iccid:%s", iccid)
}
// The latest subscription is the source of truth for the next renewal
// start. The legacy Admin transaction did not require the denormalized
// card expiration to equal that subscription; future subscriptions can
// legitimately make the two values differ.
renewBase := renewBaseSubscriptionForCard(card, bind, futureBinds[iccid])
if err := validateRenewBaseWindow(renewBase, now, iccid); err != nil {
return nil, err
}
begin, ok := renewBeginTimeFromExpire(renewBase.ExpectedExpireTime)
if !ok {
return nil, gerror.Newf("ICCID没有预计到期时间:%s", strings.TrimSpace(iccid))
}
if contract.PackageBeginTime != nil {
begin = contract.PackageBeginTime.Time
}
result = append(result, renewExecution{
detail: detail,
card: card,
bind: renewBase,
begin: begin,
end: contract.ServiceEnd(begin),
})
}
return result, nil
}
func renewBaseSubscription(current *rc.SimCardBindPkg, future []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
base := current
for _, bind := range future {
if bind == nil {
continue
}
if base == nil || subscriptionAfter(bind, base) {
base = bind
}
}
return base
}
func renewBaseSubscriptionForCard(card *rc.SimCardInfo, current *rc.SimCardBindPkg, future []*rc.SimCardBindPkg) *rc.SimCardBindPkg {
base := renewBaseSubscription(current, future)
cardExpireAt := latestCardRenewExpireTime(card)
if cardExpireAt == nil {
return base
}
if base == nil {
return &rc.SimCardBindPkg{
Iccid: strings.TrimSpace(card.Iccid),
PackageCode: strings.TrimSpace(card.CurrentPackageCode),
ExpectedExpireTime: cardExpireAt,
}
}
if base.ExpectedExpireTime != nil && !cardExpireAt.After(base.ExpectedExpireTime) {
return base
}
clone := *base
clone.ExpectedExpireTime = cardExpireAt
return &clone
}
func latestCardRenewExpireTime(card *rc.SimCardInfo) *gtime.Time {
if card == nil {
return nil
}
var expireAt *gtime.Time
for _, candidate := range []*gtime.Time{
card.FlowPkgEndAt,
card.FlowPkgPresentAt,
card.FlowPkgRealPresent,
} {
if candidate == nil || candidate.IsZero() {
continue
}
if expireAt == nil || candidate.After(expireAt) {
expireAt = candidate
}
}
return expireAt
}
func validateRenewBaseWindow(base *rc.SimCardBindPkg, now time.Time, iccid string) error {
if base == nil || base.ExpectedExpireTime == nil {
return gerror.Newf("ICCID没有预计到期时间:%s", strings.TrimSpace(iccid))
}
if renewExpireAfterAllowedWindow(base.ExpectedExpireTime, now) {
return gerror.Newf("%s:%s", renewExpireFailureReason, strings.TrimSpace(iccid))
}
return nil
}
func validateRunningRenewDetails(locked, loaded []*rc.BatchDetailRow) error {
if len(locked) != len(loaded) {
return gerror.New("renew detail count changed during execution")
}
for i, detail := range locked {
if detail == nil || detail.DetailStatus != consts.BatchDetailStatusRunning {
return gerror.New("renew detail is no longer running")
}
loadedDetail := loaded[i]
if loadedDetail == nil || detail.Id != loadedDetail.Id || detail.RowNo != loadedDetail.RowNo ||
strings.TrimSpace(detail.ItemId) != strings.TrimSpace(loadedDetail.ItemId) {
return gerror.New("renew detail changed during execution")
}
}
return nil
}
func renewServiceRange(executions []renewExecution) (time.Time, time.Time) {
minBegin := executions[0].begin
maxEnd := executions[0].end
for _, execution := range executions[1:] {
if execution.begin.Before(minBegin) {
minBegin = execution.begin
}
if execution.end.After(maxEnd) {
maxEnd = execution.end
}
}
return minBegin, maxEnd
}
func generateRenewSubscriptionNo(now time.Time) (string, error) {
orderSN, err := rc.NextOrderSN(now)
if err != nil {
return "", err
}
return "SUB" + strings.TrimPrefix(orderSN, "SO"), nil
}
func buildRenewBind(contract *ExecutionContract, pkg *rc.FlowPackageInfo, execution renewExecution, orderSN, subscriptionNo string, now time.Time) *rc.SimCardBindPkg {
remark := contract.Remark
if remark == "" {
remark = "card renew subscription"
}
return &rc.SimCardBindPkg{
SubscriptionNo: subscriptionNo,
BatchNo: contract.BatchNo,
Iccid: execution.card.Iccid,
Msisdn: execution.card.Msisdn,
DeptId: contract.CustomerAgentID,
PackageType: pkg.PackageType,
PackageCode: pkg.PackageCode,
PreviousPackageCode: pkg.PackageCode,
BusinessType: consts.PackageBusinessTypeRenew,
EffectiveStatus: consts.EffectiveStatusPending,
HandleTime: gtime.NewFromTime(now),
EffectiveTime: gtime.NewFromTime(execution.begin),
ExpectedExpireTime: gtime.NewFromTime(execution.end),
DurationMonths: contract.DurationMonths,
GiftDurationMonths: contract.GiftDurationMonths,
OrderCode: orderSN,
Remark: remark,
TenantId: contract.TenantID,
CreatedDept: contract.CreatedDeptID,
CreatedBy: contract.OperatorUserID,
CreatedAt: gtime.NewFromTime(now),
UpdatedBy: contract.OperatorUserID,
UpdatedAt: gtime.NewFromTime(now),
}
}
func buildRenewDetailSnapshot(execution renewExecution, contract *ExecutionContract, pkg *rc.FlowPackageInfo, orderSN, subscriptionNo string) *gjson.Json {
data := map[string]any{}
if execution.detail.Snapshot != nil {
for key, value := range execution.detail.Snapshot.Map() {
data[key] = value
}
}
data["iccid"] = execution.card.Iccid
data["msisdn"] = execution.card.Msisdn
data["imsi"] = execution.card.Imsi
data["renewBatchNo"] = contract.BatchNo
data["packageCode"] = pkg.PackageCode
data["packageName"] = pkg.PackageName
data["oldSubscriptionNo"] = execution.bind.SubscriptionNo
data["newSubscriptionNo"] = subscriptionNo
data["orderSn"] = orderSN
data["oldExpectedExpireTime"] = formatRenewTime(execution.bind.ExpectedExpireTime)
data["renewEffectiveTime"] = formatRenewTime(gtime.NewFromTime(execution.begin))
data["renewExpectedExpireTime"] = formatRenewTime(gtime.NewFromTime(execution.end))
data["cardFlowPkgEndAt"] = formatRenewTime(gtime.NewFromTime(execution.end))
data["durationValue"] = contract.DisplayDurationValue()
data["actualDurationMonths"] = contract.DurationMonths
data["durationMonths"] = contract.DurationMonths
data["giftDurationMonths"] = contract.GiftDurationMonths
data["billingMethod"] = contract.BillingMethod
data["renewDurationUnit"] = contract.RenewDurationUnit
data["renewDurationUnitText"] = contract.RenewDurationUnitText
data["renewDate"] = contract.RenewDate
data["amount"] = contract.TotalAmount()
data["renewUnitPrice"] = contract.UnitPrice
data["priceSource"] = contract.PriceSource
data["rowNo"] = execution.detail.RowNo
if contract.SourceBatchNo != "" {
data["sourceBatchNo"] = contract.SourceBatchNo
}
return gjson.New(data)
}
func formatRenewTime(value *gtime.Time) string {
if value == nil {
return ""
}
return value.Format("Y-m-d H:i:s")
}
func renewPackageStatus(now time.Time, begin, end *gtime.Time) int {
if begin != nil && begin.After(gtime.NewFromTime(now)) {
return consts.PackageStatusPending
}
if end != nil && !end.After(gtime.NewFromTime(now)) {
return consts.PackageStatusExpired
}
return consts.PackageStatusEffective
}
func strictIccidsFromDetails(details []*rc.BatchDetailRow) ([]string, error) {
seen := make(map[string]struct{}, len(details))
result := make([]string, 0, len(details))
for _, detail := range details {
if detail == nil {
return nil, gerror.New("renew detail is nil")
}
iccid := strings.TrimSpace(detail.ItemId)
if iccid == "" {
return nil, gerror.New("renew detail ICCID is empty")
}
if _, exists := seen[iccid]; exists {
return nil, gerror.Newf("renew detail ICCID is duplicated: %s", iccid)
}
seen[iccid] = struct{}{}
result = append(result, iccid)
}
return result, nil
}
// uniqueIccidsFromDetails remains available to the change executor while the
// renew path uses strictIccidsFromDetails to reject malformed input.
func uniqueIccidsFromDetails(details []*rc.BatchDetailRow) []string {
seen := make(map[string]struct{}, len(details))
result := make([]string, 0, len(details))
for _, detail := range details {
if detail == nil {
continue
}
iccid := strings.TrimSpace(detail.ItemId)
if iccid == "" {
continue
}
if _, exists := seen[iccid]; exists {
continue
}
seen[iccid] = struct{}{}
result = append(result, iccid)
}
return result
}
func (r *RenewExecutor) finalizeFailure(ctx context.Context, main *rc.BatchMainRow, reason string) error {
now := pnow(r.nowFunc)
contract, _ := ParseExecutionContract(main)
if contract == nil {
contract = renewRefundContractFromBatch(main)
}
nowTime := gtime.NewFromTime(now)
err := r.repo.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
lockedMain, err := r.repo.Tables().LockRenewBatchMain(ctx, tx, main.BatchNo, main.BatchType)
if err != nil {
return err
}
if contract != nil {
if err := refundRenewBalanceIfNeeded(ctx, tx, lockedMain, contract, reason, nowTime); err != nil {
return err
}
}
if err := r.repo.MarkAllDetailsFailed(ctx, tx, main.BatchNo, main.BatchType, reason, main.CreatedBy); err != nil {
return err
}
return r.repo.FinalizeBatchMain(ctx, tx, main.BatchNo, main.BatchType, &rc.BatchMainRow{
BatchAsyncStatus: consts.BatchAsyncStatusFailed,
RunResult: consts.BatchRunResultFailed,
TotalCount: main.TotalCount,
FailCount: main.TotalCount,
FailReason: reason,
EndTime: nowTime,
UpdatedBy: main.CreatedBy,
})
})
if err != nil {
return gerror.Wrap(err, "finalize renew failure failed")
}
snap, _ := r.repo.ResolveResultSnapshot(ctx, main.BatchNo, main.BatchType)
_ = r.repo.WriteResultFileSafe(ctx, main.BatchNo, main.BatchType, main.TenantId, main.CreatedDept, main.CreatedBy, snap)
g.Log().Warningf(ctx, "renew batch failed, batchNo=%s, reason=%s, tenant=%s", main.BatchNo, reason, main.TenantId)
return nil
}
func mergeBatchInfo(prev *gjson.Json, addition map[string]any) map[string]any {
out := map[string]any{}
if prev != nil {
for key, value := range prev.Map() {
out[key] = value
}
}
for key, value := range addition {
out[key] = value
}
return out
}
func pnow(fn NowFunc) time.Time {
if fn == nil {
return timeNow()
}
return fn()
}