Files
iotlcm/internal/service/renewchange/renew.go
T
MiFaZhan 2bec257289 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),属既有缺陷,本仓未引入亦未修复。
2026-10-22 16:37:56 +08:00

577 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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()
}