chore: 初始开源发布

iotlcm-app:小程序端 + 用户端服务。

- app/:uni-app 小程序前端(基于 unibest,MIT)
  - 微信 AppID → YOUR_WX_APPID,uni-app AppID → __UNI__YOURAPPID
  - 内网后端地址 → admin.iotlcm.example.com
  - 应用标题与 unibest 模板 README 重写(保留上游致谢与 MIT 声明)
- server/:GoFrame 用户端服务(基于 XiuAdmin,Apache-2.0)
  - module xiuadmin → git.5iot.com/iotlcm-public/iotlcm-app(360 文件)
  - 支付回调地址 → app.iotlcm.example.com,商户名与应用名泛化
  - 真实手机号 → 合法 11 位编造号(保留号段前 3 位 + 后 4 位)
  - 依赖号码尾部的断言同步更新
  - 补 server/LICENSE(Apache-2.0)
- 移除内部资产:CI 触发器
- 补根 README,LF 行尾 + .gitattributes

已知:TestSysDept_RefreshDeptAncestors 在源仓同一提交上同样失败,属既有缺陷。
This commit is contained in:
MiFaZhan
2026-10-26 14:01:25 +08:00
commit 2ed5dfa6a1
1013 changed files with 138466 additions and 0 deletions
+393
View File
@@ -0,0 +1,393 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/mcache"
"github.com/gogf/gf/v2/errors/gerror"
)
type appFeatureAccessRule struct {
Feature string
Permissions []string
Routes []string
}
// appFeatureAccessRules maps mini-program business features to existing console
// menu permissions in sys_menu.perms. It deliberately avoids role names so the
// app follows the same role-menu binding model as the admin console.
var appFeatureAccessRules = []appFeatureAccessRule{
{
Feature: "card:list",
Permissions: []string{"cpa:terminal:user", "cpm:iot:tSimCardInfo:list"},
Routes: []string{"/pages/card/index"},
},
{
Feature: "card:detail",
Permissions: []string{"cpm:iot:tSimCardInfo:view", "cpm:iot:tSimCardInfo:list"},
Routes: []string{"/pages/card/views/detail/index"},
},
{
Feature: "package:view",
Permissions: []string{
"cpm:iot:tSimCardBindPkg:list",
"cpm:iot:tSimFlowPackageInfo:list",
"cpm:iot:tSimFlowPackageInfo:view",
"cpm:iot:tSimCardBindPkg:getPkgInfo",
},
Routes: []string{"/pages/card/views/packageDetail/index"},
},
{
Feature: "card:bind",
Permissions: []string{consts.SysCheckPermissionCurrentUser},
},
{
Feature: "card:renew",
Permissions: []string{"cpm:iot:tSimCardRenew:batch:confirm"},
Routes: []string{
"/pages/business/views/renew/index",
"/pages/business/views/cardRenew/index",
},
},
{
Feature: "card:renew:submit",
Permissions: []string{"cpm:iot:tSimCardRenew:batch:confirm"},
},
{
Feature: "card:addon",
Permissions: []string{
"cpm:iot:tSimCardBindPkg:list",
"cpm:iot:tSimFlowPackageInfo:view",
"cpm:iot:tSimCardBindPkg:getPkgInfo",
},
Routes: []string{"/pages/business/views/addon/index"},
},
{
Feature: "card:change",
Permissions: []string{"cpm:iot:tSimCardChange:batch:submit"},
Routes: []string{"/pages/business/views/change/index"},
},
{
Feature: "card:change:submit",
Permissions: []string{"cpm:iot:tSimCardChange:batch:submit"},
},
{
Feature: "card:allocate",
Permissions: []string{
"cpm:iot:tSimCardAllocate:targets&cpm:iot:tSimCardAllocate:input:prepare&cpm:iot:tSimCardAllocate:batch:submit",
},
Routes: []string{"/pages/business/views/allocate/index"},
},
{
Feature: "card:stop",
Permissions: []string{"cpm:iot:tSimCardStop:input:submit"},
},
{
Feature: "card:start",
Permissions: []string{"cpm:iot:tSimCardStart:input:submit"},
},
{
Feature: "card:restart",
Permissions: []string{"cpm:iot:tSimCardRestart:input:submit"},
},
{
Feature: "card:dataStart",
Permissions: []string{"cpm:iot:tSimCardDataStart:input:submit"},
},
{
Feature: "card:dataStop",
Permissions: []string{"cpm:iot:tSimCardDataStop:input:submit"},
},
{
Feature: "card:diagnosis",
Permissions: []string{
"cpm:iot:tSimdiagnose:info",
"cpm:iot:tSimdiagnose:flow",
"cpm:iot:tSimCardInfo:querySimSession",
"cpm:iot:tSimCardInfo:querySimStatus",
},
Routes: []string{"/pages/business/views/diagnosis/index"},
},
{
Feature: "warn:view",
Permissions: []string{
"cpm:iot:simWarningConfig:list",
"cpm:iot:simWarningConfig:view",
"cpm:iot:simWarningConfig:save",
"cpm:iot:tSimWarningConfig:input:submit",
"cpm:iot:tSimWarningConfig:range:submit",
"cpm:iot:tSimWarningConfig:excel:submit",
},
Routes: []string{"/pages/business/views/warning/index"},
},
{
Feature: "order:list",
Permissions: []string{
"cpm:common:tBalanceDetail:list",
"cpm:common:tFinanceApply:list",
"cpm:gen:tFinanceApply:list",
},
Routes: []string{"/pages/business/views/order/index"},
},
{
Feature: "order:detail",
Permissions: []string{
"cpm:common:tBalanceDetail:view",
"cpm:common:tBalanceDetail:list",
"cpm:common:tFinanceApply:view",
"cpm:common:tFinanceApply:list",
"cpm:gen:tFinanceApply:list",
},
},
{
Feature: "customer:list",
Permissions: []string{"cpm:agent:dept:list", "cpm:agent:tAgentEnterprise:list"},
Routes: []string{"/pages/business/views/customer/index"},
},
{
Feature: "customer:detail",
Permissions: []string{"cpm:agent:tAgentEnterprise:view", "cpm:agent:tAgentEnterprise:list"},
Routes: []string{"/pages/business/views/customerDetail/index"},
},
{
Feature: "message:view",
Permissions: []string{consts.SysCheckPermissionCurrentUser, "cpm:system:notice:list"},
Routes: []string{"/pages/message/index", "/pages/message/views/cardDetail/index", "/pages/message/views/systemDetail/index"},
},
{
Feature: "workorder:list",
Permissions: []string{"cpm:gen:tWoMain:list"},
Routes: []string{"/pages/business/views/workOrder/index"},
},
{
Feature: "workorder:create",
Permissions: []string{"cpm:gen:tWoMain:edit"},
Routes: []string{"/pages/business/views/workOrder/index", "/pages/business/views/workOrderCreate/index"},
},
{
Feature: "workorder:detail",
Permissions: []string{"cpm:gen:tWoMain:view", "cpm:gen:tWoMain:list"},
Routes: []string{"/pages/business/views/workOrderDetail/index"},
},
}
func appEnsureFeatureAccess(ctx context.Context, feature string) error {
feature = strings.TrimSpace(feature)
if feature == "" {
return nil
}
configured, err := appAccessConfigured(ctx)
if err != nil {
return err
}
if !configured || contexts.IsSuperAdmin(ctx) {
return nil
}
userId := contexts.GetUserId(ctx)
if userId == 0 {
return gerror.New("登录已过期")
}
requiredCodes := appFeaturePermissionCandidates(feature)
if len(requiredCodes) == 0 {
return nil
}
accessCodes, _, err := mcache.GetUserAccessCodeList(ctx, userId)
if err != nil {
return err
}
for _, requiredCode := range requiredCodes {
if appAccessCodesMatchExpression(accessCodes, requiredCode) {
return nil
}
}
return gerror.New("暂无操作权限")
}
func appAccessCodesMatchExpression(accessCodes []string, expression string) bool {
parts := appPermissionExpressionParts(expression)
if len(parts) == 0 {
return false
}
for _, part := range parts {
matched := false
for _, accessCode := range accessCodes {
if appMatchAccessCode(accessCode, part) {
matched = true
break
}
}
if !matched {
return false
}
}
return true
}
func appMatchAccessCode(pattern string, code string) bool {
pattern = appNormalizePermissionCode(pattern)
code = appNormalizePermissionCode(code)
if pattern == "" || code == "" {
return false
}
if pattern == code || pattern == "*" || pattern == "cpm:*" {
return true
}
if strings.HasSuffix(pattern, "*") {
return strings.HasPrefix(code, pattern[:len(pattern)-1])
}
return false
}
func appFeaturePermissionCandidates(feature string) []string {
feature = appNormalizeFeatureKey(feature)
if feature == "" {
return nil
}
if feature == "*" {
return []string{"*"}
}
codes := make([]string, 0)
for _, rule := range appFeatureAccessRules {
if rule.Feature != feature {
continue
}
for _, permission := range rule.Permissions {
codes = appendUniqueString(codes, appNormalizePermissionExpression(permission))
}
}
if len(codes) == 0 && strings.HasPrefix(feature, consts.SysCheckPermissionPrefix) {
codes = appendUniqueString(codes, appNormalizePermissionCode(feature))
}
return codes
}
func appFeaturesByPermission(permission string) []string {
permission = appNormalizePermissionCode(permission)
if permission == "" {
return nil
}
features := make([]string, 0)
for _, rule := range appFeatureAccessRules {
for _, rulePermission := range rule.Permissions {
if strings.Contains(rulePermission, "&") {
continue
}
if appMatchAccessCode(permission, rulePermission) || appMatchAccessCode(rulePermission, permission) {
features = appendUniqueString(features, rule.Feature)
break
}
}
}
return features
}
func appRoutesByFeature(feature string) []string {
feature = appNormalizeFeatureKey(feature)
routes := make([]string, 0)
for _, rule := range appFeatureAccessRules {
if rule.Feature != feature {
continue
}
for _, route := range rule.Routes {
routes = appendUniqueString(routes, route)
}
}
return routes
}
func appKnownPermissionCodes() []string {
codes := make([]string, 0)
for _, rule := range appFeatureAccessRules {
for _, permission := range rule.Permissions {
for _, part := range appPermissionExpressionParts(permission) {
codes = appendUniqueString(codes, appNormalizePermissionCode(part))
}
}
}
return codes
}
func appAppendFeaturesByPermissionSet(accessPermissions []string, appendFeature func(string)) {
if len(accessPermissions) == 0 || appendFeature == nil {
return
}
for _, rule := range appFeatureAccessRules {
for _, permission := range rule.Permissions {
if appAccessCodesMatchExpression(accessPermissions, permission) {
appendFeature(rule.Feature)
break
}
}
}
}
func appMenuPermCandidates() []string {
values := make([]string, 0)
for _, permission := range appKnownPermissionCodes() {
if permission == "" || permission == consts.SysCheckPermissionCurrentUser {
continue
}
values = appendUniqueString(values, permission)
if strings.HasPrefix(permission, consts.SysCheckPermissionMenuPrefix) {
values = appendUniqueString(values, strings.TrimPrefix(permission, consts.SysCheckPermissionMenuPrefix))
}
}
return values
}
func appNormalizeFeatureKey(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if value == "*" || value == "cpm:*" {
return "*"
}
return value
}
func appNormalizePermissionCode(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
for strings.HasPrefix(value, consts.SysCheckPermissionMenuPrefix+consts.SysCheckPermissionMenuPrefix) {
value = strings.TrimPrefix(value, consts.SysCheckPermissionMenuPrefix)
}
if value == "*" ||
value == consts.SysCheckPermissionCurrentUser ||
value == consts.SysCheckPermissionTerminalUser ||
strings.HasPrefix(value, consts.SysCheckPermissionMenuPrefix) ||
strings.HasPrefix(value, "cpa:") ||
strings.HasPrefix(value, consts.SysCheckPermissionRolePrefix) ||
strings.HasPrefix(value, consts.SysCheckPermissionDeptPrefix) ||
strings.HasPrefix(value, consts.SysCheckPermissionPostPrefix) ||
strings.HasPrefix(value, consts.SysCheckPermissionUserPrefix) {
return value
}
return consts.SysCheckPermissionMenuPrefix + value
}
func appNormalizePermissionExpression(value string) string {
parts := appPermissionExpressionParts(value)
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "&")
}
func appPermissionExpressionParts(value string) []string {
rawParts := strings.Split(strings.TrimSpace(value), "&")
parts := make([]string, 0, len(rawParts))
for _, part := range rawParts {
part = appNormalizePermissionCode(part)
if part != "" {
parts = appendUniqueString(parts, part)
}
}
return parts
}
@@ -0,0 +1,43 @@
package app
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
"github.com/gogf/gf/v2/frame/g"
)
func appAccessConfigured(ctx context.Context) (bool, error) {
return appAccessConfiguredForTenant(ctx, contexts.GetTenantId(ctx))
}
func appAccessConfiguredForTenant(ctx context.Context, tenantId string) (bool, error) {
perms := appMenuPermCandidates()
if len(perms) == 0 {
return false, nil
}
mod := dao.SysMenu.Ctx(ctx).
Where(dao.SysMenu.Columns().Status, consts.SysMenuStatusNormal).
WhereIn(dao.SysMenu.Columns().Perms, perms)
count, err := mod.Count()
if err != nil {
g.Log().Errorf(ctx, "appAccessConfiguredForTenant count failed: %v", err)
return false, err
}
return count > 0, nil
}
func appendUniqueString(values []string, value string) []string {
if value == "" {
return values
}
for _, existing := range values {
if existing == value {
return values
}
}
return append(values, value)
}
@@ -0,0 +1,111 @@
package app
import "testing"
func TestAppAccessCodesMatchExpression(t *testing.T) {
accessCodes := []string{
"cpm:iot:tSimCardInfo:edit",
"cpm:agent:dept:list",
}
if !appAccessCodesMatchExpression(accessCodes, "cpm:iot:tSimCardInfo:edit&cpm:agent:dept:list") {
t.Fatal("expected AND expression to match when all permissions are granted")
}
if appAccessCodesMatchExpression(accessCodes, "cpm:iot:tSimCardInfo:edit&cpm:agent:tAgentEnterprise:list") {
t.Fatal("expected AND expression to fail when one permission is missing")
}
}
func TestNormalizeFeatureDoesNotGrantLegacyAppPermission(t *testing.T) {
if got := appNormalizeFeatureKey("cpm:app:*"); got == "*" {
t.Fatal("legacy cpm:app:* must not be normalized to wildcard access")
}
if got := appNormalizeFeatureKey("app:card:list"); got == "card:list" {
t.Fatal("legacy app: feature prefix must not be stripped")
}
}
func TestRestartFeatureUsesDedicatedConsolePermission(t *testing.T) {
if appAccessCodesMatchExpression(
[]string{"cpm:iot:tSimCardInfo:edit"},
appFeaturePermissionCandidates("card:restart")[0],
) {
t.Fatal("card edit permission must not grant restart feature")
}
if !appAccessCodesMatchExpression(
[]string{"cpm:iot:tSimCardRestart:input:submit"},
appFeaturePermissionCandidates("card:restart")[0],
) {
t.Fatal("restart submit permission should grant restart feature")
}
}
func TestRenewAndChangeFeaturesUseSubmitPermissions(t *testing.T) {
if appAccessCodesMatchExpression(
[]string{"cpm:iot:tSimCardRenew:input:prepare"},
appFeaturePermissionCandidates("card:renew")[0],
) {
t.Fatal("renew prepare permission must not grant mini app renew entry")
}
if !appAccessCodesMatchExpression(
[]string{"cpm:iot:tSimCardRenew:batch:confirm"},
appFeaturePermissionCandidates("card:renew")[0],
) {
t.Fatal("renew batch confirm permission should grant renew entry")
}
if appAccessCodesMatchExpression(
[]string{"cpm:iot:tSimCardChange:input:prepare", "cpm:iot:tSimCardBindPkg:getChangePkg"},
appFeaturePermissionCandidates("card:change")[0],
) {
t.Fatal("change prepare permission must not grant mini app change entry")
}
if !appAccessCodesMatchExpression(
[]string{"cpm:iot:tSimCardChange:batch:submit"},
appFeaturePermissionCandidates("card:change")[0],
) {
t.Fatal("change batch submit permission should grant change entry")
}
}
func TestAllocateFeatureUsesDedicatedConsolePermissions(t *testing.T) {
if appAccessCodesMatchExpression(
[]string{"cpm:iot:tSimCardInfo:edit", "cpm:agent:dept:list"},
appFeaturePermissionCandidates("card:allocate")[0],
) {
t.Fatal("card edit and customer list permissions must not grant mini app allocate entry")
}
if !appAccessCodesMatchExpression(
[]string{
"cpm:iot:tSimCardAllocate:targets",
"cpm:iot:tSimCardAllocate:input:prepare",
"cpm:iot:tSimCardAllocate:batch:submit",
},
appFeaturePermissionCandidates("card:allocate")[0],
) {
t.Fatal("complete allocate permissions should grant allocate entry")
}
if appAccessCodesMatchExpression(
[]string{"cpm:iot:tSimCardAllocate:batch:submit"},
appFeaturePermissionCandidates("card:allocate")[0],
) {
t.Fatal("single allocate submit permission must not grant full allocate entry")
}
}
func TestDataServiceFeaturesUseConsolePermissions(t *testing.T) {
tests := []struct {
feature string
permission string
}{
{feature: "card:dataStart", permission: "cpm:iot:tSimCardDataStart:input:submit"},
{feature: "card:dataStop", permission: "cpm:iot:tSimCardDataStop:input:submit"},
}
for _, tt := range tests {
candidates := appFeaturePermissionCandidates(tt.feature)
if len(candidates) != 1 || !appAccessCodesMatchExpression([]string{tt.permission}, candidates[0]) {
t.Fatalf("%s should use %s", tt.feature, tt.permission)
}
}
}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
package app
import (
"context"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
func TestPhoneRegisterParamAllowsEmptySmsCode(t *testing.T) {
err := g.Validator().Data(&model.AppPhoneRegisterParam{
TenantId: "000000",
Phone: "13800008000",
Password: "123456",
NickName: "测试用户",
}).Run(context.Background())
if err != nil {
t.Fatalf("AppPhoneRegisterParam should allow empty smsCode, got: %v", err)
}
}
func TestVerifyOptionalAppSmsCodeSkipsEmptyCode(t *testing.T) {
if err := verifyOptionalAppSmsCode(context.Background(), "000000", "13800008000", "register", ""); err != nil {
t.Fatalf("empty smsCode should skip sms verification, got: %v", err)
}
if err := verifyOptionalAppSmsCode(context.Background(), "000000", "13800008000", "register", " "); err != nil {
t.Fatalf("blank smsCode should skip sms verification, got: %v", err)
}
}
func TestDeletedRegisterUserNameFitsSysUserUniqueColumn(t *testing.T) {
name := deletedRegisterUserName(88, "18800004698")
if name != "del88_004698" {
t.Fatalf("unexpected deleted register username: %s", name)
}
if len(name) > 30 {
t.Fatalf("deleted register username length = %d, want <= 30", len(name))
}
}
func TestIsSysUserNameUniqueKeyError(t *testing.T) {
if !isSysUserNameUniqueKeyError(gerror.New("Error 1062: Duplicate entry '000000-18800004698' for key 'sys_user.uk_user_name'")) {
t.Fatal("expected sys_user.uk_user_name duplicate error to be recognized")
}
if isSysUserNameUniqueKeyError(gerror.New("Error 1062: Duplicate entry 'x' for key 'other_key'")) {
t.Fatal("unexpected duplicate key match for other indexes")
}
}
func TestAppWechatMiniSecretMissing(t *testing.T) {
if !appWechatMiniSecretMissing("") {
t.Fatal("empty mini app secret should be treated as missing")
}
if !appWechatMiniSecretMissing(appWechatMiniAppSecretPlaceholder) {
t.Fatal("placeholder mini app secret should be treated as missing")
}
if appWechatMiniSecretMissing("real-secret") {
t.Fatal("non-placeholder mini app secret should not be treated as missing")
}
}
func TestAppEnsureWechatBindPhoneVerifiedSkipsVerifiedPhone(t *testing.T) {
err := appEnsureWechatBindPhoneVerified(
context.Background(),
&model.AppWechatBindPhoneParam{},
&appWechatBindTicketCache{VerifiedPhone: "13800008000"},
"13800008000",
)
if err != nil {
t.Fatalf("verified phone should skip captcha verification, got: %v", err)
}
err = appEnsureWechatBindPhoneVerified(
context.Background(),
&model.AppWechatBindPhoneParam{},
&appWechatBindTicketCache{VerifiedPhone: "13800008001"},
"13800008000",
)
if err == nil {
t.Fatal("different phone should require captcha verification")
}
}
func TestAvailableAppLoginUsersByPasswordFiltersCandidates(t *testing.T) {
svc := &sAppAuth{}
users := []*entity.SysUser{
appAuthTestUser(1, "phone-a", "right-password"),
appAuthTestUser(2, "phone-b", "other-password"),
}
matched, err := svc.availableAppLoginUsersByPassword(users, "wrong-password")
if err != nil {
t.Fatalf("availableAppLoginUsersByPassword returned error: %v", err)
}
if len(matched) != 0 {
t.Fatalf("wrong password matched %d users, want 0", len(matched))
}
matched, err = svc.availableAppLoginUsersByPassword(users, "right-password")
if err != nil {
t.Fatalf("availableAppLoginUsersByPassword returned error: %v", err)
}
if len(matched) != 1 || matched[0].UserId != 1 {
t.Fatalf("right password matched %#v, want only user 1", matched)
}
}
func appAuthTestUser(userId int64, username string, password string) *entity.SysUser {
salt := "abcde"
return &entity.SysUser{
UserId: userId,
UserName: username,
Salt: salt,
Password: appPasswordEncryptForStore(password, salt),
Status: consts.SysUserStatusNormal,
}
}
+379
View File
@@ -0,0 +1,379 @@
package app
import (
"context"
"fmt"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type appBalanceAccount struct {
AccountId int64
CreatedDept int64
Balance float64
AccountLabel string
Status string
}
func (s *sAppCard) deductAppBalance(ctx context.Context, tx gdb.TX, user *entity.SysUser, orderSn string, amount float64, remark string, now *gtime.Time) (*appBalanceAccount, error) {
return s.deductAppBalanceForDept(ctx, tx, user, orderSn, amount, remark, 0, now)
}
func (s *sAppCard) deductAppBalanceForDept(ctx context.Context, tx gdb.TX, user *entity.SysUser, orderSn string, amount float64, remark string, payerDeptId int64, now *gtime.Time) (*appBalanceAccount, error) {
amount = appRoundAmount(amount)
if amount <= 0 {
return nil, gerror.New("扣款金额必须大于0")
}
var account *appBalanceAccount
var err error
if s.isTerminalUser(ctx, user) {
account, err = s.lockAppBalanceAccount(ctx, tx, user)
} else {
account, err = s.lockAppAgentBalanceAccount(ctx, tx, user, payerDeptId)
}
if err != nil {
return nil, err
}
if err = s.ensureAppBalanceAccountConsumable(account); err != nil {
return nil, err
}
if account.Balance < amount {
return nil, gerror.Newf("账户余额不足,当前余额 %.2f 元,应付 %.2f 元", account.Balance, amount)
}
afterBalance := appRoundAmount(account.Balance - amount)
if s.isTerminalUser(ctx, user) {
result, err := dao.TTerminal.Ctx(ctx).TX(tx).
Where(dao.TTerminal.Columns().TenantId, user.TenantId).
Where(dao.TTerminal.Columns().TerminalId, user.UserId).
Data(do.TTerminal{
Balance: afterBalance,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return nil, gerror.Wrap(err, "扣减终端账户余额失败")
}
if affected, err := result.RowsAffected(); err != nil {
return nil, gerror.Wrap(err, "读取终端账户余额扣减数量失败")
} else if affected != 1 {
return nil, gerror.New("终端账户余额已变化,请刷新后重试")
}
} else {
result, err := dao.TAgentBalance.Ctx(ctx).TX(tx).
Where(dao.TAgentBalance.Columns().TenantId, user.TenantId).
Where(dao.TAgentBalance.Columns().DeptId, account.AccountId).
Data(do.TAgentBalance{
Balance: afterBalance,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return nil, gerror.Wrap(err, "扣减企业账户余额失败")
}
if affected, err := result.RowsAffected(); err != nil {
return nil, gerror.Wrap(err, "读取企业账户余额扣减数量失败")
} else if affected != 1 {
return nil, gerror.New("企业账户余额已变化,请刷新后重试")
}
}
account.Balance = afterBalance
if err = s.insertAppBalanceDetail(ctx, tx, user, account, orderSn, amount, account.Balance+amount, afterBalance, consts.BalanceExpense, consts.BalanceTransactionTypeConsume, remark, now); err != nil {
return nil, err
}
return account, nil
}
func (s *sAppCard) creditAppBalance(ctx context.Context, tx gdb.TX, user *entity.SysUser, orderSn string, amount float64, remark string, channelSerialNo string, now *gtime.Time) (*appBalanceAccount, error) {
return s.creditAppBalanceWithChannel(ctx, tx, user, orderSn, amount, remark, consts.BalanceTransactionChannelWechat, channelSerialNo, now)
}
func (s *sAppCard) creditAppBalanceWithChannel(ctx context.Context, tx gdb.TX, user *entity.SysUser, orderSn string, amount float64, remark string, transactionChannel int, channelSerialNo string, now *gtime.Time) (*appBalanceAccount, error) {
amount = appRoundAmount(amount)
if amount <= 0 {
return nil, gerror.New("入账金额必须大于0")
}
account, err := s.lockAppBalanceAccount(ctx, tx, user)
if err != nil {
return nil, err
}
beforeBalance := account.Balance
afterBalance := appRoundAmount(beforeBalance + amount)
if s.isTerminalUser(ctx, user) {
result, err := dao.TTerminal.Ctx(ctx).TX(tx).
Where(dao.TTerminal.Columns().TenantId, user.TenantId).
Where(dao.TTerminal.Columns().TerminalId, user.UserId).
Data(do.TTerminal{
Balance: afterBalance,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return nil, gerror.Wrap(err, "增加终端账户余额失败")
}
if affected, err := result.RowsAffected(); err != nil {
return nil, gerror.Wrap(err, "读取终端账户余额入账数量失败")
} else if affected != 1 {
return nil, gerror.New("终端账户余额已变化,请刷新后重试")
}
} else {
result, err := dao.TAgentBalance.Ctx(ctx).TX(tx).
Where(dao.TAgentBalance.Columns().TenantId, user.TenantId).
Where(dao.TAgentBalance.Columns().DeptId, user.DeptId).
Data(do.TAgentBalance{
Balance: afterBalance,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return nil, gerror.Wrap(err, "增加企业账户余额失败")
}
if affected, err := result.RowsAffected(); err != nil {
return nil, gerror.Wrap(err, "读取企业账户余额入账数量失败")
} else if affected != 1 {
return nil, gerror.New("企业账户余额已变化,请刷新后重试")
}
}
account.Balance = afterBalance
if err = s.insertAppBalanceDetailWithChannel(ctx, tx, user, account, orderSn, amount, beforeBalance, afterBalance, consts.BalanceIncome, consts.BalanceTransactionTypeRecharge, transactionChannel, channelSerialNo, remark, now); err != nil {
return nil, err
}
return account, nil
}
func (s *sAppCard) lockAppBalanceAccount(ctx context.Context, tx gdb.TX, user *entity.SysUser) (*appBalanceAccount, error) {
if user == nil {
return nil, gerror.New("当前用户不存在")
}
if s.isTerminalUser(ctx, user) {
var terminal *entity.TTerminal
if err := dao.TTerminal.Ctx(ctx).TX(tx).
Where(dao.TTerminal.Columns().TenantId, user.TenantId).
Where(dao.TTerminal.Columns().TerminalId, user.UserId).
LockUpdate().
Limit(1).
Scan(&terminal); err != nil {
return nil, gerror.Wrap(err, "锁定终端账户余额失败")
}
if terminal == nil || terminal.TerminalId == 0 {
return nil, gerror.New("终端账户不存在,无法余额扣款")
}
return &appBalanceAccount{
AccountId: user.UserId,
CreatedDept: appUserDeptOrDefault(user),
Balance: terminal.Balance,
AccountLabel: "terminal",
Status: strings.TrimSpace(terminal.Status),
}, nil
}
return s.lockAppAgentBalanceAccount(ctx, tx, user, 0)
}
func (s *sAppCard) lockAppAgentBalanceAccount(ctx context.Context, tx gdb.TX, user *entity.SysUser, payerDeptId int64) (*appBalanceAccount, error) {
if user == nil {
return nil, gerror.New("当前用户不存在")
}
payerDeptId = appBalancePayerDeptId(user, payerDeptId)
if payerDeptId <= 0 {
return nil, gerror.New("当前用户未绑定机构,无法余额扣款")
}
var agentBalance *entity.TAgentBalance
if err := dao.TAgentBalance.Ctx(ctx).TX(tx).
Where(dao.TAgentBalance.Columns().TenantId, user.TenantId).
Where(dao.TAgentBalance.Columns().DeptId, payerDeptId).
LockUpdate().
Limit(1).
Scan(&agentBalance); err != nil {
return nil, gerror.Wrap(err, "锁定企业账户余额失败")
}
if agentBalance == nil || agentBalance.Id == 0 {
createdBalance, createErr := s.ensureAppAgentBalanceForDept(ctx, tx, user, payerDeptId, gtime.Now())
if createErr != nil {
return nil, createErr
}
agentBalance = createdBalance
}
if agentBalance.Status != 0 {
return nil, gerror.New("企业账户已停用,无法余额扣款")
}
return &appBalanceAccount{
AccountId: payerDeptId,
CreatedDept: payerDeptId,
Balance: agentBalance.Balance,
AccountLabel: "agent",
Status: fmt.Sprintf("%d", agentBalance.Status),
}, nil
}
func (s *sAppCard) ensureAppBalanceAccountConsumable(account *appBalanceAccount) error {
if account == nil {
return gerror.New("余额账户信息不完整")
}
switch account.AccountLabel {
case "terminal":
if status := strings.TrimSpace(account.Status); status != "" && status != consts.SysUserStatusNormal {
return gerror.New("终端账户已停用,无法扣减余额")
}
case "agent":
if status := strings.TrimSpace(account.Status); status != "" && status != "0" {
return gerror.New("企业账户已停用,无法扣减余额")
}
}
return nil
}
func (s *sAppCard) ensureAppAgentBalance(ctx context.Context, tx gdb.TX, user *entity.SysUser, now *gtime.Time) (*entity.TAgentBalance, error) {
return s.ensureAppAgentBalanceForDept(ctx, tx, user, 0, now)
}
func (s *sAppCard) ensureAppAgentBalanceForDept(ctx context.Context, tx gdb.TX, user *entity.SysUser, payerDeptId int64, now *gtime.Time) (*entity.TAgentBalance, error) {
if user == nil {
return nil, gerror.New("当前用户未绑定企业,无法初始化企业账户")
}
payerDeptId = appBalancePayerDeptId(user, payerDeptId)
if payerDeptId <= 0 {
return nil, gerror.New("当前用户未绑定企业,无法初始化企业账户")
}
if now == nil {
now = gtime.Now()
}
var dept *entity.SysDept
deptMod := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, user.TenantId).
Where(dao.SysDept.Columns().DeptId, payerDeptId).
Limit(1)
if tx != nil {
deptMod = deptMod.TX(tx).LockUpdate()
}
if err := deptMod.Scan(&dept); err != nil {
return nil, gerror.Wrap(err, "查询企业机构失败")
}
if dept == nil || dept.DeptId == 0 {
return nil, gerror.New("企业机构不存在,无法初始化余额账户")
}
balanceModel := func() *gdb.Model {
mod := dao.TAgentBalance.Ctx(ctx)
if tx != nil {
mod = mod.TX(tx)
}
return mod
}
var existing *entity.TAgentBalance
if err := balanceModel().
Where(dao.TAgentBalance.Columns().TenantId, user.TenantId).
Where(dao.TAgentBalance.Columns().DeptId, payerDeptId).
LockUpdate().
Limit(1).
Scan(&existing); err != nil {
return nil, gerror.Wrap(err, "查询企业余额账户失败")
}
if existing != nil && existing.Id > 0 {
return existing, nil
}
id, err := balanceModel().Data(do.TAgentBalance{
DeptId: payerDeptId,
Balance: 0,
WithdrawAmount: 0,
WithdrawAmountStatus: 1,
InvoiceAmount: 0,
CanInvoiceAmount: 0,
CreditLimit: 0,
UsedCredit: 0,
PendingRepayment: 0,
Status: 0,
TenantId: user.TenantId,
CreatedDept: payerDeptId,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).InsertAndGetId()
if err != nil {
var created *entity.TAgentBalance
if queryErr := balanceModel().
Where(dao.TAgentBalance.Columns().TenantId, user.TenantId).
Where(dao.TAgentBalance.Columns().DeptId, payerDeptId).
LockUpdate().
Limit(1).
Scan(&created); queryErr == nil && created != nil && created.Id > 0 {
return created, nil
}
return nil, gerror.Wrap(err, "初始化企业余额账户失败")
}
return &entity.TAgentBalance{
Id: id,
DeptId: payerDeptId,
Balance: 0,
WithdrawAmountStatus: 1,
Status: 0,
TenantId: user.TenantId,
CreatedDept: payerDeptId,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}, nil
}
func appBalancePayerDeptId(user *entity.SysUser, payerDeptId int64) int64 {
if payerDeptId > 0 {
return payerDeptId
}
if user != nil {
return user.DeptId
}
return 0
}
func (s *sAppCard) insertAppBalanceDetail(ctx context.Context, tx gdb.TX, user *entity.SysUser, account *appBalanceAccount, orderSn string, amount, beforeBalance, afterBalance float64, incomeExpenseType int, transactionType int, remark string, now *gtime.Time) error {
return s.insertAppBalanceDetailWithChannel(ctx, tx, user, account, orderSn, amount, beforeBalance, afterBalance, incomeExpenseType, transactionType, consts.BalanceTransactionChannelBalance, "", remark, now)
}
func (s *sAppCard) insertAppBalanceDetailWithChannel(ctx context.Context, tx gdb.TX, user *entity.SysUser, account *appBalanceAccount, orderSn string, amount, beforeBalance, afterBalance float64, incomeExpenseType int, transactionType int, transactionChannel int, channelSerialNo string, remark string, now *gtime.Time) error {
if user == nil || account == nil {
return gerror.New("余额流水账户信息不完整")
}
serialNo := appCardBatchNo("BAL", user.UserId, now)
if _, err := dao.TBalanceDetail.Ctx(ctx).TX(tx).Data(do.TBalanceDetail{
DeptId: account.AccountId,
SerialNo: serialNo,
TransactionTime: now,
IncomeExpenseType: incomeExpenseType,
TransactionType: transactionType,
TransactionChannel: transactionChannel,
Amount: appRoundAmount(amount),
BeforeBalance: appRoundAmount(beforeBalance),
BalanceAfter: appRoundAmount(afterBalance),
ChannelSerialNo: channelSerialNo,
OrderSn: orderSn,
Remark: fmt.Sprintf("%s:%s", account.AccountLabel, remark),
TenantId: user.TenantId,
CreatedDept: account.CreatedDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Insert(); err != nil {
return gerror.Wrap(err, "写入余额流水失败")
}
return nil
}
@@ -0,0 +1,320 @@
package app
import (
"context"
"fmt"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"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"
)
type appBalanceRechargeAccount struct {
AccountType string
AccountId int64
CustomerType int
CustomerAgentId int64
EndUserId int64
AgentId int64
CreatedDept int64
}
// BalanceRechargeSubmit 创建余额充值待支付订单,并返回前端可使用的支付信息。
// 余额入账和余额流水只在支付回调或模拟支付确认成功后写入。
func (s *sAppCard) BalanceRechargeSubmit(ctx context.Context, param *model.AppBalanceRechargeSubmitParam) (res *model.AppBalanceRechargeSubmitModel, err error) {
if !appBalanceRechargeEnabled(ctx) {
return nil, gerror.New("充值功能暂未开放")
}
if param == nil {
param = &model.AppBalanceRechargeSubmitParam{}
}
amount := appRoundAmount(param.Amount)
if amount <= 0 {
return nil, gerror.New("充值金额必须大于0")
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
now := gtime.Now()
orderSn := appOrderNo("SO", user.UserId, now)
payProvider := strings.ToLower(strings.TrimSpace(param.PayProvider))
if payProvider == "" {
payProvider = "wechat"
}
switch payProvider {
case "mock":
if !appMockPaymentEnabled(ctx) {
return nil, gerror.New("模拟支付未启用")
}
case "wechat":
if !appWechatEnabled(ctx) {
return nil, gerror.New("微信支付通道未配置")
}
default:
return nil, gerror.New("暂不支持的支付方式")
}
var orderId int64
err = dao.TOrder.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
account, err := s.ensureAppBalanceRechargeAccount(ctx, tx, user, now)
if err != nil {
return err
}
orderId, err = s.insertAppBalanceRechargeOrder(ctx, tx, user, account, orderSn, strings.TrimSpace(param.BuyerNote), amount, now)
if err != nil {
return err
}
return s.insertAppBalanceRechargeOrderItem(ctx, tx, user, account, orderId, orderSn, amount, now)
})
if err != nil {
return nil, err
}
var payRequest *appWechatPayRequest
if payProvider == "wechat" {
if appNormalizeWechatPayScene(param.PayScene) == "mini" {
if err = service.AppAuth().BindWechatOpenIdByLoginCode(ctx, user, param.WxCode); err != nil {
return nil, err
}
}
payRequest, err = appBuildWechatPayRequest(ctx, &entity.TOrder{
Id: orderId,
OrderSn: orderSn,
OrderType: consts.OrderTypeBalanceRecharge,
PayStatus: consts.PayStatusUnpaid,
TotalAmount: amount,
CreatedAt: now,
}, param.PayScene, user)
if err != nil {
return nil, err
}
}
paymentExpireAt, paymentRemainingSeconds, paymentExpired := appBalanceRechargePaymentTiming(ctx, &entity.TOrder{
OrderType: consts.OrderTypeBalanceRecharge,
PayStatus: consts.PayStatusUnpaid,
CreatedAt: now,
})
return &model.AppBalanceRechargeSubmitModel{
OrderId: orderId,
OrderSn: orderSn,
TotalAmount: amount,
PriceText: appFormatMoney(amount),
OrderStatus: consts.OrderStatusPendingPay,
PayStatus: consts.PayStatusUnpaid,
Status: "pending_pay",
PaymentExpireAt: paymentExpireAt,
PaymentRemainingSeconds: paymentRemainingSeconds,
PaymentExpired: paymentExpired,
PayProvider: payProvider,
PayUrl: appWechatPayUrl(payRequest),
PayMethod: appWechatPayMethod(payRequest),
PayGateway: "",
PayParams: appWechatPayParams(payRequest),
}, nil
}
func (s *sAppCard) ensureAppBalanceRechargeAccount(ctx context.Context, tx gdb.TX, user *entity.SysUser, now *gtime.Time) (*appBalanceRechargeAccount, error) {
if user == nil {
return nil, gerror.New("当前用户不存在")
}
if s.isTerminalUser(ctx, user) {
if err := syncTerminalUserProfile(ctx, tx, user, &model.AppUpdateUserInfoParam{
NickName: user.NickName,
TerminalName: user.NickName,
Phone: user.Phonenumber,
Email: user.Email,
Sex: user.Sex,
}, now); err != nil {
return nil, gerror.Wrap(err, "初始化终端余额账户失败")
}
var terminal *entity.TTerminal
if err := dao.TTerminal.Ctx(ctx).TX(tx).
Where(dao.TTerminal.Columns().TenantId, user.TenantId).
Where(dao.TTerminal.Columns().TerminalId, user.UserId).
Limit(1).
Scan(&terminal); err != nil {
return nil, gerror.Wrap(err, "查询终端余额账户失败")
}
if terminal == nil || terminal.TerminalId == 0 {
return nil, gerror.New("终端余额账户不存在,无法充值")
}
if strings.TrimSpace(terminal.Status) != "" && strings.TrimSpace(terminal.Status) != consts.SysUserStatusNormal {
return nil, gerror.New("终端账户已停用,无法充值")
}
return &appBalanceRechargeAccount{
AccountType: "terminal",
AccountId: user.UserId,
CustomerType: consts.CustomerTypeTerminal,
CustomerAgentId: appUserDeptOrDefault(user),
EndUserId: user.UserId,
AgentId: 0,
CreatedDept: appUserDeptOrDefault(user),
}, nil
}
if user.DeptId <= 0 {
return nil, gerror.New("当前用户未绑定机构,无法充值")
}
var agentBalance *entity.TAgentBalance
if err := dao.TAgentBalance.Ctx(ctx).TX(tx).
Where(dao.TAgentBalance.Columns().TenantId, user.TenantId).
Where(dao.TAgentBalance.Columns().DeptId, user.DeptId).
Limit(1).
Scan(&agentBalance); err != nil {
return nil, gerror.Wrap(err, "查询企业余额账户失败")
}
if agentBalance == nil || agentBalance.Id == 0 {
createdBalance, createErr := s.ensureAppAgentBalance(ctx, tx, user, now)
if createErr != nil {
return nil, createErr
}
agentBalance = createdBalance
}
if agentBalance.Status != 0 {
return nil, gerror.New("企业账户已停用,无法充值")
}
sellerDeptId, err := s.renewSellerDeptId(ctx, user)
if err != nil {
return nil, err
}
if sellerDeptId <= 0 {
sellerDeptId = user.DeptId
}
return &appBalanceRechargeAccount{
AccountType: "agent",
AccountId: user.DeptId,
CustomerType: consts.CustomerTypeAgent,
CustomerAgentId: user.DeptId,
EndUserId: 0,
AgentId: sellerDeptId,
CreatedDept: user.DeptId,
}, nil
}
func (s *sAppCard) insertAppBalanceRechargeOrder(ctx context.Context, tx gdb.TX, user *entity.SysUser, account *appBalanceRechargeAccount, orderSn string, buyerNote string, amount float64, now *gtime.Time) (int64, error) {
orderId, err := dao.TOrder.Ctx(ctx).TX(tx).Data(do.TOrder{
OrderSn: orderSn,
ParentOrderId: 0,
ParentOrderSn: "",
CustomerType: account.CustomerType,
CustomerAgentId: account.CustomerAgentId,
EndUserId: account.EndUserId,
AgentId: account.AgentId,
OrderType: consts.OrderTypeBalanceRecharge,
OrderStatus: consts.OrderStatusPendingPay,
ShippingStatus: 0,
ReturnStatus: 0,
Consignee: "",
Mobile: "",
Email: "",
Address: "",
AreaCode: "",
BuyerNote: strings.TrimSpace(buyerNote),
AdminNote: "",
Remark: "小程序余额充值待支付订单",
ShippingMethod: consts.ShippingMethodNone,
LogisticsId: 0,
LogisticsName: "",
ShippingTypeId: 0,
ShippingTypeName: "",
TrackingNo: "",
PayType: consts.PayTypeOnline,
PayStatus: consts.PayStatusUnpaid,
PayOrderSn: "",
TotalAmount: amount,
ProductAmount: amount,
ShippingFee: 0,
ServiceFee: 0,
InvoiceFee: 0,
CouponAmount: 0,
DiscountAmount: 0,
Balance: 0,
PaidAmount: 0,
OnlinePaidAmount: 0,
OfflinePaidAmount: 0,
UnpaidAmount: amount,
UnrefundAmount: 0,
IsNeedCommission: 0,
DistributionStatus: 0,
TenantId: user.TenantId,
CreatedDept: account.CreatedDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "创建余额充值订单失败")
}
return orderId, nil
}
func (s *sAppCard) insertAppBalanceRechargeOrderItem(ctx context.Context, tx gdb.TX, user *entity.SysUser, account *appBalanceRechargeAccount, orderId int64, orderSn string, amount float64, now *gtime.Time) error {
skuData := appRenewMarshalJSON([]map[string]any{
{"skuName": "业务类型", "skuValue": "余额充值"},
{"skuName": "充值金额", "skuValue": appFormatMoney(amount), "amount": amount},
})
extraSkuData := appRenewMarshalJSON(map[string]any{
"business": "miniapp_balance_recharge",
"accountType": account.AccountType,
"accountId": account.AccountId,
"amount": amount,
"payChannel": "pending",
})
if _, err := dao.TOrderItem.Ctx(ctx).TX(tx).Data(do.TOrderItem{
OrderId: orderId,
OrderSn: orderSn,
CustomerType: account.CustomerType,
CustomerAgentId: account.CustomerAgentId,
EndUserId: account.EndUserId,
Price: amount,
Quantity: 1,
ProductId: 0,
ProductName: "余额充值",
ProductSn: "BALANCE_RECHARGE",
PicThumb: "",
SkuId: 0,
SkuData: skuData,
DeliveryQuantity: 0,
ProductType: consts.ProductTypeBalance,
IsGift: 0,
AgentId: account.AgentId,
PrepayPrice: 0,
Commission: "",
OriginPrice: amount,
PromotionData: "",
ExtraSkuData: extraSkuData,
PayMode: 0,
TenantId: user.TenantId,
CreatedDept: account.CreatedDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Insert(); err != nil {
return gerror.Wrap(err, "创建余额充值订单项失败")
}
return nil
}
func appFormatMoney(value float64) string {
return fmt.Sprintf("¥%.2f", appRoundAmount(value))
}
func appBalanceRechargeEnabled(ctx context.Context) bool {
return g.Cfg().MustGet(ctx, "payment.balanceRecharge.enabled", false).Bool()
}
@@ -0,0 +1,356 @@
package app
import (
"context"
"fmt"
"math"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type appBalanceRechargePaymentResult struct {
Order *entity.TOrder
BalanceAfter float64
PayTradeNo string
PayProvider string
}
func (s *sAppCard) completeAppBalanceRechargePayment(ctx context.Context, orderSn string, loginUser *entity.SysUser, payTradeNo string, payProvider string, transactionChannel int, expectedAmount float64, paidAt *gtime.Time) (*appBalanceRechargePaymentResult, error) {
orderSn = strings.TrimSpace(orderSn)
if orderSn == "" {
return nil, gerror.New("订单号不能为空")
}
payTradeNo = strings.TrimSpace(payTradeNo)
payProvider = strings.TrimSpace(payProvider)
if payProvider == "" {
payProvider = "online"
}
if paidAt == nil {
paidAt = gtime.Now()
}
var result *appBalanceRechargePaymentResult
err := dao.TOrder.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
query := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().OrderSn, orderSn).
LockUpdate().
Limit(1)
if loginUser != nil {
query = query.Where(dao.TOrder.Columns().TenantId, loginUser.TenantId)
}
var lockedOrder *entity.TOrder
if err := query.Scan(&lockedOrder); err != nil {
return gerror.Wrap(err, "锁定充值订单失败")
}
if lockedOrder == nil || lockedOrder.Id == 0 {
return gerror.New("充值订单不存在")
}
if lockedOrder.OrderType != consts.OrderTypeBalanceRecharge {
return gerror.New("订单不是余额充值订单")
}
user := loginUser
if user == nil {
var err error
user, err = s.loadAppBalanceRechargeOrderUser(ctx, tx, lockedOrder)
if err != nil {
return err
}
} else if err := s.validateAppBalanceRechargeOrderOwner(ctx, user, lockedOrder); err != nil {
return err
}
amount := appRoundAmount(lockedOrder.TotalAmount)
if amount <= 0 {
return gerror.New("充值订单金额不正确")
}
if expectedAmount > 0 && math.Abs(appRoundAmount(expectedAmount)-amount) >= 0.01 {
return gerror.Newf("支付金额与订单金额不一致,订单 %.2f 元,支付 %.2f 元", amount, expectedAmount)
}
account, err := s.lockAppBalanceAccount(ctx, tx, user)
if err != nil {
return err
}
if lockedOrder.PayStatus == consts.PayStatusPaid {
if payTradeNo == "" {
payTradeNo = strings.TrimSpace(lockedOrder.PayOrderSn)
}
result = &appBalanceRechargePaymentResult{
Order: lockedOrder,
BalanceAfter: appRoundAmount(account.Balance),
PayTradeNo: payTradeNo,
PayProvider: payProvider,
}
return nil
}
if lockedOrder.PayStatus != consts.PayStatusUnpaid || lockedOrder.OrderStatus != consts.OrderStatusPendingPay {
return gerror.New("充值订单状态不允许支付")
}
creditedAccount, err := s.creditAppBalanceWithChannel(ctx, tx, user, lockedOrder.OrderSn, amount, fmt.Sprintf("%s充值", payProvider), transactionChannel, payTradeNo, paidAt)
if err != nil {
return err
}
if _, err = dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().Id, lockedOrder.Id).
Data(do.TOrder{
OrderStatus: consts.OrderStatusCompleted,
PayType: consts.PayTypeOnline,
PayStatus: consts.PayStatusPaid,
PayOrderSn: payTradeNo,
PayTime: paidAt,
PaidAmount: amount,
OnlinePaidAmount: amount,
UnpaidAmount: 0,
Remark: fmt.Sprintf("小程序余额充值%s支付成功", payProvider),
UpdatedBy: user.UserId,
UpdatedAt: paidAt,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新充值订单支付状态失败")
}
if err = s.updateAppBalanceRechargeOrderItemPayment(ctx, tx, user, lockedOrder, creditedAccount, payProvider, payTradeNo, paidAt); err != nil {
return err
}
lockedOrder.OrderStatus = consts.OrderStatusCompleted
lockedOrder.PayType = consts.PayTypeOnline
lockedOrder.PayStatus = consts.PayStatusPaid
lockedOrder.PayOrderSn = payTradeNo
lockedOrder.PayTime = paidAt
lockedOrder.PaidAmount = amount
lockedOrder.OnlinePaidAmount = amount
lockedOrder.UnpaidAmount = 0
result = &appBalanceRechargePaymentResult{
Order: lockedOrder,
BalanceAfter: appRoundAmount(creditedAccount.Balance),
PayTradeNo: payTradeNo,
PayProvider: payProvider,
}
return nil
})
if err != nil {
return nil, err
}
if result == nil || result.Order == nil {
return nil, gerror.New("支付结果不完整")
}
_ = s.publishAppBusinessAsyncEvent(ctx, appBusinessAsyncEvent{
EventType: appAsyncEventBalanceRechargePaid,
OrderSn: result.Order.OrderSn,
PayProvider: result.PayProvider,
PayTradeNo: result.PayTradeNo,
TenantId: result.Order.TenantId,
UserId: result.Order.CreatedBy,
DeptId: result.Order.CreatedDept,
Trigger: "payment_complete",
})
return result, nil
}
func (s *sAppCard) BalanceRechargeMockPay(ctx context.Context, param *model.AppBalanceRechargeMockPayParam) (*model.AppBalanceRechargeStatusModel, error) {
if param == nil {
param = &model.AppBalanceRechargeMockPayParam{}
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
if !appMockPaymentEnabled(ctx) {
return nil, gerror.New("模拟支付未启用")
}
orderSn := strings.TrimSpace(param.OrderSn)
if orderSn == "" {
return nil, gerror.New("订单号不能为空")
}
now := gtime.Now()
payTradeNo := appCardBatchNo("MOCK", user.UserId, now)
if _, err = s.completeAppBalanceRechargePayment(ctx, orderSn, user, payTradeNo, "mock", consts.BalanceTransactionChannelMock, 0, now); err != nil {
return nil, err
}
return s.BalanceRechargeStatus(ctx, &model.AppBalanceRechargeStatusParam{OrderSn: orderSn})
}
func (s *sAppCard) loadAppBalanceRechargeOrderUser(ctx context.Context, tx gdb.TX, order *entity.TOrder) (*entity.SysUser, error) {
if order == nil || order.CreatedBy <= 0 {
return nil, gerror.New("充值订单创建人信息不完整")
}
var user *entity.SysUser
if err := dao.SysUser.Ctx(ctx).TX(tx).
Where(dao.SysUser.Columns().TenantId, order.TenantId).
Where(dao.SysUser.Columns().UserId, order.CreatedBy).
Limit(1).
Scan(&user); err != nil {
return nil, gerror.Wrap(err, "查询充值订单用户失败")
}
if user == nil || user.UserId == 0 {
return nil, gerror.New("充值订单用户不存在")
}
if err := s.validateAppBalanceRechargeOrderOwner(ctx, user, order); err != nil {
return nil, err
}
return user, nil
}
func (s *sAppCard) validateAppBalanceRechargeOrderOwner(ctx context.Context, user *entity.SysUser, order *entity.TOrder) error {
if user == nil || order == nil {
return gerror.New("充值订单信息不完整")
}
if order.CreatedBy != user.UserId {
if order.CustomerType != consts.CustomerTypeTerminal || order.EndUserId != user.UserId {
return gerror.New("无权操作该充值订单")
}
}
if s.isTerminalUser(ctx, user) {
if order.CustomerType != consts.CustomerTypeTerminal || order.EndUserId != user.UserId {
return gerror.New("无权操作该充值订单")
}
return nil
}
if user.DeptId <= 0 {
return gerror.New("当前用户未绑定机构,无法操作充值订单")
}
if order.CustomerType != consts.CustomerTypeAgent || order.CustomerAgentId != user.DeptId {
return gerror.New("无权操作该充值订单")
}
return nil
}
func (s *sAppCard) updateAppBalanceRechargeOrderItemPayment(ctx context.Context, tx gdb.TX, user *entity.SysUser, order *entity.TOrder, account *appBalanceAccount, payProvider string, payTradeNo string, paidAt *gtime.Time) error {
if user == nil || order == nil || account == nil {
return gerror.New("充值订单项支付快照信息不完整")
}
extraSkuData := appRenewMarshalJSON(map[string]any{
"business": "miniapp_balance_recharge",
"accountType": account.AccountLabel,
"accountId": account.AccountId,
"amount": appRoundAmount(order.TotalAmount),
"payChannel": payProvider,
"payTradeNo": payTradeNo,
"paidAt": appRenewFormatTime(paidAt),
})
if _, err := dao.TOrderItem.Ctx(ctx).TX(tx).
Where(dao.TOrderItem.Columns().OrderSn, order.OrderSn).
Data(do.TOrderItem{
ExtraSkuData: extraSkuData,
UpdatedBy: user.UserId,
UpdatedAt: paidAt,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新充值订单项支付快照失败")
}
return nil
}
func (s *sAppCard) BalanceRechargeStatus(ctx context.Context, param *model.AppBalanceRechargeStatusParam) (*model.AppBalanceRechargeStatusModel, error) {
if param == nil {
param = &model.AppBalanceRechargeStatusParam{}
}
orderSn := strings.TrimSpace(param.OrderSn)
if orderSn == "" {
return nil, gerror.New("订单号不能为空")
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
var order *entity.TOrder
var balanceAfter float64
err = dao.TOrder.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().TenantId, user.TenantId).
Where(dao.TOrder.Columns().OrderSn, orderSn).
Limit(1).
Scan(&order); err != nil {
return gerror.Wrap(err, "查询充值订单失败")
}
if order == nil || order.Id == 0 {
return gerror.New("充值订单不存在")
}
if err := s.validateAppBalanceRechargeOrderOwner(ctx, user, order); err != nil {
return err
}
account, err := s.lockAppBalanceAccount(ctx, tx, user)
if err != nil {
return err
}
balanceAfter = appRoundAmount(account.Balance)
return nil
})
if err != nil {
return nil, err
}
status := "pending_pay"
if order.PayStatus == consts.PayStatusPaid {
status = "paid"
}
paymentExpireAt, paymentRemainingSeconds, paymentExpired := appBalanceRechargePaymentTiming(ctx, order)
if paymentExpired {
status = "expired"
}
var payRequest *appWechatPayRequest
payProvider := appBalancePayProviderFromTradeNo(order.PayOrderSn)
if order.PayStatus == consts.PayStatusUnpaid && !paymentExpired && appWechatEnabled(ctx) {
payProvider = "wechat"
if appNormalizeWechatPayScene(param.PayScene) == "mini" && strings.TrimSpace(param.WxCode) != "" {
if err = service.AppAuth().BindWechatOpenIdByLoginCode(ctx, user, param.WxCode); err != nil {
return nil, err
}
}
payRequest, err = appBuildWechatPayRequest(ctx, order, param.PayScene, user)
if err != nil {
return nil, err
}
}
return &model.AppBalanceRechargeStatusModel{
OrderId: order.Id,
OrderSn: order.OrderSn,
TotalAmount: appRoundAmount(order.TotalAmount),
PriceText: appFormatMoney(order.TotalAmount),
OrderStatus: order.OrderStatus,
PayStatus: order.PayStatus,
Status: status,
PaymentExpireAt: paymentExpireAt,
PaymentRemainingSeconds: paymentRemainingSeconds,
PaymentExpired: paymentExpired,
BalanceAfter: balanceAfter,
BalanceAfterText: appFormatMoney(balanceAfter),
PayProvider: payProvider,
PayTradeNo: order.PayOrderSn,
PayUrl: appWechatPayUrl(payRequest),
PayMethod: appWechatPayMethod(payRequest),
PayGateway: "",
PayParams: appWechatPayParams(payRequest),
}, nil
}
func appBalancePayProviderFromTradeNo(tradeNo string) string {
if strings.TrimSpace(tradeNo) == "" {
return ""
}
upper := strings.ToUpper(tradeNo)
if strings.HasPrefix(upper, "MOCK") {
return "mock"
}
if strings.HasPrefix(upper, "WX") {
return "wechat"
}
return "wechat"
}
@@ -0,0 +1,516 @@
package app
import (
"context"
"fmt"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"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/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
)
const (
appRefundStatusSuccess = "SUCCESS"
appRefundStatusProcessing = "PROCESSING"
appRefundStatusAbnormal = "ABNORMAL"
appRefundStatusClosed = "CLOSED"
)
func (s *sAppCard) BalanceRechargeRefund(ctx context.Context, param *model.AppBalanceRechargeRefundParam) (*model.AppOrderModel, error) {
if param == nil {
param = &model.AppBalanceRechargeRefundParam{}
}
orderSn := strings.TrimSpace(param.OrderSn)
if orderSn == "" {
return nil, gerror.New("订单号不能为空")
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
cfg, err := appLoadWechatConfig(ctx)
if err != nil {
return nil, err
}
if !cfg.Enabled {
return nil, gerror.New("微信支付未启用")
}
if err = appValidateWechatCommonConfig(cfg); err != nil {
return nil, err
}
if strings.TrimSpace(cfg.RefundNotifyUrl) == "" {
return nil, gerror.New("微信退款回调地址未配置")
}
client, err := appWechatClient(ctx, cfg)
if err != nil {
return nil, err
}
refundSvc := refunddomestic.RefundsApiService{Client: client}
now := gtime.Now()
refundReason := strings.TrimSpace(param.RefundReason)
if refundReason == "" {
refundReason = "用户发起余额充值退款"
}
outRefundNo := appBalanceRechargeRefundNo(orderSn)
var payOrderSn string
var refundAmount float64
var refundAmountFen int64
err = dao.TOrder.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
order, err := s.lockAppBalanceRechargeRefundOrder(ctx, tx, user, orderSn)
if err != nil {
return err
}
if appBalanceRechargeRefundNoteValue(order.AdminNote, "refund_no") == outRefundNo {
return gerror.New("该订单已提交过退款申请,请等待处理结果")
}
refundAmount = appRoundAmount(order.TotalAmount)
if refundAmount <= 0 {
return gerror.New("订单退款金额不正确")
}
refundAmountFen = appWechatAmountFen(refundAmount)
if refundAmountFen <= 0 {
return gerror.New("订单退款金额不正确")
}
payOrderSn = strings.TrimSpace(order.PayOrderSn)
if _, err = s.deductAppBalance(
ctx,
tx,
user,
order.OrderSn,
refundAmount,
"微信充值退款扣回",
now,
); err != nil {
return err
}
if _, err = dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().Id, order.Id).
Data(do.TOrder{
ReturnStatus: consts.OrderReturnStatusPending,
UnrefundAmount: refundAmount,
Remark: "小程序余额充值退款申请中",
AdminNote: appBalanceRechargeRefundNoteWithBalance(outRefundNo, appRefundStatusProcessing, "", refundReason, true),
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新退款申请状态失败")
}
return nil
})
if err != nil {
return nil, err
}
refundResp, _, err := refundSvc.Create(ctx, refunddomestic.CreateRequest{
TransactionId: core.String(payOrderSn),
OutTradeNo: core.String(orderSn),
OutRefundNo: core.String(outRefundNo),
Reason: core.String(refundReason),
NotifyUrl: core.String(cfg.RefundNotifyUrl),
Amount: &refunddomestic.AmountReq{
Refund: core.Int64(refundAmountFen),
Total: core.Int64(refundAmountFen),
Currency: core.String("CNY"),
},
})
if err != nil {
if rollbackErr := s.rollbackAppBalanceRechargeRefund(ctx, orderSn, outRefundNo, "FAILED", "", refundReason, refundAmount, gtime.Now()); rollbackErr != nil {
g.Log().Errorf(ctx, "余额充值退款申请失败后回滚余额失败: order=%s refundNo=%s error=%v", orderSn, outRefundNo, rollbackErr)
}
return nil, gerror.Wrap(err, "提交微信退款申请失败")
}
refundStatus := appWechatRefundStatus(refundResp)
switch refundStatus {
case "", appRefundStatusProcessing, appRefundStatusSuccess:
if err = s.applyAppBalanceRechargeRefundAccepted(ctx, orderSn, outRefundNo, refundStatus, appWechatRefundId(refundResp), refundReason, refundAmount, gtime.Now()); err != nil {
return nil, err
}
default:
if rollbackErr := s.rollbackAppBalanceRechargeRefund(ctx, orderSn, outRefundNo, refundStatus, appWechatRefundId(refundResp), refundReason, refundAmount, gtime.Now()); rollbackErr != nil {
g.Log().Errorf(ctx, "微信退款申请未受理后回滚余额失败: order=%s refundNo=%s status=%s error=%v", orderSn, outRefundNo, refundStatus, rollbackErr)
}
return nil, gerror.Newf("微信退款申请未受理:%s", refundStatus)
}
return service.AppOrder().Detail(ctx, &model.AppOrderDetailParam{OrderSn: orderSn})
}
func (s *sAppCard) applyAppBalanceRechargeRefundAccepted(ctx context.Context, orderSn string, outRefundNo string, refundStatus string, refundId string, refundReason string, refundAmount float64, now *gtime.Time) error {
return dao.TOrder.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var order *entity.TOrder
if err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().OrderSn, orderSn).
LockUpdate().
Limit(1).
Scan(&order); err != nil {
return gerror.Wrap(err, "锁定退款订单失败")
}
if order == nil || order.Id == 0 {
return gerror.New("退款订单不存在")
}
if order.ReturnStatus != consts.OrderReturnStatusPending && order.ReturnStatus != consts.OrderReturnStatusRunning {
return nil
}
metaRefundNo := appBalanceRechargeRefundNoteValue(order.AdminNote, "refund_no")
if metaRefundNo != "" && outRefundNo != "" && metaRefundNo != outRefundNo {
return nil
}
returnStatus := consts.OrderReturnStatusPending
if refundStatus == appRefundStatusProcessing {
returnStatus = consts.OrderReturnStatusRunning
} else if refundStatus == appRefundStatusSuccess {
returnStatus = consts.OrderReturnStatusSuccess
}
unrefundAmount := refundAmount
remark := "小程序余额充值退款申请中"
if returnStatus == consts.OrderReturnStatusSuccess {
unrefundAmount = 0
remark = "小程序余额充值已退款"
}
if unrefundAmount <= 0 && returnStatus != consts.OrderReturnStatusSuccess {
unrefundAmount = appRoundAmount(order.UnrefundAmount)
}
if _, err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().Id, order.Id).
Data(do.TOrder{
ReturnStatus: returnStatus,
UnrefundAmount: unrefundAmount,
Remark: remark,
AdminNote: appBalanceRechargeRefundNoteWithBalance(outRefundNo, refundStatus, refundId, refundReason, true),
UpdatedAt: now,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新退款申请状态失败")
}
return nil
})
}
func (s *sAppCard) rollbackAppBalanceRechargeRefund(ctx context.Context, orderSn string, outRefundNo string, refundStatus string, refundId string, refundReason string, refundAmount float64, now *gtime.Time) error {
return dao.TOrder.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var order *entity.TOrder
if err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().OrderSn, orderSn).
LockUpdate().
Limit(1).
Scan(&order); err != nil {
return gerror.Wrap(err, "锁定退款订单失败")
}
if order == nil || order.Id == 0 {
return nil
}
if order.ReturnStatus != consts.OrderReturnStatusPending && order.ReturnStatus != consts.OrderReturnStatusRunning {
return nil
}
metaRefundNo := appBalanceRechargeRefundNoteValue(order.AdminNote, "refund_no")
if metaRefundNo != "" && outRefundNo != "" && metaRefundNo != outRefundNo {
return nil
}
if appBalanceRechargeRefundBalanceDeducted(order.AdminNote) {
orderUser, err := s.loadAppBalanceRechargeOrderUser(ctx, tx, order)
if err != nil {
return err
}
if refundAmount <= 0 {
refundAmount = appRoundAmount(order.UnrefundAmount)
}
if refundAmount <= 0 {
refundAmount = appRoundAmount(order.TotalAmount)
}
if _, err = s.creditAppBalanceWithChannel(
ctx,
tx,
orderUser,
order.OrderSn,
refundAmount,
"微信退款失败余额回退",
consts.BalanceTransactionChannelWechat,
outRefundNo,
now,
); err != nil {
return err
}
}
if _, err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().Id, order.Id).
Data(do.TOrder{
ReturnStatus: consts.OrderReturnStatusPartial,
UnrefundAmount: 0,
Remark: "小程序余额充值退款异常,余额已回退",
AdminNote: appBalanceRechargeRefundNoteWithBalance(outRefundNo, refundStatus, refundId, refundReason, false),
UpdatedAt: now,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新退款异常状态失败")
}
return nil
})
}
func (s *sAppCard) lockAppBalanceRechargeRefundOrder(ctx context.Context, tx gdb.TX, user *entity.SysUser, orderSn string) (*entity.TOrder, error) {
var order *entity.TOrder
if err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().TenantId, user.TenantId).
Where(dao.TOrder.Columns().OrderSn, orderSn).
LockUpdate().
Limit(1).
Scan(&order); err != nil {
return nil, gerror.Wrap(err, "锁定充值订单失败")
}
if order == nil || order.Id == 0 {
return nil, gerror.New("充值订单不存在")
}
if err := s.validateAppBalanceRechargeOrderOwner(ctx, user, order); err != nil {
return nil, err
}
if order.OrderType != consts.OrderTypeBalanceRecharge {
return nil, gerror.New("仅余额充值订单支持退款")
}
if order.PayStatus != consts.PayStatusPaid || order.OrderStatus != consts.OrderStatusCompleted {
return nil, gerror.New("仅已支付完成的充值订单支持退款")
}
if order.PayType != consts.PayTypeOnline || !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(order.PayOrderSn)), "WX") {
return nil, gerror.New("仅微信支付充值订单支持退款")
}
switch order.ReturnStatus {
case consts.OrderReturnStatusPending, consts.OrderReturnStatusRunning:
return nil, gerror.New("退款申请正在处理中")
case consts.OrderReturnStatusSuccess:
return nil, gerror.New("该订单已退款")
case consts.OrderReturnStatusPartial:
return nil, gerror.New("该订单退款异常,请联系管理员处理")
}
return order, nil
}
func (s *sAppCard) applyAppBalanceRechargeRefundNotify(ctx context.Context, refund *refunddomestic.Refund) error {
if refund == nil {
return nil
}
orderSn := strings.TrimSpace(appStringValue(refund.OutTradeNo))
if orderSn == "" {
return gerror.New("微信退款通知订单号为空")
}
outRefundNo := strings.TrimSpace(appStringValue(refund.OutRefundNo))
if outRefundNo == "" {
outRefundNo = appBalanceRechargeRefundNo(orderSn)
}
refundStatus := appWechatRefundStatus(refund)
refundId := appStringValue(refund.RefundId)
refundAmount := appRoundAmount(float64(appWechatRefundAmountFen(refund)) / 100)
now := gtime.Now()
return dao.TOrder.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var order *entity.TOrder
if err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().OrderSn, orderSn).
LockUpdate().
Limit(1).
Scan(&order); err != nil {
return gerror.Wrap(err, "锁定退款订单失败")
}
if order == nil || order.Id == 0 {
g.Log().Warningf(ctx, "微信退款通知订单不存在: order=%s refundNo=%s", orderSn, outRefundNo)
return nil
}
if order.OrderType != consts.OrderTypeBalanceRecharge {
return nil
}
if order.ReturnStatus != consts.OrderReturnStatusPending && order.ReturnStatus != consts.OrderReturnStatusRunning {
return nil
}
metaRefundNo := appBalanceRechargeRefundNoteValue(order.AdminNote, "refund_no")
if metaRefundNo != "" && outRefundNo != "" && metaRefundNo != outRefundNo {
return nil
}
switch refundStatus {
case appRefundStatusSuccess:
if !appBalanceRechargeRefundBalanceDeducted(order.AdminNote) {
user, err := s.loadAppBalanceRechargeOrderUser(ctx, tx, order)
if err != nil {
return err
}
if refundAmount <= 0 {
refundAmount = appRoundAmount(order.UnrefundAmount)
}
if refundAmount <= 0 {
refundAmount = appRoundAmount(order.TotalAmount)
}
if _, err = s.deductAppBalance(
ctx,
tx,
user,
order.OrderSn,
refundAmount,
"微信充值退款扣回",
now,
); err != nil {
return err
}
}
if _, err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().Id, order.Id).
Data(do.TOrder{
ReturnStatus: consts.OrderReturnStatusSuccess,
UnrefundAmount: 0,
Remark: "小程序余额充值已退款",
AdminNote: appBalanceRechargeRefundNoteWithBalance(outRefundNo, refundStatus, refundId, appBalanceRechargeRefundNoteValue(order.AdminNote, "refund_reason"), true),
UpdatedAt: now,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新退款成功状态失败")
}
case "", appRefundStatusProcessing:
if _, err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().Id, order.Id).
Data(do.TOrder{
ReturnStatus: consts.OrderReturnStatusRunning,
AdminNote: appBalanceRechargeRefundNoteWithBalance(outRefundNo, appRefundStatusProcessing, refundId, appBalanceRechargeRefundNoteValue(order.AdminNote, "refund_reason"), appBalanceRechargeRefundBalanceDeducted(order.AdminNote)),
UpdatedAt: now,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新退款处理中状态失败")
}
case appRefundStatusAbnormal, appRefundStatusClosed:
if appBalanceRechargeRefundBalanceDeducted(order.AdminNote) {
user, err := s.loadAppBalanceRechargeOrderUser(ctx, tx, order)
if err != nil {
return err
}
if refundAmount <= 0 {
refundAmount = appRoundAmount(order.UnrefundAmount)
}
if refundAmount <= 0 {
refundAmount = appRoundAmount(order.TotalAmount)
}
if _, err = s.creditAppBalanceWithChannel(
ctx,
tx,
user,
order.OrderSn,
refundAmount,
"微信退款失败余额回退",
consts.BalanceTransactionChannelWechat,
outRefundNo,
now,
); err != nil {
return err
}
}
if _, err := dao.TOrder.Ctx(ctx).TX(tx).
Where(dao.TOrder.Columns().Id, order.Id).
Data(do.TOrder{
ReturnStatus: consts.OrderReturnStatusPartial,
UnrefundAmount: 0,
Remark: "小程序余额充值退款异常,余额已回退",
AdminNote: appBalanceRechargeRefundNoteWithBalance(outRefundNo, refundStatus, refundId, appBalanceRechargeRefundNoteValue(order.AdminNote, "refund_reason"), false),
UpdatedAt: now,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新退款异常状态失败")
}
default:
g.Log().Warningf(ctx, "微信退款通知状态暂不处理: order=%s refundNo=%s status=%s", orderSn, outRefundNo, refundStatus)
}
return nil
})
}
func appBalanceRechargeRefundNo(orderSn string) string {
orderSn = strings.TrimSpace(orderSn)
if strings.HasPrefix(orderSn, "RF") {
return orderSn
}
return "RF" + orderSn
}
func appWechatRefundStatus(refund *refunddomestic.Refund) string {
if refund == nil || refund.Status == nil {
return ""
}
return strings.ToUpper(strings.TrimSpace(string(*refund.Status)))
}
func appWechatRefundId(refund *refunddomestic.Refund) string {
if refund == nil {
return ""
}
return appStringValue(refund.RefundId)
}
func appWechatRefundAmountFen(refund *refunddomestic.Refund) int64 {
if refund == nil || refund.Amount == nil {
return 0
}
if refund.Amount.PayerRefund != nil && *refund.Amount.PayerRefund > 0 {
return *refund.Amount.PayerRefund
}
if refund.Amount.Refund != nil && *refund.Amount.Refund > 0 {
return *refund.Amount.Refund
}
return 0
}
func appBalanceRechargeRefundNote(outRefundNo string, status string, refundId string, reason string) string {
return fmt.Sprintf(
"refund_no=%s;refund_status=%s;refund_id=%s;refund_reason=%s",
strings.TrimSpace(outRefundNo),
strings.ToLower(strings.TrimSpace(status)),
strings.TrimSpace(refundId),
strings.ReplaceAll(strings.TrimSpace(reason), ";", " "),
)
}
func appBalanceRechargeRefundNoteWithBalance(outRefundNo string, status string, refundId string, reason string, balanceDeducted bool) string {
note := appBalanceRechargeRefundNote(outRefundNo, status, refundId, reason)
if balanceDeducted {
return note + ";refund_balance_deducted=1"
}
return note + ";refund_balance_deducted=0"
}
func appBalanceRechargeRefundBalanceDeducted(note string) bool {
value := strings.ToLower(appBalanceRechargeRefundNoteValue(note, "refund_balance_deducted"))
return value == "1" || value == "true" || value == "yes"
}
func appBalanceRechargeRefundNoteValue(note string, key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
prefix := key + "="
for _, part := range strings.Split(note, ";") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, prefix) {
return strings.TrimSpace(strings.TrimPrefix(part, prefix))
}
}
return ""
}
@@ -0,0 +1,29 @@
package app
import (
"testing"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
)
func TestAppBalancePayerDeptIdPrefersExplicitDepartment(t *testing.T) {
user := &entity.SysUser{DeptId: 175}
if got := appBalancePayerDeptId(user, 185); got != 185 {
t.Fatalf("appBalancePayerDeptId() = %d, want 185", got)
}
}
func TestAppBalancePayerDeptIdFallsBackToUserDepartment(t *testing.T) {
user := &entity.SysUser{DeptId: 175}
if got := appBalancePayerDeptId(user, 0); got != 175 {
t.Fatalf("appBalancePayerDeptId() = %d, want 175", got)
}
}
func TestAppBalancePayerDeptIdReturnsZeroForNilUser(t *testing.T) {
if got := appBalancePayerDeptId(nil, 0); got != 0 {
t.Fatalf("appBalancePayerDeptId() = %d, want 0", got)
}
}
@@ -0,0 +1,77 @@
package app
import (
"context"
"encoding/json"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/rabbitmq"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
const (
appAsyncEventBalanceRechargePaid = "balance_recharge_paid"
appAsyncEventRenewCompleted = "card_renew_completed"
appAsyncEventChangeCompleted = "card_change_completed"
appAsyncEventAllocateCompleted = "card_allocate_completed"
)
type appBusinessAsyncEvent struct {
EventType string `json:"eventType"`
OrderSn string `json:"orderSn,omitempty"`
BatchNo string `json:"batchNo,omitempty"`
BatchType int `json:"batchType,omitempty"`
PayProvider string `json:"payProvider,omitempty"`
PayTradeNo string `json:"payTradeNo,omitempty"`
TenantId string `json:"tenantId,omitempty"`
UserId int64 `json:"userId,omitempty"`
DeptId int64 `json:"deptId,omitempty"`
Trigger string `json:"trigger,omitempty"`
CreatedAt string `json:"createdAt"`
}
func (s *sAppCard) publishAppBusinessAsyncEvent(ctx context.Context, event appBusinessAsyncEvent) error {
event.EventType = strings.TrimSpace(event.EventType)
if event.EventType == "" {
return nil
}
if event.CreatedAt == "" {
event.CreatedAt = gtime.Now().Format("Y-m-d H:i:s")
}
if !g.Cfg().MustGet(ctx, "appBusinessAsync.enabled", false).Bool() {
return nil
}
payload, err := json.Marshal(event)
if err != nil {
return gerror.Wrap(err, "序列化小程序业务异步事件失败")
}
provider := strings.ToLower(strings.TrimSpace(g.Cfg().MustGet(ctx, "appBusinessAsync.provider", "rabbitmq").String()))
queueName := strings.TrimSpace(g.Cfg().MustGet(ctx, "appBusinessAsync.queueName", "iotlcm.app.business").String())
failOnPublish := g.Cfg().MustGet(ctx, "appBusinessAsync.failOnPublish", false).Bool()
var publishErr error
switch provider {
case "log", "stdout":
g.Log().Infof(ctx, "小程序业务异步事件: queue=%s payload=%s", queueName, string(payload))
case "rabbitmq":
publishErr = rabbitmq.PublishBytes(ctx, queueName, payload)
if publishErr != nil {
g.Log().Warningf(ctx, "小程序业务异步事件 RabbitMQ 发布失败: queue=%s payload=%s err:%+v", queueName, string(payload), publishErr)
} else {
g.Log().Infof(ctx, "小程序业务异步事件 RabbitMQ 发布成功: queue=%s eventType=%s orderSn=%s batchNo=%s", queueName, event.EventType, event.OrderSn, event.BatchNo)
}
default:
publishErr = gerror.Newf("不支持的小程序业务异步事件发布器: %s", provider)
g.Log().Warningf(ctx, "%v: queue=%s payload=%s", publishErr, queueName, string(payload))
}
if publishErr != nil && failOnPublish {
return publishErr
}
return nil
}
+69
View File
@@ -0,0 +1,69 @@
package app
import (
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
)
const (
appCardOwnerPublic = "public"
appCardOwnerTerminal = "terminal"
appCardOwnerAgent = "agent"
appSimTerminalDeptIdColumn = "dept_id"
appSimTerminalCurrentPackageCodeColumn = "current_package_code"
)
// sAppCard 是小程序卡片域服务入口,具体业务按文件拆分:
// 查询、套餐、绑卡、重启、字段映射和通用工具分别维护。
type sAppCard struct{}
// 承接卡主表和展示关联表的查询结果。
type appCardQueryRow struct {
entity.TSimCardInfo
PackageName string `json:"packageName"`
PackageType int `json:"packageType"`
CategoryCode string `json:"categoryCode"`
CategoryName string `json:"categoryName"`
OwnerDeptName string `json:"ownerDeptName"`
OwnerDeptKey string `json:"ownerDeptKey"`
ChanName string `json:"chanName"`
ChanAlias string `json:"chanAlias"`
TempKey string `json:"tempKey"`
TerminalName string `json:"terminalName"`
TerminalBindId int64 `json:"terminalBindId" orm:"terminal_bind_id"`
TerminalUserId int64 `json:"terminalUserId" orm:"terminal_user_id"`
TerminalDeptId int64 `json:"terminalDeptId" orm:"terminal_dept_id"`
TerminalCurrentPackageCode string `json:"terminalCurrentPackageCode" orm:"terminal_current_package_code"`
TerminalPackageName string `json:"terminalPackageName" orm:"terminal_package_name"`
TerminalPackageType int `json:"terminalPackageType" orm:"terminal_package_type"`
TerminalCategoryCode string `json:"terminalCategoryCode" orm:"terminal_category_code"`
TerminalCategoryName string `json:"terminalCategoryName" orm:"terminal_category_name"`
}
// 承接用户可见套餐订购记录的查询结果。
type appPackageQueryRow struct {
entity.TSimCardBindPkg
PackageName string `json:"packageName"`
CategoryCode string `json:"categoryCode"`
CategoryName string `json:"categoryName"`
FlowMb float64 `json:"flowMb"`
CardId int64 `json:"cardId"`
CardDeptId int64 `json:"cardDeptId"`
CardRemark string `json:"cardRemark"`
TerminalName string `json:"terminalName"`
}
// 承接公共查卡场景下当前套餐详情查询结果。
type appPublicPackageQueryRow struct {
entity.TSimFlowPackageInfo
CategoryName string `json:"categoryName"`
}
func NewAppCard() *sAppCard {
return &sAppCard{}
}
func init() {
service.RegisterAppCard(NewAppCard())
}
@@ -0,0 +1,941 @@
package app
import (
"context"
"fmt"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"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"
)
type appAllocateBatchContext struct {
Target *entity.SysDept
Cards []*appAllocateCardContext
TotalCount int
ValidCount int
FailCount int
FailReason string
}
type appAllocateCardContext struct {
Card *appCardQueryRow
ItemId string
RowNo int
SourceDeptId int64
ErrorReason string
}
// AllocateTargets 查询当前企业用户可划拨的目标客户。
func (s *sAppCard) AllocateTargets(ctx context.Context, param *model.AppCardAllocateTargetListParam) (items []*model.AppCardAllocateTargetModel, total int, err error) {
if param == nil {
param = &model.AppCardAllocateTargetListParam{}
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, 0, err
}
if s.isTerminalUser(ctx, user) {
return nil, 0, gerror.New("终端用户不能使用卡划拨功能")
}
if user.DeptId <= 0 {
return nil, 0, gerror.New("当前账号未绑定企业,不能使用卡划拨功能")
}
if param.Page <= 0 {
param.Page = 1
}
if param.PageSize <= 0 {
param.PageSize = 50
}
if param.PageSize > 1000 {
param.PageSize = 1000
}
var rows []*entity.SysDept
rows, total, err = s.loadAllocateTargetDepts(ctx, user, strings.TrimSpace(param.Keyword), param.Page, param.PageSize)
if err != nil {
return nil, 0, gerror.Wrap(err, "查询可划拨目标客户失败")
}
items = make([]*model.AppCardAllocateTargetModel, 0, len(rows))
for _, row := range rows {
items = append(items, appAllocateTargetModel(row, user.DeptId))
}
return items, total, nil
}
func (s *sAppCard) loadAllocateTargetDepts(ctx context.Context, user *entity.SysUser, keyword string, page int, pageSize int) ([]*entity.SysDept, int, error) {
if user == nil || user.DeptId <= 0 {
return []*entity.SysDept{}, 0, nil
}
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 50
}
var children []*entity.SysDept
err := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, user.TenantId).
Where(dao.SysDept.Columns().ParentId, user.DeptId).
WhereNull(dao.SysDept.Columns().DeletedAt).
OrderAsc(dao.SysDept.Columns().OrderNum).
OrderAsc(dao.SysDept.Columns().DeptId).
Scan(&children)
if err != nil {
return nil, 0, err
}
rows := make([]*entity.SysDept, 0, len(children))
for _, child := range children {
if child == nil || child.DeptId <= 0 || !appAllocateDeptMatchesKeyword(child, keyword) {
continue
}
rows = append(rows, child)
}
rows, err = appFilterDeptsByEnterpriseDataScope(ctx, user, rows)
if err != nil {
return nil, 0, err
}
total := len(rows)
start := (page - 1) * pageSize
if start >= total {
return []*entity.SysDept{}, total, nil
}
end := start + pageSize
if end > total {
end = total
}
return rows[start:end], total, nil
}
func appAllocateDeptMatchesKeyword(dept *entity.SysDept, keyword string) bool {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return true
}
if dept == nil {
return false
}
return strings.Contains(strings.TrimSpace(dept.DeptName), keyword) ||
strings.Contains(strings.TrimSpace(dept.DeptKey), keyword) ||
strings.Contains(fmt.Sprintf("%d", dept.DeptId), keyword)
}
// AllocateBatchPrepare 校验待划拨卡片,并写入划拨预处理批次。
func (s *sAppCard) AllocateBatchPrepare(ctx context.Context, param *model.AppCardAllocateBatchPrepareParam) (res *model.AppCardAllocateBatchModel, err error) {
if param == nil {
param = &model.AppCardAllocateBatchPrepareParam{}
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
batch, err := s.buildAllocateBatchContext(ctx, user, param.CardValues, param.TargetDeptId)
if err != nil {
return nil, err
}
ctx = appContextWithUser(ctx, user)
processor := newAppAllocateBatchProcessor(batch)
rows := appAllocateBatchRows(batch)
req := batchimport.SubmitRequest{
BizParams: param,
SourceType: consts.BatchSourceTypeBatchSelect,
BatchType: consts.BatchTypePrepare,
FailureBatchType: consts.BatchTypeAllocate,
SkipExecute: true,
TargetDeptId: batch.Target.DeptId,
Operator: batchimport.OperatorContext{
TenantId: user.TenantId,
DeptId: user.DeptId,
UserId: user.UserId,
},
}
result, err := batchimport.RunRows(ctx, req, processor, rows, "miniapp-allocate-prepare")
if err != nil {
return nil, gerror.Wrap(err, "写入划拨预处理批次失败")
}
status := "prepared"
batchNo := ""
if result != nil {
batchNo = result.BatchNo
}
if batch.FailCount > 0 || result == nil || result.ImportStatus != consts.ImportStatusSuccess {
status = "failed"
}
return s.buildAllocateBatchModel(ctx, user, batch, batchNo, "", consts.BatchTypePrepare, status), nil
}
// AllocateBatchSubmit 确认划拨预处理批次,并更新卡片归属;划拨后原企业的可见和办理权限仍保留。
func (s *sAppCard) AllocateBatchSubmit(ctx context.Context, param *model.AppCardAllocateBatchSubmitParam) (res *model.AppCardAllocateBatchModel, err error) {
if param == nil {
param = &model.AppCardAllocateBatchSubmitParam{}
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
if s.isTerminalUser(ctx, user) {
return nil, gerror.New("终端用户不能使用卡划拨功能")
}
now := gtime.Now()
var batch *appAllocateBatchContext
var sourceBatchNo string
batchNo := strings.TrimSpace(param.BatchNo)
err = dao.BatchMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
prepareBatch, err := s.lockAppAllocatePrepareBatch(ctx, tx, user, batchNo)
if err != nil {
return err
}
sourceBatchNo = appAllocateSourceBatchNoFromBatch(prepareBatch)
targetDeptId := prepareBatch.TargetDeptId
if param.TargetDeptId > 0 && param.TargetDeptId != targetDeptId {
return gerror.New("提交目标客户与预处理批次不一致,请重新校验")
}
target, err := s.loadAllocateTarget(ctx, user, targetDeptId)
if err != nil {
return err
}
details, err := s.loadAppAllocatePrepareBatchDetails(ctx, tx, prepareBatch.BatchNo)
if err != nil {
return err
}
if len(details) == 0 {
return gerror.New("划拨预处理批次没有可确认的卡")
}
cardValues := make([]string, 0, len(details))
for _, detail := range details {
cardValues = append(cardValues, strings.TrimSpace(detail.ItemId))
}
if len(param.CardValues) > 0 && !appSameStringSet(appNormalizeAllocateCardValues(param.CardValues), cardValues) {
return gerror.New("提交卡片与预处理批次不一致,请重新校验")
}
batch = &appAllocateBatchContext{
Target: target,
Cards: make([]*appAllocateCardContext, 0, len(details)),
}
for _, detail := range details {
card, err := s.lockAllocateCardForSubmit(ctx, tx, user, detail, target.DeptId)
if err != nil {
return err
}
sourceDeptId := card.DeptId
if detail.Snapshot != nil && detail.Snapshot.Get("sourceDeptId").Int64() > 0 {
sourceDeptId = detail.Snapshot.Get("sourceDeptId").Int64()
}
batch.Cards = append(batch.Cards, &appAllocateCardContext{
Card: card,
ItemId: card.Iccid,
RowNo: detail.RowNo,
SourceDeptId: sourceDeptId,
})
}
batch.refreshCounts()
if err = s.updateAllocateCards(ctx, tx, user, batch, prepareBatch.BatchNo, now); err != nil {
return err
}
if err = s.markAppAllocatePrepareBatchConfirmed(ctx, tx, user, prepareBatch, batch, sourceBatchNo, strings.TrimSpace(param.Remark), now); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
if batch == nil {
return nil, gerror.New("划拨提交结果不完整")
}
res = s.buildAllocateBatchModel(ctx, user, batch, batchNo, sourceBatchNo, consts.BatchTypeAllocate, "success")
_ = s.publishAppBusinessAsyncEvent(ctx, appBusinessAsyncEvent{
EventType: appAsyncEventAllocateCompleted,
BatchNo: batchNo,
BatchType: consts.BatchTypeAllocate,
TenantId: user.TenantId,
UserId: user.UserId,
DeptId: user.DeptId,
Trigger: "allocate_submit",
})
return res, nil
}
func (s *sAppCard) buildAllocateBatchContext(ctx context.Context, user *entity.SysUser, cardValues []string, targetDeptId int64) (*appAllocateBatchContext, error) {
if s.isTerminalUser(ctx, user) {
return nil, gerror.New("终端用户不能使用卡划拨功能")
}
if user.DeptId <= 0 {
return nil, gerror.New("当前账号未绑定企业,不能使用卡划拨功能")
}
values := appNormalizeAllocateCardValues(cardValues)
if len(values) == 0 {
return nil, gerror.New("请选择需要划拨的卡")
}
if len(values) > 100 {
return nil, gerror.New("单次批量划拨最多支持100张卡")
}
target, err := s.loadAllocateTarget(ctx, user, targetDeptId)
if err != nil {
return nil, err
}
batch := &appAllocateBatchContext{
Target: target,
Cards: make([]*appAllocateCardContext, 0, len(values)),
TotalCount: len(values),
}
seenIccid := make(map[string]struct{}, len(values))
for index, value := range values {
item := &appAllocateCardContext{
ItemId: strings.TrimSpace(value),
RowNo: index + 2,
}
card, err := s.findVisibleCard(ctx, user, value)
if err != nil {
return nil, err
}
if card == nil {
item.ErrorReason = fmt.Sprintf("第%d张卡不存在或无权划拨:%s", index+1, value)
batch.Cards = append(batch.Cards, item)
continue
}
item.Card = card
item.ItemId = strings.TrimSpace(card.Iccid)
item.SourceDeptId = card.DeptId
iccid := strings.TrimSpace(card.Iccid)
if _, exists := seenIccid[iccid]; exists {
item.ErrorReason = fmt.Sprintf("批量划拨卡重复:%s", iccid)
batch.Cards = append(batch.Cards, item)
continue
}
seenIccid[iccid] = struct{}{}
if err = s.ensureAllocateCardOwnedByOperator(ctx, nil, user, card); err != nil {
item.ErrorReason = err.Error()
} else if err = s.validateAllocateCardBusinessRules(ctx, nil, card); err != nil {
item.ErrorReason = err.Error()
} else if card.DeptId == target.DeptId {
item.ErrorReason = fmt.Sprintf("卡%s已属于目标客户,无需重复划拨", appFirstNotEmpty(card.Iccid, value))
}
batch.Cards = append(batch.Cards, item)
}
batch.refreshCounts()
return batch, nil
}
func (batch *appAllocateBatchContext) refreshCounts() {
if batch == nil {
return
}
batch.TotalCount = len(batch.Cards)
batch.ValidCount = 0
batch.FailCount = 0
batch.FailReason = ""
for _, item := range batch.Cards {
if item == nil {
batch.FailCount++
continue
}
if strings.TrimSpace(item.ErrorReason) != "" {
batch.FailCount++
continue
}
batch.ValidCount++
}
if batch.FailCount > 0 {
batch.FailReason = "存在校验失败数据,本次未写入"
}
}
func (item *appAllocateCardContext) allocateItemId() string {
if item == nil {
return ""
}
if item.Card != nil && strings.TrimSpace(item.Card.Iccid) != "" {
return strings.TrimSpace(item.Card.Iccid)
}
return strings.TrimSpace(item.ItemId)
}
func (s *sAppCard) insertAppAllocatePrepareBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appAllocateBatchContext, batchNo string, now *gtime.Time) error {
batch.refreshCounts()
batchStatus := consts.BatchAsyncStatusSuccess
runResult := consts.BatchRunResultSuccess
successCount := batch.ValidCount
if batch.FailCount > 0 {
batchStatus = consts.BatchAsyncStatusFailed
runResult = consts.BatchRunResultFailed
successCount = 0
}
_, err := dao.BatchMain.Ctx(ctx).TX(tx).Data(do.BatchMain{
BatchNo: batchNo,
BatchType: consts.BatchTypePrepare,
BatchStatus: batchStatus,
BatchAsyncStatus: batchStatus,
SourceType: consts.BatchSourceTypeBatchSelect,
SourceBatchNo: batchNo,
IsCrossDept: 0,
TargetDeptId: batch.Target.DeptId,
DeptId: user.DeptId,
TotalCount: batch.TotalCount,
ValidCount: batch.ValidCount,
SuccessCount: successCount,
FailCount: batch.FailCount,
RunResult: runResult,
FailReason: batch.FailReason,
BatchInfo: appAllocatePrepareBatchInfo(batch, batchNo),
StartTime: now,
EndTime: now,
TenantId: user.TenantId,
CreatedDept: user.DeptId,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Insert()
if err != nil {
return gerror.Wrap(err, "写入划拨预处理主表失败")
}
details := make([]do.BatchDetail, 0, len(batch.Cards))
for _, item := range batch.Cards {
detailStatus := consts.BatchDetailStatusSuccess
if strings.TrimSpace(item.ErrorReason) != "" {
detailStatus = consts.BatchDetailStatusFailed
}
details = append(details, do.BatchDetail{
BatchNo: batchNo,
BatchType: consts.BatchTypePrepare,
DetailStatus: detailStatus,
BlankOutStatus: consts.BlankOutStatusNo,
RowNo: item.RowNo,
ItemId: item.allocateItemId(),
ErrorReason: strings.TrimSpace(item.ErrorReason),
Snapshot: appAllocatePrepareDetailSnapshot(batch, item),
TenantId: user.TenantId,
CreatedDept: user.DeptId,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
})
}
if _, err = dao.BatchDetail.Ctx(ctx).TX(tx).Data(details).Insert(); err != nil {
return gerror.Wrap(err, "写入划拨预处理明细失败")
}
return nil
}
func (s *sAppCard) lockAppAllocatePrepareBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, batchNo string) (*entity.BatchMain, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return nil, gerror.New("预处理批次号不能为空")
}
var batch *entity.BatchMain
columns := dao.BatchMain.Columns()
err := dao.BatchMain.Ctx(ctx).TX(tx).
Where(columns.BatchNo, batchNo).
Where(columns.BatchType, consts.BatchTypePrepare).
Where(columns.TenantId, user.TenantId).
Where(columns.CreatedBy, user.UserId).
LockUpdate().
Limit(1).
Scan(&batch)
if err != nil {
return nil, gerror.Wrap(err, "查询划拨预处理批次失败")
}
if batch == nil || batch.Id == 0 {
return nil, gerror.New("划拨预处理批次不存在或无权确认")
}
if batch.BatchInfo == nil || batch.BatchInfo.Get("targetBatchType").Int() != consts.BatchTypeAllocate {
return nil, gerror.New("批次不是卡划拨预处理批次")
}
if batch.RunResult != consts.BatchRunResultSuccess || batch.FailCount > 0 {
return nil, gerror.New("划拨预处理批次存在校验失败数据,请重新校验后再提交")
}
return batch, nil
}
func (s *sAppCard) loadAppAllocatePrepareBatchDetails(ctx context.Context, tx gdb.TX, batchNo string) ([]*entity.BatchDetail, error) {
var details []*entity.BatchDetail
columns := dao.BatchDetail.Columns()
err := dao.BatchDetail.Ctx(ctx).TX(tx).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
Where(columns.BatchType, consts.BatchTypePrepare).
Where(columns.DetailStatus, consts.BatchDetailStatusSuccess).
Where(columns.BlankOutStatus, consts.BlankOutStatusNo).
OrderAsc(columns.RowNo).
Scan(&details)
if err != nil {
return nil, gerror.Wrap(err, "查询划拨预处理明细失败")
}
return details, nil
}
func (s *sAppCard) lockAllocateCardForSubmit(ctx context.Context, tx gdb.TX, user *entity.SysUser, detail *entity.BatchDetail, targetDeptId int64) (*appCardQueryRow, error) {
if detail == nil || strings.TrimSpace(detail.ItemId) == "" {
return nil, gerror.New("划拨批次明细缺少卡号")
}
expectedSourceDeptId := int64(0)
if detail.Snapshot != nil {
expectedSourceDeptId = detail.Snapshot.Get("sourceDeptId").Int64()
}
var card *appCardQueryRow
err := s.cardQueryBase(ctx, false).TX(tx).
Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().TenantId, user.TenantId).
Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Iccid, strings.TrimSpace(detail.ItemId)).
LockUpdate().
Limit(1).
Scan(&card)
if err != nil {
return nil, gerror.Wrapf(err, "锁定划拨卡%s失败", detail.ItemId)
}
if card == nil || card.Id == 0 {
return nil, gerror.Newf("卡%s不存在,不能划拨", detail.ItemId)
}
if err = s.attachCardChannelInfo(ctx, card); err != nil {
return nil, err
}
if expectedSourceDeptId > 0 && card.DeptId != expectedSourceDeptId {
return nil, gerror.Newf("卡%s归属已变化,请重新校验", card.Iccid)
}
if err = s.ensureAllocateCardVisible(ctx, tx, user, card); err != nil {
return nil, err
}
if err = s.ensureAllocateCardOwnedByOperator(ctx, tx, user, card); err != nil {
return nil, err
}
if err = s.validateAllocateCardBusinessRules(ctx, tx, card); err != nil {
return nil, err
}
if card.DeptId == targetDeptId {
return nil, gerror.Newf("卡%s已属于目标客户,无需重复划拨", card.Iccid)
}
return card, nil
}
func (s *sAppCard) validateAllocateCardBusinessRules(ctx context.Context, tx gdb.TX, card *appCardQueryRow) error {
if card == nil || strings.TrimSpace(card.Iccid) == "" {
return gerror.New("划拨卡信息不完整")
}
cardLabel := appFirstNotEmpty(card.Iccid, card.Msisdn, card.Imsi, card.Imei)
if card.BossCardStatus == 4 {
return gerror.Newf("卡%s当前为停机状态,不能划拨", cardLabel)
}
if card.PackageStatus == consts.PackageStatusExpired ||
(card.FlowPkgEndAt != nil && !card.FlowPkgEndAt.IsZero() && card.FlowPkgEndAt.Before(gtime.Now())) {
return gerror.Newf("卡%s套餐已过期,不能划拨", cardLabel)
}
return nil
}
func (s *sAppCard) updateAllocateCards(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appAllocateBatchContext, batchNo string, now *gtime.Time) error {
for _, item := range batch.Cards {
result, err := dao.TSimCardInfo.Ctx(ctx).TX(tx).
Where(dao.TSimCardInfo.Columns().Id, item.Card.Id).
Where(dao.TSimCardInfo.Columns().DeptId, item.Card.DeptId).
Data(do.TSimCardInfo{
DeptId: batch.Target.DeptId,
CreatedDept: user.DeptId,
OutboundBatchNo: batchNo,
AllocationBatchNo: batchNo,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return gerror.Wrapf(err, "更新卡%s归属失败", item.Card.Iccid)
}
if affected, err := result.RowsAffected(); err != nil {
return gerror.Wrap(err, "读取划拨更新数量失败")
} else if affected != 1 {
return gerror.Newf("卡%s归属已变化,请重新校验", item.Card.Iccid)
}
if item.SourceDeptId <= 0 {
item.SourceDeptId = item.Card.DeptId
}
item.Card.DeptId = batch.Target.DeptId
item.Card.CreatedDept = user.DeptId
item.Card.OutboundBatchNo = batchNo
item.Card.AllocationBatchNo = batchNo
}
return nil
}
func (s *sAppCard) markAppAllocatePrepareBatchConfirmed(ctx context.Context, tx gdb.TX, user *entity.SysUser, prepareBatch *entity.BatchMain, batch *appAllocateBatchContext, sourceBatchNo string, remark string, now *gtime.Time) error {
batchNo := strings.TrimSpace(prepareBatch.BatchNo)
for _, item := range batch.Cards {
_, err := dao.BatchDetail.Ctx(ctx).TX(tx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusSuccess).
Where(dao.BatchDetail.Columns().ItemId, item.Card.Iccid).
Data(do.BatchDetail{
BatchType: consts.BatchTypeAllocate,
Snapshot: appAllocateSubmitDetailSnapshot(batch, item, batchNo, sourceBatchNo),
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新划拨批次明细失败")
}
}
if _, err := dao.BatchDetail.Ctx(ctx).TX(tx).
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
Data(do.BatchDetail{
BatchType: consts.BatchTypeAllocate,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update(); err != nil {
return gerror.Wrap(err, "更新划拨批次明细类型失败")
}
logFiles, err := s.ensureAppAllocateBatchLogFiles(ctx, tx, user, batch, prepareBatch.BatchInfo, batchNo, sourceBatchNo, remark, now)
if err != nil {
return err
}
batchInfo := appAllocateBatchInfoWithLogFiles(
appAllocateSubmitBatchInfo(batch, batchNo, sourceBatchNo, remark, now),
logFiles,
)
_, err = dao.BatchMain.Ctx(ctx).TX(tx).
Where(dao.BatchMain.Columns().Id, prepareBatch.Id).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypePrepare).
Data(do.BatchMain{
BatchType: consts.BatchTypeAllocate,
BatchStatus: consts.BatchStatusSuccess,
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
TargetDeptId: batch.Target.DeptId,
TotalCount: len(batch.Cards),
ValidCount: len(batch.Cards),
SuccessCount: len(batch.Cards),
FailCount: 0,
RunResult: consts.BatchRunResultSuccess,
BatchInfo: batchInfo,
EndTime: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新划拨批次主表失败")
}
return nil
}
func (s *sAppCard) loadAllocateTarget(ctx context.Context, user *entity.SysUser, targetDeptId int64) (*entity.SysDept, error) {
if targetDeptId <= 0 {
return nil, gerror.New("请选择划拨目标客户")
}
target, err := s.loadDeptById(ctx, user.TenantId, targetDeptId)
if err != nil {
return nil, err
}
if target == nil || target.DeptId == 0 {
return nil, gerror.New("划拨目标客户不存在")
}
if target.DeptId == user.DeptId {
return nil, gerror.New("划拨目标不能选择当前企业")
}
if target.ParentId != user.DeptId {
return nil, gerror.New("划拨目标客户不在当前企业直属下级范围内")
}
visible, err := appEnterpriseDeptVisibleByDataScope(ctx, user, target.DeptId)
if err != nil {
return nil, err
}
if !visible {
return nil, gerror.New("划拨目标客户不在当前角色可操作范围内")
}
return target, nil
}
func (s *sAppCard) ensureAllocateCardVisible(ctx context.Context, tx gdb.TX, user *entity.SysUser, card *appCardQueryRow) error {
if card == nil || card.Id <= 0 || strings.TrimSpace(card.Iccid) == "" {
return gerror.New("划拨卡信息不完整")
}
var visible *appCardQueryRow
if err := s.visibleCardModel(ctx, user).TX(tx).
Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Id, card.Id).
Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Iccid, card.Iccid).
Limit(1).
Scan(&visible); err != nil {
return gerror.Wrapf(err, "校验卡%s可操作范围失败", card.Iccid)
}
if visible == nil || visible.Id == 0 {
return gerror.Newf("卡%s不在当前企业可操作范围内", card.Iccid)
}
return nil
}
func (s *sAppCard) ensureAllocateCardOwnedByOperator(ctx context.Context, tx gdb.TX, user *entity.SysUser, card *appCardQueryRow) error {
if user == nil || user.DeptId <= 0 {
return gerror.New("当前用户未绑定机构,不能划拨卡片")
}
if card == nil || strings.TrimSpace(card.Iccid) == "" {
return gerror.New("划拨卡信息不完整")
}
if card.DeptId != user.DeptId {
cardLabel := appFirstNotEmpty(card.Iccid, card.Msisdn, card.Imsi, card.Imei)
return gerror.Newf("卡%s当前归属不在本企业名下,划拨权已归当前归属客户", cardLabel)
}
return nil
}
func (s *sAppCard) loadDeptById(ctx context.Context, tenantId string, deptId int64) (*entity.SysDept, error) {
var dept *entity.SysDept
err := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, tenantId).
Where(dao.SysDept.Columns().DeptId, deptId).
WhereNull(dao.SysDept.Columns().DeletedAt).
Limit(1).
Scan(&dept)
if err != nil {
return nil, gerror.Wrap(err, "查询客户信息失败")
}
return dept, nil
}
func (s *sAppCard) buildAllocateBatchModel(ctx context.Context, user *entity.SysUser, batch *appAllocateBatchContext, batchNo string, sourceBatchNo string, batchType int, status string) *model.AppCardAllocateBatchModel {
batch.refreshCounts()
cards := make([]*model.AppCardModel, 0, len(batch.Cards))
details := make([]*model.AppCardAllocateBatchDetailModel, 0, len(batch.Cards))
for _, item := range batch.Cards {
if item == nil {
continue
}
if item.Card != nil && strings.TrimSpace(item.ErrorReason) == "" {
cards = append(cards, s.cardRowToModel(ctx, item.Card, user, appCardOwnerAgent, true))
}
details = append(details, &model.AppCardAllocateBatchDetailModel{
RowNo: item.RowNo,
ItemId: item.allocateItemId(),
Status: appAllocateDetailStatusText(item.ErrorReason),
ErrorReason: strings.TrimSpace(item.ErrorReason),
})
}
return &model.AppCardAllocateBatchModel{
BatchNo: strings.TrimSpace(batchNo),
SourceBatchNo: strings.TrimSpace(sourceBatchNo),
BatchType: batchType,
Quantity: batch.ValidCount,
TotalCount: batch.TotalCount,
ValidCount: batch.ValidCount,
FailCount: batch.FailCount,
FailReason: batch.FailReason,
Target: appAllocateTargetModel(batch.Target, user.DeptId),
Cards: cards,
Details: details,
Status: status,
}
}
func appAllocateTargetModel(dept *entity.SysDept, currentDeptId int64) *model.AppCardAllocateTargetModel {
if dept == nil {
return nil
}
label := strings.TrimSpace(dept.DeptName)
if strings.TrimSpace(dept.DeptKey) != "" {
label = fmt.Sprintf("%s(%s)", label, strings.TrimSpace(dept.DeptKey))
}
scope := "self"
scopeName := "当前机构"
if currentDeptId > 0 && dept.DeptId != currentDeptId && dept.ParentId == currentDeptId {
scope = "direct_child"
scopeName = "直属下级"
}
return &model.AppCardAllocateTargetModel{
DeptId: dept.DeptId,
ParentId: dept.ParentId,
DeptKey: strings.TrimSpace(dept.DeptKey),
DeptName: strings.TrimSpace(dept.DeptName),
DeptCategory: strings.TrimSpace(dept.DeptCategory),
TargetScope: scope,
TargetScopeName: scopeName,
Label: label,
}
}
func appAllocatePrepareBatchInfo(batch *appAllocateBatchContext, batchNo string) *gjson.Json {
return gjson.New(map[string]any{
"source": "app",
"batchNo": strings.TrimSpace(batchNo),
"sourceBatchNo": strings.TrimSpace(batchNo),
"targetBatchType": consts.BatchTypeAllocate,
"targetBatchName": "卡划拨",
"targetDeptId": batch.Target.DeptId,
"targetDeptName": batch.Target.DeptName,
"quantity": batch.ValidCount,
"totalCount": batch.TotalCount,
"validCount": batch.ValidCount,
"failCount": batch.FailCount,
"failReason": batch.FailReason,
"cards": appAllocateCardSnapshotList(batch),
})
}
func appAllocateSubmitBatchInfo(batch *appAllocateBatchContext, batchNo string, sourceBatchNo string, remark string, now *gtime.Time) *gjson.Json {
return gjson.New(map[string]any{
"source": "app",
"batchNo": strings.TrimSpace(batchNo),
"sourceBatchNo": strings.TrimSpace(sourceBatchNo),
"targetBatchType": consts.BatchTypeAllocate,
"targetBatchName": "卡划拨",
"targetDeptId": batch.Target.DeptId,
"targetDeptName": batch.Target.DeptName,
"quantity": len(batch.Cards),
"remark": strings.TrimSpace(remark),
"allocateTime": now,
"cards": appAllocateCardSnapshotList(batch),
})
}
func appAllocatePrepareDetailSnapshot(batch *appAllocateBatchContext, item *appAllocateCardContext) *gjson.Json {
if item == nil {
return gjson.New(map[string]any{})
}
card := item.Card
cardId := int64(0)
if card != nil {
cardId = card.Id
}
return gjson.New(map[string]any{
"ICCID": appAllocateSnapshotICCID(item),
"MSISDN": appAllocateSnapshotField(card, func(c *appCardQueryRow) string { return c.Msisdn }),
"IMSI": appAllocateSnapshotField(card, func(c *appCardQueryRow) string { return c.Imsi }),
"IMEI": appAllocateSnapshotField(card, func(c *appCardQueryRow) string { return c.Imei }),
"rowNo": item.RowNo,
"cardId": cardId,
"sourceDeptId": item.SourceDeptId,
"targetDeptId": batch.Target.DeptId,
"targetDeptName": batch.Target.DeptName,
"currentPackageCode": appCardCurrentPackageCode(card),
"packageName": appCardPackageName(card),
"errorReason": strings.TrimSpace(item.ErrorReason),
})
}
func appAllocateSnapshotICCID(item *appAllocateCardContext) string {
if item == nil {
return ""
}
if item.Card != nil && strings.TrimSpace(item.Card.Iccid) != "" {
return strings.TrimSpace(item.Card.Iccid)
}
return strings.TrimSpace(item.ItemId)
}
func appAllocateSnapshotField(card *appCardQueryRow, getter func(*appCardQueryRow) string) string {
if card == nil || getter == nil {
return ""
}
return strings.TrimSpace(getter(card))
}
func appAllocateDetailStatusText(reason string) string {
if strings.TrimSpace(reason) != "" {
return "failed"
}
return "success"
}
func appAllocateSubmitDetailSnapshot(batch *appAllocateBatchContext, item *appAllocateCardContext, batchNo string, sourceBatchNo string) *gjson.Json {
card := item.Card
return gjson.New(map[string]any{
"ICCID": card.Iccid,
"MSISDN": card.Msisdn,
"IMSI": card.Imsi,
"IMEI": card.Imei,
"rowNo": item.RowNo,
"cardId": card.Id,
"allocateBatchNo": strings.TrimSpace(batchNo),
"sourceBatchNo": strings.TrimSpace(sourceBatchNo),
"sourceDeptId": item.SourceDeptId,
"targetDeptId": batch.Target.DeptId,
"targetDeptName": batch.Target.DeptName,
"currentPackageCode": appCardCurrentPackageCode(card),
"packageName": appCardPackageName(card),
})
}
func appAllocateCardSnapshotList(batch *appAllocateBatchContext) []map[string]any {
cards := make([]map[string]any, 0, len(batch.Cards))
for _, item := range batch.Cards {
if item == nil {
continue
}
cardId := int64(0)
iccid := item.allocateItemId()
msisdn := ""
currentPackageCode := ""
if item.Card != nil {
cardId = item.Card.Id
msisdn = item.Card.Msisdn
currentPackageCode = appCardCurrentPackageCode(item.Card)
}
cards = append(cards, map[string]any{
"cardId": cardId,
"iccid": iccid,
"msisdn": msisdn,
"sourceDeptId": item.SourceDeptId,
"targetDeptId": batch.Target.DeptId,
"currentPackageCode": currentPackageCode,
"errorReason": strings.TrimSpace(item.ErrorReason),
})
}
return cards
}
func appAllocateSourceBatchNoFromBatch(batch *entity.BatchMain) string {
if batch == nil {
return ""
}
if batch.BatchInfo != nil {
if sourceBatchNo := strings.TrimSpace(batch.BatchInfo.Get("sourceBatchNo").String()); sourceBatchNo != "" {
return sourceBatchNo
}
}
return strings.TrimSpace(batch.SourceBatchNo)
}
func appNormalizeAllocateCardValues(values []string) []string {
seen := map[string]struct{}{}
result := make([]string, 0, len(values))
for _, raw := range values {
parts := strings.FieldsFunc(raw, func(r rune) bool {
switch r {
case ',', ',', ';', ';', '\n', '\r', '\t', ' ':
return true
default:
return false
}
})
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
}
return result
}
@@ -0,0 +1,166 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
)
type appAllocateBatchProcessor struct {
batch *appAllocateBatchContext
}
type appAllocateBatchRow struct {
rowNo int
itemID string
snapshot *gjson.Json
errorReasons []string
}
func newAppAllocateBatchProcessor(batch *appAllocateBatchContext) *appAllocateBatchProcessor {
return &appAllocateBatchProcessor{batch: batch}
}
func appAllocateBatchRows(batch *appAllocateBatchContext) []batchimport.Row {
if batch == nil {
return nil
}
rows := make([]batchimport.Row, 0, len(batch.Cards))
for _, item := range batch.Cards {
if item == nil {
continue
}
row := &appAllocateBatchRow{
rowNo: item.RowNo,
itemID: item.allocateItemId(),
snapshot: appAllocatePrepareDetailSnapshot(batch, item),
}
if reason := strings.TrimSpace(item.ErrorReason); reason != "" {
row.AddErrorReason(reason)
}
rows = append(rows, row)
}
return rows
}
func (p *appAllocateBatchProcessor) Config() batchimport.ProcessorConfig {
return batchimport.ProcessorConfig{
Code: "miniapp_card_allocate",
BatchType: consts.BatchTypeAllocate,
BatchNoPrefix: consts.BatchNoPrefixAllocate,
TaskName: consts.ResourceBatchDownloadTaskSimCardAllocate,
ExportScene: consts.ResourceExportSceneAllocate,
ImportDir: consts.SimCardAllocateSubDirImportExcel,
ResultDir: consts.SimCardAllocateSubDirResultExcel,
MaxFileSize: 50 << 20,
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI"},
RequiredHeaders: []string{"ICCID"},
}
}
func (p *appAllocateBatchProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
return nil, gerror.New("小程序划拨暂不支持文件导入")
}
func (p *appAllocateBatchProcessor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
seen := make(map[string]batchimport.Row, len(rows))
for _, row := range rows {
if row == nil {
continue
}
itemID := strings.TrimSpace(row.GetItemID())
if itemID == "" {
row.AddErrorReason("ICCID不能为空")
continue
}
if first, ok := seen[itemID]; ok {
row.AddErrorReason("ICCID重复")
first.AddErrorReason("ICCID重复")
continue
}
seen[itemID] = row
}
return nil
}
func (p *appAllocateBatchProcessor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
return gerror.New("小程序划拨批次确认由提交接口触发")
}
func (p *appAllocateBatchProcessor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
info := map[string]any{}
if p.batch != nil {
if base := appAllocatePrepareBatchInfo(p.batch, req.BatchNo); base != nil {
for key, value := range base.Map() {
info[key] = value
}
}
}
if sourceBatchNo := strings.TrimSpace(req.SourceBatchNo); sourceBatchNo != "" {
info["sourceBatchNo"] = sourceBatchNo
}
if importOss != nil && importOss.OssId > 0 {
info["importFileOssId"] = importOss.OssId
}
if resultOss != nil && resultOss.OssId > 0 {
info["resultFileOssId"] = resultOss.OssId
}
if downloadId > 0 {
info["resultDownloadId"] = downloadId
}
if result != nil && strings.TrimSpace(result.FailReason) != "" {
info["failReason"] = strings.TrimSpace(result.FailReason)
}
return gjson.New(info)
}
func (r *appAllocateBatchRow) GetRowNo() int {
if r == nil {
return 0
}
return r.rowNo
}
func (r *appAllocateBatchRow) GetItemID() string {
if r == nil {
return ""
}
return strings.TrimSpace(r.itemID)
}
func (r *appAllocateBatchRow) GetErrorReasons() []string {
if r == nil {
return nil
}
return r.errorReasons
}
func (r *appAllocateBatchRow) AddErrorReason(reason string) {
if r == nil {
return
}
reason = strings.TrimSpace(reason)
if reason == "" {
return
}
for _, item := range r.errorReasons {
if item == reason {
return
}
}
r.errorReasons = append(r.errorReasons, reason)
}
func (r *appAllocateBatchRow) Snapshot() *gjson.Json {
if r == nil || r.snapshot == nil {
return gjson.New(map[string]any{})
}
return r.snapshot
}
@@ -0,0 +1,468 @@
package app
import (
"context"
"fmt"
"os"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-app/utility/hash"
"github.com/gogf/gf/v2/crypto/gmd5"
"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"
"github.com/xuri/excelize/v2"
)
type appAllocateBatchLogFiles struct {
ImportOssId int64
ImportFileName string
ResultOssId int64
DownloadId int64
}
func (s *sAppCard) ensureAppAllocateBatchLogFiles(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appAllocateBatchContext, existingInfo *gjson.Json, batchNo string, sourceBatchNo string, remark string, now *gtime.Time) (*appAllocateBatchLogFiles, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return nil, gerror.New("划拨批次号不能为空")
}
if user == nil {
return nil, gerror.New("当前用户不存在")
}
if batch == nil || batch.Target == nil {
return nil, gerror.New("划拨批次日志文件信息不完整")
}
files := appAllocateBatchLogFilesFromInfo(existingInfo)
existingDownload, err := appAllocateBatchResultDownload(ctx, tx, batchNo)
if err != nil {
return nil, err
}
if existingDownload != nil {
files.ResultOssId = existingDownload.ResultOssId
files.DownloadId = existingDownload.DownloadId
}
if files.ImportOssId <= 0 {
importContent, err := appBuildAllocateBatchImportExcel(batch, sourceBatchNo, remark)
if err != nil {
return nil, gerror.Wrap(err, "生成划拨批次源文件失败")
}
importUpload, importOssId, err := appSaveAllocateBatchOss(ctx, tx, user, user.DeptId, importContent, batchNo+"-import.xlsx", consts.SimCardAllocateSubDirImportExcel, now)
if err != nil {
return nil, gerror.Wrap(err, "保存划拨批次源文件失败")
}
files.ImportOssId = importOssId
files.ImportFileName = strings.TrimSpace(importUpload.Name)
}
if strings.TrimSpace(files.ImportFileName) == "" {
files.ImportFileName = appAllocateBatchImportFileName(ctx, tx, files.ImportOssId, batchNo+"-import.xlsx")
}
if existingDownload != nil {
return files, nil
}
resultContent, err := appBuildAllocateBatchResultExcel(batch, files, batchNo, sourceBatchNo, now, user.UserId, 0)
if err != nil {
return nil, gerror.Wrap(err, "生成划拨批次结果文件失败")
}
resultUpload, resultOssId, err := appSaveAllocateBatchOss(ctx, tx, user, user.DeptId, resultContent, batchNo+"-result.xlsx", consts.SimCardAllocateSubDirResultExcel, now)
if err != nil {
return nil, gerror.Wrap(err, "保存划拨批次结果文件失败")
}
files.ResultOssId = resultOssId
if err = appFillAllocateResultOssId(ctx, tx, resultOssId, resultUpload.Path, user.UserId, now); err != nil {
return nil, err
}
refreshedResult, err := appLoadAllocateOssUpload(ctx, tx, resultOssId, resultUpload)
if err != nil {
return nil, err
}
quantity := len(batch.Cards)
downloadId, err := dao.TResourceBatchDownload.Ctx(ctx).TX(tx).Data(do.TResourceBatchDownload{
BatchNo: batchNo,
TaskName: consts.ResourceBatchDownloadTaskSimCardAllocate,
ResourceType: consts.ResourceTypeExcel,
ExportScene: consts.ResourceExportSceneAllocate,
TotalCount: quantity,
SuccessCount: quantity,
FailCount: 0,
DownloadStatus: consts.DownloadStatusGenerated,
OssId: resultOssId,
FileName: refreshedResult.Name,
FileExt: refreshedResult.Ext,
FileUrl: refreshedResult.FullPath,
FilePath: refreshedResult.Path,
FileSize: refreshedResult.Size,
FileMd5: refreshedResult.Md5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: user.DeptId,
StartTime: now,
FinishTime: now,
TenantId: user.TenantId,
CreatedDept: user.DeptId,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).InsertAndGetId()
if err != nil {
return nil, gerror.Wrap(err, "写入划拨批次结果下载记录失败")
}
files.DownloadId = downloadId
return files, nil
}
func appAllocateBatchLogFilesFromInfo(info *gjson.Json) *appAllocateBatchLogFiles {
files := &appAllocateBatchLogFiles{}
if info == nil {
return files
}
files.ImportOssId = info.Get("importFileOssId").Int64()
files.ResultOssId = info.Get("resultFileOssId").Int64()
files.DownloadId = info.Get("resultDownloadId").Int64()
return files
}
func appAllocateBatchResultDownload(ctx context.Context, tx gdb.TX, batchNo string) (*appAllocateBatchLogFiles, error) {
var downloads []*entity.TResourceBatchDownload
columns := dao.TResourceBatchDownload.Columns()
err := dao.TResourceBatchDownload.Ctx(ctx).TX(tx).
Fields(columns.Id, columns.OssId).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
OrderDesc(columns.Id).
Limit(1).
Scan(&downloads)
if err != nil {
return nil, gerror.Wrap(err, "查询划拨批次结果下载记录失败")
}
if len(downloads) == 0 || downloads[0] == nil || downloads[0].Id == 0 {
return nil, nil
}
return &appAllocateBatchLogFiles{
DownloadId: downloads[0].Id,
ResultOssId: downloads[0].OssId,
}, nil
}
func appAllocateBatchInfoWithLogFiles(info *gjson.Json, files *appAllocateBatchLogFiles) *gjson.Json {
data := map[string]any{}
if info != nil {
for key, value := range info.Map() {
data[key] = value
}
}
if files != nil {
if files.ImportOssId > 0 {
data["importFileOssId"] = files.ImportOssId
}
if files.ResultOssId > 0 {
data["resultFileOssId"] = files.ResultOssId
}
if files.DownloadId > 0 {
data["resultDownloadId"] = files.DownloadId
}
}
return gjson.New(data)
}
func appBuildAllocateBatchImportExcel(batch *appAllocateBatchContext, sourceBatchNo string, remark string) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
sheet := "Sheet1"
headers := []string{"ICCID", "MSISDN", "IMSI", "sourceBatchNo", "sourceDeptId", "targetDeptId", "targetDeptName", "remark"}
if err := f.SetSheetRow(sheet, "A1", &headers); err != nil {
return nil, err
}
if err := appApplyHeaderColumnsTextStyle(f, sheet); err != nil {
return nil, err
}
for index, item := range batch.Cards {
row := []interface{}{
strings.TrimSpace(item.Card.Iccid),
strings.TrimSpace(item.Card.Msisdn),
strings.TrimSpace(item.Card.Imsi),
strings.TrimSpace(sourceBatchNo),
item.SourceDeptId,
batch.Target.DeptId,
strings.TrimSpace(batch.Target.DeptName),
strings.TrimSpace(remark),
}
cell, err := excelize.CoordinatesToCellName(1, index+2)
if err != nil {
return nil, err
}
if err = f.SetSheetRow(sheet, cell, &row); err != nil {
return nil, err
}
}
_ = f.SetColWidth(sheet, "A", "A", 24)
_ = f.SetColWidth(sheet, "B", "C", 18)
_ = f.SetColWidth(sheet, "D", "H", 18)
return appExcelBytes(f)
}
func appBuildAllocateBatchResultExcel(batch *appAllocateBatchContext, files *appAllocateBatchLogFiles, batchNo string, sourceBatchNo string, now *gtime.Time, userId int64, resultOssId int64) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
leftStyleID, err := f.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
},
})
if err != nil {
return nil, err
}
resultSheet := "导入结果"
if err = f.SetSheetName("Sheet1", resultSheet); err != nil {
return nil, err
}
headers := []string{"源文件行号", "item_id", "处理状态", "异常原因"}
if err = f.SetSheetRow(resultSheet, "A1", &headers); err != nil {
return nil, err
}
for index, item := range batch.Cards {
row := []interface{}{
item.RowNo,
strings.TrimSpace(item.Card.Iccid),
"成功",
"",
}
cell, err := excelize.CoordinatesToCellName(1, index+2)
if err != nil {
return nil, err
}
if err = f.SetSheetRow(resultSheet, cell, &row); err != nil {
return nil, err
}
}
resultEndCell, err := excelize.CoordinatesToCellName(4, len(batch.Cards)+1)
if err != nil {
return nil, err
}
if err = f.SetCellStyle(resultSheet, "A1", resultEndCell, leftStyleID); err != nil {
return nil, err
}
infoSheet := "批次信息"
if _, err = f.NewSheet(infoSheet); err != nil {
return nil, err
}
infoHeaders := []string{"字段", "值"}
if err = f.SetSheetRow(infoSheet, "A1", &infoHeaders); err != nil {
return nil, err
}
infoRows := appAllocateBatchResultInfoRows(batch, files, batchNo, sourceBatchNo, now, userId, resultOssId)
for index, item := range infoRows {
row := []interface{}{item[0], item[1]}
cell, err := excelize.CoordinatesToCellName(1, index+2)
if err != nil {
return nil, err
}
if err = f.SetSheetRow(infoSheet, cell, &row); err != nil {
return nil, err
}
}
infoEndCell, err := excelize.CoordinatesToCellName(2, len(infoRows)+1)
if err != nil {
return nil, err
}
if err = f.SetCellStyle(infoSheet, "A1", infoEndCell, leftStyleID); err != nil {
return nil, err
}
_ = f.SetColWidth(resultSheet, "A", "A", 14)
_ = f.SetColWidth(resultSheet, "B", "B", 24)
_ = f.SetColWidth(resultSheet, "C", "C", 12)
_ = f.SetColWidth(resultSheet, "D", "D", 40)
_ = f.SetColWidth(infoSheet, "A", "B", 24)
f.SetActiveSheet(0)
return appExcelBytes(f)
}
func appAllocateBatchResultInfoRows(batch *appAllocateBatchContext, files *appAllocateBatchLogFiles, batchNo string, sourceBatchNo string, now *gtime.Time, userId int64, resultOssId int64) [][2]string {
importFileName := ""
importOssId := ""
if files != nil {
importFileName = strings.TrimSpace(files.ImportFileName)
if files.ImportOssId > 0 {
importOssId = fmt.Sprintf("%d", files.ImportOssId)
}
}
if importFileName == "" {
importFileName = strings.TrimSpace(batchNo) + "-import.xlsx"
}
resultFileOssId := ""
if resultOssId > 0 {
resultFileOssId = fmt.Sprintf("%d", resultOssId)
}
return [][2]string{
{"批次号", strings.TrimSpace(batchNo)},
{"批次类型", "卡划拨"},
{"处理结果", "成功"},
{"总行数", fmt.Sprintf("%d", len(batch.Cards))},
{"校验通过行数", fmt.Sprintf("%d", len(batch.Cards))},
{"失败行数", "0"},
{"实际写入行数", fmt.Sprintf("%d", len(batch.Cards))},
{"源批次号", strings.TrimSpace(sourceBatchNo)},
{"目标机构", fmt.Sprintf("%d %s", batch.Target.DeptId, strings.TrimSpace(batch.Target.DeptName))},
{"原始文件名", importFileName},
{"原始文件OSS ID", importOssId},
{"结果文件OSS ID", resultFileOssId},
{"操作人", fmt.Sprintf("%d", userId)},
{"开始时间", appRenewFormatTime(now)},
{"结束时间", appRenewFormatTime(now)},
{"备注", "导入成功"},
}
}
func appSaveAllocateBatchOss(ctx context.Context, tx gdb.TX, user *entity.SysUser, deptId int64, content []byte, fileName string, subDirName string, now *gtime.Time) (model.UploadResponse, int64, error) {
upload, err := service.SysOss().SaveContentLocal(ctx, content, consts.SysUploadNewFileTypeUserFile, fileName, subDirName, 0)
if err != nil {
return upload, 0, err
}
ossId, err := dao.SysOss.Ctx(ctx).TX(tx).Data(do.SysOss{
TenantId: user.TenantId,
FileName: upload.Name,
OriginalName: upload.OriginalName,
FileSuffix: upload.Ext,
Path: upload.Path,
Url: upload.FullPath,
CreatedDept: deptId,
CreatedAt: now,
CreatedBy: user.UserId,
UpdatedAt: now,
UpdatedBy: user.UserId,
Service: "local",
Md5: upload.Md5,
FileSize: upload.Size,
FileCrc16: upload.Crc16,
FileSum: upload.Sum16,
}).InsertAndGetId()
if err != nil {
return upload, 0, gerror.Wrap(err, "写入划拨批次OSS记录失败")
}
return upload, ossId, nil
}
func appFillAllocateResultOssId(ctx context.Context, tx gdb.TX, ossId int64, filePath string, userId int64, now *gtime.Time) error {
if ossId <= 0 || strings.TrimSpace(filePath) == "" {
return gerror.New("划拨结果文件OSS信息不完整")
}
f, err := excelize.OpenFile(filePath)
if err != nil {
return gerror.Wrap(err, "打开划拨结果文件失败")
}
defer f.Close()
rows, err := f.GetRows("批次信息")
if err != nil {
return gerror.Wrap(err, "读取划拨结果批次信息失败")
}
found := false
for index, row := range rows {
if len(row) == 0 || strings.TrimSpace(row[0]) != "结果文件OSS ID" {
continue
}
cell, err := excelize.CoordinatesToCellName(2, index+1)
if err != nil {
return err
}
if err = f.SetCellStr("批次信息", cell, fmt.Sprintf("%d", ossId)); err != nil {
return err
}
found = true
break
}
if !found {
return gerror.New("划拨结果文件缺少结果文件OSS ID字段")
}
if err = f.SaveAs(filePath); err != nil {
return gerror.Wrap(err, "回写划拨结果文件OSS ID失败")
}
return appRefreshAllocateOssFileInfo(ctx, tx, ossId, filePath, userId, now)
}
func appRefreshAllocateOssFileInfo(ctx context.Context, tx gdb.TX, ossId int64, filePath string, userId int64, now *gtime.Time) error {
fileInfo, err := os.Stat(filePath)
if err != nil {
return gerror.Wrap(err, "读取划拨结果文件信息失败")
}
md5, _ := gmd5.EncryptFile(filePath)
crc16 := hash.Crc16ChecksumFile(filePath)
sum16 := hash.Sum16ChecksumFile(filePath)
_, err = dao.SysOss.Ctx(ctx).TX(tx).
Where(dao.SysOss.Columns().OssId, ossId).
Data(do.SysOss{
Md5: md5,
FileSize: fileInfo.Size(),
FileCrc16: crc16,
FileSum: sum16,
UpdatedAt: now,
UpdatedBy: userId,
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新划拨结果文件信息失败")
}
return nil
}
func appLoadAllocateOssUpload(ctx context.Context, tx gdb.TX, ossId int64, fallback model.UploadResponse) (model.UploadResponse, error) {
if ossId <= 0 {
return fallback, nil
}
var oss *entity.SysOss
err := dao.SysOss.Ctx(ctx).TX(tx).
Where(dao.SysOss.Columns().OssId, ossId).
Limit(1).
Scan(&oss)
if err != nil {
return fallback, gerror.Wrap(err, "读取划拨结果文件OSS记录失败")
}
if oss == nil || oss.OssId == 0 {
return fallback, nil
}
fallback.Name = oss.FileName
fallback.OriginalName = oss.OriginalName
fallback.Ext = oss.FileSuffix
fallback.Path = oss.Path
fallback.FullPath = oss.Url
fallback.Size = int64(oss.FileSize)
fallback.Md5 = oss.Md5
return fallback, nil
}
func appAllocateBatchImportFileName(ctx context.Context, tx gdb.TX, ossId int64, sourceName string) string {
sourceName = strings.TrimSpace(sourceName)
if ossId <= 0 {
return sourceName
}
var oss *entity.SysOss
if err := dao.SysOss.Ctx(ctx).TX(tx).
Fields(dao.SysOss.Columns().FileName).
Where(dao.SysOss.Columns().OssId, ossId).
Limit(1).
Scan(&oss); err != nil {
return sourceName
}
if oss != nil && strings.TrimSpace(oss.FileName) != "" {
return strings.TrimSpace(oss.FileName)
}
return sourceName
}
+187
View File
@@ -0,0 +1,187 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
// 只处理终端用户绑卡。企业用户的持卡范围由管理平台权限逻辑决定,不写终端卡关联表。
type appSimTerminalBindRow struct {
Id int64 `orm:"id"`
TerminalId int64 `orm:"terminal_id"`
DeptId int64 `orm:"dept_id"`
CurrentPackageCode string `orm:"current_package_code"`
}
func (s *sAppCard) Bind(ctx context.Context, param *model.AppCardBindParam) (res *model.AppCardModel, err error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
if !s.isTerminalUser(ctx, user) {
return nil, gerror.New("企业用户无需绑定卡,请在我的卡片中查询当前可见卡")
}
card, err := s.findCardByValue(ctx, user.TenantId, param.CardValue)
if err != nil {
return nil, err
}
if card == nil {
return nil, gerror.New("卡信息不存在,请确认查询号码是否正确")
}
now := gtime.Now()
alreadyBound := false
err = dao.TSimTerminal.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
terminal, err := s.ensureTerminal(ctx, tx, user, now)
if err != nil {
return err
}
var bind *appSimTerminalBindRow
err = dao.TSimTerminal.Ctx(ctx).TX(tx).
Where(dao.TSimTerminal.Columns().TenantId, user.TenantId).
Where(dao.TSimTerminal.Columns().Iccid, card.Iccid).
Where(dao.TSimTerminal.Columns().TerminalId, user.UserId).
Scan(&bind)
if err != nil {
return err
}
if bind != nil {
alreadyBound = true
if bind.DeptId != card.DeptId || strings.TrimSpace(bind.CurrentPackageCode) != strings.TrimSpace(card.CurrentPackageCode) {
_, err = dao.TSimTerminal.Ctx(ctx).TX(tx).
Where(dao.TSimTerminal.Columns().Id, bind.Id).
Data(do.TSimTerminal{
DeptId: card.DeptId,
CurrentPackageCode: strings.TrimSpace(card.CurrentPackageCode),
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
}
return err
}
_, err = dao.TSimTerminal.Ctx(ctx).TX(tx).Data(do.TSimTerminal{
Iccid: card.Iccid,
Msisdn: card.Msisdn,
Imsi: card.Imsi,
TerminalId: user.UserId,
TerminalName: terminal.TerminalName,
DeptId: card.DeptId,
CurrentPackageCode: strings.TrimSpace(card.CurrentPackageCode),
Remark: param.Remark,
TenantId: user.TenantId,
CreatedDept: appUserDeptOrDefault(user),
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Insert()
return err
})
if err != nil {
if isOldSimTerminalUniqueKeyError(err) {
return nil, gerror.New("终端卡关联表唯一索引未更新,当前数据库仍限制一个终端用户只能绑定一张卡")
}
if isSimTerminalCardUniqueKeyError(err) {
return nil, gerror.New("该卡已绑定当前用户,请刷新后重试")
}
return nil, err
}
if alreadyBound {
return nil, gerror.New("该卡已绑定当前用户")
}
cardRow, err := s.findVisibleCard(ctx, user, card.Iccid)
if err != nil {
return nil, err
}
if cardRow == nil {
return nil, gerror.New("绑卡成功,但刷新卡信息失败")
}
return s.cardRowToModel(ctx, cardRow, user, appCardOwnerTerminal, true), nil
}
func (s *sAppCard) ensureTerminal(ctx context.Context, tx gdb.TX, user *entity.SysUser, now *gtime.Time) (*entity.TTerminal, error) {
var terminal *entity.TTerminal
err := dao.TTerminal.Ctx(ctx).TX(tx).
Where(dao.TTerminal.Columns().TerminalId, user.UserId).
Scan(&terminal)
if err != nil {
return nil, err
}
if terminal != nil {
if strings.TrimSpace(terminal.TerminalName) == "" {
terminal.TerminalName = user.NickName
}
return terminal, nil
}
name := user.NickName
if name == "" {
name = user.UserName
}
_, err = dao.TTerminal.Ctx(ctx).TX(tx).Data(do.TTerminal{
TerminalId: user.UserId,
TerminalName: name,
Phone: user.Phonenumber,
Status: consts.SysUserStatusNormal,
FullName: name,
LegalPersonName: name,
LegalPersonIdCard: "000000000000000000",
Balance: 0,
TenantId: user.TenantId,
CreatedDept: appUserDeptOrDefault(user),
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Insert()
if err != nil {
return nil, err
}
return &entity.TTerminal{
TerminalId: user.UserId,
TerminalName: name,
Phone: user.Phonenumber,
TenantId: user.TenantId,
CreatedDept: appUserDeptOrDefault(user),
}, nil
}
func (s *sAppCard) isCardBoundToUser(ctx context.Context, iccid string, user *entity.SysUser) bool {
count, err := dao.TSimTerminal.Ctx(ctx).
Where(dao.TSimTerminal.Columns().TenantId, user.TenantId).
Where(dao.TSimTerminal.Columns().TerminalId, user.UserId).
Where(dao.TSimTerminal.Columns().Iccid, iccid).
Count()
return err == nil && count > 0
}
func isOldSimTerminalUniqueKeyError(err error) bool {
if err == nil {
return false
}
errText := err.Error()
return strings.Contains(errText, "Duplicate entry") &&
strings.Contains(errText, "uk_package_terminal")
}
func isSimTerminalCardUniqueKeyError(err error) bool {
if err == nil {
return false
}
errText := err.Error()
return strings.Contains(errText, "Duplicate entry") &&
strings.Contains(errText, "uk_sim_terminal_card")
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,282 @@
package app
import (
"context"
"strconv"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/errors/gerror"
)
type appCardChannelInfo struct {
ChanKey string
ChanName string
ChanAlias string
TempKey string
}
type appCardChannelAssocRecord struct {
Id int64 `orm:"id"`
ChannelID string `orm:"channel_id"`
MatchType int `orm:"match_type"`
Iccid string `orm:"iccid"`
IccidStart string `orm:"iccid_start"`
IccidEnd string `orm:"iccid_end"`
Status int `orm:"status"`
}
func (s *sAppCard) attachCardChannelInfo(ctx context.Context, row *appCardQueryRow) error {
if row == nil {
return nil
}
info, err := s.loadCardChannelInfo(ctx, row.TenantId, row.Iccid, row.ChanKey)
if err != nil {
return err
}
if info == nil {
return nil
}
row.ChanKey = info.ChanKey
row.ChanName = info.ChanName
row.ChanAlias = info.ChanAlias
row.TempKey = info.TempKey
return nil
}
func (s *sAppCard) attachDiagnosisCardChannelInfo(ctx context.Context, row *appCardQueryRow) error {
if row == nil {
return nil
}
info, err := s.loadDiagnosisCardChannelInfo(ctx, row.TenantId, row.Iccid, row.ChanKey)
if err != nil {
return err
}
if info == nil {
return nil
}
row.ChanKey = info.ChanKey
row.ChanName = info.ChanName
row.ChanAlias = info.ChanAlias
row.TempKey = info.TempKey
return nil
}
func (s *sAppCard) attachCardChannelInfoRows(ctx context.Context, rows []*appCardQueryRow) error {
if len(rows) == 0 {
return nil
}
cache := make(map[string]*appCardChannelInfo, len(rows))
for _, row := range rows {
if row == nil {
continue
}
cacheKey := strings.Join([]string{
strings.TrimSpace(row.TenantId),
strings.TrimSpace(row.Iccid),
strings.TrimSpace(row.ChanKey),
}, "|")
info, ok := cache[cacheKey]
if !ok {
var err error
info, err = s.loadCardChannelInfo(ctx, row.TenantId, row.Iccid, row.ChanKey)
if err != nil {
return err
}
cache[cacheKey] = info
}
if info == nil {
continue
}
row.ChanKey = info.ChanKey
row.ChanName = info.ChanName
row.ChanAlias = info.ChanAlias
row.TempKey = info.TempKey
}
return nil
}
func (s *sAppCard) loadCardChannelInfo(ctx context.Context, tenantId string, iccid string, legacyChanKey string) (*appCardChannelInfo, error) {
tenantId = strings.TrimSpace(tenantId)
legacyChanKey = strings.TrimSpace(legacyChanKey)
if legacyChanKey == "" {
return nil, nil
}
return s.findLegacyCardChannelInfo(ctx, tenantId, legacyChanKey)
}
func (s *sAppCard) loadDiagnosisCardChannelInfo(ctx context.Context, tenantId string, iccid string, legacyChanKey string) (*appCardChannelInfo, error) {
iccid = strings.TrimSpace(iccid)
tenantId = strings.TrimSpace(tenantId)
legacyChanKey = strings.TrimSpace(legacyChanKey)
if iccid != "" {
info, err := s.findAssocCardChannelInfo(ctx, tenantId, iccid)
if err != nil {
return nil, err
}
if info != nil {
return info, nil
}
}
if legacyChanKey == "" {
return nil, nil
}
return s.findLegacyCardChannelInfo(ctx, tenantId, legacyChanKey)
}
func (s *sAppCard) findAssocCardChannelInfo(ctx context.Context, tenantId string, iccid string) (*appCardChannelInfo, error) {
if strings.TrimSpace(iccid) == "" {
return nil, nil
}
assoc, err := s.resolveCardChannelAssocByIccid(ctx, iccid)
if err != nil {
return nil, err
}
if assoc == nil || strings.TrimSpace(assoc.ChannelID) == "" {
return nil, nil
}
info, err := s.findCardChannelInfoByRef(ctx, tenantId, assoc.ChannelID)
if err != nil {
return nil, err
}
if info == nil || strings.TrimSpace(info.ChanKey) == "" {
return nil, gerror.New("卡通道关联已匹配,但未找到对应通道配置")
}
return info, nil
}
func (s *sAppCard) resolveCardChannelAssocByIccid(ctx context.Context, iccid string) (*appCardChannelAssocRecord, error) {
iccid = strings.TrimSpace(iccid)
if iccid == "" {
return nil, nil
}
columns := dao.CardChannelAssoc.Columns()
var exact *appCardChannelAssocRecord
err := dao.CardChannelAssoc.Ctx(ctx).
Fields(columns.Id, columns.ChannelId, columns.MatchType, columns.Iccid, columns.IccidStart, columns.IccidEnd, columns.Status).
Where(columns.Status, 1).
Where(columns.MatchType, 1).
Where(columns.Iccid+" COLLATE utf8mb4_general_ci = ?", iccid).
OrderDesc(columns.Id).
Limit(1).
Scan(&exact)
if err != nil {
return nil, gerror.Wrap(err, "查询卡通道单卡关联失败")
}
if exact != nil && strings.TrimSpace(exact.ChannelID) != "" {
return exact, nil
}
var ranged *appCardChannelAssocRecord
err = dao.CardChannelAssoc.Ctx(ctx).
Fields(columns.Id, columns.ChannelId, columns.MatchType, columns.Iccid, columns.IccidStart, columns.IccidEnd, columns.Status).
Where(columns.Status, 1).
Where(columns.MatchType, 2).
Where(columns.IccidStart+" COLLATE utf8mb4_general_ci <= ? AND "+columns.IccidEnd+" COLLATE utf8mb4_general_ci >= ?", iccid, iccid).
OrderDesc(columns.Id).
Limit(1).
Scan(&ranged)
if err != nil {
return nil, gerror.Wrap(err, "查询卡通道号段关联失败")
}
if ranged != nil && strings.TrimSpace(ranged.ChannelID) != "" {
return ranged, nil
}
return nil, nil
}
func (s *sAppCard) findCardChannelInfoByRef(ctx context.Context, tenantId string, channelRef string) (*appCardChannelInfo, error) {
channelRef = strings.TrimSpace(channelRef)
if channelRef == "" {
return nil, nil
}
if id, err := strconv.ParseInt(channelRef, 10, 64); err == nil && id > 0 {
if info, err := s.findCardChannelInfoByID(ctx, tenantId, id); err != nil || info != nil {
return info, err
}
}
return s.findLegacyCardChannelInfo(ctx, tenantId, channelRef)
}
func (s *sAppCard) findCardChannelInfoByID(ctx context.Context, tenantId string, channelID int64) (*appCardChannelInfo, error) {
if channelID <= 0 {
return nil, nil
}
if tenantId = strings.TrimSpace(tenantId); tenantId == "" {
tenantId = contexts.GetTenantId(ctx)
}
if tenantId != "" {
channel, err := s.findCardChannelEntityByID(ctx, tenantId, channelID)
if err != nil || channel != nil {
return appCardChannelInfoFromEntity(channel), err
}
}
channel, err := s.findCardChannelEntityByID(ctx, "", channelID)
return appCardChannelInfoFromEntity(channel), err
}
func (s *sAppCard) findCardChannelEntityByID(ctx context.Context, tenantId string, channelID int64) (*entity.TSimChannels, error) {
var channel *entity.TSimChannels
mod := dao.TSimChannels.Ctx(ctx).
Where(dao.TSimChannels.Columns().Id, channelID)
if tenantId = strings.TrimSpace(tenantId); tenantId != "" {
mod = mod.Where(dao.TSimChannels.Columns().TenantId, tenantId)
}
err := mod.Limit(1).Scan(&channel)
if err != nil {
return nil, gerror.Wrap(err, "查询通道信息失败")
}
return channel, nil
}
func (s *sAppCard) findLegacyCardChannelInfo(ctx context.Context, tenantId string, chanKey string) (*appCardChannelInfo, error) {
chanKey = strings.TrimSpace(chanKey)
if chanKey == "" {
return nil, nil
}
if tenantId = strings.TrimSpace(tenantId); tenantId == "" {
tenantId = contexts.GetTenantId(ctx)
}
if tenantId != "" {
channel, err := s.findCardChannelEntityByKey(ctx, tenantId, chanKey)
if err != nil || channel != nil {
return appCardChannelInfoFromEntity(channel), err
}
}
channel, err := s.findCardChannelEntityByKey(ctx, "", chanKey)
return appCardChannelInfoFromEntity(channel), err
}
func (s *sAppCard) findCardChannelEntityByKey(ctx context.Context, tenantId string, chanKey string) (*entity.TSimChannels, error) {
var channel *entity.TSimChannels
mod := dao.TSimChannels.Ctx(ctx).
Where(dao.TSimChannels.Columns().ChanKey, chanKey).
Fields(dao.TSimChannels.Columns().Id, dao.TSimChannels.Columns().ChanKey, dao.TSimChannels.Columns().ChanName, dao.TSimChannels.Columns().ChanAlias, dao.TSimChannels.Columns().TempKey)
if tenantId = strings.TrimSpace(tenantId); tenantId != "" {
mod = mod.Where(dao.TSimChannels.Columns().TenantId, tenantId)
}
err := mod.Limit(1).Scan(&channel)
if err != nil {
return nil, gerror.Wrap(err, "查询通道信息失败")
}
if channel == nil || channel.Id == 0 {
return nil, nil
}
return channel, nil
}
func appCardChannelInfoFromEntity(channel *entity.TSimChannels) *appCardChannelInfo {
if channel == nil || channel.Id == 0 || strings.TrimSpace(channel.ChanKey) == "" {
return nil
}
return &appCardChannelInfo{
ChanKey: strings.TrimSpace(channel.ChanKey),
ChanName: strings.TrimSpace(channel.ChanName),
ChanAlias: strings.TrimSpace(channel.ChanAlias),
TempKey: strings.TrimSpace(channel.TempKey),
}
}
@@ -0,0 +1,538 @@
package app
import (
"context"
"fmt"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
func (s *sAppCard) cardRowToModel(ctx context.Context, row *appCardQueryRow, user *entity.SysUser, ownerType string, canBusiness bool) *model.AppCardModel {
if row == nil {
return nil
}
canBusiness = canBusiness && s.canOperateCard(ctx, user, row)
remain := row.PackageTotalFlowMb - row.UsedFlowMb
if remain < 0 {
remain = 0
}
usedPercent := float64(0)
if row.PackageTotalFlowMb > 0 {
usedPercent = row.UsedFlowMb / row.PackageTotalFlowMb * 100
}
isBound := false
if user != nil && s.isTerminalUser(ctx, user) && ownerType == appCardOwnerTerminal {
isBound = true
} else if user != nil {
isBound = s.isCardBoundToUser(ctx, row.Iccid, user)
}
currentPackageCode := appCardCurrentPackageCode(row)
packageName := appCardPackageName(row)
packageType := appCardPackageType(row)
categoryCode := appCardCategoryCode(row)
categoryName := appCardCategoryName(row)
abnormalReasons := appCardAbnormalReasons(row.BossCardStatus, row.PackageStatus)
deptName := ""
deptKey := ""
deptId := int64(0)
tenantId := ""
chanName := ""
chanAlias := ""
if ownerType == appCardOwnerAgent {
actualDeptId := appCardOwnerDeptId(row)
if user != nil && !s.isTerminalUser(ctx, user) && user.DeptId > 0 && actualDeptId > 0 {
if displayDept, err := appEnterpriseDisplayDept(ctx, row.TenantId, user.DeptId, actualDeptId); err == nil && displayDept != nil && displayDept.DeptId > 0 {
deptId = displayDept.DeptId
deptName = strings.TrimSpace(displayDept.DeptName)
deptKey = strings.TrimSpace(displayDept.DeptKey)
}
} else {
deptId = actualDeptId
deptName = appCardOwnerDeptName(row)
deptKey = appCardOwnerDeptKey(row)
}
tenantId = row.TenantId
chanName = row.ChanName
chanAlias = row.ChanAlias
}
return &model.AppCardModel{
Id: row.Id,
Iccid: row.Iccid,
Msisdn: row.Msisdn,
Imsi: row.Imsi,
Imei: row.Imei,
RegionCode: row.RegionCode,
DeptId: deptId,
DeptName: deptName,
DeptKey: deptKey,
CurrentPackageCode: currentPackageCode,
PackageName: packageName,
PackageType: packageType,
CategoryCode: categoryCode,
CategoryName: categoryName,
PackageStatus: row.PackageStatus,
AccessType: row.AccessType,
BossCardStatus: row.BossCardStatus,
AbnormalReason: strings.Join(abnormalReasons, "、"),
AbnormalReasons: abnormalReasons,
CardType: row.CardType,
NetModel: row.NetModel,
PackageTotalFlowMb: row.PackageTotalFlowMb,
UsedFlowMb: row.UsedFlowMb,
RemainFlowMb: remain,
UsedFlowPercent: usedPercent,
FlowPkgBeginAt: row.FlowPkgBeginAt,
FlowPkgEndAt: row.FlowPkgEndAt,
DataServiceStatus: row.DataServiceStatus,
PackageTotalSms: row.PackageTotalSms,
UsedSms: row.UsedSms,
SmsServiceStatus: row.SmsServiceStatus,
PackageTotalVoice: row.PackageTotalVoice,
UsedVoice: row.UsedVoice,
VoiceServiceStatus: row.VoiceServiceStatus,
RoamServiceStatus: row.RoamServiceStatus,
StatusSyncTime: row.StatusSyncTime,
FlowSyncTime: row.FlowSyncTime,
ChanKey: "",
ChanName: chanName,
ChanAlias: chanAlias,
Remark: row.Remark,
TenantId: tenantId,
TerminalName: row.TerminalName,
IsBound: isBound,
CanBusiness: canBusiness,
CanAllocate: s.canAllocateCard(ctx, user, row, ownerType, canBusiness),
OwnerType: ownerType,
DataSource: "local",
}
}
func (s *sAppCard) canAllocateCard(ctx context.Context, user *entity.SysUser, row *appCardQueryRow, ownerType string, canBusiness bool) bool {
return user != nil &&
row != nil &&
ownerType == appCardOwnerAgent &&
!s.isTerminalUser(ctx, user) &&
canBusiness &&
user.DeptId > 0 &&
row.DeptId == user.DeptId
}
func (s *sAppCard) publicCardRowToModel(ctx context.Context, row *appCardQueryRow, cardValue string) *model.AppCardModel {
card := s.cardRowToModel(ctx, row, nil, appCardOwnerPublic, false)
if card == nil {
return nil
}
card.Id = 0
card.DeptId = 0
card.DeptName = ""
card.DeptKey = ""
card.ChanKey = ""
card.ChanName = ""
card.ChanAlias = ""
card.Remark = ""
card.TenantId = ""
card.TerminalName = ""
card.IsBound = false
card.CanBusiness = false
card.CanAllocate = false
card.OwnerType = appCardOwnerPublic
return card
}
func (s *sAppCard) upstreamPublicCardToModel(info *appSimBasicInfoOutput, cardValue string) *model.AppCardModel {
if info == nil {
return nil
}
cardStatus := info.CardStatus
if cardStatus == 0 {
cardStatus = consts.V3ApiSimBossCardStatusActivated
}
packageCode, packageName := appOneLinkMockPackageFromRemark(info.Remark)
abnormalReasons := appCardAbnormalReasons(cardStatus, 0)
return &model.AppCardModel{
Iccid: appFirstNotEmpty(info.Iccid, strings.TrimSpace(cardValue)),
Msisdn: info.Msisdn,
Imsi: info.Imsi,
Imei: info.Imei,
CurrentPackageCode: packageCode,
PackageName: packageName,
BossCardStatus: cardStatus,
AbnormalReason: strings.Join(abnormalReasons, "、"),
AbnormalReasons: abnormalReasons,
StatusSyncTime: appParseOneLinkTime(info.LastChangeDate),
FlowPkgBeginAt: appParseOneLinkTime(info.ActiveDate),
OwnerType: appCardOwnerPublic,
DataSource: info.Provider,
UpstreamProvider: info.Provider,
IsBound: false,
CanBusiness: false,
CanAllocate: false,
}
}
func (s *sAppCard) packageRowToModel(row *appPackageQueryRow, ownerType string, canBusiness bool) *model.AppPackageModel {
if row == nil {
return nil
}
status, statusType := appPackageStatus(row.EffectiveStatus, row.ExpectedExpireTime)
return &model.AppPackageModel{
Id: row.Id,
SubscriptionNo: row.SubscriptionNo,
PackageCode: row.PackageCode,
PackageName: row.PackageName,
PackageType: row.PackageType,
PackageTypeName: appPackageTypeName(row.PackageType),
CategoryCode: row.CategoryCode,
CategoryName: row.CategoryName,
Iccid: row.Iccid,
Msisdn: row.Msisdn,
CardId: row.CardId,
CardValue: appFirstNotEmpty(row.Msisdn, row.Iccid),
CardRemark: row.CardRemark,
TerminalName: row.TerminalName,
BusinessType: row.BusinessType,
BusinessTypeName: appBusinessTypeName(row.BusinessType),
EffectiveStatus: row.EffectiveStatus,
Status: status,
StatusType: statusType,
FilterValue: row.PackageCode,
EffectiveTime: row.EffectiveTime,
ExpireTime: row.ExpectedExpireTime,
ValidText: appPackageValidText(row.EffectiveTime, row.ExpectedExpireTime),
EffectText: appPackageEffectText(row.EffectiveStatus),
FlowMb: row.FlowMb,
FlowText: appFlowText(row.FlowMb),
OwnerType: ownerType,
CanBusiness: canBusiness,
}
}
func (s *sAppCard) publicPackageRowToModel(card *appCardQueryRow, pkg *appPublicPackageQueryRow) *model.AppPackageModel {
if card == nil || pkg == nil {
return nil
}
effectiveStatus := appEffectiveStatusFromCardPackageStatus(card.PackageStatus)
status, statusType := appPackageStatus(effectiveStatus, card.FlowPkgEndAt)
flowMb := pkg.FlowMb
if flowMb <= 0 {
flowMb = card.PackageTotalFlowMb
}
return &model.AppPackageModel{
Id: pkg.Id,
PackageCode: pkg.PackageCode,
PackageName: pkg.PackageName,
PackageType: pkg.PackageType,
PackageTypeName: appPackageTypeName(pkg.PackageType),
CategoryCode: pkg.CategoryCode,
CategoryName: pkg.CategoryName,
Iccid: card.Iccid,
Msisdn: card.Msisdn,
CardId: card.Id,
CardValue: appFirstNotEmpty(card.Msisdn, card.Iccid),
CardRemark: card.Remark,
TerminalName: card.TerminalName,
BusinessTypeName: "当前套餐",
EffectiveStatus: effectiveStatus,
Status: status,
StatusType: statusType,
FilterValue: pkg.PackageCode,
EffectiveTime: card.FlowPkgBeginAt,
ExpireTime: card.FlowPkgEndAt,
ValidText: appPackageValidText(card.FlowPkgBeginAt, card.FlowPkgEndAt),
EffectText: appPackageEffectText(effectiveStatus),
FlowMb: flowMb,
FlowText: appFlowText(flowMb),
OwnerType: appCardOwnerPublic,
CanBusiness: false,
}
}
func appSanitizePublicPackageModel(pkg *model.AppPackageModel, cardValue string) *model.AppPackageModel {
if pkg == nil {
return nil
}
pkg.Id = 0
pkg.SubscriptionNo = ""
pkg.CardId = 0
pkg.CardValue = appFirstNotEmpty(pkg.Msisdn, pkg.Iccid, strings.TrimSpace(cardValue))
pkg.CardRemark = ""
pkg.TerminalName = ""
pkg.BusinessType = 0
pkg.BusinessTypeName = "当前套餐"
pkg.OwnerType = appCardOwnerPublic
pkg.CanBusiness = false
return pkg
}
func appPackageStatus(status int, expireAt *gtime.Time) (string, string) {
if expireAt != nil && !expireAt.IsZero() && expireAt.Before(gtime.Now()) {
return "已过期", "danger"
}
if status == 1 && expireAt != nil && !expireAt.IsZero() && expireAt.Before(gtime.Now().AddDate(0, 0, 30)) {
return "即将到期", "warning"
}
switch status {
case 0:
return "待生效", "primary"
case 1:
return "生效中", "success"
case 2:
return "已过期", "danger"
case 3:
return "已终止", "danger"
case 4:
return "已作废", "danger"
default:
return "未知", "primary"
}
}
func appCardAbnormalReasons(bossCardStatus int, packageStatus int) []string {
reasons := make([]string, 0, 2)
if packageStatus == consts.PackageStatusExpired {
reasons = append(reasons, "套餐已过期")
}
switch bossCardStatus {
case 0:
reasons = append(reasons, "卡状态未知")
case consts.V3ApiSimBossCardStatusStopped:
reasons = append(reasons, "卡状态停机")
case 8:
reasons = append(reasons, "卡状态预销户")
case 9:
reasons = append(reasons, "卡状态已销户")
case consts.V3ApiSimBossCardStatusPendingActivate,
consts.V3ApiSimBossCardStatusActivated,
6,
7:
default:
reasons = append(reasons, fmt.Sprintf("未知卡状态:%d", bossCardStatus))
}
return reasons
}
func appEffectiveStatusFromCardPackageStatus(status int) int {
switch status {
case 1:
return 0
case 2:
return 1
case 3:
return 2
default:
return 4
}
}
func appPackageTypeName(packageType int) string {
switch packageType {
case 101:
return "单卡套餐"
case 102:
return "池套餐"
case 103:
return "单卡加油包"
case 104:
return "池卡加油包"
default:
return gconv.String(packageType)
}
}
func appBusinessTypeName(businessType int) string {
switch businessType {
case 0:
return "订购"
case 1:
return "变更"
case 2:
return "续费"
default:
return gconv.String(businessType)
}
}
func appPackageValidText(beginAt, expireAt *gtime.Time) string {
if beginAt == nil || beginAt.IsZero() || expireAt == nil || expireAt.IsZero() {
return ""
}
return beginAt.Format("Y-m-d") + " 至 " + expireAt.Format("Y-m-d")
}
func appPackageEffectText(status int) string {
switch status {
case 0:
return "套餐待生效"
case 1:
return "套餐生效中"
case 2:
return "套餐已过期"
case 3:
return "套餐已终止"
case 4:
return "套餐已作废"
default:
return ""
}
}
func appFlowText(flowMb float64) string {
if flowMb <= 0 {
return ""
}
if flowMb >= 1024 {
return fmt.Sprintf("%.2fGB", flowMb/1024)
}
return fmt.Sprintf("%.0fMB", flowMb)
}
func appFirstNotEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func appCardOwnerDeptId(row *appCardQueryRow) int64 {
if row == nil {
return 0
}
if row.TerminalDeptId > 0 {
return row.TerminalDeptId
}
return row.DeptId
}
func appCardOwnerDeptName(row *appCardQueryRow) string {
if row == nil {
return ""
}
return strings.TrimSpace(row.OwnerDeptName)
}
func appCardOwnerDeptKey(row *appCardQueryRow) string {
if row == nil {
return ""
}
return strings.TrimSpace(row.OwnerDeptKey)
}
func appCardCurrentPackageCode(row *appCardQueryRow) string {
if row == nil {
return ""
}
if strings.TrimSpace(row.TerminalCurrentPackageCode) != "" {
return strings.TrimSpace(row.TerminalCurrentPackageCode)
}
return strings.TrimSpace(row.CurrentPackageCode)
}
func appCardPackageName(row *appCardQueryRow) string {
if row == nil {
return ""
}
terminalPackageCode := strings.TrimSpace(row.TerminalCurrentPackageCode)
if terminalPackageCode != "" {
if strings.TrimSpace(row.TerminalPackageName) != "" {
return row.TerminalPackageName
}
if terminalPackageCode != strings.TrimSpace(row.CurrentPackageCode) {
return ""
}
}
return row.PackageName
}
func appCardPackageType(row *appCardQueryRow) int {
if row == nil {
return 0
}
terminalPackageCode := strings.TrimSpace(row.TerminalCurrentPackageCode)
if terminalPackageCode != "" {
if row.TerminalPackageType > 0 {
return row.TerminalPackageType
}
if terminalPackageCode != strings.TrimSpace(row.CurrentPackageCode) {
return 0
}
}
return row.PackageType
}
func appCardCategoryCode(row *appCardQueryRow) string {
if row == nil {
return ""
}
terminalPackageCode := strings.TrimSpace(row.TerminalCurrentPackageCode)
if terminalPackageCode != "" {
if strings.TrimSpace(row.TerminalCategoryCode) != "" {
return row.TerminalCategoryCode
}
if terminalPackageCode != strings.TrimSpace(row.CurrentPackageCode) {
return ""
}
}
return row.CategoryCode
}
func appCardCategoryName(row *appCardQueryRow) string {
if row == nil {
return ""
}
terminalPackageCode := strings.TrimSpace(row.TerminalCurrentPackageCode)
if terminalPackageCode != "" {
if strings.TrimSpace(row.TerminalCategoryName) != "" {
return row.TerminalCategoryName
}
if terminalPackageCode != strings.TrimSpace(row.CurrentPackageCode) {
return ""
}
}
return row.CategoryName
}
func appOneLinkMockPackageFromRemark(remark string) (packageCode string, packageName string) {
for _, part := range strings.Split(remark, ";") {
item := strings.TrimSpace(part)
if item == "" {
continue
}
key, value, ok := strings.Cut(item, "=")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "package":
packageCode = strings.TrimSpace(value)
case "name":
packageName = strings.TrimSpace(value)
}
}
if packageName == "" {
packageName = packageCode
}
return packageCode, packageName
}
func appParseOneLinkTime(value string) *gtime.Time {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
parsed := gtime.NewFromStr(value)
if parsed == nil || parsed.IsZero() {
return nil
}
return parsed
}
@@ -0,0 +1,73 @@
package app
import (
"context"
"reflect"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
)
func TestPublicCardRowToModelKeepsCardIdentifiers(t *testing.T) {
svc := &sAppCard{}
row := &appCardQueryRow{
TSimCardInfo: entity.TSimCardInfo{
Id: 100,
Iccid: "89860123456789012345",
Msisdn: "14400005678",
Imsi: "460001234567890",
Imei: "861234567890123",
DeptId: 10,
TenantId: "000000",
},
}
card := svc.publicCardRowToModel(context.Background(), row, "14400005678")
if card == nil {
t.Fatal("publicCardRowToModel returned nil")
}
if card.Iccid != row.Iccid || card.Msisdn != row.Msisdn || card.Imsi != row.Imsi || card.Imei != row.Imei {
t.Fatalf("card identifiers were not preserved: %#v", card)
}
if card.Id != 0 || card.DeptId != 0 || card.TenantId != "" {
t.Fatalf("private card fields should still be hidden: %#v", card)
}
}
func TestAppCardAbnormalReasons(t *testing.T) {
tests := []struct {
name string
bossStatus int
packageStatus int
want []string
}{
{
name: "expired package on activated card",
bossStatus: consts.V3ApiSimBossCardStatusActivated,
packageStatus: consts.PackageStatusExpired,
want: []string{"套餐已过期"},
},
{
name: "stopped card",
bossStatus: consts.V3ApiSimBossCardStatusStopped,
packageStatus: consts.PackageStatusEffective,
want: []string{"卡状态停机"},
},
{
name: "unknown boss status",
bossStatus: 99,
packageStatus: consts.PackageStatusEffective,
want: []string{"未知卡状态:99"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := appCardAbnormalReasons(tt.bossStatus, tt.packageStatus)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("appCardAbnormalReasons() = %#v, want %#v", got, tt.want)
}
})
}
}
@@ -0,0 +1,299 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
// 查询当前登录用户可见的套餐订购记录。
func (s *sAppCard) PackageList(ctx context.Context, param *model.AppPackageListParam) (items []*model.AppPackageModel, total int, err error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, 0, err
}
mod := s.visiblePackageModel(ctx, user)
mod = s.applyPackageFilters(mod, param)
if param.Page <= 0 {
param.Page = 1
}
if param.PageSize <= 0 {
param.PageSize = 10
}
var rows []*appPackageQueryRow
err = mod.OrderDesc(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().Id).
Page(param.Page, param.PageSize).
ScanAndCount(&rows, &total, false)
if err != nil {
return nil, 0, gerror.Wrap(err, "查询套餐列表失败,请稍后重试")
}
ownerType := s.ownerType(ctx, user)
items = make([]*model.AppPackageModel, 0, len(rows))
for _, row := range rows {
items = append(items, s.packageRowToModel(row, ownerType, s.canOperatePackageRow(ctx, user, row)))
}
return items, total, nil
}
// 查询卡片当前套餐详情。
// 核心规则是“卡主表 current_package_code -> 套餐表 package_code”,订购记录只做详情兜底,
// 避免卡主表与订购表不同步时前端报“套餐信息不存在”。
func (s *sAppCard) PublicPackageDetail(ctx context.Context, param *model.AppPackagePublicDetailParam) (res *model.AppPackageModel, err error) {
if param == nil {
param = &model.AppPackagePublicDetailParam{}
}
card, err := s.findPublicCard(ctx, param.TenantId, param.CardValue)
if err != nil {
return nil, err
}
if card == nil {
return nil, gerror.New("卡信息不存在,请确认查询号码是否正确")
}
ownerType := appCardOwnerPublic
canBusiness := false
displayCard := card
if userId := contexts.GetUserId(ctx); userId > 0 {
user, userErr := appCurrentUser(ctx)
if userErr == nil && user != nil {
visibleCard, visibleErr := s.findVisibleCard(ctx, user, param.CardValue)
if visibleErr == nil && visibleCard != nil &&
strings.TrimSpace(visibleCard.Iccid) == strings.TrimSpace(card.Iccid) &&
strings.TrimSpace(visibleCard.TenantId) == strings.TrimSpace(card.TenantId) {
displayCard = visibleCard
ownerType = s.ownerType(ctx, user)
canBusiness = s.canOperateCard(ctx, user, visibleCard)
}
}
}
var subscriptionBind *entity.TSimCardBindPkg
if strings.TrimSpace(param.SubscriptionNo) != "" {
subscriptionBind, err = s.loadRenewSubscription(ctx, card, param.SubscriptionNo)
if err != nil {
return nil, err
}
}
latestBind, err := s.loadLatestRenewBind(ctx, card)
if err != nil {
return nil, err
}
candidates := appPublicPackageCandidates(displayCard, subscriptionBind, latestBind, param.PackageCode)
if len(candidates) == 0 {
return nil, gerror.New("当前卡片没有套餐信息")
}
for _, packageCode := range candidates {
pkg, err := s.loadPublicPackageInfo(ctx, card.TenantId, packageCode)
if err != nil {
return nil, err
}
if pkg == nil {
continue
}
result := s.publicPackageRowToModel(displayCard, pkg)
result.OwnerType = ownerType
result.CanBusiness = canBusiness
if subscriptionBind != nil && strings.TrimSpace(subscriptionBind.PackageCode) == strings.TrimSpace(pkg.PackageCode) {
appFillPublicPackageFromBind(result, subscriptionBind)
} else if latestBind != nil && strings.TrimSpace(latestBind.PackageCode) == strings.TrimSpace(pkg.PackageCode) {
appFillPublicPackageFromBind(result, latestBind)
}
if ownerType == appCardOwnerPublic {
result = appSanitizePublicPackageModel(result, param.CardValue)
}
return result, nil
}
return nil, gerror.Newf("当前卡片套餐编码 %s 未配置套餐信息", strings.Join(candidates, "、"))
}
func appPublicPackageCandidates(card *appCardQueryRow, subscriptionBind *entity.TSimCardBindPkg, latestBind *entity.TSimCardBindPkg, requestedPackageCode string) []string {
var candidates []string
addCandidate := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
for _, item := range candidates {
if item == value {
return
}
}
candidates = append(candidates, value)
}
if subscriptionBind != nil {
addCandidate(subscriptionBind.PackageCode)
}
currentPackageCode := ""
if card != nil {
currentPackageCode = appCardCurrentPackageCode(card)
addCandidate(currentPackageCode)
}
if latestBind != nil && currentPackageCode == "" {
addCandidate(latestBind.PackageCode)
}
if appPublicPackageCodeAllowed(requestedPackageCode, card, subscriptionBind, latestBind) {
addCandidate(requestedPackageCode)
}
return candidates
}
func appPublicPackageCodeAllowed(packageCode string, card *appCardQueryRow, subscriptionBind *entity.TSimCardBindPkg, latestBind *entity.TSimCardBindPkg) bool {
packageCode = strings.TrimSpace(packageCode)
if packageCode == "" {
return false
}
if card != nil {
currentPackageCode := appCardCurrentPackageCode(card)
if currentPackageCode != "" {
return currentPackageCode == packageCode
}
}
if subscriptionBind != nil && strings.TrimSpace(subscriptionBind.PackageCode) == packageCode {
return true
}
return latestBind != nil && strings.TrimSpace(latestBind.PackageCode) == packageCode
}
func (s *sAppCard) loadPublicPackageInfo(ctx context.Context, tenantId string, packageCode string) (*appPublicPackageQueryRow, error) {
packageTable := dao.TSimFlowPackageInfo.Table()
categoryTable := dao.TSimPackageCategory.Table()
packageColumns := dao.TSimFlowPackageInfo.Columns()
categoryColumns := dao.TSimPackageCategory.Columns()
var pkg *appPublicPackageQueryRow
err := dao.TSimFlowPackageInfo.Ctx(ctx).
FieldsPrefix(packageTable, entity.TSimFlowPackageInfo{}).
Fields(categoryTable+"."+categoryColumns.CategoryName+" as categoryName").
LeftJoin(categoryTable, categoryTable+"."+categoryColumns.CategoryCode+"="+packageTable+"."+packageColumns.CategoryCode+" AND "+categoryTable+"."+categoryColumns.TenantId+"="+packageTable+"."+packageColumns.TenantId).
Where(packageTable+"."+packageColumns.TenantId, strings.TrimSpace(tenantId)).
Where(packageTable+"."+packageColumns.PackageCode, strings.TrimSpace(packageCode)).
Limit(1).
Scan(&pkg)
if err != nil {
return nil, gerror.Wrap(err, "查询套餐详情失败")
}
if pkg == nil || pkg.Id == 0 {
return nil, nil
}
return pkg, nil
}
func appFillPublicPackageFromBind(pkg *model.AppPackageModel, bind *entity.TSimCardBindPkg) {
if pkg == nil || bind == nil {
return
}
pkg.Id = bind.Id
pkg.SubscriptionNo = bind.SubscriptionNo
pkg.BusinessType = bind.BusinessType
pkg.BusinessTypeName = appBusinessTypeName(bind.BusinessType)
pkg.EffectiveStatus = bind.EffectiveStatus
pkg.EffectiveTime = bind.EffectiveTime
pkg.ExpireTime = bind.ExpectedExpireTime
pkg.ValidText = appPackageValidText(bind.EffectiveTime, bind.ExpectedExpireTime)
pkg.EffectText = appPackageEffectText(bind.EffectiveStatus)
}
func (s *sAppCard) visiblePackageModel(ctx context.Context, user *entity.SysUser) *gdb.Model {
cardTable := dao.TSimCardInfo.Table()
bindPkgTable := dao.TSimCardBindPkg.Table()
packageTable := dao.TSimFlowPackageInfo.Table()
categoryTable := dao.TSimPackageCategory.Table()
terminalBindTable := dao.TSimTerminal.Table()
mod := dao.TSimCardBindPkg.Ctx(ctx).
FieldsPrefix(bindPkgTable, entity.TSimCardBindPkg{}).
Fields(cardTable+"."+dao.TSimCardInfo.Columns().Id+" as cardId").
Fields(cardTable+"."+dao.TSimCardInfo.Columns().DeptId+" as cardDeptId").
Fields(cardTable+"."+dao.TSimCardInfo.Columns().Remark+" as cardRemark").
Fields(packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageName+" as packageName").
Fields(packageTable+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode+" as categoryCode").
Fields(packageTable+"."+dao.TSimFlowPackageInfo.Columns().FlowMb+" as flowMb").
Fields(categoryTable+"."+dao.TSimPackageCategory.Columns().CategoryName+" as categoryName").
LeftJoin(cardTable, cardTable+"."+dao.TSimCardInfo.Columns().Iccid+"="+bindPkgTable+"."+dao.TSimCardBindPkg.Columns().Iccid+" AND "+cardTable+"."+dao.TSimCardInfo.Columns().TenantId+"="+bindPkgTable+"."+dao.TSimCardBindPkg.Columns().TenantId).
LeftJoin(packageTable, packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+bindPkgTable+"."+dao.TSimCardBindPkg.Columns().PackageCode+" AND "+packageTable+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+bindPkgTable+"."+dao.TSimCardBindPkg.Columns().TenantId).
LeftJoin(categoryTable, categoryTable+"."+dao.TSimPackageCategory.Columns().CategoryCode+"="+packageTable+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode+" AND "+categoryTable+"."+dao.TSimPackageCategory.Columns().TenantId+"="+packageTable+"."+dao.TSimFlowPackageInfo.Columns().TenantId)
if s.isTerminalUser(ctx, user) {
return mod.
Fields(terminalBindTable+"."+dao.TSimTerminal.Columns().TerminalName+" as terminalName").
InnerJoin(terminalBindTable, terminalBindTable+"."+dao.TSimTerminal.Columns().Iccid+"="+cardTable+"."+dao.TSimCardInfo.Columns().Iccid+" AND "+terminalBindTable+"."+dao.TSimTerminal.Columns().TenantId+"="+cardTable+"."+dao.TSimCardInfo.Columns().TenantId).
Where(terminalBindTable+"."+dao.TSimTerminal.Columns().TenantId, user.TenantId).
Where(terminalBindTable+"."+dao.TSimTerminal.Columns().TerminalId, user.UserId).
Where(bindPkgTable+"."+dao.TSimCardBindPkg.Columns().TenantId, user.TenantId)
}
mod = mod.Where(bindPkgTable+"."+dao.TSimCardBindPkg.Columns().TenantId, user.TenantId)
return appApplyEnterpriseDataScope(
ctx,
user,
mod,
cardTable+"."+dao.TSimCardInfo.Columns().DeptId,
cardTable+"."+dao.TSimCardInfo.Columns().CreatedBy,
func(legacyMod *gdb.Model) *gdb.Model {
return appLegacyEnterpriseDeptAndSubScope(ctx, user, legacyMod, cardTable+"."+dao.TSimCardInfo.Columns().DeptId)
},
)
}
func (s *sAppCard) applyPackageFilters(mod *gdb.Model, param *model.AppPackageListParam) *gdb.Model {
if strings.TrimSpace(param.Keyword) != "" {
keyword := "%" + strings.TrimSpace(param.Keyword) + "%"
mod = mod.Where(
"("+dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().Iccid+" LIKE ? OR "+
dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().Msisdn+" LIKE ? OR "+
dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Imsi+" LIKE ? OR "+
dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Imei+" LIKE ? OR "+
dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().PackageCode+" LIKE ? OR "+
dao.TSimFlowPackageInfo.Table()+"."+dao.TSimFlowPackageInfo.Columns().PackageName+" LIKE ? OR "+
dao.TSimPackageCategory.Table()+"."+dao.TSimPackageCategory.Columns().CategoryName+" LIKE ? OR "+
dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().Remark+" LIKE ?)",
keyword, keyword, keyword, keyword, keyword, keyword, keyword, keyword,
)
}
if strings.TrimSpace(param.CardValue) != "" {
value := strings.TrimSpace(param.CardValue)
mod = mod.Where(
"("+dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().Iccid+" = ? OR "+
dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().Msisdn+" = ? OR "+
dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Imsi+" = ? OR "+
dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Imei+" = ?)",
value, value, value, value,
)
}
if strings.TrimSpace(param.SubscriptionNo) != "" {
mod = mod.Where(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().SubscriptionNo, strings.TrimSpace(param.SubscriptionNo))
}
if strings.TrimSpace(param.PackageCode) != "" {
mod = mod.Where(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().PackageCode, strings.TrimSpace(param.PackageCode))
}
if strings.TrimSpace(param.CategoryCode) != "" {
mod = mod.Where(dao.TSimFlowPackageInfo.Table()+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode, strings.TrimSpace(param.CategoryCode))
}
switch strings.TrimSpace(param.Status) {
case "active":
mod = mod.Where(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().EffectiveStatus, 1)
case "expiring":
mod = mod.Where(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().ExpectedExpireTime+" IS NOT NULL").
Where(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().ExpectedExpireTime+" >= ?", gtime.Now()).
Where(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().ExpectedExpireTime+" <= ?", gtime.Now().AddDate(0, 0, 30))
case "expired":
mod = mod.Where(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().EffectiveStatus, 2)
case "pending":
mod = mod.Where(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().EffectiveStatus, 0)
case "addon":
mod = mod.WhereIn(dao.TSimCardBindPkg.Table()+"."+dao.TSimCardBindPkg.Columns().PackageType, []int{103, 104})
}
return mod
}
+366
View File
@@ -0,0 +1,366 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/xgorm/handler"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
const appCardListMaxPageSize = 100
// 处理首页免登录公共查卡,只返回卡基础信息,不开放绑卡、重启等业务能力。
func (s *sAppCard) PublicQuery(ctx context.Context, param *model.AppCardQueryParam) (res *model.AppCardModel, err error) {
card, err := s.findPublicCard(ctx, param.TenantId, param.CardValue)
if err != nil {
return nil, err
}
if card == nil {
return s.publicQueryUpstream(ctx, param.CardValue)
}
return s.publicCardRowToModel(ctx, card, param.CardValue), nil
}
// 查询当前登录用户可见卡片。终端用户走 t_sim_terminal 关联表,
// 企业用户沿用管理平台数据权限查询持卡列表。
func (s *sAppCard) List(ctx context.Context, param *model.AppCardListParam) (items []*model.AppCardModel, total int, err error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, 0, err
}
mod := s.visibleCardModel(ctx, user)
mod = s.applyCardFilters(mod, param)
if param.Page <= 0 {
param.Page = 1
}
if param.PageSize <= 0 {
param.PageSize = 10
}
if param.PageSize > appCardListMaxPageSize {
param.PageSize = appCardListMaxPageSize
}
var rows []*appCardQueryRow
includeTotal := param.IncludeTotal == nil || *param.IncludeTotal
queryPageSize := param.PageSize
if !includeTotal {
queryPageSize++
}
query := mod.OrderDesc(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Id).
Page(param.Page, queryPageSize)
if includeTotal {
err = query.ScanAndCount(&rows, &total, false)
} else {
err = query.Scan(&rows)
if len(rows) > param.PageSize {
rows = rows[:param.PageSize]
}
}
if err != nil {
return nil, 0, gerror.Wrap(err, "查询卡列表失败,请稍后重试")
}
if err = s.attachCardChannelInfoRows(ctx, rows); err != nil {
return nil, 0, err
}
ownerType := s.ownerType(ctx, user)
items = make([]*model.AppCardModel, 0, len(rows))
for _, row := range rows {
items = append(items, s.cardRowToModel(ctx, row, user, ownerType, true))
}
return items, total, nil
}
// 登录后先查用户可见卡;查不到时回退公共查卡,保证登录后仍能查看公共卡详情。
func (s *sAppCard) Detail(ctx context.Context, param *model.AppCardDetailParam) (res *model.AppCardModel, err error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
card, err := s.findVisibleCard(ctx, user, param.CardValue)
if err != nil {
return nil, err
}
if card != nil {
model := s.attachYesterdayDeptFlow(ctx, s.cardRowToModel(ctx, card, user, s.ownerType(ctx, user), true))
return s.attachAppCardRealtimeSession(ctx, card, model), nil
}
card, err = s.findPublicCard(ctx, user.TenantId, param.CardValue)
if err != nil {
return nil, err
}
if card == nil {
return s.publicQueryUpstream(ctx, param.CardValue)
}
if err = s.attachCardChannelInfo(ctx, card); err != nil {
return nil, err
}
return s.publicCardRowToModel(ctx, card, param.CardValue), nil
}
func (s *sAppCard) publicQueryUpstream(ctx context.Context, cardValue string) (*model.AppCardModel, error) {
provider, err := newAppSimUpstreamProvider(ctx)
if err != nil {
return nil, appErrUpstreamUnavailable(err)
}
info, err := provider.QuerySimBasicInfo(ctx, appSimBasicInfoInputFromCardValue(cardValue))
if err != nil {
return nil, appErrUpstreamUnavailable(err)
}
card := s.upstreamPublicCardToModel(info, cardValue)
if card == nil {
return nil, gerror.New("卡信息不存在,请确认查询号码是否正确")
}
return card, nil
}
func (s *sAppCard) attachYesterdayDeptFlow(ctx context.Context, card *model.AppCardModel) *model.AppCardModel {
if card == nil || card.DeptId <= 0 || strings.TrimSpace(card.TenantId) == "" {
return card
}
statDate := gtime.Now().AddDate(0, 0, -1).Format("Y-m-d")
var daily *entity.TDeptFlowDaily
_ = dao.TDeptFlowDaily.Ctx(ctx).
Where(dao.TDeptFlowDaily.Columns().TenantId, card.TenantId).
Where(dao.TDeptFlowDaily.Columns().DeptId, card.DeptId).
Where(dao.TDeptFlowDaily.Columns().StatDate, statDate).
Limit(1).
Scan(&daily)
if daily == nil || daily.Id == 0 {
return card
}
card.YesterdayFlowStatDate = statDate
if daily.StatDate != nil && !daily.StatDate.IsZero() {
card.YesterdayFlowStatDate = daily.StatDate.Format("Y-m-d")
}
card.YesterdayDeptUsedFlowMb = daily.TotalUsedFlowMb
card.YesterdayDeptSms = daily.TotalSms
card.YesterdayDeptVoiceMin = daily.TotalVoiceMin
return card
}
func (s *sAppCard) findPublicCard(ctx context.Context, tenantId string, cardValue string) (*appCardQueryRow, error) {
mod := s.cardQueryBase(ctx, false)
tenantId = strings.TrimSpace(tenantId)
if tenantId == "" {
tenantId = consts.DefaultSystemTenantCode
}
mod = mod.Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().TenantId, tenantId)
var row *appCardQueryRow
err := mod.
Where(appCardValueCondition(), appCardValueArgs(cardValue)...).
Limit(1).
Scan(&row)
if err != nil {
return nil, err
}
if err = s.attachCardChannelInfo(ctx, row); err != nil {
return nil, err
}
return row, nil
}
func (s *sAppCard) findOpenCard(ctx context.Context, tenantId string, cardValue string) (*appCardQueryRow, error) {
if strings.TrimSpace(cardValue) == "" {
return nil, gerror.New("请输入ICCID、MSISDN、IMSI或IMEI")
}
cardTable := dao.TSimCardInfo.Table()
mod := s.cardQueryBase(ctx, false)
if tenantId = strings.TrimSpace(tenantId); tenantId != "" {
mod = mod.Where(cardTable+"."+dao.TSimCardInfo.Columns().TenantId, tenantId)
}
var row *appCardQueryRow
err := mod.
Where(appCardValueCondition(), appCardValueArgs(cardValue)...).
OrderDesc(cardTable + "." + dao.TSimCardInfo.Columns().Id).
Limit(1).
Scan(&row)
if err != nil {
return nil, err
}
if err = s.attachCardChannelInfo(ctx, row); err != nil {
return nil, err
}
return row, nil
}
func (s *sAppCard) findCardByValue(ctx context.Context, tenantId string, cardValue string) (*entity.TSimCardInfo, error) {
if strings.TrimSpace(cardValue) == "" {
return nil, gerror.New("请输入ICCID、MSISDN、IMSI或IMEI")
}
var card *entity.TSimCardInfo
err := dao.TSimCardInfo.Ctx(ctx).
Where(dao.TSimCardInfo.Columns().TenantId, tenantId).
Where(appCardValueCondition(), appCardValueArgs(cardValue)...).
Limit(1).
Scan(&card)
if err != nil {
return nil, err
}
return card, nil
}
func (s *sAppCard) findVisibleCard(ctx context.Context, user *entity.SysUser, cardValue string) (*appCardQueryRow, error) {
var row *appCardQueryRow
err := s.visibleCardModel(ctx, user).
Where(appCardValueCondition(), appCardValueArgs(cardValue)...).
Limit(1).
Scan(&row)
if err != nil {
return nil, err
}
if err = s.attachCardChannelInfo(ctx, row); err != nil {
return nil, err
}
return row, nil
}
// findDiagnosisCard 查询诊断卡片。企业用户诊断只隔离租户,不套用部门数据范围;
// 终端用户仍沿用已绑定卡范围,避免终端账号读取其他用户的卡片。
func (s *sAppCard) findDiagnosisCard(ctx context.Context, user *entity.SysUser, cardValue string) (*appCardQueryRow, error) {
if user == nil || strings.TrimSpace(user.TenantId) == "" {
return nil, nil
}
if s.isTerminalUser(ctx, user) {
return s.findVisibleCard(ctx, user, cardValue)
}
cardTable := dao.TSimCardInfo.Table()
var row *appCardQueryRow
err := s.cardQueryBase(ctx, false).
Where(cardTable+"."+dao.TSimCardInfo.Columns().TenantId, strings.TrimSpace(user.TenantId)).
Where(appCardValueCondition(), appCardValueArgs(cardValue)...).
OrderDesc(cardTable + "." + dao.TSimCardInfo.Columns().Id).
Limit(1).
Scan(&row)
if err != nil {
return nil, err
}
if err = s.attachCardChannelInfo(ctx, row); err != nil {
return nil, err
}
return row, nil
}
func (s *sAppCard) visibleCardModel(ctx context.Context, user *entity.SysUser) *gdb.Model {
cardTable := dao.TSimCardInfo.Table()
if s.isTerminalUser(ctx, user) {
bindTable := dao.TSimTerminal.Table()
packageAlias := "terminal_package"
categoryAlias := "terminal_category"
packageJoinTable := dao.TSimFlowPackageInfo.Table() + " " + packageAlias
categoryJoinTable := dao.TSimPackageCategory.Table() + " " + categoryAlias
return s.cardQueryBase(ctx, false).
InnerJoin(bindTable, bindTable+"."+dao.TSimTerminal.Columns().Iccid+"="+cardTable+"."+dao.TSimCardInfo.Columns().Iccid).
LeftJoin(packageJoinTable, packageAlias+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+bindTable+"."+appSimTerminalCurrentPackageCodeColumn+" AND "+packageAlias+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+bindTable+"."+dao.TSimTerminal.Columns().TenantId).
LeftJoin(categoryJoinTable, categoryAlias+"."+dao.TSimPackageCategory.Columns().CategoryCode+"="+packageAlias+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode+" AND "+categoryAlias+"."+dao.TSimPackageCategory.Columns().TenantId+"="+packageAlias+"."+dao.TSimFlowPackageInfo.Columns().TenantId).
Where(cardTable+"."+dao.TSimCardInfo.Columns().TenantId, user.TenantId).
Where(bindTable+"."+dao.TSimTerminal.Columns().TenantId, user.TenantId).
Where(bindTable+"."+dao.TSimTerminal.Columns().TerminalId, user.UserId).
Fields(bindTable + "." + dao.TSimTerminal.Columns().Id + " as terminal_bind_id").
Fields(bindTable + "." + dao.TSimTerminal.Columns().TerminalId + " as terminal_user_id").
Fields(bindTable + "." + dao.TSimTerminal.Columns().TerminalName + " as terminalName").
Fields(bindTable + "." + appSimTerminalDeptIdColumn + " as terminal_dept_id").
Fields(bindTable + "." + appSimTerminalCurrentPackageCodeColumn + " as terminal_current_package_code").
Fields(packageAlias + "." + dao.TSimFlowPackageInfo.Columns().PackageName + " as terminal_package_name").
Fields(packageAlias + "." + dao.TSimFlowPackageInfo.Columns().PackageType + " as terminal_package_type").
Fields(packageAlias + "." + dao.TSimFlowPackageInfo.Columns().CategoryCode + " as terminal_category_code").
Fields(categoryAlias + "." + dao.TSimPackageCategory.Columns().CategoryName + " as terminal_category_name")
}
return s.enterpriseVisibleCardModel(ctx, user)
}
func (s *sAppCard) enterpriseVisibleCardModel(ctx context.Context, user *entity.SysUser) *gdb.Model {
cardTable := dao.TSimCardInfo.Table()
if user == nil || user.DeptId <= 0 {
return s.cardQueryBase(ctx, false).Where("1=0")
}
mod := s.cardQueryBase(ctx, false).
Where(cardTable+"."+dao.TSimCardInfo.Columns().TenantId, user.TenantId)
return appApplyEnterpriseDataScope(
ctx,
user,
mod,
cardTable+"."+dao.TSimCardInfo.Columns().DeptId,
cardTable+"."+dao.TSimCardInfo.Columns().CreatedBy,
func(legacyMod *gdb.Model) *gdb.Model {
return appLegacyEnterpriseDeptAndSubScope(ctx, user, legacyMod, cardTable+"."+dao.TSimCardInfo.Columns().DeptId)
},
)
}
// 统一卡查询基础 JOIN,其中 current_package_code 对应套餐表 package_code。
// 卡片展示和公共当前套餐详情都依赖这个关联关系。
func (s *sAppCard) cardQueryBase(ctx context.Context, useAuth bool) *gdb.Model {
cardTable := dao.TSimCardInfo.Table()
packageTable := dao.TSimFlowPackageInfo.Table()
categoryTable := dao.TSimPackageCategory.Table()
ownerDeptAlias := "owner_dept"
ownerDeptJoinTable := dao.SysDept.Table() + " " + ownerDeptAlias
mod := dao.TSimCardInfo.Ctx(ctx).
FieldsPrefix(cardTable, entity.TSimCardInfo{}).
Fields(packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageName+" as packageName").
Fields(packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageType+" as packageType").
Fields(packageTable+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode+" as categoryCode").
Fields(categoryTable+"."+dao.TSimPackageCategory.Columns().CategoryName+" as categoryName").
Fields(ownerDeptAlias+"."+dao.SysDept.Columns().DeptName+" as ownerDeptName").
Fields(ownerDeptAlias+"."+dao.SysDept.Columns().DeptKey+" as ownerDeptKey").
LeftJoin(packageTable, packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardTable+"."+dao.TSimCardInfo.Columns().CurrentPackageCode+" AND "+packageTable+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+cardTable+"."+dao.TSimCardInfo.Columns().TenantId).
LeftJoin(categoryTable, categoryTable+"."+dao.TSimPackageCategory.Columns().CategoryCode+"="+packageTable+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode+" AND "+categoryTable+"."+dao.TSimPackageCategory.Columns().TenantId+"="+packageTable+"."+dao.TSimFlowPackageInfo.Columns().TenantId).
LeftJoin(ownerDeptJoinTable, ownerDeptAlias+"."+dao.SysDept.Columns().DeptId+"="+cardTable+"."+dao.TSimCardInfo.Columns().DeptId+" AND "+ownerDeptAlias+"."+dao.SysDept.Columns().TenantId+"="+cardTable+"."+dao.TSimCardInfo.Columns().TenantId)
if useAuth {
mod = handler.Model(mod, &handler.Option{FilterAuth: true, FilterTenant: true})
}
return mod
}
func (s *sAppCard) applyCardFilters(mod *gdb.Model, param *model.AppCardListParam) *gdb.Model {
if strings.TrimSpace(param.CardValue) != "" {
mod = mod.Where(appCardValueCondition(), appCardValueArgs(param.CardValue)...)
}
if strings.TrimSpace(param.CategoryCode) != "" {
mod = mod.Where(dao.TSimFlowPackageInfo.Table()+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode, strings.TrimSpace(param.CategoryCode))
}
switch strings.TrimSpace(param.Status) {
case "normal":
mod = mod.Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().BossCardStatus, 2).
WhereNot(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().PackageStatus, 3)
case "low-flow":
mod = mod.Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().PackageTotalFlowMb+">?", 0).
Where("("+dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().PackageTotalFlowMb+"-"+dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().UsedFlowMb+")/"+dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().PackageTotalFlowMb+" <= ?", 0.2)
case "expiring":
mod = mod.Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().FlowPkgEndAt+" IS NOT NULL").
Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().FlowPkgEndAt+" >= ?", gtime.Now()).
Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().FlowPkgEndAt+" <= ?", gtime.Now().AddDate(0, 0, 30))
case "error":
mod = mod.Where(
"("+dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().PackageStatus+" = ? OR "+dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().BossCardStatus+" IN(?, ?, ?, ?) OR "+dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().BossCardStatus+" NOT IN(?, ?, ?, ?, ?, ?, ?, ?))",
consts.PackageStatusExpired,
0,
consts.V3ApiSimBossCardStatusStopped,
8,
9,
0,
consts.V3ApiSimBossCardStatusPendingActivate,
consts.V3ApiSimBossCardStatusActivated,
consts.V3ApiSimBossCardStatusStopped,
6,
7,
8,
9,
)
}
return mod
}
@@ -0,0 +1,521 @@
package app
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
internalai "git.5iot.com/iotlcm-public/iotlcm-app/internal/ai"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"github.com/cloudwego/eino/schema"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
const (
cardRecognitionCodeRecognized = "CARD_RECOGNIZED"
cardRecognitionCodeAmbiguous = "CARD_AMBIGUOUS"
cardRecognitionCodeUnreadable = "CARD_UNREADABLE"
cardRecognitionCodeTimeout = "RECOGNITION_TIMEOUT"
cardRecognitionCodeFailed = "RECOGNITION_FAILED"
cardRecognitionCodeDisabled = "RECOGNITION_DISABLED"
cardRecognitionCodeRateLimited = "RECOGNITION_RATE_LIMITED"
cardRecognitionDefaultBaseURL = "https://xueyangai.com/v1"
cardRecognitionDefaultModel = "gpt-4o"
cardRecognitionDefaultMaxImageSize = int64(8 * 1024 * 1024)
cardRecognitionDefaultMaxTokens = 256
cardRecognitionDefaultRateCount = 20
cardRecognitionDefaultRatePeriod = time.Minute
cardRecognitionRateLimitRedisKey = "iotlcm:app:card-recognition:rate:"
)
var cardRecognitionCandidatePattern = regexp.MustCompile(`(^|[^0-9])(89([-[:space:]]*[0-9]){17,18})($|[^0-9])`)
type cardRecognitionConfig struct {
Enabled bool
Provider string
APIKey string
BaseURL string
Endpoint string
Model string
Timeout time.Duration
MaxImageBytes int64
MaxTokens int
RateLimitEnabled bool
RateLimitCount int
RateLimitPeriod time.Duration
}
type cardRecognitionModelOutput struct {
Code string `json:"code"`
CardType string `json:"cardType"`
Message string `json:"message"`
ICCID string `json:"iccid"`
CardValue string `json:"cardValue"`
Candidates []string `json:"candidates"`
Confidence float64 `json:"confidence"`
}
type cardRecognitionParsedResult struct {
Code string
Candidates []string
Confidence float64
}
func (s *sAppCard) RecognizeCard(ctx context.Context) (*model.AppCardRecognitionModel, error) {
result := &model.AppCardRecognitionModel{
Code: cardRecognitionCodeFailed,
CardType: "iccid",
Candidates: []string{},
}
req := g.RequestFromCtx(ctx)
if req == nil {
result.Message = "无法读取上传请求,请稍后重试"
return result, nil
}
file := req.GetUploadFile("file")
if file == nil {
result.Code = cardRecognitionCodeUnreadable
result.Message = "请上传一张包含 ICCID 的图片"
return result, nil
}
config := loadCardRecognitionConfig(ctx)
if !config.Enabled || config.APIKey == "" {
result.Code = cardRecognitionCodeDisabled
result.Message = "图片识别服务暂未配置"
return result, nil
}
allowed, err := checkCardRecognitionRateLimit(ctx, config, req)
if err != nil {
g.Log().Warningf(ctx, "card recognition rate limit check failed: %v", err)
} else if !allowed {
result.Code = cardRecognitionCodeRateLimited
result.Message = "图片识别请求过于频繁,请稍后再试"
return result, nil
}
content, contentType, err := readCardRecognitionImage(file, config.MaxImageBytes)
if err != nil {
result.Code = cardRecognitionCodeUnreadable
result.Message = err.Error()
return result, nil
}
rawContent, err := requestCardRecognitionModel(ctx, config, content, contentType)
if err != nil {
g.Log().Warningf(ctx, "card recognition model request failed: %v", err)
if isCardRecognitionTimeoutError(ctx, err) {
result.Code = cardRecognitionCodeTimeout
result.Message = "图片识别超时,请稍后重试"
} else {
result.Code = cardRecognitionCodeFailed
result.Message = "图片识别服务暂时不可用,请稍后重试"
}
return result, nil
}
parsed := parseCardRecognitionResult(rawContent)
result.Confidence = parsed.Confidence
result.Candidates = parsed.Candidates
switch {
case parsed.Code == cardRecognitionCodeUnreadable && len(parsed.Candidates) == 0:
result.Code = cardRecognitionCodeUnreadable
result.Message = "未能从图片中识别出清晰的 ICCID,请重新拍摄"
return result, nil
case parsed.Code == cardRecognitionCodeAmbiguous && len(parsed.Candidates) > 1:
result.Code = cardRecognitionCodeAmbiguous
result.Message = "图片中存在多个可能的 ICCID,请重新拍摄单张卡片"
return result, nil
}
switch len(parsed.Candidates) {
case 0:
result.Code = cardRecognitionCodeUnreadable
result.Message = "未能从图片中识别出清晰的 ICCID,请重新拍摄"
return result, nil
case 1:
result.CardValue = parsed.Candidates[0]
result.Code = cardRecognitionCodeRecognized
result.Message = "已识别出 ICCID"
return result, nil
default:
result.Code = cardRecognitionCodeAmbiguous
result.Message = "图片中存在多个可能的 ICCID,请重新拍摄单张卡片"
return result, nil
}
}
func loadCardRecognitionConfig(ctx context.Context) cardRecognitionConfig {
timeout := 25 * time.Second
if value := strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.timeout", "25s").String()); value != "" {
if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 {
timeout = parsed
}
}
if value := appEnvFirst("APP_CARD_RECOGNITION_TIMEOUT"); value != "" {
if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 {
timeout = parsed
}
}
maxImageBytes := g.Cfg().MustGet(ctx, "appCardRecognition.maxImageBytes", cardRecognitionDefaultMaxImageSize).Int64()
if value := appEnvFirst("APP_CARD_RECOGNITION_MAX_IMAGE_BYTES"); value != "" {
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil && parsed > 0 {
maxImageBytes = parsed
}
}
if maxImageBytes <= 0 {
maxImageBytes = cardRecognitionDefaultMaxImageSize
}
maxTokens := g.Cfg().MustGet(ctx, "appCardRecognition.maxTokens", cardRecognitionDefaultMaxTokens).Int()
if value := appEnvFirst("APP_CARD_RECOGNITION_MAX_TOKENS"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 {
maxTokens = parsed
}
}
if maxTokens <= 0 {
maxTokens = cardRecognitionDefaultMaxTokens
}
rateLimitPeriod := cardRecognitionDefaultRatePeriod
if value := strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.rateLimit.period", rateLimitPeriod.String()).String()); value != "" {
if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 {
rateLimitPeriod = parsed
}
}
if value := appEnvFirst("APP_CARD_RECOGNITION_RATE_LIMIT_PERIOD"); value != "" {
if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 {
rateLimitPeriod = parsed
}
}
rateLimitCount := g.Cfg().MustGet(ctx, "appCardRecognition.rateLimit.count", cardRecognitionDefaultRateCount).Int()
if value := appEnvFirst("APP_CARD_RECOGNITION_RATE_LIMIT_COUNT"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil && parsed > 0 {
rateLimitCount = parsed
}
}
if rateLimitCount <= 0 {
rateLimitCount = cardRecognitionDefaultRateCount
}
baseURL := appEnvFirst("APP_CARD_RECOGNITION_BASE_URL")
endpoint := appEnvFirst("APP_CARD_RECOGNITION_ENDPOINT")
if baseURL == "" && endpoint == "" {
baseURL = strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.baseUrl", "").String())
endpoint = strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.endpoint", "").String())
}
if baseURL == "" && endpoint == "" {
baseURL = cardRecognitionDefaultBaseURL
}
modelName := strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.model", cardRecognitionDefaultModel).String())
if value := appEnvFirst("APP_CARD_RECOGNITION_MODEL"); value != "" {
modelName = value
}
if modelName == "" {
modelName = cardRecognitionDefaultModel
}
apiKey := strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.apiKey", "").String())
if value := appEnvFirst("APP_CARD_RECOGNITION_API_KEY", "XUEYANGAI_API_KEY"); value != "" {
apiKey = value
}
provider := appEnvFirst("APP_CARD_RECOGNITION_PROVIDER")
if provider == "" {
provider = strings.TrimSpace(g.Cfg().MustGet(ctx, "appCardRecognition.provider", internalai.ProviderOpenAICompatible).String())
}
return cardRecognitionConfig{
Enabled: appEnvBool("APP_CARD_RECOGNITION_ENABLED", g.Cfg().MustGet(ctx, "appCardRecognition.enabled", false).Bool()),
Provider: provider,
APIKey: apiKey,
BaseURL: baseURL,
Endpoint: endpoint,
Model: modelName,
Timeout: timeout,
MaxImageBytes: maxImageBytes,
MaxTokens: maxTokens,
RateLimitEnabled: appEnvBool("APP_CARD_RECOGNITION_RATE_LIMIT_ENABLED", g.Cfg().MustGet(ctx, "appCardRecognition.rateLimit.enabled", true).Bool()),
RateLimitCount: rateLimitCount,
RateLimitPeriod: rateLimitPeriod,
}
}
func readCardRecognitionImage(file *ghttp.UploadFile, maxImageBytes int64) ([]byte, string, error) {
if file == nil || file.FileHeader == nil {
return nil, "", gerror.New("上传图片无效")
}
if maxImageBytes <= 0 {
maxImageBytes = cardRecognitionDefaultMaxImageSize
}
if file.Size > maxImageBytes {
return nil, "", gerror.Newf("图片不能超过 %d MB", maxImageBytes/(1024*1024))
}
reader, err := file.Open()
if err != nil {
return nil, "", gerror.New("无法读取图片")
}
defer reader.Close()
content, err := io.ReadAll(io.LimitReader(reader, maxImageBytes+1))
if err != nil {
return nil, "", gerror.New("无法读取图片")
}
if int64(len(content)) > maxImageBytes {
return nil, "", gerror.Newf("图片不能超过 %d MB", maxImageBytes/(1024*1024))
}
contentType := strings.ToLower(strings.TrimSpace(file.Header.Get("Content-Type")))
if contentType == "" || contentType == "application/octet-stream" {
contentType = http.DetectContentType(content)
}
switch contentType {
case "image/jpeg", "image/png", "image/webp":
default:
return nil, "", gerror.New("仅支持 JPG、PNG 或 WEBP 图片")
}
return content, contentType, nil
}
func checkCardRecognitionRateLimit(ctx context.Context, config cardRecognitionConfig, req *ghttp.Request) (bool, error) {
if !config.RateLimitEnabled || config.RateLimitCount <= 0 {
return true, nil
}
periodSeconds := int(config.RateLimitPeriod.Seconds())
if periodSeconds <= 0 {
periodSeconds = int(cardRecognitionDefaultRatePeriod.Seconds())
}
clientIP := "unknown"
if req != nil {
if value := strings.TrimSpace(req.GetClientIp()); value != "" {
clientIP = value
}
}
key := cardRecognitionRateLimitRedisKey + cleanCardRecognitionRateLimitKey(clientIP)
script := `
local v = redis.call("INCR", KEYS[1])
if v == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return v`
res, err := g.Redis().Do(ctx, "EVAL", script, 1, key, periodSeconds)
if err != nil {
return true, gerror.Wrap(err, "检查图片识别限流失败")
}
return res.Int() <= config.RateLimitCount, nil
}
func cleanCardRecognitionRateLimitKey(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "unknown"
}
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
return r
}
return '_'
}, value)
}
func requestCardRecognitionModel(ctx context.Context, config cardRecognitionConfig, content []byte, contentType string) (string, error) {
requestCtx, cancel := context.WithTimeout(ctx, config.Timeout)
defer cancel()
chatModel, err := internalai.NewChatModel(requestCtx, internalai.ChatModelConfig{
Provider: config.Provider,
APIKey: config.APIKey,
BaseURL: config.BaseURL,
Endpoint: config.Endpoint,
Model: config.Model,
Timeout: config.Timeout,
MaxTokens: config.MaxTokens,
JSONOutput: true,
})
if err != nil {
return "", err
}
imageBase64 := base64.StdEncoding.EncodeToString(content)
response, err := chatModel.Generate(requestCtx, []*schema.Message{
{
Role: schema.User,
UserInputMultiContent: []schema.MessageInputPart{
{
Type: schema.ChatMessagePartTypeText,
Text: "请只识别图片中物联网卡的 ICCID。忽略 MSISDN、IMSI、IMEI、订单号和其他数字。请仅返回 JSON,不要返回 Markdown 或解释:{\"code\":\"CARD_RECOGNIZED|CARD_AMBIGUOUS|CARD_UNREADABLE\",\"cardType\":\"iccid\",\"iccid\":\"19或20位数字,没有则为空\",\"candidates\":[\"候选ICCID\"],\"confidence\":0到1之间的小数}。图片模糊、遮挡、反光或无法确定时,iccid 置空并返回 CARD_UNREADABLE;有多个卡号时返回 CARD_AMBIGUOUS,并把全部候选 ICCID 放入 candidates。",
},
{
Type: schema.ChatMessagePartTypeImageURL,
Image: &schema.MessageInputImage{
MessagePartCommon: schema.MessagePartCommon{
Base64Data: &imageBase64,
MIMEType: contentType,
},
Detail: schema.ImageURLDetailHigh,
},
},
},
},
})
if err != nil {
return "", gerror.Wrap(err, "Eino调用图片识别模型失败")
}
if response == nil || strings.TrimSpace(response.Content) == "" {
return "", gerror.New("图片识别响应为空")
}
return response.Content, nil
}
func parseCardRecognitionResult(raw string) cardRecognitionParsedResult {
raw = trimCardRecognitionJSON(raw)
output := cardRecognitionModelOutput{}
if json.Unmarshal([]byte(raw), &output) == nil {
rawValues := append([]string{output.ICCID, output.CardValue}, output.Candidates...)
candidates := normalizeCardRecognitionCandidates(rawValues)
code := normalizeCardRecognitionCode(output.Code)
if code == "" {
switch len(candidates) {
case 0:
code = cardRecognitionCodeUnreadable
case 1:
code = cardRecognitionCodeRecognized
default:
code = cardRecognitionCodeAmbiguous
}
}
return cardRecognitionParsedResult{
Code: code,
Candidates: candidates,
Confidence: clampCardRecognitionConfidence(output.Confidence),
}
}
candidates := normalizeCardRecognitionCandidates([]string{raw})
code := cardRecognitionCodeUnreadable
if len(candidates) == 1 {
code = cardRecognitionCodeRecognized
} else if len(candidates) > 1 {
code = cardRecognitionCodeAmbiguous
}
return cardRecognitionParsedResult{
Code: code,
Candidates: candidates,
Confidence: 0,
}
}
func normalizeCardRecognitionCandidates(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
for _, candidate := range extractCardRecognitionCandidates(value) {
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
result = append(result, candidate)
}
}
return result
}
func extractCardRecognitionCandidates(value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
result := make([]string, 0, 1)
if cleaned := cleanCardRecognitionCandidate(value); isValidCardRecognitionCandidate(cleaned) {
result = append(result, cleaned)
}
for _, match := range cardRecognitionCandidatePattern.FindAllStringSubmatch(value, -1) {
if len(match) < 3 {
continue
}
if cleaned := cleanCardRecognitionCandidate(match[2]); isValidCardRecognitionCandidate(cleaned) {
result = append(result, cleaned)
}
}
return result
}
func cleanCardRecognitionCandidate(value string) string {
return strings.Map(func(r rune) rune {
if r >= '0' && r <= '9' {
return r
}
return -1
}, strings.TrimSpace(value))
}
func isValidCardRecognitionCandidate(value string) bool {
return (len(value) == 19 || len(value) == 20) && strings.HasPrefix(value, "89")
}
func clampCardRecognitionConfidence(value float64) float64 {
if value < 0 {
return 0
}
if value > 1 {
return 1
}
return value
}
func trimCardRecognitionJSON(raw string) string {
raw = strings.TrimSpace(raw)
for _, prefix := range []string{"```json", "```JSON", "```"} {
if strings.HasPrefix(raw, prefix) {
raw = strings.TrimSpace(strings.TrimPrefix(raw, prefix))
break
}
}
if strings.HasSuffix(raw, "```") {
raw = strings.TrimSpace(strings.TrimSuffix(raw, "```"))
}
return raw
}
func normalizeCardRecognitionCode(code string) string {
switch strings.ToUpper(strings.TrimSpace(code)) {
case cardRecognitionCodeRecognized:
return cardRecognitionCodeRecognized
case cardRecognitionCodeAmbiguous:
return cardRecognitionCodeAmbiguous
case cardRecognitionCodeUnreadable:
return cardRecognitionCodeUnreadable
default:
return ""
}
}
func isCardRecognitionTimeoutError(ctx context.Context, err error) bool {
if ctx != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) {
return true
}
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "timeout")
}
@@ -0,0 +1,40 @@
package app
import (
"bytes"
"context"
"image"
"image/color"
"image/draw"
"image/png"
"os"
"strings"
"testing"
)
func TestCardRecognitionLive(t *testing.T) {
if os.Getenv("RUN_CARD_RECOGNITION_LIVE") != "1" {
t.Skip("explicit opt-in required: calls configured model service")
}
ctx := context.Background()
cfg := loadCardRecognitionConfig(ctx)
if !cfg.Enabled || cfg.APIKey == "" {
t.Fatal("disabled or missing API key")
}
img := image.NewRGBA(image.Rect(0, 0, 256, 128))
draw.Draw(img, img.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatal(err)
}
t.Logf("provider=%s model=%s timeout=%s", cfg.Provider, cfg.Model, cfg.Timeout)
raw, err := requestCardRecognitionModel(ctx, cfg, buf.Bytes(), "image/png")
if err != nil {
t.Fatal(strings.ReplaceAll(err.Error(), cfg.APIKey, "[REDACTED]"))
}
result := parseCardRecognitionResult(raw)
t.Logf("result code=%s candidates=%d", result.Code, len(result.Candidates))
if result.Code != cardRecognitionCodeUnreadable || len(result.Candidates) != 0 {
t.Fatal("blank image must not produce an ICCID")
}
}
@@ -0,0 +1,164 @@
package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type timeoutNetError struct{}
func (timeoutNetError) Error() string { return "timeout" }
func (timeoutNetError) Timeout() bool { return true }
func (timeoutNetError) Temporary() bool { return true }
func TestNormalizeCardRecognitionCandidates(t *testing.T) {
got := normalizeCardRecognitionCandidates([]string{
"ICCID: 8986-0401-7854-7796-048",
"8986040178547796048",
"89860401785477960480",
"7986040178547796048",
"898604017854779604",
"898604017854779604800",
})
want := []string{
"8986040178547796048",
"89860401785477960480",
}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d, got=%v", len(got), len(want), got)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("got[%d] = %s, want %s", index, got[index], want[index])
}
}
}
func TestRequestCardRecognitionModelUsesEinoMultimodalMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s, want /v1/chat/completions", request.URL.Path)
}
var body struct {
Messages []struct {
Content []struct {
Type string `json:"type"`
ImageURL struct {
URL string `json:"url"`
} `json:"image_url"`
} `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
if len(body.Messages) != 1 || len(body.Messages[0].Content) != 2 {
t.Errorf("unexpected messages: %+v", body.Messages)
} else if !strings.HasPrefix(body.Messages[0].Content[1].ImageURL.URL, "data:image/png;base64,") {
t.Errorf("image url = %s", body.Messages[0].Content[1].ImageURL.URL)
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"{\"code\":\"CARD_RECOGNIZED\",\"iccid\":\"8986040178547796048\"}"},"finish_reason":"stop"}]}`))
}))
defer server.Close()
content, err := requestCardRecognitionModel(context.Background(), cardRecognitionConfig{
APIKey: "test-key",
BaseURL: server.URL + "/v1",
Model: "test-model",
Timeout: 3 * time.Second,
MaxTokens: 64,
}, []byte("image"), "image/png")
if err != nil {
t.Fatalf("requestCardRecognitionModel() error = %v", err)
}
if !strings.Contains(content, "8986040178547796048") {
t.Fatalf("content = %s", content)
}
}
func TestParseCardRecognitionResultMarkdownJSON(t *testing.T) {
got := parseCardRecognitionResult("```json\n{\"code\":\"CARD_RECOGNIZED\",\"cardType\":\"iccid\",\"iccid\":\"8986040178547796048\",\"candidates\":[\"8986040178547796048\"],\"confidence\":1.2}\n```")
if got.Code != cardRecognitionCodeRecognized {
t.Fatalf("Code = %s, want %s", got.Code, cardRecognitionCodeRecognized)
}
if got.Confidence != 1 {
t.Fatalf("Confidence = %v, want 1", got.Confidence)
}
if len(got.Candidates) != 1 || got.Candidates[0] != "8986040178547796048" {
t.Fatalf("Candidates = %v", got.Candidates)
}
}
func TestParseCardRecognitionResultAmbiguous(t *testing.T) {
got := parseCardRecognitionResult(`{
"code": "CARD_AMBIGUOUS",
"cardType": "iccid",
"candidates": ["8986040178547796048", "89860401785477960480"],
"confidence": 0.84
}`)
if got.Code != cardRecognitionCodeAmbiguous {
t.Fatalf("Code = %s, want %s", got.Code, cardRecognitionCodeAmbiguous)
}
if len(got.Candidates) != 2 {
t.Fatalf("Candidates = %v", got.Candidates)
}
}
func TestParseCardRecognitionResultFiltersInvalidCandidates(t *testing.T) {
got := parseCardRecognitionResult(`{
"code": "CARD_RECOGNIZED",
"iccid": "13800008000",
"candidates": ["898604017854779604", "898604017854779604800", "7986040178547796048"],
"confidence": -0.4
}`)
if got.Confidence != 0 {
t.Fatalf("Confidence = %v, want 0", got.Confidence)
}
if len(got.Candidates) != 0 {
t.Fatalf("Candidates = %v, want empty", got.Candidates)
}
}
func TestParseCardRecognitionResultFallbackFromText(t *testing.T) {
got := parseCardRecognitionResult("识别到 ICCID 8986040178547796048,另一个候选是 89860401785477960480。")
if got.Code != cardRecognitionCodeAmbiguous {
t.Fatalf("Code = %s, want %s", got.Code, cardRecognitionCodeAmbiguous)
}
if len(got.Candidates) != 2 {
t.Fatalf("Candidates = %v", got.Candidates)
}
}
func TestClampCardRecognitionConfidence(t *testing.T) {
if got := clampCardRecognitionConfidence(-0.1); got != 0 {
t.Fatalf("negative clamp = %v, want 0", got)
}
if got := clampCardRecognitionConfidence(1.1); got != 1 {
t.Fatalf("over one clamp = %v, want 1", got)
}
if got := clampCardRecognitionConfidence(0.5); got != 0.5 {
t.Fatalf("normal clamp = %v, want 0.5", got)
}
}
func TestIsCardRecognitionTimeoutError(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
defer cancel()
<-ctx.Done()
if !isCardRecognitionTimeoutError(ctx, errors.New("other")) {
t.Fatal("deadline exceeded context should be treated as timeout")
}
if !isCardRecognitionTimeoutError(context.Background(), timeoutNetError{}) {
t.Fatal("net timeout error should be treated as timeout")
}
if isCardRecognitionTimeoutError(context.Background(), errors.New("temporary unavailable")) {
t.Fatal("non-timeout error should not be treated as timeout")
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,306 @@
package app
import (
"context"
"fmt"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type appRenewExecutionContext struct {
CardPeriods []map[string]any
LatestBindsByIccid map[string]*entity.TSimCardBindPkg
NewBindsByIccid map[string]*entity.TSimCardBindPkg
BeginAtByIccid map[string]*gtime.Time
EndAtByIccid map[string]*gtime.Time
MinBeginAt *gtime.Time
MaxEndAt *gtime.Time
}
func (s *sAppCard) prepareAppRenewExecution(ctx context.Context, tx gdb.TX, batch *appRenewBatchContext, durationMonths int) (*appRenewExecutionContext, error) {
if batch == nil || len(batch.Items) == 0 {
return nil, gerror.New("续费卡不能为空")
}
if durationMonths <= 0 {
return nil, gerror.New("续费时长必须大于0")
}
exec := &appRenewExecutionContext{
CardPeriods: make([]map[string]any, 0, len(batch.Items)),
LatestBindsByIccid: make(map[string]*entity.TSimCardBindPkg, len(batch.Items)),
NewBindsByIccid: make(map[string]*entity.TSimCardBindPkg, len(batch.Items)),
BeginAtByIccid: make(map[string]*gtime.Time, len(batch.Items)),
EndAtByIccid: make(map[string]*gtime.Time, len(batch.Items)),
}
for index, item := range batch.Items {
if item == nil || item.Card == nil {
return nil, gerror.New("续费卡信息不完整")
}
lockedCard, err := s.lockRenewCardForSubmit(ctx, tx, item.Card, item.ExpectedCurrentPackageCode)
if err != nil {
return nil, err
}
latestBind, err := s.lockLatestRenewBindForSubmit(ctx, tx, lockedCard, item)
if err != nil {
return nil, err
}
baseExpireAt := appRenewBaseExpireTimeFromEntity(lockedCard, latestBind)
if baseExpireAt == nil || baseExpireAt.IsZero() {
return nil, gerror.Newf("ICCID最新订购记录没有预计到期时间,不能续费:%s", lockedCard.Iccid)
}
if appRenewExpireAfterAllowedWindow(baseExpireAt) {
return nil, gerror.New("距离到期超过6个月,不能续费")
}
beginAt := appRenewBeginDateFromExpire(baseExpireAt)
endAt := beginAt.AddDate(0, durationMonths, 0)
exec.MinBeginAt = appEarlierTime(exec.MinBeginAt, beginAt)
exec.MaxEndAt = appLaterTime(exec.MaxEndAt, endAt)
exec.LatestBindsByIccid[item.Card.Iccid] = latestBind
exec.BeginAtByIccid[item.Card.Iccid] = beginAt
exec.EndAtByIccid[item.Card.Iccid] = endAt
item.LatestBind = latestBind
exec.CardPeriods = append(exec.CardPeriods, map[string]any{
"rowNo": appRenewBatchSourceRowNo(index),
"cardId": item.Card.Id,
"iccid": item.Card.Iccid,
"msisdn": item.Card.Msisdn,
"oldSubscriptionNo": latestBind.SubscriptionNo,
"serviceBeginAt": appRenewFormatTime(beginAt),
"serviceEndAt": appRenewFormatTime(endAt),
"oldExpectedExpireTime": appRenewFormatTime(latestBind.ExpectedExpireTime),
})
}
return exec, nil
}
func (s *sAppCard) lockLatestRenewBindForSubmit(ctx context.Context, tx gdb.TX, lockedCard *entity.TSimCardInfo, item *appRenewPackageContext) (*entity.TSimCardBindPkg, error) {
if lockedCard == nil || item == nil || item.Card == nil {
return nil, gerror.New("续费卡信息不完整")
}
expectedPackageCode := strings.TrimSpace(item.ExpectedCurrentPackageCode)
if expectedPackageCode == "" {
return nil, gerror.New("ICCID没有当前套餐,不能续费")
}
var bind *entity.TSimCardBindPkg
err := dao.TSimCardBindPkg.Ctx(ctx).TX(tx).
Where(dao.TSimCardBindPkg.Columns().Iccid, lockedCard.Iccid).
OrderDesc(dao.TSimCardBindPkg.Columns().ExpectedExpireTime).
OrderDesc(dao.TSimCardBindPkg.Columns().Id).
LockUpdate().
Limit(1).
Scan(&bind)
if err != nil {
return nil, gerror.Wrap(err, "锁定最新订购记录失败")
}
if bind == nil || bind.Id == 0 {
return nil, gerror.Newf("ICCID没有订购记录,不能续费:%s", lockedCard.Iccid)
}
if strings.TrimSpace(bind.PackageCode) != expectedPackageCode {
return nil, gerror.Newf("ICCID当前套餐与最新订购记录不一致,不能续费:%s", lockedCard.Iccid)
}
return bind, nil
}
func (s *sAppCard) insertAppRenewSubscriptionBinds(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appRenewBatchContext, exec *appRenewExecutionContext, batchNo string, orderSn string, durationMonths int, now *gtime.Time) error {
if user == nil || batch == nil || batch.Package == nil || batch.Party == nil || exec == nil {
return gerror.New("续费订购记录信息不完整")
}
batchNo = strings.TrimSpace(batchNo)
orderSn = strings.TrimSpace(orderSn)
if batchNo == "" || orderSn == "" {
return gerror.New("续费批次号或订单号不能为空")
}
periodByIccid := make(map[string]map[string]any, len(exec.CardPeriods))
for _, period := range exec.CardPeriods {
iccid := strings.TrimSpace(fmt.Sprint(period["iccid"]))
if iccid != "" {
periodByIccid[iccid] = period
}
}
rows := make([]do.TSimCardBindPkg, 0, len(batch.Items))
for _, item := range batch.Items {
card := item.Card
if card == nil {
return gerror.New("续费卡信息不完整")
}
latestBind := exec.LatestBindsByIccid[card.Iccid]
if latestBind == nil {
return gerror.Newf("ICCID最新订购记录不完整:%s", card.Iccid)
}
periodBegin := exec.BeginAtByIccid[card.Iccid]
periodEnd := exec.EndAtByIccid[card.Iccid]
if periodBegin == nil || periodBegin.IsZero() || periodEnd == nil || periodEnd.IsZero() {
return gerror.Newf("ICCID续费服务周期不完整:%s", card.Iccid)
}
subscriptionNo := appOrderNo("SUB", user.UserId, now)
row := do.TSimCardBindPkg{
SubscriptionNo: subscriptionNo,
BatchNo: batchNo,
Iccid: card.Iccid,
Msisdn: strings.TrimSpace(card.Msisdn),
DeptId: fmt.Sprintf("%d", batch.Party.CustomerAgentId),
PackageType: batch.Package.PackageType,
PackageCode: batch.Package.PackageCode,
PreviousPackageCode: item.ExpectedCurrentPackageCode,
BusinessType: consts.PackageBusinessTypeRenew,
EffectiveStatus: consts.EffectiveStatusPending,
HandleTime: now,
EffectiveTime: periodBegin,
ExpectedExpireTime: periodEnd,
DurationMonths: durationMonths,
GiftDurationMonths: 0,
OrderCode: orderSn,
Remark: batchNo,
TenantId: user.TenantId,
CreatedDept: batch.Party.CreatedDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}
rows = append(rows, row)
newBind := &entity.TSimCardBindPkg{
SubscriptionNo: subscriptionNo,
BatchNo: batchNo,
Iccid: card.Iccid,
Msisdn: strings.TrimSpace(card.Msisdn),
DeptId: fmt.Sprintf("%d", batch.Party.CustomerAgentId),
PackageType: batch.Package.PackageType,
PackageCode: batch.Package.PackageCode,
PreviousPackageCode: item.ExpectedCurrentPackageCode,
BusinessType: consts.PackageBusinessTypeRenew,
EffectiveStatus: consts.EffectiveStatusPending,
HandleTime: now,
EffectiveTime: periodBegin,
ExpectedExpireTime: periodEnd,
DurationMonths: durationMonths,
GiftDurationMonths: 0,
OrderCode: orderSn,
Remark: batchNo,
TenantId: user.TenantId,
CreatedDept: batch.Party.CreatedDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}
exec.NewBindsByIccid[card.Iccid] = newBind
if period := periodByIccid[card.Iccid]; period != nil {
period["newSubscriptionNo"] = subscriptionNo
period["renewEffectiveTime"] = appRenewFormatTime(periodBegin)
period["renewExpectedExpireTime"] = appRenewFormatTime(periodEnd)
period["cardFlowPkgEndAt"] = appRenewFormatTime(periodEnd)
}
}
if len(rows) > 0 {
if _, err := dao.TSimCardBindPkg.Ctx(ctx).TX(tx).Data(rows).Insert(); err != nil {
return gerror.Wrap(err, "写入续费订购记录失败")
}
}
return s.updateCardsAfterAppRenew(ctx, tx, user, batch, exec, now)
}
func (s *sAppCard) updateCardsAfterAppRenew(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appRenewBatchContext, exec *appRenewExecutionContext, now *gtime.Time) error {
for _, item := range batch.Items {
if item == nil || item.Card == nil {
return gerror.New("续费卡信息不完整")
}
newBind := exec.NewBindsByIccid[item.Card.Iccid]
if newBind == nil || newBind.ExpectedExpireTime == nil {
return gerror.Newf("ICCID续费订购记录不完整:%s", item.Card.Iccid)
}
packageStatus := appCalcPackageStatus(now, item.Card.FlowPkgBeginAt, newBind.ExpectedExpireTime)
result, err := dao.TSimCardInfo.Ctx(ctx).TX(tx).
Where(dao.TSimCardInfo.Columns().TenantId, item.Card.TenantId).
Where(dao.TSimCardInfo.Columns().Iccid, item.Card.Iccid).
Data(do.TSimCardInfo{
CurrentPackageCode: batch.Package.PackageCode,
FlowPkgEndAt: newBind.ExpectedExpireTime,
PackageStatus: packageStatus,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新续费后卡套餐到期时间失败")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "读取续费后卡信息更新数量失败")
}
if affected != 1 {
return gerror.Newf("ICCID卡信息已变化,请刷新后重试:%s", item.Card.Iccid)
}
item.Card.FlowPkgEndAt = newBind.ExpectedExpireTime
item.Card.PackageStatus = packageStatus
item.Card.CurrentPackageCode = batch.Package.PackageCode
if err = s.updateTerminalBindAfterAppRenew(ctx, tx, user, batch, item, now); err != nil {
return err
}
}
return nil
}
func (s *sAppCard) updateTerminalBindAfterAppRenew(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appRenewBatchContext, item *appRenewPackageContext, now *gtime.Time) error {
if item == nil || item.Card == nil || batch == nil || batch.Party == nil || !s.isTerminalUser(ctx, user) {
return nil
}
card := item.Card
if card.TerminalBindId <= 0 && card.TerminalUserId <= 0 {
return nil
}
mod := dao.TSimTerminal.Ctx(ctx).TX(tx).
Where(dao.TSimTerminal.Columns().TenantId, card.TenantId).
Where(dao.TSimTerminal.Columns().Iccid, card.Iccid)
if card.TerminalBindId > 0 {
mod = mod.Where(dao.TSimTerminal.Columns().Id, card.TerminalBindId)
} else {
mod = mod.Where(dao.TSimTerminal.Columns().TerminalId, user.UserId)
}
result, err := mod.Data(do.TSimTerminal{
DeptId: batch.Party.CustomerAgentId,
CurrentPackageCode: strings.TrimSpace(batch.Package.PackageCode),
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Update()
if err != nil {
return gerror.Wrap(err, "更新续费后的终端卡关联信息失败")
}
affected, err := result.RowsAffected()
if err != nil {
return gerror.Wrap(err, "读取续费后的终端卡关联更新数量失败")
}
if affected != 1 {
return gerror.Newf("ICCID终端卡关联已变化,请刷新后重试:%s", card.Iccid)
}
card.TerminalDeptId = batch.Party.CustomerAgentId
card.TerminalCurrentPackageCode = strings.TrimSpace(batch.Package.PackageCode)
return nil
}
func appCalcPackageStatus(now, beginAt, endAt *gtime.Time) int {
if now == nil {
now = gtime.Now()
}
if beginAt != nil && beginAt.After(now) {
return consts.PackageStatusPending
}
if endAt != nil && !endAt.After(now) {
return consts.PackageStatusExpired
}
return consts.PackageStatusEffective
}
@@ -0,0 +1,142 @@
package app
import (
"reflect"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/os/gtime"
)
func TestAppRenewBeginDateFromExpireUsesPreviousMonthEnd(t *testing.T) {
expireAt := gtime.NewFromStr("2026-08-15 00:00:00")
got := appRenewBeginDateFromExpire(expireAt)
if got == nil {
t.Fatal("expected renew begin time")
}
if want := "2026-07-31 23:59:59"; got.Format("Y-m-d H:i:s") != want {
t.Fatalf("expected %s, got %s", want, got.Format("Y-m-d H:i:s"))
}
}
func TestAppRenewExpireAfterUsesPreviousMonthEnd(t *testing.T) {
expireAt := gtime.NewFromStr("2026-08-15 00:00:00")
got := appRenewExpireAfter(expireAt, 3)
if got == nil {
t.Fatal("expected renew expire after time")
}
if want := "2026-10-31 23:59:59"; got.Format("Y-m-d H:i:s") != want {
t.Fatalf("expected %s, got %s", want, got.Format("Y-m-d H:i:s"))
}
}
func TestAppRenewableDurationYearOptionsUsesYearConfig(t *testing.T) {
pkg := &entity.TSimFlowPackageInfo{
RenewableDurationConfig: gjson.New([]int{1, 2, 3}),
}
got, err := appRenewableDurationYearOptions(pkg)
if err != nil {
t.Fatalf("appRenewableDurationYearOptions() error = %v", err)
}
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
t.Fatalf("appRenewableDurationYearOptions() = %#v, want %#v", got, want)
}
}
func TestAppRenewableDurationYearOptionsCompatibleWithLegacyMonthConfig(t *testing.T) {
pkg := &entity.TSimFlowPackageInfo{
RenewableDurationConfig: gjson.New([]int{12, 24, 36}),
}
got, err := appRenewableDurationYearOptions(pkg)
if err != nil {
t.Fatalf("appRenewableDurationYearOptions() error = %v", err)
}
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
t.Fatalf("appRenewableDurationYearOptions() = %#v, want %#v", got, want)
}
}
func TestAppRenewApplyPriceRuleNormalizesPercentRatio(t *testing.T) {
if got, want := appRenewApplyPriceRule(100, 85, 5), 90.0; got != want {
t.Fatalf("appRenewApplyPriceRule() = %v, want %v", got, want)
}
if got, want := appRenewApplyPriceRule(100, 0.85, 5), 90.0; got != want {
t.Fatalf("appRenewApplyPriceRule() = %v, want %v", got, want)
}
}
func TestAppRenewEffectiveSalePriceUsesPriceOwnerBaseCost(t *testing.T) {
salePrice := appRenewEffectiveSalePriceFromBaseCosts(
appRenewEffectiveBaseCosts{
AgentCost: 80,
TerminalCost: 120,
},
appRenewPriceRule{
AgentCostRatio: 1.2,
AgentCostProfit: 3,
TerminalCostRatio: 1.5,
TerminalCostProfit: 6,
},
)
if got, want := salePrice.AgentShipmentPrice, 99.0; got != want {
t.Fatalf("agent shipment price = %v, want %v", got, want)
}
if got, want := salePrice.TerminalSalePrice, 186.0; got != want {
t.Fatalf("terminal sale price = %v, want %v", got, want)
}
}
func TestAppRenewPriceRuleUsesTargetAndDefaultSnapshots(t *testing.T) {
target := appRenewPriceRuleFromOverride("target", &entity.TSimFlowPackagePrice{
Id: 11,
AgentCostRatio: 0.9,
AgentCostProfit: 3,
UserCostRatio: 1.2,
UserCostProfit: 5,
})
if target.PriceSource != "target" || target.TargetPriceId != 11 || target.AgentDefaultPriceId != 0 {
t.Fatalf("target rule snapshot = %#v", target)
}
if got, want := appRenewEffectiveSalePriceFromBaseCosts(appRenewEffectiveBaseCosts{
AgentCost: 100,
TerminalCost: 100,
}, target).AgentShipmentPrice, 93.0; got != want {
t.Fatalf("target agent sale price = %v, want %v", got, want)
}
defaultRule := appRenewPriceRuleFromOverride("agentDefault", &entity.TSimFlowPackagePrice{
Id: 12,
AgentCostRatio: 80,
AgentCostProfit: 1,
UserCostRatio: 150,
UserCostProfit: 2,
})
if defaultRule.PriceSource != "agentDefault" || defaultRule.AgentDefaultPriceId != 12 || defaultRule.TargetPriceId != 0 {
t.Fatalf("default rule snapshot = %#v", defaultRule)
}
if got, want := appRenewEffectiveSalePriceFromBaseCosts(appRenewEffectiveBaseCosts{
AgentCost: 100,
TerminalCost: 100,
}, defaultRule).TerminalSalePrice, 152.0; got != want {
t.Fatalf("default terminal sale price = %v, want %v", got, want)
}
}
func TestAppRenewSystemBaseCostsFallsBackToCostPriceForTerminal(t *testing.T) {
pkg := &entity.TSimFlowPackageInfo{
CostPrice: 80,
TerminalCost: 0,
}
got := appRenewSystemBaseCosts(pkg)
if got.AgentCost != 80 || got.TerminalCost != 80 {
t.Fatalf("appRenewSystemBaseCosts() = %#v, want agent=80 terminal=80", got)
}
}
@@ -0,0 +1,166 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
)
type appRenewBatchProcessor struct {
batch *appRenewBatchContext
}
type appRenewBatchRow struct {
rowNo int
itemID string
snapshot *gjson.Json
errorReasons []string
}
func newAppRenewBatchProcessor(batch *appRenewBatchContext) *appRenewBatchProcessor {
return &appRenewBatchProcessor{batch: batch}
}
func appRenewBatchRows(batch *appRenewBatchContext) []batchimport.Row {
if batch == nil {
return nil
}
rows := make([]batchimport.Row, 0, len(batch.Items))
for index, item := range batch.Items {
rowNo := appRenewBatchSourceRowNo(index)
itemID := ""
var snapshot *gjson.Json
if item != nil && item.Card != nil {
itemID = strings.TrimSpace(item.Card.Iccid)
snapshot = appRenewPrepareDetailSnapshot(batch, item, rowNo)
}
rows = append(rows, &appRenewBatchRow{
rowNo: rowNo,
itemID: itemID,
snapshot: snapshot,
})
}
return rows
}
func (p *appRenewBatchProcessor) Config() batchimport.ProcessorConfig {
return batchimport.ProcessorConfig{
Code: "miniapp_card_renew",
BatchType: consts.BatchTypeRenew,
BatchNoPrefix: consts.BatchNoPrefixRenew,
TaskName: consts.ResourceBatchDownloadTaskSimCardRenew,
ExportScene: consts.ResourceExportSceneRenew,
ImportDir: consts.SimCardRenewSubDirImportExcel,
ResultDir: consts.SimCardRenewSubDirResultExcel,
MaxFileSize: 50 << 20,
TemplateHeaders: []string{"ICCID", "MSISDN", "IMSI"},
RequiredHeaders: []string{"ICCID"},
}
}
func (p *appRenewBatchProcessor) ParseRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
return nil, gerror.New("小程序续费暂不支持文件导入")
}
func (p *appRenewBatchProcessor) ValidateRows(ctx context.Context, rows []batchimport.Row) error {
seen := make(map[string]batchimport.Row, len(rows))
for _, row := range rows {
if row == nil {
continue
}
itemID := strings.TrimSpace(row.GetItemID())
if itemID == "" {
row.AddErrorReason("ICCID不能为空")
continue
}
if first, ok := seen[itemID]; ok {
row.AddErrorReason("ICCID重复")
first.AddErrorReason("ICCID重复")
continue
}
seen[itemID] = row
}
return nil
}
func (p *appRenewBatchProcessor) Execute(ctx context.Context, tx gdb.TX, req *batchimport.SubmitRequest, rows []batchimport.Row) error {
return gerror.New("小程序续费批次确认由余额支付流程触发")
}
func (p *appRenewBatchProcessor) BuildBatchInfo(ctx context.Context, req batchimport.SubmitRequest, result *batchimport.Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json {
info := map[string]any{}
if p.batch != nil {
if base := appRenewPrepareBatchInfo(p.batch, "", "", 0, 0); base != nil {
for key, value := range base.Map() {
info[key] = value
}
}
}
if sourceBatchNo := strings.TrimSpace(req.SourceBatchNo); sourceBatchNo != "" {
info["sourceBatchNo"] = sourceBatchNo
}
if importOss != nil && importOss.OssId > 0 {
info["importFileOssId"] = importOss.OssId
}
if resultOss != nil && resultOss.OssId > 0 {
info["resultFileOssId"] = resultOss.OssId
}
if downloadId > 0 {
info["resultDownloadId"] = downloadId
}
if result != nil && result.FailReason != "" {
info["failReason"] = result.FailReason
}
return gjson.New(info)
}
func (r *appRenewBatchRow) GetRowNo() int {
if r == nil {
return 0
}
return r.rowNo
}
func (r *appRenewBatchRow) GetItemID() string {
if r == nil {
return ""
}
return strings.TrimSpace(r.itemID)
}
func (r *appRenewBatchRow) GetErrorReasons() []string {
if r == nil {
return nil
}
return r.errorReasons
}
func (r *appRenewBatchRow) AddErrorReason(reason string) {
if r == nil {
return
}
reason = strings.TrimSpace(reason)
if reason == "" {
return
}
for _, item := range r.errorReasons {
if item == reason {
return
}
}
r.errorReasons = append(r.errorReasons, reason)
}
func (r *appRenewBatchRow) Snapshot() *gjson.Json {
if r == nil || r.snapshot == nil {
return gjson.New(map[string]any{})
}
return r.snapshot
}
@@ -0,0 +1,269 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type appRenewPackageContext struct {
Card *appCardQueryRow
Package *entity.TSimFlowPackageInfo
PackageCode string
ExpectedCurrentPackageCode string
Subscription *entity.TSimCardBindPkg
LatestBind *entity.TSimCardBindPkg
}
type appRenewTargetContext struct {
Package *entity.TSimFlowPackageInfo
Price *appRenewPriceSnapshot
Durations []int
}
// resolveRenewPackageContext 统一解析“当前可续费套餐”。
// 当前套餐只用于锁定卡片和读取 renewable_package_codes,续费目标套餐由后续步骤单独解析。
func (s *sAppCard) resolveRenewPackageContext(ctx context.Context, card *appCardQueryRow, subscriptionNo string) (*appRenewPackageContext, error) {
if card == nil || strings.TrimSpace(card.Iccid) == "" {
return nil, gerror.New("卡信息不存在或无权续费")
}
currentPackageCode := appCardCurrentPackageCode(card)
renewCtx := &appRenewPackageContext{
Card: card,
ExpectedCurrentPackageCode: currentPackageCode,
}
subscriptionNo = strings.TrimSpace(subscriptionNo)
if subscriptionNo != "" {
bind, err := s.loadRenewSubscription(ctx, card, subscriptionNo)
if err != nil {
return nil, err
}
if currentPackageCode != "" && strings.TrimSpace(bind.PackageCode) != currentPackageCode {
return nil, gerror.New("订购记录不是当前卡片套餐,请刷新后重试")
}
renewCtx.Subscription = bind
}
latestBind, err := s.loadLatestRenewBind(ctx, card)
if err != nil {
return nil, err
}
renewCtx.LatestBind = latestBind
if err = appValidateRenewLatestBind(card, currentPackageCode, latestBind); err != nil {
return nil, err
}
pkg, err := s.findRenewPackageOrNil(ctx, card.TenantId, currentPackageCode)
if err != nil {
return nil, err
}
if pkg == nil {
return nil, gerror.Newf("当前卡片套餐编码 %s 未配置套餐信息", currentPackageCode)
}
renewCtx.Package = pkg
renewCtx.PackageCode = pkg.PackageCode
return renewCtx, nil
}
func (s *sAppCard) resolveRenewTargetPackages(ctx context.Context, user *entity.SysUser, renewCtx *appRenewPackageContext, requestedPackageCode string) ([]string, []*appRenewTargetContext, *appRenewTargetContext, error) {
if renewCtx == nil || renewCtx.Card == nil || renewCtx.Package == nil {
return nil, nil, nil, gerror.New("当前套餐信息不完整")
}
codes := appChangePackageCodes(renewCtx.Package.RenewablePackageCodes)
if len(codes) == 0 {
return nil, nil, nil, gerror.New("当前套餐未配置可续费套餐")
}
requestedPackageCode = appRenewRequestedTargetPackageCode(renewCtx, requestedPackageCode, codes)
packages, err := s.loadRenewPackagesByCodes(ctx, renewCtx.Card.TenantId, codes)
if err != nil {
return nil, nil, nil, err
}
targets := make([]*appRenewTargetContext, 0, len(codes))
var selected *appRenewTargetContext
for _, code := range codes {
pkg := packages[strings.TrimSpace(code)]
if pkg == nil {
continue
}
durations, err := appRenewableDurationYearOptions(pkg)
if err != nil {
return nil, nil, nil, gerror.Newf("续费套餐%s:%v", pkg.PackageName, err)
}
price, err := s.resolveRenewUnitPrice(ctx, user, renewCtx.Card, pkg)
if err != nil {
return nil, nil, nil, err
}
if price.UnitPrice < 0 {
return nil, nil, nil, gerror.Newf("续费套餐%s价格配置不正确", pkg.PackageName)
}
target := &appRenewTargetContext{
Package: pkg,
Price: price,
Durations: durations,
}
targets = append(targets, target)
if requestedPackageCode != "" && strings.TrimSpace(pkg.PackageCode) == requestedPackageCode {
selected = target
}
}
if len(targets) == 0 {
return nil, nil, nil, gerror.New("当前套餐可续费套餐不存在或未配置续费年限")
}
if requestedPackageCode != "" && selected == nil {
return nil, nil, nil, gerror.New("续费套餐不存在")
}
if selected == nil {
selected = targets[0]
}
return codes, targets, selected, nil
}
func (s *sAppCard) loadRenewPackagesByCodes(ctx context.Context, tenantId string, codes []string) (map[string]*entity.TSimFlowPackageInfo, error) {
result := make(map[string]*entity.TSimFlowPackageInfo, len(codes))
codes = appNormalizePackageCodes(codes)
if len(codes) == 0 {
return result, nil
}
var rows []*entity.TSimFlowPackageInfo
err := dao.TSimFlowPackageInfo.Ctx(ctx).
Where(dao.TSimFlowPackageInfo.Columns().TenantId, strings.TrimSpace(tenantId)).
WhereIn(dao.TSimFlowPackageInfo.Columns().PackageCode, codes).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询可续费套餐列表失败")
}
for _, row := range rows {
if row != nil && strings.TrimSpace(row.PackageCode) != "" {
result[strings.TrimSpace(row.PackageCode)] = row
}
}
return result, nil
}
func appRenewRequestedTargetPackageCode(renewCtx *appRenewPackageContext, requestedPackageCode string, renewableCodes []string) string {
requestedPackageCode = strings.TrimSpace(requestedPackageCode)
if requestedPackageCode == "" {
return ""
}
if appContainsString(renewableCodes, requestedPackageCode) {
return requestedPackageCode
}
if renewCtx != nil && strings.TrimSpace(renewCtx.PackageCode) == requestedPackageCode {
return ""
}
return requestedPackageCode
}
func appContainsString(values []string, target string) bool {
target = strings.TrimSpace(target)
if target == "" {
return false
}
for _, value := range values {
if strings.TrimSpace(value) == target {
return true
}
}
return false
}
func appValidateRenewLatestBind(card *appCardQueryRow, currentPackageCode string, latestBind *entity.TSimCardBindPkg) error {
if card == nil {
return gerror.New("卡信息不存在或无权续费")
}
currentPackageCode = strings.TrimSpace(currentPackageCode)
if currentPackageCode == "" || card.PackageStatus == consts.PackageStatusNone {
return gerror.Newf("ICCID没有当前套餐,不能续费:%s", card.Iccid)
}
if latestBind == nil || latestBind.Id == 0 {
return gerror.Newf("ICCID没有订购记录,不能续费:%s", card.Iccid)
}
if strings.TrimSpace(latestBind.PackageCode) != currentPackageCode {
return gerror.Newf("ICCID当前套餐与最新订购记录不一致,不能续费:%s", card.Iccid)
}
baseExpireAt := appRenewBaseExpireTimeFromRow(card, latestBind)
if baseExpireAt == nil || baseExpireAt.IsZero() {
return gerror.Newf("ICCID最新订购记录没有预计到期时间,不能续费:%s", card.Iccid)
}
if appRenewExpireAfterAllowedWindow(baseExpireAt) {
return gerror.New("距离到期超过6个月,不能续费")
}
return nil
}
func (s *sAppCard) loadRenewSubscription(ctx context.Context, card *appCardQueryRow, subscriptionNo string) (*entity.TSimCardBindPkg, error) {
var bind *entity.TSimCardBindPkg
err := dao.TSimCardBindPkg.Ctx(ctx).
Where(dao.TSimCardBindPkg.Columns().SubscriptionNo, strings.TrimSpace(subscriptionNo)).
Limit(1).
Scan(&bind)
if err != nil {
return nil, gerror.Wrap(err, "查询订购记录失败")
}
if bind == nil || bind.Id == 0 || strings.TrimSpace(bind.Iccid) != strings.TrimSpace(card.Iccid) {
return nil, gerror.New("订购记录不存在或不属于当前卡片")
}
return bind, nil
}
func (s *sAppCard) loadLatestRenewBind(ctx context.Context, card *appCardQueryRow) (*entity.TSimCardBindPkg, error) {
var bind *entity.TSimCardBindPkg
err := dao.TSimCardBindPkg.Ctx(ctx).
Where(dao.TSimCardBindPkg.Columns().Iccid, card.Iccid).
OrderDesc(dao.TSimCardBindPkg.Columns().ExpectedExpireTime).
OrderDesc(dao.TSimCardBindPkg.Columns().Id).
Limit(1).
Scan(&bind)
if err != nil {
return nil, gerror.Wrap(err, "查询最新订购记录失败")
}
if bind == nil || bind.Id == 0 {
return nil, nil
}
return bind, nil
}
func (s *sAppCard) findRenewPackageOrNil(ctx context.Context, tenantId string, packageCode string) (*entity.TSimFlowPackageInfo, error) {
packageCode = strings.TrimSpace(packageCode)
if packageCode == "" {
return nil, nil
}
var pkg *entity.TSimFlowPackageInfo
err := dao.TSimFlowPackageInfo.Ctx(ctx).
Where(dao.TSimFlowPackageInfo.Columns().TenantId, strings.TrimSpace(tenantId)).
Where(dao.TSimFlowPackageInfo.Columns().PackageCode, packageCode).
Limit(1).
Scan(&pkg)
if err != nil {
return nil, gerror.Wrap(err, "查询续费套餐失败")
}
if pkg == nil || pkg.Id == 0 {
return nil, nil
}
return pkg, nil
}
func (renewCtx *appRenewPackageContext) expireAt() *gtime.Time {
if renewCtx == nil {
return nil
}
expireAt := appRenewBaseExpireTimeFromRow(renewCtx.Card, renewCtx.LatestBind)
if renewCtx.Subscription != nil &&
renewCtx.Subscription.ExpectedExpireTime != nil &&
!renewCtx.Subscription.ExpectedExpireTime.IsZero() &&
(expireAt == nil || renewCtx.Subscription.ExpectedExpireTime.After(expireAt)) {
return renewCtx.Subscription.ExpectedExpireTime
}
return expireAt
}
@@ -0,0 +1,546 @@
package app
import (
"bytes"
"context"
"fmt"
"os"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-app/utility/hash"
"github.com/gogf/gf/v2/crypto/gmd5"
"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"
"github.com/xuri/excelize/v2"
)
type appRenewBatchLogFiles struct {
ImportOssId int64
ImportFileName string
ResultOssId int64
DownloadId int64
}
func (s *sAppCard) ensureAppRenewBatchLogFiles(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appRenewBatchContext, existingInfo *gjson.Json, batchNo string, sourceBatchNo string, orderSn string, durationMonths int, singleCardAmount float64, totalAmount float64, beginAt, endAt *gtime.Time, cardPeriods []map[string]any, buyerNote string, now *gtime.Time) (*appRenewBatchLogFiles, error) {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return nil, gerror.New("续费批次号不能为空")
}
if user == nil {
return nil, gerror.New("当前用户不存在")
}
if batch == nil || batch.Party == nil {
return nil, gerror.New("续费批次日志文件信息不完整")
}
files := appRenewBatchLogFilesFromInfo(existingInfo)
existingDownload, err := appRenewBatchResultDownload(ctx, tx, batchNo)
if err != nil {
return nil, err
}
if existingDownload != nil {
files.ResultOssId = existingDownload.ResultOssId
files.DownloadId = existingDownload.DownloadId
}
if files.ImportOssId <= 0 {
importContent, err := appBuildRenewBatchImportExcel(batch, sourceBatchNo, durationMonths, buyerNote, now)
if err != nil {
return nil, gerror.Wrap(err, "生成续费批次源文件失败")
}
importFileName := appRenewBatchImportFileNameByBatchNo(batchNo)
importUpload, importOssId, err := appSaveRenewBatchOss(ctx, tx, user, batch.Party.CreatedDept, importContent, importFileName, consts.SimCardRenewSubDirImportExcel, now)
if err != nil {
return nil, gerror.Wrap(err, "保存续费批次源文件失败")
}
files.ImportOssId = importOssId
files.ImportFileName = strings.TrimSpace(importUpload.Name)
}
if strings.TrimSpace(files.ImportFileName) == "" {
files.ImportFileName = appRenewBatchImportFileName(ctx, tx, files.ImportOssId, appRenewBatchImportFileNameByBatchNo(batchNo))
}
if existingDownload != nil {
return files, nil
}
resultContent, err := appBuildRenewBatchResultExcel(batch, files, batchNo, orderSn, durationMonths, singleCardAmount, totalAmount, beginAt, endAt, cardPeriods, now, user.UserId, 0)
if err != nil {
return nil, gerror.Wrap(err, "生成续费批次结果文件失败")
}
resultUpload, resultOssId, err := appSaveRenewBatchOss(ctx, tx, user, batch.Party.CreatedDept, resultContent, batchNo+"-result.xlsx", consts.SimCardRenewSubDirResultExcel, now)
if err != nil {
return nil, gerror.Wrap(err, "保存续费批次结果文件失败")
}
files.ResultOssId = resultOssId
if err = appFillRenewResultOssId(ctx, tx, resultOssId, resultUpload.Path, user.UserId, now); err != nil {
return nil, err
}
refreshedResult, err := appLoadRenewOssUpload(ctx, tx, resultOssId, resultUpload)
if err != nil {
return nil, err
}
quantity := len(batch.Items)
downloadId, err := dao.TResourceBatchDownload.Ctx(ctx).TX(tx).Data(do.TResourceBatchDownload{
BatchNo: batchNo,
TaskName: consts.ResourceBatchDownloadTaskSimCardRenew,
ResourceType: consts.ResourceTypeExcel,
ExportScene: consts.ResourceExportSceneRenew,
TotalCount: quantity,
SuccessCount: quantity,
FailCount: 0,
DownloadStatus: consts.DownloadStatusGenerated,
OssId: resultOssId,
FileName: refreshedResult.Name,
FileExt: refreshedResult.Ext,
FileUrl: refreshedResult.FullPath,
FilePath: refreshedResult.Path,
FileSize: refreshedResult.Size,
FileMd5: refreshedResult.Md5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: batch.Party.CreatedDept,
StartTime: now,
FinishTime: now,
TenantId: user.TenantId,
CreatedDept: batch.Party.CreatedDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).InsertAndGetId()
if err != nil {
return nil, gerror.Wrap(err, "写入续费批次结果下载记录失败")
}
files.DownloadId = downloadId
return files, nil
}
func appRenewBatchLogFilesFromInfo(info *gjson.Json) *appRenewBatchLogFiles {
files := &appRenewBatchLogFiles{}
if info == nil {
return files
}
files.ImportOssId = info.Get("importFileOssId").Int64()
files.ResultOssId = info.Get("resultFileOssId").Int64()
files.DownloadId = info.Get("resultDownloadId").Int64()
return files
}
func appRenewBatchResultDownload(ctx context.Context, tx gdb.TX, batchNo string) (*appRenewBatchLogFiles, error) {
var downloads []*entity.TResourceBatchDownload
columns := dao.TResourceBatchDownload.Columns()
err := dao.TResourceBatchDownload.Ctx(ctx).TX(tx).
Fields(columns.Id, columns.OssId).
Where(columns.BatchNo, strings.TrimSpace(batchNo)).
OrderDesc(columns.Id).
Limit(1).
Scan(&downloads)
if err != nil {
return nil, gerror.Wrap(err, "查询续费批次结果下载记录失败")
}
if len(downloads) == 0 || downloads[0] == nil || downloads[0].Id == 0 {
return nil, nil
}
return &appRenewBatchLogFiles{
DownloadId: downloads[0].Id,
ResultOssId: downloads[0].OssId,
}, nil
}
func appRenewBatchInfoWithLogFiles(info *gjson.Json, files *appRenewBatchLogFiles) *gjson.Json {
data := map[string]any{}
if info != nil {
for key, value := range info.Map() {
data[key] = value
}
}
if files != nil {
if files.ImportOssId > 0 {
data["importFileOssId"] = files.ImportOssId
}
if files.ResultOssId > 0 {
data["resultFileOssId"] = files.ResultOssId
}
if files.DownloadId > 0 {
data["resultDownloadId"] = files.DownloadId
}
}
return gjson.New(data)
}
func appSaveRenewBatchOss(ctx context.Context, tx gdb.TX, user *entity.SysUser, deptId int64, content []byte, fileName string, subDirName string, now *gtime.Time) (model.UploadResponse, int64, error) {
upload, err := service.SysOss().SaveContentLocal(ctx, content, consts.SysUploadNewFileTypeUserFile, fileName, subDirName, 0)
if err != nil {
return upload, 0, err
}
ossId, err := dao.SysOss.Ctx(ctx).TX(tx).Data(do.SysOss{
TenantId: user.TenantId,
FileName: upload.Name,
OriginalName: upload.OriginalName,
FileSuffix: upload.Ext,
Path: upload.Path,
Url: upload.FullPath,
CreatedDept: deptId,
CreatedAt: now,
CreatedBy: user.UserId,
UpdatedAt: now,
UpdatedBy: user.UserId,
Service: "local",
Md5: upload.Md5,
FileSize: upload.Size,
FileCrc16: upload.Crc16,
FileSum: upload.Sum16,
}).InsertAndGetId()
if err != nil {
return upload, 0, gerror.Wrap(err, "写入续费批次OSS记录失败")
}
return upload, ossId, nil
}
func appRenewBatchImportHeaders() []string {
return []string{
"ICCID",
"MSISDN",
"IMSI",
"续费年限",
"是否走线下",
}
}
func appBuildRenewBatchImportExcel(batch *appRenewBatchContext, sourceBatchNo string, durationMonths int, buyerNote string, now *gtime.Time) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
sheet := "Sheet1"
headers := appRenewBatchImportHeaders()
if err := f.SetSheetRow(sheet, "A1", &headers); err != nil {
return nil, err
}
// 源文件保持管理平台续费导入模板格式,方便批次日志统一下载和排查。
if err := appApplyHeaderColumnsTextStyle(f, sheet); err != nil {
return nil, err
}
durationYears := appRenewDurationYearsFromLegacy(durationMonths, batch.Durations)
for index, item := range batch.Items {
row := []interface{}{
strings.TrimSpace(item.Card.Iccid),
strings.TrimSpace(item.Card.Msisdn),
strings.TrimSpace(item.Card.Imsi),
durationYears,
true,
}
cell, err := excelize.CoordinatesToCellName(1, index+2)
if err != nil {
return nil, err
}
if err = f.SetSheetRow(sheet, cell, &row); err != nil {
return nil, err
}
}
_ = f.SetColWidth(sheet, "A", "A", 24)
_ = f.SetColWidth(sheet, "B", "B", 14)
_ = f.SetColWidth(sheet, "C", "C", 18)
_ = f.SetColWidth(sheet, "D", "D", 18)
_ = f.SetColWidth(sheet, "E", "E", 18)
return appExcelBytes(f)
}
func appBuildRenewBatchResultExcel(batch *appRenewBatchContext, files *appRenewBatchLogFiles, batchNo string, orderSn string, durationMonths int, singleCardAmount float64, totalAmount float64, beginAt, endAt *gtime.Time, cardPeriods []map[string]any, now *gtime.Time, userId int64, resultOssId int64) ([]byte, error) {
f := excelize.NewFile()
defer f.Close()
leftStyleID, err := f.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
},
})
if err != nil {
return nil, err
}
resultSheet := "导入结果"
if err = f.SetSheetName("Sheet1", resultSheet); err != nil {
return nil, err
}
headers := []string{"源文件行号", "item_id", "处理状态", "异常原因"}
if err = f.SetSheetRow(resultSheet, "A1", &headers); err != nil {
return nil, err
}
for index, item := range batch.Items {
row := []interface{}{
appRenewBatchSourceRowNo(index),
strings.TrimSpace(item.Card.Iccid),
"成功",
"",
}
cell, err := excelize.CoordinatesToCellName(1, index+2)
if err != nil {
return nil, err
}
if err = f.SetSheetRow(resultSheet, cell, &row); err != nil {
return nil, err
}
}
resultEndCell, err := excelize.CoordinatesToCellName(4, len(batch.Items)+1)
if err != nil {
return nil, err
}
if err = f.SetCellStyle(resultSheet, "A1", resultEndCell, leftStyleID); err != nil {
return nil, err
}
infoSheet := "批次信息"
if _, err = f.NewSheet(infoSheet); err != nil {
return nil, err
}
infoHeaders := []string{"字段", "值"}
if err = f.SetSheetRow(infoSheet, "A1", &infoHeaders); err != nil {
return nil, err
}
infoRows := appRenewBatchResultInfoRows(batch, files, batchNo, durationMonths, singleCardAmount, totalAmount, beginAt, endAt, now, userId, resultOssId)
for index, item := range infoRows {
row := []interface{}{item[0], item[1]}
cell, err := excelize.CoordinatesToCellName(1, index+2)
if err != nil {
return nil, err
}
if err = f.SetSheetRow(infoSheet, cell, &row); err != nil {
return nil, err
}
}
infoEndCell, err := excelize.CoordinatesToCellName(2, len(infoRows)+1)
if err != nil {
return nil, err
}
if err = f.SetCellStyle(infoSheet, "A1", infoEndCell, leftStyleID); err != nil {
return nil, err
}
_ = f.SetColWidth(resultSheet, "A", "A", 14)
_ = f.SetColWidth(resultSheet, "B", "B", 24)
_ = f.SetColWidth(resultSheet, "C", "C", 12)
_ = f.SetColWidth(resultSheet, "D", "D", 40)
_ = f.SetColWidth(infoSheet, "A", "B", 24)
f.SetActiveSheet(0)
return appExcelBytes(f)
}
func appApplyHeaderColumnsTextStyle(f *excelize.File, sheetName string) error {
if f == nil {
return gerror.New("Excel文件不能为空")
}
sheetName = strings.TrimSpace(sheetName)
if sheetName == "" {
sheetName = f.GetSheetName(f.GetActiveSheetIndex())
}
if sheetName == "" {
sheets := f.GetSheetList()
if len(sheets) == 0 {
return gerror.New("Excel文件没有工作表")
}
sheetName = sheets[0]
}
rows, err := f.GetRows(sheetName)
if err != nil {
return err
}
if len(rows) == 0 || len(rows[0]) == 0 {
return gerror.New("Excel文件表头为空")
}
styleID, err := f.NewStyle(&excelize.Style{NumFmt: 49})
if err != nil {
return err
}
for index, header := range rows[0] {
if strings.TrimSpace(header) == "" {
continue
}
colName, err := excelize.ColumnNumberToName(index + 1)
if err != nil {
return err
}
if err = f.SetColStyle(sheetName, colName, styleID); err != nil {
return err
}
}
return nil
}
func appRenewBatchResultInfoRows(batch *appRenewBatchContext, files *appRenewBatchLogFiles, batchNo string, durationMonths int, singleCardAmount float64, totalAmount float64, beginAt, endAt *gtime.Time, now *gtime.Time, userId int64, resultOssId int64) [][2]string {
importFileName := ""
importOssId := ""
if files != nil {
importFileName = strings.TrimSpace(files.ImportFileName)
if files.ImportOssId > 0 {
importOssId = fmt.Sprintf("%d", files.ImportOssId)
}
}
if importFileName == "" {
importFileName = appRenewBatchImportFileNameByBatchNo(batchNo)
}
resultFileOssId := ""
if resultOssId > 0 {
resultFileOssId = fmt.Sprintf("%d", resultOssId)
}
return [][2]string{
{"批次号", strings.TrimSpace(batchNo)},
{"批次类型", "卡续费"},
{"处理结果", "成功"},
{"总行数", fmt.Sprintf("%d", len(batch.Items))},
{"校验通过行数", fmt.Sprintf("%d", len(batch.Items))},
{"失败行数", "0"},
{"实际写入行数", fmt.Sprintf("%d", len(batch.Items))},
{"原始文件名", importFileName},
{"原始文件OSS ID", importOssId},
{"结果文件OSS ID", resultFileOssId},
{"操作人", fmt.Sprintf("%d", userId)},
{"开始时间", appRenewFormatTime(now)},
{"结束时间", appRenewFormatTime(now)},
{"备注", "导入成功"},
}
}
func appFillRenewResultOssId(ctx context.Context, tx gdb.TX, ossId int64, filePath string, userId int64, now *gtime.Time) error {
if ossId <= 0 || strings.TrimSpace(filePath) == "" {
return gerror.New("续费结果文件OSS信息不完整")
}
f, err := excelize.OpenFile(filePath)
if err != nil {
return gerror.Wrap(err, "打开续费结果文件失败")
}
defer f.Close()
rows, err := f.GetRows("批次信息")
if err != nil {
return gerror.Wrap(err, "读取续费结果批次信息失败")
}
found := false
for index, row := range rows {
if len(row) == 0 || strings.TrimSpace(row[0]) != "结果文件OSS ID" {
continue
}
cell, err := excelize.CoordinatesToCellName(2, index+1)
if err != nil {
return err
}
if err = f.SetCellStr("批次信息", cell, fmt.Sprintf("%d", ossId)); err != nil {
return err
}
found = true
break
}
if !found {
return gerror.New("续费结果文件缺少结果文件OSS ID字段")
}
if err = f.SaveAs(filePath); err != nil {
return gerror.Wrap(err, "回写续费结果文件OSS ID失败")
}
return appRefreshRenewOssFileInfo(ctx, tx, ossId, filePath, userId, now)
}
func appRefreshRenewOssFileInfo(ctx context.Context, tx gdb.TX, ossId int64, filePath string, userId int64, now *gtime.Time) error {
fileInfo, err := os.Stat(filePath)
if err != nil {
return gerror.Wrap(err, "读取续费结果文件信息失败")
}
md5, _ := gmd5.EncryptFile(filePath)
crc16 := hash.Crc16ChecksumFile(filePath)
sum16 := hash.Sum16ChecksumFile(filePath)
_, err = dao.SysOss.Ctx(ctx).TX(tx).
Where(dao.SysOss.Columns().OssId, ossId).
Data(do.SysOss{
Md5: md5,
FileSize: fileInfo.Size(),
FileCrc16: crc16,
FileSum: sum16,
UpdatedAt: now,
UpdatedBy: userId,
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新续费结果文件信息失败")
}
return nil
}
func appLoadRenewOssUpload(ctx context.Context, tx gdb.TX, ossId int64, fallback model.UploadResponse) (model.UploadResponse, error) {
if ossId <= 0 {
return fallback, nil
}
var oss *entity.SysOss
err := dao.SysOss.Ctx(ctx).TX(tx).
Where(dao.SysOss.Columns().OssId, ossId).
Limit(1).
Scan(&oss)
if err != nil {
return fallback, gerror.Wrap(err, "读取续费结果文件OSS记录失败")
}
if oss == nil || oss.OssId == 0 {
return fallback, nil
}
fallback.Name = oss.FileName
fallback.OriginalName = oss.OriginalName
fallback.Ext = oss.FileSuffix
fallback.Path = oss.Path
fallback.FullPath = oss.Url
fallback.Size = int64(oss.FileSize)
fallback.Md5 = oss.Md5
return fallback, nil
}
func appRenewBatchImportFileName(ctx context.Context, tx gdb.TX, ossId int64, sourceName string) string {
if ossId <= 0 {
sourceName = strings.TrimSpace(sourceName)
if sourceName != "" {
return sourceName
}
return "input-prepare"
}
var oss *entity.SysOss
if err := dao.SysOss.Ctx(ctx).TX(tx).
Fields(dao.SysOss.Columns().FileName).
Where(dao.SysOss.Columns().OssId, ossId).
Limit(1).
Scan(&oss); err != nil {
return strings.TrimSpace(sourceName)
}
if oss != nil && strings.TrimSpace(oss.FileName) != "" {
return strings.TrimSpace(oss.FileName)
}
return strings.TrimSpace(sourceName)
}
func appRenewBatchImportFileNameByBatchNo(batchNo string) string {
batchNo = strings.TrimSpace(batchNo)
if batchNo == "" {
return "input-prepare"
}
return batchNo + "-import.xlsx"
}
func appRenewBatchRenewDate(now *gtime.Time) string {
if now == nil {
return ""
}
return now.Format("Y-m-d") + " 00:00:00"
}
func appExcelBytes(f *excelize.File) ([]byte, error) {
buffer := bytes.NewBuffer(nil)
if err := f.Write(buffer); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
@@ -0,0 +1,175 @@
package app
import (
"bytes"
"reflect"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/os/gtime"
"github.com/xuri/excelize/v2"
)
func TestAppBuildRenewBatchImportExcelMatchesRenewTemplate(t *testing.T) {
data, err := appBuildRenewBatchImportExcel(appRenewResultTestBatch(), "REN20260709164715A8F22BD5", 3, "测试备注", gtime.NewFromStr("2026-07-09 16:47:15"))
if err != nil {
t.Fatalf("build import excel: %v", err)
}
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("open import excel: %v", err)
}
defer f.Close()
headers, err := f.GetRows("Sheet1")
if err != nil {
t.Fatalf("read import rows: %v", err)
}
wantHeaders := []string{"ICCID", "MSISDN", "IMSI", "续费年限", "是否走线下"}
if len(headers) < 2 || !reflect.DeepEqual(headers[0], wantHeaders) {
t.Fatalf("headers mismatch: %#v", headers)
}
wantRow := []string{"898604F4152391181413", "1441234567890", "460041234567890", "3", "TRUE"}
if !reflect.DeepEqual(headers[1], wantRow) {
t.Fatalf("row mismatch: %#v", headers[1])
}
for _, cell := range []string{"A1", "B1", "C1", "D1", "E1", "A2", "B2", "C2", "D2", "E2"} {
styleID, err := f.GetCellStyle("Sheet1", cell)
if err != nil {
t.Fatalf("get style %s: %v", cell, err)
}
style, err := f.GetStyle(styleID)
if err != nil {
t.Fatalf("get style detail %s: %v", cell, err)
}
if style.NumFmt != 49 {
t.Fatalf("%s numFmt = %d, want 49", cell, style.NumFmt)
}
}
for col, want := range map[string]float64{
"A": 24,
"B": 14,
"C": 18,
"D": 18,
"E": 18,
} {
got, err := f.GetColWidth("Sheet1", col)
if err != nil {
t.Fatalf("get width %s: %v", col, err)
}
if got != want {
t.Fatalf("width %s = %v, want %v", col, got, want)
}
}
}
func TestAppBuildRenewBatchResultExcelMatchesBatchResultFormat(t *testing.T) {
data, err := appBuildRenewBatchResultExcel(appRenewResultTestBatch(), &appRenewBatchLogFiles{
ImportOssId: 309,
ImportFileName: "REN20260709164715A8F22BD5-import.xlsx",
}, "REN20260709164715A8F22BD5", "SO202607091647150000875NYX", 3, 30, 30, nil, nil, nil, gtime.NewFromStr("2026-07-09 16:47:15"), 87, 310)
if err != nil {
t.Fatalf("build result excel: %v", err)
}
f, err := excelize.OpenReader(bytes.NewReader(data))
if err != nil {
t.Fatalf("open result excel: %v", err)
}
defer f.Close()
if sheets := f.GetSheetList(); !reflect.DeepEqual(sheets, []string{"导入结果", "批次信息"}) {
t.Fatalf("sheets mismatch: %#v", sheets)
}
resultRows, err := f.GetRows("导入结果")
if err != nil {
t.Fatalf("read result rows: %v", err)
}
if !reflect.DeepEqual(resultRows[0], []string{"源文件行号", "item_id", "处理状态", "异常原因"}) {
t.Fatalf("result headers mismatch: %#v", resultRows[0])
}
if !reflect.DeepEqual(resultRows[1][:3], []string{"2", "898604F4152391181413", "成功"}) {
t.Fatalf("result row mismatch: %#v", resultRows[1])
}
infoRows, err := f.GetRows("批次信息")
if err != nil {
t.Fatalf("read info rows: %v", err)
}
if !reflect.DeepEqual(infoRows[0], []string{"字段", "值"}) {
t.Fatalf("info headers mismatch: %#v", infoRows[0])
}
if !reflect.DeepEqual(infoRows[1], []string{"批次号", "REN20260709164715A8F22BD5"}) {
t.Fatalf("info row mismatch: %#v", infoRows[1])
}
}
func TestAppRenewBatchResultInfoRowsUsesBatchImportFileName(t *testing.T) {
rows := appRenewBatchResultInfoRows(
appRenewResultTestBatch(),
&appRenewBatchLogFiles{},
"REN202607091748552P7KDBXN",
3,
10,
10,
nil,
nil,
gtime.NewFromStr("2026-07-09 17:48:56"),
87,
340,
)
info := map[string]string{}
for _, row := range rows {
info[row[0]] = row[1]
}
if info["原始文件名"] != "REN202607091748552P7KDBXN-import.xlsx" {
t.Fatalf("source file name = %q, want batch import file name", info["原始文件名"])
}
if info["原始文件OSS ID"] != "" {
t.Fatalf("source file oss id = %q, want empty", info["原始文件OSS ID"])
}
if info["结果文件OSS ID"] != "340" {
t.Fatalf("result file oss id = %q, want 340", info["结果文件OSS ID"])
}
}
func appRenewResultTestBatch() *appRenewBatchContext {
return &appRenewBatchContext{
CurrentPackage: &entity.TSimFlowPackageInfo{
PackageCode: "OLDPKG",
PackageName: "原套餐",
},
Package: &entity.TSimFlowPackageInfo{
Id: 2001,
PackageCode: "NEWPKG",
PackageName: "续费套餐",
},
Price: &appRenewPriceSnapshot{
UnitPrice: 10,
PriceSource: "system",
},
Party: &appRenewOrderParty{
CustomerAgentId: 175,
CreatedDept: 175,
},
Durations: []int{1, 2, 3},
OwnerType: appCardOwnerAgent,
RenewablePackageCodes: []string{"NEWPKG"},
Items: []*appRenewPackageContext{
{
ExpectedCurrentPackageCode: "OLDPKG",
Card: &appCardQueryRow{
TSimCardInfo: entity.TSimCardInfo{
Id: 1001,
Iccid: "898604F4152391181413",
Msisdn: "1441234567890",
Imsi: "460041234567890",
},
},
},
},
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,147 @@
package app
import (
"testing"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/os/gtime"
)
func TestAppLifecycleShouldPublishWorkerIncludesRestart(t *testing.T) {
for _, code := range []string{"stop", "start", "restart"} {
operation, err := appCardLifecycleOperationByCode(code)
if err != nil {
t.Fatalf("operation %s: %v", code, err)
}
if !appLifecycleShouldPublishWorker(operation) {
t.Fatalf("%s should publish worker batch", code)
}
}
if appLifecycleShouldPublishWorker(appCardLifecycleOperation{BatchType: -1}) {
t.Fatal("unsupported batch type should not publish worker batch")
}
}
func TestAppLifecycleBatchInfoMatchesWorkerRestartProtocol(t *testing.T) {
operation, err := appCardLifecycleOperationByCode("restart")
if err != nil {
t.Fatal(err)
}
info := appLifecycleBatchInfo(operation, []*appCardLifecycleItem{testLifecycleItem()}, "RST20260901094936", "manual", gtime.NewFromStr("2026-09-01 09:49:36"))
data := info.Map()
if info.Get("operation").String() != "restart" {
t.Fatalf("operation = %q", info.Get("operation").String())
}
if info.Get("stopOperType").String() != consts.SimCardStatusOperTypeStop {
t.Fatalf("stopOperType = %q", info.Get("stopOperType").String())
}
if info.Get("resumeOperType").String() != consts.SimCardStatusOperTypeStart {
t.Fatalf("resumeOperType = %q", info.Get("resumeOperType").String())
}
if info.Get("resumeDelaySeconds").Int() != appLifecycleRestartDelaySeconds {
t.Fatalf("resumeDelaySeconds = %d", info.Get("resumeDelaySeconds").Int())
}
if info.Get("restart.phase").String() != appLifecycleRestartPhaseCreated {
t.Fatalf("restart.phase = %q", info.Get("restart.phase").String())
}
if _, ok := data["operType"]; ok {
t.Fatal("restart batch should not contain single-stage operType")
}
if _, ok := data["onelinkMock"]; ok {
t.Fatal("worker batch info should not contain onelinkMock")
}
if _, ok := data["localMock"]; ok {
t.Fatal("worker batch info should not contain localMock")
}
}
func TestAppLifecycleBatchInfoUsesOperTypeForStopAndStart(t *testing.T) {
tests := []struct {
code string
want string
}{
{code: "stop", want: consts.SimCardStatusOperTypeStop},
{code: "start", want: consts.SimCardStatusOperTypeStart},
}
for _, tt := range tests {
operation, err := appCardLifecycleOperationByCode(tt.code)
if err != nil {
t.Fatalf("operation %s: %v", tt.code, err)
}
info := appLifecycleBatchInfo(operation, []*appCardLifecycleItem{testLifecycleItem()}, "BATCH", "", gtime.Now())
if got := info.Get("operType").String(); got != tt.want {
t.Fatalf("%s operType = %q, want %q", tt.code, got, tt.want)
}
}
}
func TestAppLifecycleBatchInfoMatchesWorkerDataServiceProtocol(t *testing.T) {
tests := []struct {
code string
batchType int
operType int
}{
{code: "dataStart", batchType: consts.BatchTypeDataStart, operType: consts.SimCardDataOperTypeStart},
{code: "dataStop", batchType: consts.BatchTypeDataStop, operType: consts.SimCardDataOperTypeStop},
}
for _, tt := range tests {
operation, err := appCardLifecycleOperationByCode(tt.code)
if err != nil {
t.Fatalf("operation %s: %v", tt.code, err)
}
if operation.BatchType != tt.batchType {
t.Fatalf("%s batch type = %d, want %d", tt.code, operation.BatchType, tt.batchType)
}
if !appLifecycleShouldPublishWorker(operation) {
t.Fatalf("%s should publish worker batch", tt.code)
}
info := appLifecycleBatchInfo(operation, []*appCardLifecycleItem{testLifecycleItem()}, "BATCH", "", gtime.Now())
if got := info.Get("operType").Int(); got != tt.operType {
t.Fatalf("%s operType = %d, want %d", tt.code, got, tt.operType)
}
if got := info.Get("apnName").String(); got != consts.SimCardDataApnName {
t.Fatalf("%s apnName = %q", tt.code, got)
}
}
}
func TestAppLifecyclePendingDetailSnapshotDoesNotMarkWorkerBatchAsMock(t *testing.T) {
operation, err := appCardLifecycleOperationByCode("restart")
if err != nil {
t.Fatal(err)
}
snapshot := appLifecyclePendingDetailSnapshot(operation, testLifecycleItem(), "RST20260901094936")
data := snapshot.Map()
if _, ok := data["onelinkMock"]; ok {
t.Fatal("worker detail snapshot should not contain onelinkMock")
}
if _, ok := data["localMock"]; ok {
t.Fatal("worker detail snapshot should not contain localMock")
}
}
func testLifecycleItem() *appCardLifecycleItem {
card := &appCardQueryRow{
TSimCardInfo: entity.TSimCardInfo{
Id: 1,
Iccid: "89860400000000000001",
Msisdn: "1064900000001",
Imsi: "460040000000001",
ChanKey: "onelink",
BossCardStatus: consts.V3ApiSimBossCardStatusActivated,
},
}
return &appCardLifecycleItem{
Card: card,
RowNo: 2,
StatusBefore: card.BossCardStatus,
}
}
@@ -0,0 +1,45 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"github.com/gogf/gf/v2/frame/g"
)
func (s *sAppCard) attachAppCardRealtimeSession(ctx context.Context, row *appCardQueryRow, card *model.AppCardModel) *model.AppCardModel {
if row == nil || card == nil {
return card
}
if strings.TrimSpace(row.Msisdn) == "" && strings.TrimSpace(row.Iccid) == "" && strings.TrimSpace(row.Imsi) == "" {
return card
}
session, source, err := queryAppCardRealtimeSession(ctx, row)
if err != nil {
g.Log().Warningf(ctx, "app card session query failed, iccid:%s, msisdn:%s, err:%+v", row.Iccid, row.Msisdn, err)
return card
}
if source == "onelink_mock" {
g.Log().Debugf(ctx, "app card session loaded from onelink mock, iccid:%s", row.Iccid)
}
if session == nil {
return card
}
return applyAppCardRealtimeSession(card, session)
}
func applyAppCardRealtimeSession(card *model.AppCardModel, session *appOneLinkMockSimSessionItem) *model.AppCardModel {
if card == nil || session == nil {
return card
}
card.OnlineStatus = strings.TrimSpace(session.Status)
card.ApnId = strings.TrimSpace(session.ApnId)
card.Rat = strings.TrimSpace(session.Rat)
card.Ipv4 = strings.TrimSpace(session.Ipv4)
card.Ipv6 = strings.TrimSpace(session.Ipv6)
card.Ipv6Prefix = strings.TrimSpace(session.Ipv6Prefix)
card.SessionCreateTime = strings.TrimSpace(session.CreateTime)
return card
}
+254
View File
@@ -0,0 +1,254 @@
package app
import (
"context"
"crypto/rand"
"fmt"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/appidentity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/utility"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
const appCardBatchNoAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func (s *sAppCard) isTerminalUser(ctx context.Context, user *entity.SysUser) bool {
return appIsTerminalUser(ctx, user)
}
func (s *sAppCard) ownerType(ctx context.Context, user *entity.SysUser) string {
if s.isTerminalUser(ctx, user) {
return appCardOwnerTerminal
}
return appCardOwnerAgent
}
func (s *sAppCard) canOperateCard(ctx context.Context, user *entity.SysUser, card *appCardQueryRow) bool {
if user == nil || card == nil {
return false
}
if s.isTerminalUser(ctx, user) {
return card.TerminalUserId == user.UserId
}
return user.DeptId > 0
}
func (s *sAppCard) ensureOperateCard(ctx context.Context, user *entity.SysUser, card *appCardQueryRow, actionName string) error {
if s.canOperateCard(ctx, user, card) {
return nil
}
cardLabel := ""
if card != nil {
cardLabel = appFirstNotEmpty(card.Iccid, card.Msisdn, card.Imsi, card.Imei)
}
actionName = strings.TrimSpace(actionName)
if actionName == "" {
actionName = "办理业务"
}
if s.isTerminalUser(ctx, user) {
return gerror.Newf("卡%s不在当前终端用户可操作范围内", cardLabel)
}
return gerror.Newf("卡%s不在当前企业可操作范围内", cardLabel)
}
func (s *sAppCard) canOperatePackageRow(ctx context.Context, user *entity.SysUser, row *appPackageQueryRow) bool {
if user == nil || row == nil {
return false
}
if s.isTerminalUser(ctx, user) {
return true
}
return user.DeptId > 0
}
func appIsTerminalUser(ctx context.Context, user *entity.SysUser) bool {
isTerminal, err := appidentity.IsTerminalUser(ctx, user)
return err == nil && isTerminal
}
// appEnterpriseDeptAndSubIds 返回企业账号可查看的机构范围:当前机构及所有下级机构。
// 这里按 sys_dept.parent_id 逐层递归,不依赖 ancestors 字符串,避免祖级字段不规范导致漏数据。
func appEnterpriseDeptAndSubIds(ctx context.Context, tenantId string, rootDeptId int64) ([]int64, error) {
if rootDeptId <= 0 {
return []int64{}, nil
}
tenantId = strings.TrimSpace(tenantId)
if tenantId == "" {
tenantId = consts.DefaultSystemTenantCode
}
seen := map[int64]struct{}{
rootDeptId: {},
}
result := []int64{rootDeptId}
level := []int64{rootDeptId}
columns := dao.SysDept.Columns()
for len(level) > 0 {
var rows []*entity.SysDept
err := dao.SysDept.Ctx(ctx).
Fields(columns.DeptId).
Where(columns.TenantId, tenantId).
WhereIn(columns.ParentId, level).
WhereNull(columns.DeletedAt).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询企业下级机构失败")
}
nextLevel := make([]int64, 0, len(rows))
for _, row := range rows {
if row == nil || row.DeptId <= 0 {
continue
}
if _, ok := seen[row.DeptId]; ok {
continue
}
seen[row.DeptId] = struct{}{}
result = append(result, row.DeptId)
nextLevel = append(nextLevel, row.DeptId)
}
level = nextLevel
}
return result, nil
}
// appEnterpriseDisplayDept 按当前企业视角返回卡片归属展示机构。
// 真实卡归属可能在下下级,前端只允许看到本级或直属下级,避免泄露更深层级组织信息。
func appEnterpriseDisplayDept(ctx context.Context, tenantId string, rootDeptId int64, actualDeptId int64) (*entity.SysDept, error) {
if rootDeptId <= 0 || actualDeptId <= 0 {
return nil, nil
}
if actualDeptId == rootDeptId {
return appLoadDeptForDisplay(ctx, tenantId, rootDeptId)
}
currentDeptId := actualDeptId
seen := make(map[int64]struct{})
for currentDeptId > 0 {
if _, ok := seen[currentDeptId]; ok {
return nil, nil
}
seen[currentDeptId] = struct{}{}
dept, err := appLoadDeptForDisplay(ctx, tenantId, currentDeptId)
if err != nil {
return nil, err
}
if dept == nil || dept.DeptId == 0 {
return nil, nil
}
if dept.ParentId == rootDeptId {
return dept, nil
}
if dept.ParentId <= 0 || dept.ParentId == dept.DeptId {
return nil, nil
}
currentDeptId = dept.ParentId
}
return nil, nil
}
func appLoadDeptForDisplay(ctx context.Context, tenantId string, deptId int64) (*entity.SysDept, error) {
tenantId = strings.TrimSpace(tenantId)
if tenantId == "" {
tenantId = consts.DefaultSystemTenantCode
}
var dept *entity.SysDept
if err := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, tenantId).
Where(dao.SysDept.Columns().DeptId, deptId).
WhereNull(dao.SysDept.Columns().DeletedAt).
Limit(1).
Scan(&dept); err != nil {
return nil, gerror.Wrap(err, "查询机构展示信息失败")
}
return dept, nil
}
func appCurrentUser(ctx context.Context) (*entity.SysUser, error) {
userId := contexts.GetUserId(ctx)
if userId == 0 {
return nil, gerror.New("登录已过期")
}
var user *entity.SysUser
err := dao.SysUser.Ctx(ctx).
Where(dao.SysUser.Columns().UserId, userId).
Scan(&user)
if err != nil {
return nil, err
}
if user == nil {
return nil, gerror.New("用户不存在")
}
return user, nil
}
func appContextWithUser(ctx context.Context, user *entity.SysUser) context.Context {
if user == nil || user.UserId <= 0 {
return ctx
}
if current := contexts.GetUser(ctx); current != nil && current.ID > 0 && strings.TrimSpace(current.TenantId) != "" {
return ctx
}
return contexts.Set(ctx, &model.Context{
User: &model.Identity{
BaseClaims: model.BaseClaims{
ID: user.UserId,
Username: user.UserName,
NickName: user.NickName,
TenantId: user.TenantId,
DeptId: user.DeptId,
},
},
})
}
func appCardValueCondition() string {
cardTable := dao.TSimCardInfo.Table()
return "(" + cardTable + "." + dao.TSimCardInfo.Columns().Iccid + " = ? OR " +
cardTable + "." + dao.TSimCardInfo.Columns().Msisdn + " = ? OR " +
cardTable + "." + dao.TSimCardInfo.Columns().Imsi + " = ? OR " +
cardTable + "." + dao.TSimCardInfo.Columns().Imei + " = ?)"
}
func appCardValueArgs(cardValue string) []interface{} {
value := strings.TrimSpace(cardValue)
return []interface{}{value, value, value, value}
}
func appNextBatchNo(ctx context.Context, prefix string) (string, error) {
return batchimport.NextBatchNoContext(ctx, prefix)
}
func appCardBatchNo(prefix string, userId int64, now *gtime.Time) string {
if now == nil {
now = gtime.Now()
}
prefix = strings.TrimSpace(strings.ToUpper(prefix))
random := make([]byte, 8)
randomBytes := make([]byte, len(random))
if _, err := rand.Read(randomBytes); err != nil {
fallback := strings.ToUpper(utility.RandomString(len(random)))
for i := range random {
random[i] = appCardBatchNoAlphabet[int(fallback[i])%len(appCardBatchNoAlphabet)]
}
return fmt.Sprintf("%s%s%s", prefix, now.Format("YmdHis"), string(random))
}
for i, b := range randomBytes {
random[i] = appCardBatchNoAlphabet[int(b)%len(appCardBatchNoAlphabet)]
}
return fmt.Sprintf("%s%s%s", prefix, now.Format("YmdHis"), string(random))
}
func appOrderNo(prefix string, userId int64, now *gtime.Time) string {
return appCardBatchNo(prefix, userId, now)
}
@@ -0,0 +1,17 @@
package app
import (
"regexp"
"testing"
"github.com/gogf/gf/v2/os/gtime"
)
func TestAppOrderNoMatchesBatchNoFormat(t *testing.T) {
now := gtime.NewFromStr("2026-07-09 16:47:15")
got := appOrderNo("SO", 875, now)
want := regexp.MustCompile(`^SO20260709164715[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{8}$`)
if !want.MatchString(got) {
t.Fatalf("order no = %q, want SO + yyyyMMddHHmmss + 8 chars", got)
}
}
+755
View File
@@ -0,0 +1,755 @@
package app
import (
"context"
"strconv"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/errors/gerror"
)
const (
appCustomerTypeAll = "all"
appCustomerTypeEnterprise = "enterprise"
appCustomerTypeTerminal = "terminal"
)
type appCustomerCardStat struct {
DeptId int64 `orm:"dept_id"`
TerminalId int64 `orm:"terminal_id"`
CardCount int `orm:"card_count"`
NormalCardCount int `orm:"normal_card_count"`
}
type appTerminalCustomerIdRow struct {
TerminalId int64 `orm:"terminal_id"`
}
func (s *sAppUser) CustomerList(ctx context.Context, param *model.AppCustomerListParam) (items []*model.AppCustomerModel, total int, err error) {
if param == nil {
param = &model.AppCustomerListParam{}
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, 0, err
}
if err = appEnsureEnterpriseCustomerScope(ctx, user); err != nil {
return nil, 0, err
}
if param.Page <= 0 {
param.Page = 1
}
if param.PageSize <= 0 {
param.PageSize = 20
}
if param.PageSize > 200 {
param.PageSize = 200
}
customerType := appNormalizeCustomerType(param.CustomerType)
if customerType == appCustomerTypeTerminal {
return nil, 0, gerror.New("暂不支持查看终端客户")
}
list, err := s.enterpriseCustomerList(ctx, user, param)
if err != nil {
return nil, 0, err
}
total = len(list)
start := (param.Page - 1) * param.PageSize
if start >= total {
return []*model.AppCustomerModel{}, total, nil
}
end := start + param.PageSize
if end > total {
end = total
}
return list[start:end], total, nil
}
func (s *sAppUser) CustomerDetail(ctx context.Context, param *model.AppCustomerDetailParam) (*model.AppCustomerDetailModel, error) {
if param == nil {
param = &model.AppCustomerDetailParam{}
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
if err = appEnsureEnterpriseCustomerScope(ctx, user); err != nil {
return nil, err
}
customerType := appNormalizeCustomerType(param.CustomerType)
switch customerType {
case appCustomerTypeEnterprise:
return s.enterpriseCustomerDetail(ctx, user, param.CustomerId)
case appCustomerTypeTerminal:
return nil, gerror.New("暂不支持查看终端客户")
default:
return nil, gerror.New("客户类型不正确")
}
}
func (s *sAppUser) enterpriseCustomerList(ctx context.Context, user *entity.SysUser, param *model.AppCustomerListParam) ([]*model.AppCustomerModel, error) {
depts, err := appEnterpriseDirectCustomerDepts(ctx, user)
if err != nil {
return nil, err
}
depts = appFilterEnterpriseDepts(depts, strings.TrimSpace(param.Keyword), strings.TrimSpace(param.Status))
deptIds := appDeptIds(depts)
stats, err := appCardStatsByDept(ctx, user.TenantId, deptIds)
if err != nil {
return nil, err
}
profiles, err := appEnterpriseProfileMap(ctx, user.TenantId, deptIds)
if err != nil {
return nil, err
}
list := make([]*model.AppCustomerModel, 0, len(depts))
for _, dept := range depts {
profile := profiles[dept.DeptId]
stat := stats[dept.DeptId]
item := appEnterpriseCustomerModel(dept, profile, 0, stat)
list = append(list, item)
}
return list, nil
}
func (s *sAppUser) enterpriseCustomerDetail(ctx context.Context, user *entity.SysUser, deptId int64) (*model.AppCustomerDetailModel, error) {
dept, err := appLoadCustomerDept(ctx, user.TenantId, deptId)
if err != nil {
return nil, err
}
if dept == nil || dept.DeptId == 0 {
return nil, gerror.New("企业客户不存在")
}
visible, err := appEnterpriseCustomerDeptVisible(ctx, user, dept.DeptId)
if err != nil {
return nil, err
}
if !visible {
return nil, gerror.New("企业客户不在当前企业可查看范围内")
}
profile, err := appEnterpriseProfileByDeptId(ctx, user.TenantId, dept.DeptId)
if err != nil {
return nil, err
}
stats, err := appCardStatsByDept(ctx, user.TenantId, []int64{dept.DeptId})
if err != nil {
return nil, err
}
item := appEnterpriseCustomerModel(dept, profile, 0, stats[dept.DeptId])
return &model.AppCustomerDetailModel{
AppCustomerModel: *item,
EnterpriseProfile: appEnterpriseProfileModel(dept, profile),
}, nil
}
func (s *sAppUser) terminalCustomerList(ctx context.Context, user *entity.SysUser, param *model.AppCustomerListParam) ([]*model.AppCustomerModel, error) {
visibleDeptIds, err := appVisibleCustomerDeptIds(ctx, user)
if err != nil {
return nil, err
}
terminalIds, err := appTerminalIdsByVisibleCards(ctx, user.TenantId, visibleDeptIds)
if err != nil {
return nil, err
}
if len(terminalIds) == 0 {
return []*model.AppCustomerModel{}, nil
}
terminals, err := appTerminalMap(ctx, user.TenantId, terminalIds)
if err != nil {
return nil, err
}
stats, err := appCardStatsByTerminal(ctx, user.TenantId, visibleDeptIds)
if err != nil {
return nil, err
}
keyword := strings.TrimSpace(param.Keyword)
status := strings.TrimSpace(param.Status)
list := make([]*model.AppCustomerModel, 0, len(terminals))
for _, terminalId := range terminalIds {
terminal := terminals[terminalId]
if terminal == nil || terminal.TerminalId == 0 {
continue
}
if status != "" && strings.TrimSpace(terminal.Status) != status {
continue
}
if keyword != "" && !appTerminalMatchesKeyword(terminal, keyword) {
continue
}
item := appTerminalCustomerModel(terminal, stats[terminal.TerminalId])
list = append(list, item)
}
return list, nil
}
func (s *sAppUser) terminalCustomerDetail(ctx context.Context, user *entity.SysUser, terminalId int64) (*model.AppCustomerDetailModel, error) {
visibleDeptIds, err := appVisibleCustomerDeptIds(ctx, user)
if err != nil {
return nil, err
}
visible, err := appTerminalHasVisibleCard(ctx, user.TenantId, terminalId, visibleDeptIds)
if err != nil {
return nil, err
}
if !visible {
return nil, gerror.New("终端客户不在当前企业可查看范围内")
}
terminals, err := appTerminalMap(ctx, user.TenantId, []int64{terminalId})
if err != nil {
return nil, err
}
terminal := terminals[terminalId]
if terminal == nil || terminal.TerminalId == 0 {
return nil, gerror.New("终端客户不存在")
}
stats, err := appCardStatsByTerminal(ctx, user.TenantId, visibleDeptIds)
if err != nil {
return nil, err
}
item := appTerminalCustomerModel(terminal, stats[terminal.TerminalId])
return &model.AppCustomerDetailModel{
AppCustomerModel: *item,
TerminalProfile: appTerminalProfileModel(terminal),
}, nil
}
func appEnsureEnterpriseCustomerScope(ctx context.Context, user *entity.SysUser) error {
if user == nil {
return gerror.New("登录已过期")
}
if appIsTerminalUser(ctx, user) {
return gerror.New("终端用户不能查看客户管理")
}
if user.DeptId <= 0 {
return gerror.New("当前账号未绑定企业,不能查看客户管理")
}
return nil
}
func appNormalizeCustomerType(customerType string) string {
switch strings.TrimSpace(customerType) {
case appCustomerTypeEnterprise:
return appCustomerTypeEnterprise
case appCustomerTypeTerminal:
return appCustomerTypeTerminal
default:
return appCustomerTypeAll
}
}
func appDirectChildDepts(ctx context.Context, user *entity.SysUser) ([]*entity.SysDept, error) {
var rows []*entity.SysDept
err := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, user.TenantId).
Where(dao.SysDept.Columns().ParentId, user.DeptId).
WhereNull(dao.SysDept.Columns().DeletedAt).
OrderAsc(dao.SysDept.Columns().OrderNum).
OrderAsc(dao.SysDept.Columns().DeptId).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询直属下级企业客户失败")
}
return rows, nil
}
func appEnterpriseDirectCustomerDepts(ctx context.Context, user *entity.SysUser) ([]*entity.SysDept, error) {
rows, err := appDirectChildDepts(ctx, user)
if err != nil {
return nil, err
}
excludeIds, err := appEnterpriseCustomerExcludedDeptIds(ctx, user.TenantId, user.DeptId)
if err != nil {
return nil, err
}
result := make([]*entity.SysDept, 0, len(rows))
for _, row := range rows {
if row == nil || row.DeptId <= 0 {
continue
}
if _, excluded := excludeIds[row.DeptId]; excluded {
continue
}
result = append(result, row)
}
return appFilterDeptsByEnterpriseDataScope(ctx, user, result)
}
func appEnterpriseSubCustomerDepts(ctx context.Context, user *entity.SysUser) ([]*entity.SysDept, error) {
if user == nil || user.DeptId <= 0 {
return []*entity.SysDept{}, nil
}
visibleIds, err := appEnterpriseDeptAndSubIds(ctx, user.TenantId, user.DeptId)
if err != nil {
return nil, err
}
excludeIds, err := appEnterpriseCustomerExcludedDeptIds(ctx, user.TenantId, user.DeptId)
if err != nil {
return nil, err
}
deptIds := make([]int64, 0, len(visibleIds))
for _, deptId := range visibleIds {
if deptId <= 0 || deptId == user.DeptId {
continue
}
if _, excluded := excludeIds[deptId]; excluded {
continue
}
deptIds = append(deptIds, deptId)
}
if len(deptIds) == 0 {
return []*entity.SysDept{}, nil
}
var rows []*entity.SysDept
err = dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, user.TenantId).
WhereIn(dao.SysDept.Columns().DeptId, deptIds).
WhereNull(dao.SysDept.Columns().DeletedAt).
OrderAsc(dao.SysDept.Columns().ParentId).
OrderAsc(dao.SysDept.Columns().OrderNum).
OrderAsc(dao.SysDept.Columns().DeptId).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询下级企业客户失败")
}
return rows, nil
}
func appEnterpriseCustomerDeptVisible(ctx context.Context, user *entity.SysUser, deptId int64) (bool, error) {
if user == nil || user.DeptId <= 0 || deptId <= 0 {
return false, nil
}
if deptId == user.DeptId {
return true, nil
}
excludeIds, err := appEnterpriseCustomerExcludedDeptIds(ctx, user.TenantId, user.DeptId)
if err != nil {
return false, err
}
if _, excluded := excludeIds[deptId]; excluded {
return false, nil
}
dept, err := appLoadCustomerDept(ctx, user.TenantId, deptId)
if err != nil {
return false, err
}
if dept == nil || dept.DeptId <= 0 || dept.ParentId != user.DeptId {
return false, nil
}
return appEnterpriseDeptVisibleByDataScope(ctx, user, dept.DeptId)
}
func appEnterpriseCustomerExcludedDeptIds(ctx context.Context, tenantId string, currentDeptId int64) (map[int64]struct{}, error) {
excludeIds := make(map[int64]struct{})
registerDeptId := appRegisterDeptOrDefault(ctx, tenantId)
if registerDeptId <= 0 {
return excludeIds, nil
}
registerDeptIds, err := appEnterpriseDeptAndSubIds(ctx, tenantId, registerDeptId)
if err != nil {
return nil, err
}
for _, deptId := range registerDeptIds {
if deptId == currentDeptId {
return excludeIds, nil
}
}
for _, deptId := range registerDeptIds {
if deptId > 0 {
excludeIds[deptId] = struct{}{}
}
}
return excludeIds, nil
}
func appVisibleCustomerDeptIds(ctx context.Context, user *entity.SysUser) ([]int64, error) {
children, err := appEnterpriseSubCustomerDepts(ctx, user)
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(children)+1)
ids = append(ids, user.DeptId)
for _, child := range children {
if child != nil && child.DeptId > 0 {
ids = append(ids, child.DeptId)
}
}
depts := make([]*entity.SysDept, 0, len(ids))
for _, deptId := range ids {
depts = append(depts, &entity.SysDept{DeptId: deptId})
}
filtered, err := appFilterDeptsByEnterpriseDataScope(ctx, user, depts)
if err != nil {
return nil, err
}
return appDeptIds(filtered), nil
}
func appFilterEnterpriseDepts(depts []*entity.SysDept, keyword string, status string) []*entity.SysDept {
result := make([]*entity.SysDept, 0, len(depts))
for _, dept := range depts {
if dept == nil || dept.DeptId == 0 {
continue
}
if status != "" && strings.TrimSpace(dept.Status) != status {
continue
}
if keyword != "" && !appDeptMatchesKeyword(dept, keyword) {
continue
}
result = append(result, dept)
}
return result
}
func appDeptMatchesKeyword(dept *entity.SysDept, keyword string) bool {
if dept == nil {
return false
}
fields := []string{
dept.DeptName,
dept.DeptKey,
dept.Phone,
dept.Email,
dept.CreditCode,
strconv.FormatInt(dept.DeptId, 10),
}
for _, field := range fields {
if strings.Contains(strings.TrimSpace(field), keyword) {
return true
}
}
return false
}
func appTerminalMatchesKeyword(terminal *entity.TTerminal, keyword string) bool {
if terminal == nil {
return false
}
fields := []string{
terminal.TerminalName,
terminal.Phone,
terminal.Email,
terminal.FullName,
terminal.CreditCode,
strconv.FormatInt(terminal.TerminalId, 10),
}
for _, field := range fields {
if strings.Contains(strings.TrimSpace(field), keyword) {
return true
}
}
return false
}
func appDeptIds(depts []*entity.SysDept) []int64 {
ids := make([]int64, 0, len(depts))
for _, dept := range depts {
if dept != nil && dept.DeptId > 0 {
ids = append(ids, dept.DeptId)
}
}
return ids
}
func appCardStatsByDept(ctx context.Context, tenantId string, deptIds []int64) (map[int64]*appCustomerCardStat, error) {
result := make(map[int64]*appCustomerCardStat)
if len(deptIds) == 0 {
return result, nil
}
var rows []*appCustomerCardStat
cardTable := dao.TSimCardInfo.Table()
normalCountExpr := "SUM(CASE WHEN " + cardTable + "." + dao.TSimCardInfo.Columns().BossCardStatus + " = " + strconv.Itoa(consts.V3ApiSimBossCardStatusActivated) +
" AND " + cardTable + "." + dao.TSimCardInfo.Columns().PackageStatus + " <> " + strconv.Itoa(consts.PackageStatusExpired) + " THEN 1 ELSE 0 END) AS normal_card_count"
err := dao.TSimCardInfo.Ctx(ctx).
Fields(cardTable+"."+dao.TSimCardInfo.Columns().DeptId+" AS dept_id").
Fields("COUNT(1) AS card_count").
Fields(normalCountExpr).
Where(cardTable+"."+dao.TSimCardInfo.Columns().TenantId, tenantId).
WhereIn(cardTable+"."+dao.TSimCardInfo.Columns().DeptId, deptIds).
Group(cardTable + "." + dao.TSimCardInfo.Columns().DeptId).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "统计企业客户卡数量失败")
}
for _, row := range rows {
if row != nil {
result[row.DeptId] = row
}
}
return result, nil
}
func appCardStatsByTerminal(ctx context.Context, tenantId string, visibleDeptIds []int64) (map[int64]*appCustomerCardStat, error) {
result := make(map[int64]*appCustomerCardStat)
if len(visibleDeptIds) == 0 {
return result, nil
}
bindTable := dao.TSimTerminal.Table()
cardTable := dao.TSimCardInfo.Table()
var rows []*appCustomerCardStat
normalCountExpr := "SUM(CASE WHEN " + cardTable + "." + dao.TSimCardInfo.Columns().BossCardStatus + " = " + strconv.Itoa(consts.V3ApiSimBossCardStatusActivated) +
" AND " + cardTable + "." + dao.TSimCardInfo.Columns().PackageStatus + " <> " + strconv.Itoa(consts.PackageStatusExpired) + " THEN 1 ELSE 0 END) AS normal_card_count"
err := dao.TSimTerminal.Ctx(ctx).
Fields(bindTable+"."+dao.TSimTerminal.Columns().TerminalId+" AS terminal_id").
Fields("COUNT(DISTINCT "+bindTable+"."+dao.TSimTerminal.Columns().Iccid+") AS card_count").
Fields(normalCountExpr).
InnerJoin(cardTable, cardTable+"."+dao.TSimCardInfo.Columns().Iccid+"="+bindTable+"."+dao.TSimTerminal.Columns().Iccid+" AND "+cardTable+"."+dao.TSimCardInfo.Columns().TenantId+"="+bindTable+"."+dao.TSimTerminal.Columns().TenantId).
Where(bindTable+"."+dao.TSimTerminal.Columns().TenantId, tenantId).
WhereIn(cardTable+"."+dao.TSimCardInfo.Columns().DeptId, visibleDeptIds).
Group(bindTable + "." + dao.TSimTerminal.Columns().TerminalId).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "统计终端客户卡数量失败")
}
for _, row := range rows {
if row != nil {
result[row.TerminalId] = row
}
}
return result, nil
}
func appAgentBalanceMap(ctx context.Context, tenantId string, deptIds []int64) (map[int64]float64, error) {
result := make(map[int64]float64)
if len(deptIds) == 0 {
return result, nil
}
var rows []*entity.TAgentBalance
err := dao.TAgentBalance.Ctx(ctx).
Where(dao.TAgentBalance.Columns().TenantId, tenantId).
WhereIn(dao.TAgentBalance.Columns().DeptId, deptIds).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询企业客户余额失败")
}
for _, row := range rows {
if row != nil {
result[row.DeptId] = appRoundAmount(row.Balance)
}
}
return result, nil
}
func appEnterpriseProfileMap(ctx context.Context, tenantId string, deptIds []int64) (map[int64]*entity.TAgentEnterprise, error) {
result := make(map[int64]*entity.TAgentEnterprise)
if len(deptIds) == 0 {
return result, nil
}
var rows []*entity.TAgentEnterprise
err := dao.TAgentEnterprise.Ctx(ctx).
Where(dao.TAgentEnterprise.Columns().TenantId, tenantId).
WhereIn(dao.TAgentEnterprise.Columns().DeptId, deptIds).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询企业客户实名资料失败")
}
for _, row := range rows {
if row != nil {
result[row.DeptId] = row
}
}
return result, nil
}
func appEnterpriseProfileByDeptId(ctx context.Context, tenantId string, deptId int64) (*entity.TAgentEnterprise, error) {
var profile *entity.TAgentEnterprise
err := dao.TAgentEnterprise.Ctx(ctx).
Where(dao.TAgentEnterprise.Columns().TenantId, tenantId).
Where(dao.TAgentEnterprise.Columns().DeptId, deptId).
Limit(1).
Scan(&profile)
if err != nil {
return nil, gerror.Wrap(err, "查询企业客户实名资料失败")
}
return profile, nil
}
func appTerminalIdsByVisibleCards(ctx context.Context, tenantId string, visibleDeptIds []int64) ([]int64, error) {
if len(visibleDeptIds) == 0 {
return []int64{}, nil
}
bindTable := dao.TSimTerminal.Table()
cardTable := dao.TSimCardInfo.Table()
var rows []*appTerminalCustomerIdRow
err := dao.TSimTerminal.Ctx(ctx).
Fields(bindTable+"."+dao.TSimTerminal.Columns().TerminalId+" AS terminal_id").
InnerJoin(cardTable, cardTable+"."+dao.TSimCardInfo.Columns().Iccid+"="+bindTable+"."+dao.TSimTerminal.Columns().Iccid+" AND "+cardTable+"."+dao.TSimCardInfo.Columns().TenantId+"="+bindTable+"."+dao.TSimTerminal.Columns().TenantId).
Where(bindTable+"."+dao.TSimTerminal.Columns().TenantId, tenantId).
WhereIn(cardTable+"."+dao.TSimCardInfo.Columns().DeptId, visibleDeptIds).
Group(bindTable + "." + dao.TSimTerminal.Columns().TerminalId).
OrderAsc(bindTable + "." + dao.TSimTerminal.Columns().TerminalId).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询终端客户范围失败")
}
ids := make([]int64, 0, len(rows))
for _, row := range rows {
if row != nil && row.TerminalId > 0 {
ids = append(ids, row.TerminalId)
}
}
return ids, nil
}
func appTerminalMap(ctx context.Context, tenantId string, terminalIds []int64) (map[int64]*entity.TTerminal, error) {
result := make(map[int64]*entity.TTerminal)
if len(terminalIds) == 0 {
return result, nil
}
var rows []*entity.TTerminal
err := dao.TTerminal.Ctx(ctx).
Where(dao.TTerminal.Columns().TenantId, tenantId).
WhereIn(dao.TTerminal.Columns().TerminalId, terminalIds).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询终端客户资料失败")
}
for _, row := range rows {
if row != nil {
result[row.TerminalId] = row
}
}
return result, nil
}
func appTerminalHasVisibleCard(ctx context.Context, tenantId string, terminalId int64, visibleDeptIds []int64) (bool, error) {
if terminalId <= 0 || len(visibleDeptIds) == 0 {
return false, nil
}
bindTable := dao.TSimTerminal.Table()
cardTable := dao.TSimCardInfo.Table()
count, err := dao.TSimTerminal.Ctx(ctx).
InnerJoin(cardTable, cardTable+"."+dao.TSimCardInfo.Columns().Iccid+"="+bindTable+"."+dao.TSimTerminal.Columns().Iccid+" AND "+cardTable+"."+dao.TSimCardInfo.Columns().TenantId+"="+bindTable+"."+dao.TSimTerminal.Columns().TenantId).
Where(bindTable+"."+dao.TSimTerminal.Columns().TenantId, tenantId).
Where(bindTable+"."+dao.TSimTerminal.Columns().TerminalId, terminalId).
WhereIn(cardTable+"."+dao.TSimCardInfo.Columns().DeptId, visibleDeptIds).
Count()
if err != nil {
return false, gerror.Wrap(err, "校验终端客户范围失败")
}
return count > 0, nil
}
func appLoadCustomerDept(ctx context.Context, tenantId string, deptId int64) (*entity.SysDept, error) {
var dept *entity.SysDept
err := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, tenantId).
Where(dao.SysDept.Columns().DeptId, deptId).
Limit(1).
Scan(&dept)
if err != nil {
return nil, gerror.Wrap(err, "查询企业客户失败")
}
return dept, nil
}
func appEnterpriseCustomerModel(dept *entity.SysDept, profile *entity.TAgentEnterprise, balance float64, stat *appCustomerCardStat) *model.AppCustomerModel {
item := &model.AppCustomerModel{
CustomerType: appCustomerTypeEnterprise,
CustomerTypeName: "企业客户",
CustomerId: dept.DeptId,
DeptId: dept.DeptId,
ParentDeptId: dept.ParentId,
CustomerCode: strings.TrimSpace(dept.DeptKey),
CustomerName: strings.TrimSpace(dept.DeptName),
DeptCategory: strings.TrimSpace(dept.DeptCategory),
Phone: appMaskPhone(dept.Phone),
Email: appMaskEmail(dept.Email),
Status: strings.TrimSpace(dept.Status),
StatusText: appDeptStatusText(dept.Status),
Balance: balance,
Address: appMaskAddress(dept.Address),
CreatedAt: appProfileDate(dept.CreatedAt),
}
if profile != nil {
item.FullName = strings.TrimSpace(profile.FullName)
item.RealNameStatus = appRealNameStatus(profile.FullName, profile.LegalPersonName, profile.LegalPersonIdCard)
} else {
item.RealNameStatus = "实名资料待完善"
}
if stat != nil {
item.CardCount = stat.CardCount
item.NormalCardCount = stat.NormalCardCount
}
return item
}
func appTerminalCustomerModel(terminal *entity.TTerminal, stat *appCustomerCardStat) *model.AppCustomerModel {
item := &model.AppCustomerModel{
CustomerType: appCustomerTypeTerminal,
CustomerTypeName: "终端客户",
CustomerId: terminal.TerminalId,
TerminalId: terminal.TerminalId,
CustomerName: strings.TrimSpace(terminal.TerminalName),
Phone: appMaskPhone(terminal.Phone),
Email: appMaskEmail(terminal.Email),
Status: strings.TrimSpace(terminal.Status),
StatusText: appTerminalStatusText(terminal.Status),
RealNameStatus: appRealNameStatus(terminal.FullName, terminal.LegalPersonName, terminal.LegalPersonIdCard),
Balance: appRoundAmount(terminal.Balance),
Address: appMaskAddress(terminal.Address),
FullName: strings.TrimSpace(terminal.FullName),
CreatedAt: appProfileDate(terminal.CreatedAt),
}
if item.CustomerName == "" {
item.CustomerName = item.FullName
}
if item.CustomerName == "" {
item.CustomerName = "终端客户" + strconv.FormatInt(terminal.TerminalId, 10)
}
if stat != nil {
item.CardCount = stat.CardCount
item.NormalCardCount = stat.NormalCardCount
}
return item
}
func appEnterpriseProfileModel(dept *entity.SysDept, enterprise *entity.TAgentEnterprise) *model.AppEnterpriseProfileModel {
if dept == nil || dept.DeptId == 0 {
return nil
}
profile := &model.AppEnterpriseProfileModel{
DeptId: dept.DeptId,
DeptName: strings.TrimSpace(dept.DeptName),
DeptKey: strings.TrimSpace(dept.DeptKey),
Phone: appMaskPhone(dept.Phone),
Email: appMaskEmail(dept.Email),
StatusText: appDeptStatusText(dept.Status),
CreditCode: appMaskCreditCode(dept.CreditCode),
AreaCode: strings.TrimSpace(dept.AreaCode),
Address: appMaskAddress(dept.Address),
FullName: strings.TrimSpace(dept.DeptName),
RealNameStatus: "实名资料待完善",
}
if enterprise != nil && enterprise.Id > 0 {
profile.FullName = appFirstNotEmpty(enterprise.FullName, dept.DeptName)
profile.Industry = strings.TrimSpace(enterprise.Industry)
profile.CompanyType = strings.TrimSpace(enterprise.CompanyType)
profile.EstablishTime = appProfileDate(enterprise.EstablishTime)
profile.RealNameStatus = appRealNameStatus(enterprise.FullName, enterprise.LegalPersonName, enterprise.LegalPersonIdCard)
}
return profile
}
func appDeptStatusText(status string) string {
switch strings.TrimSpace(status) {
case consts.SysDeptStatusNormal:
return "正常"
case consts.SysDeptStatusDisable:
return "停用"
case "":
return "未设置"
default:
return "未知"
}
}
+397
View File
@@ -0,0 +1,397 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"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/util/gconv"
)
type appEnterpriseDataScope struct {
Configured bool
All bool
Restricted bool
DeptIds []int64
UserIds []int64
}
func appApplyEnterpriseDataScope(ctx context.Context, user *entity.SysUser, mod *gdb.Model, deptField string, userField string, legacy func(*gdb.Model) *gdb.Model) *gdb.Model {
scope, err := appResolveEnterpriseDataScope(ctx, user)
if err != nil {
g.Log().Errorf(ctx, "appApplyEnterpriseDataScope resolve error: %v", err)
return mod.Where("1 = 0")
}
if !scope.Configured {
if legacy != nil {
return legacy(mod)
}
return mod
}
if scope.All {
return mod
}
hasDeptScope := deptField != "" && len(scope.DeptIds) > 0
hasUserScope := userField != "" && len(scope.UserIds) > 0
if hasDeptScope && hasUserScope {
return mod.Where("("+deptField+" IN (?) OR "+userField+" IN (?))", scope.DeptIds, scope.UserIds)
}
if hasDeptScope {
return mod.WhereIn(deptField, scope.DeptIds)
}
if hasUserScope {
return mod.WhereIn(userField, scope.UserIds)
}
if scope.Restricted {
return mod.Where("1 = 0")
}
return mod.Where("1 = 0")
}
func appResolveEnterpriseDataScope(ctx context.Context, user *entity.SysUser) (*appEnterpriseDataScope, error) {
scope := &appEnterpriseDataScope{}
if user == nil || user.UserId <= 0 {
scope.Restricted = true
return scope, nil
}
configured, err := appAccessConfigured(ctx)
if err != nil {
return nil, err
}
scope.Configured = configured
if !configured {
return scope, nil
}
if contexts.IsSuperAdmin(ctx) {
scope.All = true
return scope, nil
}
currentRoleId, currentRoleDataScope := appCurrentRoleDataScope(ctx)
if currentRoleId > 0 && currentRoleDataScope != "" {
return appBuildEnterpriseDataScope(ctx, user, currentRoleId, currentRoleDataScope, true)
}
roles := contexts.GetRoles(ctx)
if len(roles) == 0 {
roles, err = appLoadUserRoleScopes(ctx, user)
if err != nil {
return nil, err
}
}
if len(roles) == 0 {
scope.Restricted = true
return scope, nil
}
scope.Restricted = true
for _, role := range roles {
part, err := appBuildEnterpriseDataScope(ctx, user, role.RoleId, role.DataScope, true)
if err != nil {
return nil, err
}
if part.All {
return part, nil
}
scope.DeptIds = append(scope.DeptIds, part.DeptIds...)
scope.UserIds = append(scope.UserIds, part.UserIds...)
}
scope.DeptIds = appUniqueInt64(scope.DeptIds)
scope.UserIds = appUniqueInt64(scope.UserIds)
return scope, nil
}
func appCurrentRoleDataScope(ctx context.Context) (int64, string) {
roleId := int64(0)
dataScope := ""
if value := contexts.GetDataValue(ctx, "currentRoleId"); value != nil {
roleId = gconv.Int64(value)
}
if value := contexts.GetDataValue(ctx, "currentRoleDataScope"); value != nil {
dataScope = strings.TrimSpace(gconv.String(value))
}
return roleId, dataScope
}
func appBuildEnterpriseDataScope(ctx context.Context, user *entity.SysUser, roleId int64, dataScope string, configured bool) (*appEnterpriseDataScope, error) {
scope := &appEnterpriseDataScope{
Configured: configured,
}
dataScope = strings.TrimSpace(dataScope)
if dataScope == "" {
dataScope = consts.SysRoleDataScopeAll
}
switch dataScope {
case consts.SysRoleDataScopeAll:
scope.All = true
case consts.SysRoleDataScopeCustom:
scope.Restricted = true
deptIds, err := appCustomRoleDeptIds(ctx, user.TenantId, roleId)
if err != nil {
return nil, err
}
scope.DeptIds = deptIds
case consts.SysRoleDataScopeDept:
scope.Restricted = true
if user.DeptId > 0 {
scope.DeptIds = []int64{user.DeptId}
}
case consts.SysRoleDataScopeDeptAndBelow:
scope.Restricted = true
deptIds, err := appEnterpriseDeptAndSubIds(ctx, user.TenantId, user.DeptId)
if err != nil {
return nil, err
}
scope.DeptIds = deptIds
case consts.SysRoleDataScopePersonal:
scope.Restricted = true
scope.UserIds = []int64{user.UserId}
case consts.SysRoleDataScopeDeptAndBelowOrPersonal:
scope.Restricted = true
deptIds, err := appEnterpriseDeptAndSubIds(ctx, user.TenantId, user.DeptId)
if err != nil {
return nil, err
}
scope.DeptIds = deptIds
scope.UserIds = []int64{user.UserId}
case consts.SysRoleDataScopeOrgAndNextLevel:
scope.Restricted = true
deptIds, err := appOrgAndNextLevelDeptIds(ctx, user.TenantId, user.DeptId)
if err != nil {
return nil, err
}
scope.DeptIds = deptIds
case consts.SysRoleDataScopeOrgNextLevel:
scope.Restricted = true
deptIds, err := appOrgNextLevelDeptIds(ctx, user.TenantId, user.DeptId)
if err != nil {
return nil, err
}
scope.DeptIds = deptIds
default:
scope.Restricted = true
}
scope.DeptIds = appUniqueInt64(scope.DeptIds)
scope.UserIds = appUniqueInt64(scope.UserIds)
return scope, nil
}
func appLoadUserRoleScopes(ctx context.Context, user *entity.SysUser) ([]model.Role, error) {
if user == nil || user.UserId <= 0 {
return []model.Role{}, nil
}
var userRoles []*entity.SysUserRole
if err := dao.SysUserRole.Ctx(ctx).
Where(dao.SysUserRole.Columns().UserId, user.UserId).
Scan(&userRoles); err != nil {
return nil, err
}
roleIds := make([]int64, 0, len(userRoles))
for _, userRole := range userRoles {
if userRole != nil && userRole.RoleId > 0 {
roleIds = append(roleIds, userRole.RoleId)
}
}
roleIds = appUniqueInt64(roleIds)
if len(roleIds) == 0 {
return []model.Role{}, nil
}
var rows []*entity.SysRole
err := dao.SysRole.Ctx(ctx).
Fields(dao.SysRole.Columns().RoleId, dao.SysRole.Columns().DataScope).
Where(dao.SysRole.Columns().TenantId, user.TenantId).
WhereIn(dao.SysRole.Columns().RoleId, roleIds).
Where(dao.SysRole.Columns().Status, consts.SysStatusNormal).
Scan(&rows)
if err != nil {
return nil, err
}
roles := make([]model.Role, 0, len(rows))
for _, row := range rows {
if row == nil || row.RoleId <= 0 {
continue
}
roles = append(roles, model.Role{
RoleId: row.RoleId,
DataScope: row.DataScope,
})
}
return roles, nil
}
func appCustomRoleDeptIds(ctx context.Context, tenantId string, roleId int64) ([]int64, error) {
if roleId <= 0 {
return []int64{}, nil
}
var rows []*entity.SysRoleDept
err := dao.SysRoleDept.Ctx(ctx).
Where("tenant_id", tenantId).
Where(dao.SysRoleDept.Columns().RoleId, roleId).
Scan(&rows)
if err != nil {
return nil, err
}
deptIds := make([]int64, 0, len(rows))
for _, row := range rows {
if row != nil && row.DeptId > 0 {
deptIds = append(deptIds, row.DeptId)
}
}
return appUniqueInt64(deptIds), nil
}
func appLegacyEnterpriseDeptAndSubScope(ctx context.Context, user *entity.SysUser, mod *gdb.Model, deptField string) *gdb.Model {
if user == nil || user.DeptId <= 0 || deptField == "" {
return mod.Where("1 = 0")
}
deptIds, err := appEnterpriseDeptAndSubIds(ctx, user.TenantId, user.DeptId)
if err != nil || len(deptIds) == 0 {
if err != nil {
g.Log().Errorf(ctx, "appLegacyEnterpriseDeptAndSubScope error: %v", err)
}
return mod.Where("1 = 0")
}
return mod.WhereIn(deptField, deptIds)
}
func appFilterDeptsByEnterpriseDataScope(ctx context.Context, user *entity.SysUser, depts []*entity.SysDept) ([]*entity.SysDept, error) {
scope, err := appResolveEnterpriseDataScope(ctx, user)
if err != nil {
return nil, err
}
if !scope.Configured || scope.All {
return depts, nil
}
if len(scope.DeptIds) == 0 {
return []*entity.SysDept{}, nil
}
allowed := make(map[int64]struct{}, len(scope.DeptIds))
for _, deptId := range scope.DeptIds {
allowed[deptId] = struct{}{}
}
result := make([]*entity.SysDept, 0, len(depts))
for _, dept := range depts {
if dept == nil || dept.DeptId <= 0 {
continue
}
if _, ok := allowed[dept.DeptId]; ok {
result = append(result, dept)
}
}
return result, nil
}
func appEnterpriseDeptVisibleByDataScope(ctx context.Context, user *entity.SysUser, deptId int64) (bool, error) {
if deptId <= 0 {
return false, nil
}
scope, err := appResolveEnterpriseDataScope(ctx, user)
if err != nil {
return false, err
}
if !scope.Configured || scope.All {
return true, nil
}
for _, item := range scope.DeptIds {
if item == deptId {
return true, nil
}
}
return false, nil
}
func appParentCompanyDeptId(ctx context.Context, tenantId string, deptId int64) (int64, error) {
if deptId <= 0 {
return deptId, nil
}
var dept *entity.SysDept
if err := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, tenantId).
Where(dao.SysDept.Columns().DeptId, deptId).
WhereNull(dao.SysDept.Columns().DeletedAt).
Limit(1).
Scan(&dept); err != nil {
return 0, gerror.Wrap(err, "查询上级组织失败")
}
if dept == nil || dept.DeptId == 0 {
return deptId, nil
}
if dept.ParentId == 0 || dept.DeptType == 1 {
return dept.DeptId, nil
}
return appParentCompanyDeptId(ctx, tenantId, dept.ParentId)
}
func appDeptDirectSubIds(ctx context.Context, tenantId string, deptId int64) ([]int64, error) {
if deptId <= 0 {
return []int64{}, nil
}
var rows []*entity.SysDept
err := dao.SysDept.Ctx(ctx).
Fields(dao.SysDept.Columns().DeptId).
Where(dao.SysDept.Columns().TenantId, tenantId).
Where(dao.SysDept.Columns().ParentId, deptId).
WhereNull(dao.SysDept.Columns().DeletedAt).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询下级组织失败")
}
deptIds := make([]int64, 0, len(rows))
for _, row := range rows {
if row != nil && row.DeptId > 0 {
deptIds = append(deptIds, row.DeptId)
}
}
return appUniqueInt64(deptIds), nil
}
func appOrgAndNextLevelDeptIds(ctx context.Context, tenantId string, deptId int64) ([]int64, error) {
orgDeptId, err := appParentCompanyDeptId(ctx, tenantId, deptId)
if err != nil {
return nil, err
}
ids := []int64{}
if orgDeptId > 0 {
ids = append(ids, orgDeptId)
}
children, err := appDeptDirectSubIds(ctx, tenantId, orgDeptId)
if err != nil {
return nil, err
}
ids = append(ids, children...)
return appUniqueInt64(ids), nil
}
func appOrgNextLevelDeptIds(ctx context.Context, tenantId string, deptId int64) ([]int64, error) {
orgDeptId, err := appParentCompanyDeptId(ctx, tenantId, deptId)
if err != nil {
return nil, err
}
return appDeptDirectSubIds(ctx, tenantId, orgDeptId)
}
func appUniqueInt64(values []int64) []int64 {
result := make([]int64, 0, len(values))
seen := make(map[int64]struct{}, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,228 @@
package app
import (
"encoding/json"
"strings"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
)
func TestQueryDiagnosisFlowByCardRejectsUnsupportedPeriod(t *testing.T) {
_, err := NewAppDiagnosis().queryDiagnosisFlowByCard(nil, nil, "12M")
if err == nil || !strings.Contains(err.Error(), "3M") {
t.Fatalf("unsupported period error = %v, want query type hint", err)
}
}
func TestAppCachedDiagnosisRegionModuleTreatsCachedDataAsSuccess(t *testing.T) {
raw, err := json.Marshal(map[string]string{
"regionLimitStatus": "1",
"orderStatus": "0",
"limitStatus": "1",
"limitType": "province",
"limitArea": "山东",
})
if err != nil {
t.Fatalf("marshal region data: %v", err)
}
item := &appUpstreamSimQueryItem{
Code: 1001,
Message: "上游一小时内限制重复查询",
ResData: raw,
}
status, message := appCachedDiagnosisRegionModule(item)
if status != "cached" {
t.Fatalf("status = %q, want cached", status)
}
if !strings.Contains(message, "区域限制结果来自缓存") {
t.Fatalf("message = %q, want cache hint", message)
}
res := &model.AppDiagnosisInfoModel{Info: &model.AppDiagnosisInfoItem{}}
appDiagnosisApplyRegionLimit(res, item)
if res.Info.RegionLimitStatus == nil || *res.Info.RegionLimitStatus != "1" {
t.Fatalf("RegionLimitStatus = %#v, want 1", res.Info.RegionLimitStatus)
}
if res.Info.RegionLimitArea == nil || *res.Info.RegionLimitArea != "山东" {
t.Fatalf("RegionLimitArea = %#v, want 山东", res.Info.RegionLimitArea)
}
}
func TestAppCachedDiagnosisRegionModuleFailsEmptyCacheData(t *testing.T) {
status, _ := appCachedDiagnosisRegionModule(&appUpstreamSimQueryItem{Code: 0})
if status != "error" {
t.Fatalf("status = %q, want error", status)
}
}
func TestAppDiagnosisApplyRateLimitIncludesApnDetails(t *testing.T) {
inner, err := json.Marshal(map[string]any{
"rateLimitStatus": "0",
"rateLimitReason": "套餐达量",
"rateLimitDetail": "APN触发限速",
"apnDetails": []map[string]any{
{
"apnName": "cmiot",
"apnStatus": "0",
"modifyTime": "2026-08-28 10:00:00",
"overUseDetails": "流量超额",
"apnServiceUsageState": "超额",
"pccDetails": []map[string]any{
{
"pccServiceCode": "PCC001",
"pccName": "白名单",
"pccPolicyStatus": "1",
"pccServiceUsageState": "正常",
"modifyTime": "2026-08-28 10:01:00",
},
},
},
},
})
if err != nil {
t.Fatalf("marshal rate limit data: %v", err)
}
raw, err := json.Marshal(map[string]any{"resData": json.RawMessage(inner)})
if err != nil {
t.Fatalf("marshal wrapped rate limit data: %v", err)
}
res := &model.AppDiagnosisInfoModel{Info: &model.AppDiagnosisInfoItem{}}
appDiagnosisApplyRateLimit(res, &appUpstreamSimQueryItem{Code: 0, ResData: raw})
if res.Info.RateLimitStatus == nil || *res.Info.RateLimitStatus != "0" {
t.Fatalf("RateLimitStatus = %#v, want 0", res.Info.RateLimitStatus)
}
if len(res.Info.RateLimitApnDetails) != 1 {
t.Fatalf("RateLimitApnDetails length = %d, want 1", len(res.Info.RateLimitApnDetails))
}
apn := res.Info.RateLimitApnDetails[0]
if apn.ApnName != "cmiot" || len(apn.PccDetails) != 1 || apn.PccDetails[0].PccServiceCode != "PCC001" {
t.Fatalf("unexpected APN detail: %#v", apn)
}
}
func TestSanitizeOpenDiagnosisInfoMasksIdentifiersAndRemovesNetworkData(t *testing.T) {
res := &model.AppDiagnosisInfoModel{
Info: &model.AppDiagnosisInfoItem{
Iccid: appDiagnosisStringPtr("8986001234567890123"),
Imsi: appDiagnosisStringPtr("460001234567890"),
Msisdn: appDiagnosisStringPtr("13800008000"),
RateLimitApnDetails: []model.AppDiagnosisRateLimitApnItem{
{ApnName: "cmiot"},
},
},
Session: &model.AppDiagnosisSessionItem{
ApnId: appDiagnosisStringPtr("internet"),
Imei: appDiagnosisStringPtr("860123456789012"),
Ipv4: appDiagnosisStringPtr("10.0.0.1"),
Ipv6: appDiagnosisStringPtr("2001:db8::1"),
},
Card: &model.AppCardModel{
Iccid: "8986001234567890123",
Msisdn: "13800008000",
Imsi: "460001234567890",
Imei: "860123456789012",
Ipv4: "10.0.0.1",
},
}
sanitizeOpenDiagnosisInfo(res)
if got := *res.Info.Iccid; got != "8986****0123" {
t.Fatalf("masked ICCID = %q", got)
}
if got := *res.Info.Msisdn; got != "1380****8000" {
t.Fatalf("masked MSISDN = %q", got)
}
if res.Info.Imsi != nil || len(res.Info.RateLimitApnDetails) != 0 || res.Session.ApnId != nil || res.Session.Imei != nil || res.Session.Ipv4 != nil {
t.Fatal("sensitive diagnosis fields were not removed")
}
if res.Card.Imsi != "" || res.Card.Imei != "" || res.Card.Ipv4 != "" {
t.Fatal("sensitive card fields were not removed")
}
}
func TestBuildDiagnosisInfoKeepsSessionImeiBeforeOpenSanitization(t *testing.T) {
res := &model.AppDiagnosisInfoModel{
Session: &model.AppDiagnosisSessionItem{Imei: appDiagnosisStringPtr("860123456789012")},
}
if res.Session.Imei == nil || *res.Session.Imei != "860123456789012" {
t.Fatal("authenticated diagnosis should keep session IMEI")
}
sanitizeOpenDiagnosisInfo(res)
if res.Session.Imei != nil {
t.Fatal("open diagnosis should remove session IMEI")
}
}
func TestAppDiagnosisStatusMappingsMatchConsoleContract(t *testing.T) {
tests := []struct {
name string
input string
want string
mapper func(string) *string
}{
{name: "imei separated", input: "0", want: "0", mapper: appDiagnosisImeiStatus},
{name: "imei bound", input: "1", want: "1", mapper: appDiagnosisImeiStatus},
{name: "imei unknown", input: "2", want: "2", mapper: appDiagnosisImeiStatus},
{name: "online", input: "0", want: "1", mapper: appDiagnosisOnlineStatus},
{name: "offline", input: "-1", want: "0", mapper: appDiagnosisOnlineStatus},
{name: "unknown", input: "-2", want: "2", mapper: appDiagnosisOnlineStatus},
{name: "human limited", input: "1", want: "0", mapper: appDiagnosisHumanLimitStatus},
{name: "human normal", input: "0", want: "1", mapper: appDiagnosisHumanLimitStatus},
{name: "human unknown", input: "2", want: "2", mapper: appDiagnosisHumanLimitStatus},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := test.mapper(test.input)
if got == nil || *got != test.want {
t.Fatalf("mapping %q = %#v, want %q", test.input, got, test.want)
}
})
}
}
func TestAppDiagnosisBindStatusSupportsTelecomCardBindingType(t *testing.T) {
tests := []struct {
bindStatus string
cardBindingType string
want string
}{
{bindStatus: "0", cardBindingType: "1", want: "0"},
{bindStatus: "1", cardBindingType: "2", want: "1"},
{bindStatus: "2", cardBindingType: "1", want: "2"},
{cardBindingType: "1", want: "1"},
{cardBindingType: "5", want: "1"},
{cardBindingType: "-1", want: "2"},
{cardBindingType: "unknown", want: "2"},
}
for _, test := range tests {
if got := appDiagnosisBindStatus(test.bindStatus, test.cardBindingType); got != test.want {
t.Fatalf("bindStatus=%q cardBindingType=%q => %q, want %q", test.bindStatus, test.cardBindingType, got, test.want)
}
}
}
func TestAppDiagnosisSessionStatusNormalizesUpstreamValues(t *testing.T) {
tests := map[string]string{
"0": "0",
"01": "0",
"ONLINE": "0",
"-1": "-1",
"00": "-1",
"OFFLINE": "-1",
"-2": "-2",
"02": "-2",
"2": "-2",
"": "-2",
}
for input, want := range tests {
if got := appDiagnosisSessionStatus(input); got != want {
t.Fatalf("session status %q => %q, want %q", input, got, want)
}
}
}
@@ -0,0 +1,269 @@
package app
import (
"context"
"database/sql"
"fmt"
"hash/fnv"
"regexp"
"strings"
"time"
"unicode"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
_ "github.com/taosdata/driver-go/v3/taosRestful"
_ "github.com/taosdata/driver-go/v3/taosWS"
)
const (
appDiagnosisDailyStable = "t_sim_card_usage_daily"
appDiagnosisMonthlyStable = "t_sim_card_usage_monthly"
)
var appDiagnosisIdentifierPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type appDiagnosisTrafficConfig struct {
Enabled bool
DriverName string
Dsn string
DbName string
DailyDbName string
MonthlyDbName string
MaxOpenConns int
MaxIdleConns int
}
// dbNameFor 返回指定超级表所在 database。
//
// 日流量与月流量分库存储(两库 duration 不同,而 duration 只能建库时确定);
// 未单独配置 dailyDbName/monthlyDbName 时回退到 DbName,与改造前行为一致。
func (c appDiagnosisTrafficConfig) dbNameFor(stable string) string {
if stable == appDiagnosisDailyStable && c.DailyDbName != "" {
return c.DailyDbName
}
if stable == appDiagnosisMonthlyStable && c.MonthlyDbName != "" {
return c.MonthlyDbName
}
return c.DbName
}
type appDiagnosisTrafficRecord struct {
Ts time.Time
UsageMb float64
PackageCode string
Iccid string
}
func (s *sAppDiagnosis) queryDiagnosisDailyFlow(ctx context.Context, iccid string, days int) (*model.AppDiagnosisFlowModel, error) {
if days <= 0 {
days = 7
}
now := time.Now()
startTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -(days - 1))
endTime := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
records, err := appDiagnosisQueryTrafficRows(ctx, iccid, appDiagnosisDailyStable, startTime, endTime)
if err != nil {
return nil, gerror.Wrap(err, "查询SIM卡使用流量失败,请稍后重试")
}
usageMap := make(map[string]float64, len(records))
for _, item := range records {
usageMap[item.Ts.Format("2006-01-02")] = item.UsageMb
}
res := &model.AppDiagnosisFlowModel{
Times: make([]string, 0, days),
Flow: make([]float64, 0, days),
Unit: "MB",
Granularity: "day",
HasData: len(records) > 0,
}
for i := 0; i < days; i++ {
current := startTime.AddDate(0, 0, i)
res.Times = append(res.Times, current.Format("01-02"))
res.Flow = append(res.Flow, usageMap[current.Format("2006-01-02")])
}
return res, nil
}
func (s *sAppDiagnosis) queryDiagnosisMonthlyFlow(ctx context.Context, iccid string, months int) (*model.AppDiagnosisFlowModel, error) {
if months <= 0 {
months = 6
}
now := time.Now()
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
startMonth := currentMonth.AddDate(0, -(months - 1), 0)
endTime := time.Date(now.Year(), now.Month()+1, 0, 23, 59, 59, 0, now.Location())
records, err := appDiagnosisQueryTrafficRows(ctx, iccid, appDiagnosisMonthlyStable, startMonth, endTime)
if err != nil {
return nil, gerror.Wrap(err, "查询SIM卡使用流量失败,请稍后重试")
}
usageMap := make(map[string]float64, len(records))
for _, item := range records {
usageMap[item.Ts.Format("2006-01")] = item.UsageMb
}
res := &model.AppDiagnosisFlowModel{
Times: make([]string, 0, months),
Flow: make([]float64, 0, months),
Unit: "MB",
Granularity: "month",
HasData: len(records) > 0,
}
for i := 0; i < months; i++ {
current := startMonth.AddDate(0, i, 0)
key := current.Format("2006-01")
res.Times = append(res.Times, key)
res.Flow = append(res.Flow, usageMap[key])
}
return res, nil
}
func appDiagnosisQueryTrafficRows(ctx context.Context, iccid string, stable string, startTime, endTime time.Time) ([]appDiagnosisTrafficRecord, error) {
iccid = strings.TrimSpace(iccid)
if iccid == "" {
return nil, gerror.New("traffic usage query iccid is empty")
}
cfg := appDiagnosisGetTrafficConfig(ctx)
if !cfg.Enabled || strings.TrimSpace(cfg.Dsn) == "" {
g.Log().Debug(ctx, "app diagnosis traffic query skipped, tdengine disabled")
return []appDiagnosisTrafficRecord{}, nil
}
dbName := cfg.dbNameFor(stable)
if err := appDiagnosisValidateTableConfig(dbName, stable); err != nil {
return nil, err
}
driverName := strings.TrimSpace(cfg.DriverName)
if driverName == "" {
driverName = "taosWS"
}
db, err := sql.Open(driverName, cfg.Dsn)
if err != nil {
return nil, gerror.Wrap(err, "tdengine open failed")
}
defer db.Close()
db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
table := appDiagnosisQualify(dbName, appDiagnosisChildTrafficTableName(stable, iccid))
rows, err := db.QueryContext(ctx, fmt.Sprintf(
"SELECT ts, usage_mb, package_code, iccid FROM %s%s ORDER BY ts ASC",
table,
appDiagnosisWhereRangeSQL(startTime, endTime),
))
if err != nil {
if appDiagnosisIsTableNotExist(err) {
return []appDiagnosisTrafficRecord{}, nil
}
return nil, gerror.Wrap(err, "tdengine query traffic usage failed")
}
defer rows.Close()
list := make([]appDiagnosisTrafficRecord, 0)
for rows.Next() {
var item appDiagnosisTrafficRecord
if err = rows.Scan(&item.Ts, &item.UsageMb, &item.PackageCode, &item.Iccid); err != nil {
return nil, gerror.Wrap(err, "tdengine scan traffic usage failed")
}
list = append(list, item)
}
if err = rows.Err(); err != nil {
return nil, gerror.Wrap(err, "tdengine rows traffic usage failed")
}
return list, nil
}
func appDiagnosisGetTrafficConfig(ctx context.Context) appDiagnosisTrafficConfig {
// dbName 只是兑底键名,不决定实际访问哪个库;默认取日库,避免兑底到不存在的库名。
dbName := strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dbName", "iotlcm_daily").String())
return appDiagnosisTrafficConfig{
Enabled: strings.EqualFold(g.Cfg().MustGet(ctx, "tsdb.database", "TdEngine").String(), "TdEngine"),
DriverName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.type", "taosWS").String()),
Dsn: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dsn", "").String()),
DbName: dbName,
DailyDbName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dailyDbName", dbName).String()),
MonthlyDbName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.monthlyDbName", dbName).String()),
MaxOpenConns: g.Cfg().MustGet(ctx, "tsdb.tdengine.maxOpenConns", 100).Int(),
MaxIdleConns: g.Cfg().MustGet(ctx, "tsdb.tdengine.maxIdleConns", 10).Int(),
}
}
func appDiagnosisValidateTableConfig(database string, stable string) error {
if !appDiagnosisIdentifierPattern.MatchString(database) {
return gerror.Newf("invalid tdengine database name: %s", database)
}
if !appDiagnosisIdentifierPattern.MatchString(stable) {
return gerror.Newf("invalid tdengine stable name: %s", stable)
}
return nil
}
func appDiagnosisQualify(database, table string) string {
return fmt.Sprintf("%s.%s", database, table)
}
func appDiagnosisChildTrafficTableName(stable string, iccid string) string {
prefix := "sim_card_usage_m_"
if stable == appDiagnosisDailyStable {
prefix = "sim_card_usage_d_"
}
return appDiagnosisChildTableNameWithPrefix(prefix, iccid)
}
func appDiagnosisChildTableNameWithPrefix(prefix string, value string) string {
var builder strings.Builder
builder.WriteString(prefix)
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
builder.WriteRune(r)
}
}
name := builder.String()
if name == prefix || len(name) > 190 {
return fmt.Sprintf("%s%x", prefix, appDiagnosisHashString(value))
}
return name
}
func appDiagnosisHashString(value string) uint32 {
hash := fnv.New32a()
_, _ = hash.Write([]byte(value))
return hash.Sum32()
}
func appDiagnosisWhereRangeSQL(startTime, endTime time.Time) string {
clauses := make([]string, 0, 2)
if !startTime.IsZero() {
clauses = append(clauses, "ts >= "+appDiagnosisSQLTime(startTime))
}
if !endTime.IsZero() {
clauses = append(clauses, "ts <= "+appDiagnosisSQLTime(endTime))
}
if len(clauses) == 0 {
return ""
}
return " WHERE " + strings.Join(clauses, " AND ")
}
func appDiagnosisSQLTime(value time.Time) string {
return appDiagnosisSQLString(value.Format("2006-01-02 15:04:05.000"))
}
func appDiagnosisSQLString(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func appDiagnosisIsTableNotExist(err error) bool {
if err == nil {
return false
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "table does not exist") ||
strings.Contains(text, "table not exist") ||
strings.Contains(text, "does not exist")
}
+197
View File
@@ -0,0 +1,197 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type sAppMessage struct{}
func NewAppMessage() *sAppMessage {
return &sAppMessage{}
}
func init() {
service.RegisterAppMessage(NewAppMessage())
}
func (s *sAppMessage) List(ctx context.Context, param *model.AppMessageListParam) (items []*model.AppMessageModel, total int, err error) {
if param == nil {
param = &model.AppMessageListParam{}
}
if param.Page <= 0 {
param.Page = 1
}
if param.PageSize <= 0 {
param.PageSize = 20
}
if param.PageSize > 100 {
param.PageSize = 100
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, 0, err
}
mod := s.applyFilters(s.ownerMessageModel(ctx, user), param)
total, err = mod.Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "查询消息数量失败")
}
if total == 0 {
return []*model.AppMessageModel{}, 0, nil
}
if err = mod.
Fields(appMessageFields()).
Page(param.Page, param.PageSize).
OrderDesc("COALESCE(ml." + dao.TMessageLog.Columns().SendTime + ", um." + dao.TUserMessage.Columns().CreatedAt + ")").
OrderDesc("um." + dao.TUserMessage.Columns().MessageId).
Scan(&items); err != nil {
return nil, 0, gerror.Wrap(err, "查询消息列表失败")
}
for _, item := range items {
fillAppMessageText(item)
}
return items, total, nil
}
func (s *sAppMessage) Detail(ctx context.Context, param *model.AppMessageDetailParam) (*model.AppMessageModel, error) {
if param == nil || param.MessageId == 0 {
return nil, gerror.New("消息ID不能为空")
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
var item *model.AppMessageModel
if err = s.ownerMessageModel(ctx, user).
Fields(appMessageFields()).
Where("um."+dao.TUserMessage.Columns().MessageId, param.MessageId).
Limit(1).
Scan(&item); err != nil {
return nil, gerror.Wrap(err, "查询消息详情失败")
}
if item == nil || item.MessageId == 0 {
return nil, gerror.New("消息不存在或无权查看")
}
fillAppMessageText(item)
if item.IsRead == 0 {
if err = s.Read(ctx, &model.AppMessageReadParam{Ids: []uint64{item.MessageId}}); err != nil {
return nil, err
}
item.IsRead = 1
}
return item, nil
}
func (s *sAppMessage) Read(ctx context.Context, param *model.AppMessageReadParam) error {
if param == nil || len(param.Ids) == 0 {
return gerror.New("消息ID不能为空")
}
user, err := appCurrentUser(ctx)
if err != nil {
return err
}
_, err = dao.TUserMessage.Ctx(ctx).
Where(dao.TUserMessage.Columns().TenantId, user.TenantId).
Where(dao.TUserMessage.Columns().UserId, user.UserId).
WhereIn(dao.TUserMessage.Columns().MessageId, param.Ids).
Where(dao.TUserMessage.Columns().IsRead, 0).
Data(do.TUserMessage{
IsRead: 1,
UpdatedBy: user.UserId,
UpdatedAt: gtime.Now(),
}).
Update()
if err != nil {
return gerror.Wrap(err, "标记消息已读失败")
}
return nil
}
func (s *sAppMessage) ownerMessageModel(ctx context.Context, user *entity.SysUser) *gdb.Model {
return dao.TUserMessage.Ctx(ctx).As("um").
LeftJoin(dao.TMessageLog.Table()+" ml", "ml."+dao.TMessageLog.Columns().MessageLogId+" = um."+dao.TUserMessage.Columns().MessageLogId).
Where("um."+dao.TUserMessage.Columns().TenantId, user.TenantId).
Where("um."+dao.TUserMessage.Columns().UserId, user.UserId).
Where("(ml."+dao.TMessageLog.Columns().MessageLogId+" IS NULL OR ml."+dao.TMessageLog.Columns().IsRecall+" = ?)", 0)
}
func (s *sAppMessage) applyFilters(mod *gdb.Model, param *model.AppMessageListParam) *gdb.Model {
if param.IsRead != nil {
mod = mod.Where("um."+dao.TUserMessage.Columns().IsRead, *param.IsRead)
}
if param.MessageType != nil && *param.MessageType > 0 {
mod = mod.Where("ml."+dao.TMessageLog.Columns().MessageType, *param.MessageType)
}
keyword := strings.TrimSpace(param.Keyword)
if keyword != "" {
like := "%" + keyword + "%"
mod = mod.Where(
"(ml."+dao.TMessageLog.Columns().MessageTitle+" LIKE ? OR ml."+dao.TMessageLog.Columns().MessageContent+" LIKE ? OR ml."+dao.TMessageLog.Columns().MessageLink+" LIKE ?)",
like, like, like,
)
}
return mod
}
func appMessageFields() string {
return strings.Join([]string{
"um." + dao.TUserMessage.Columns().MessageId + " AS message_id",
"um." + dao.TUserMessage.Columns().MessageId + " AS id",
"um." + dao.TUserMessage.Columns().MessageLogId + " AS message_log_id",
"COALESCE(ml." + dao.TMessageLog.Columns().MessageType + ", 1) AS message_type",
"COALESCE(ml." + dao.TMessageLog.Columns().MessageTitle + ", '') AS title",
"COALESCE(ml." + dao.TMessageLog.Columns().MessageContent + ", '') AS content",
"COALESCE(ml." + dao.TMessageLog.Columns().MessageLink + ", '') AS link",
"um." + dao.TUserMessage.Columns().IsRead + " AS is_read",
"um." + dao.TUserMessage.Columns().CreatedAt + " AS created_at",
"um." + dao.TUserMessage.Columns().UpdatedAt + " AS updated_at",
"COALESCE(ml." + dao.TMessageLog.Columns().SendTime + ", um." + dao.TUserMessage.Columns().CreatedAt + ") AS send_time",
}, ", ")
}
func fillAppMessageText(item *model.AppMessageModel) {
if item == nil {
return
}
item.Id = item.MessageId
if strings.TrimSpace(item.Title) == "" {
item.Title = "未命名消息"
}
item.MessageTypeText = appMessageTypeText(item.MessageType)
}
func appMessageTypeText(messageType int) string {
switch messageType {
case 1:
return "系统通知"
case 2:
return "平台公告"
case 3:
return "订单通知"
case 4:
return "账户通知"
default:
return "消息"
}
}
@@ -0,0 +1,543 @@
package app
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type appOneLinkMockConfig struct {
Enabled bool `json:"enabled"`
BaseURL string `json:"baseUrl"`
AppID string `json:"appid"`
Password string `json:"password"`
Timeout string `json:"timeout"`
}
type appOneLinkMockClient struct {
cfg *appOneLinkMockConfig
httpClient *http.Client
token string
}
type appOneLinkMockTokenResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
Token string `json:"token"`
TTL string `json:"ttl"`
} `json:"result"`
}
type appOneLinkMockChangeStatusResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
Msisdn string `json:"msisdn"`
OrderNum string `json:"orderNum"`
} `json:"result"`
}
type appOneLinkMockBasicInfoResponse struct {
Status string `json:"status"`
Code int `json:"code"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
Data []struct {
Code int `json:"code"`
Message string `json:"message"`
ResData json.RawMessage `json:"resData"`
} `json:"data"`
}
type appOneLinkMockSimSessionResponse struct {
Status string `json:"status"`
Code int `json:"code"`
Message string `json:"message"`
Result json.RawMessage `json:"result"`
Data []struct {
Code int `json:"code"`
Message string `json:"message"`
ResData appOneLinkMockSimSessionItem `json:"resData"`
} `json:"data"`
}
type appOneLinkMockSimSessionItem struct {
ApnId string `json:"apnId"`
Rat string `json:"rat"`
Imei string `json:"imei"`
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
Ipv6Prefix string `json:"ipv6Prefix"`
Status string `json:"status"`
CreateTime string `json:"createTime"`
}
type appOneLinkMockStatusOutcome = appSimStatusChangeOutcome
func newAppOneLinkMockClient(ctx context.Context) (*appOneLinkMockClient, error) {
cfg, err := appLoadOneLinkMockConfig(ctx)
if err != nil {
return nil, err
}
timeout := 10 * time.Second
if cfg.Timeout != "" {
if parsed, err := time.ParseDuration(cfg.Timeout); err == nil && parsed > 0 {
timeout = parsed
}
}
return &appOneLinkMockClient{
cfg: cfg,
httpClient: &http.Client{Timeout: timeout},
}, nil
}
func appLoadOneLinkMockConfig(ctx context.Context) (*appOneLinkMockConfig, error) {
cfg := &appOneLinkMockConfig{
BaseURL: "https://iotlcm.example.com/onelink-mock/v5",
Timeout: "10s",
}
if err := g.Cfg().MustGet(ctx, "upstream.onelinkMock").Scan(cfg); err != nil {
return nil, gerror.Wrap(err, "读取OneLink挡板配置失败")
}
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
cfg.AppID = strings.TrimSpace(cfg.AppID)
cfg.Password = strings.TrimSpace(cfg.Password)
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
if !cfg.Enabled {
return nil, gerror.New("OneLink挡板未启用")
}
if cfg.BaseURL == "" {
return nil, gerror.New("OneLink挡板地址未配置")
}
if cfg.AppID == "" || cfg.Password == "" {
return nil, gerror.New("OneLink挡板appid或password未配置")
}
return cfg, nil
}
func (c *appOneLinkMockClient) ChangeSimStatus(ctx context.Context, operation appCardLifecycleOperation, row *appCardLifecycleExecutionRow) (appOneLinkMockStatusOutcome, error) {
if c == nil || c.cfg == nil {
return appOneLinkMockStatusOutcome{}, gerror.New("OneLink挡板客户端未初始化")
}
if row == nil {
return appOneLinkMockStatusOutcome{}, gerror.New("卡片操作行不能为空")
}
msisdn := strings.TrimSpace(row.Msisdn)
if msisdn == "" && row.Card != nil {
msisdn = strings.TrimSpace(row.Card.Msisdn)
}
if msisdn == "" {
return appOneLinkMockStatusOutcome{}, gerror.New("卡片缺少MSISDN,不能请求OneLink挡板")
}
operType, err := appOneLinkMockOperType(operation)
if err != nil {
return appOneLinkMockStatusOutcome{}, err
}
token, err := c.ensureToken(ctx)
if err != nil {
return appOneLinkMockStatusOutcome{}, err
}
params := url.Values{}
params.Set("appid", c.cfg.AppID)
params.Set("transid", appOneLinkMockTransID(c.cfg.AppID))
params.Set("token", token)
params.Set("msisdn", msisdn)
params.Set("operType", operType)
body, err := c.get(ctx, c.cfg.BaseURL+"/ec/change/sim-status?"+params.Encode())
if err != nil {
return appOneLinkMockStatusOutcome{}, err
}
var response appOneLinkMockChangeStatusResponse
if err = json.Unmarshal(body, &response); err != nil {
return appOneLinkMockStatusOutcome{}, gerror.Wrap(err, "解析OneLink挡板状态变更响应失败")
}
if strings.TrimSpace(response.Status) != "0" {
message := strings.TrimSpace(response.Message)
if message == "" {
message = "OneLink挡板状态变更失败"
}
return appOneLinkMockStatusOutcome{Code: -1, Message: message}, nil
}
orderNum := ""
if len(response.Result) > 0 {
orderNum = strings.TrimSpace(response.Result[0].OrderNum)
}
return appOneLinkMockStatusOutcome{
Success: true,
Code: 0,
Message: appOneLinkMockSuccessMessage(response.Message),
OrderNum: orderNum,
}, nil
}
func (c *appOneLinkMockClient) QuerySimBasicInfo(ctx context.Context, input appSimBasicInfoInput) (*appSimBasicInfoOutput, error) {
if c == nil || c.cfg == nil {
return nil, gerror.New("OneLink鎸℃澘瀹㈡埛绔湭鍒濆鍖?")
}
token, err := c.ensureToken(ctx)
if err != nil {
return nil, err
}
params := url.Values{}
params.Set("appid", c.cfg.AppID)
params.Set("transid", appOneLinkMockTransID(c.cfg.AppID))
params.Set("token", token)
appOneLinkMockAddBasicInfoQueryParam(params, input)
if params.Get("msisdn") == "" && params.Get("iccid") == "" && params.Get("imsi") == "" && params.Get("imei") == "" {
return nil, gerror.New("鍗$墖缂哄皯鏌ヨ鏍囪瘑锛屼笉鑳借姹侽neLink鎸℃澘")
}
body, err := c.get(ctx, c.cfg.BaseURL+"/ec/query/sim-basic-info?"+params.Encode())
if err != nil {
return nil, err
}
var response appOneLinkMockBasicInfoResponse
if err = json.Unmarshal(body, &response); err != nil {
return nil, gerror.Wrap(err, "瑙f瀽OneLink鎸℃澘鍩烘湰淇℃伅鍝嶅簲澶辫触")
}
if status := strings.TrimSpace(response.Status); status != "" && status != "0" {
message := strings.TrimSpace(response.Message)
if message == "" {
message = "OneLink鎸℃澘鍩烘湰淇℃伅鏌ヨ澶辫触"
}
return nil, gerror.New(message)
}
if response.Code != 0 {
message := strings.TrimSpace(response.Message)
if message == "" {
message = "OneLink鎸℃澘鍩烘湰淇℃伅鏌ヨ澶辫触"
}
return nil, gerror.New(message)
}
if output := appOneLinkMockParseBasicInfoOutput(response.Result, input); output != nil {
return output, nil
}
for _, item := range response.Data {
if item.Code != 0 {
continue
}
if output := appOneLinkMockParseBasicInfoOutput(item.ResData, input); output != nil {
return output, nil
}
}
return nil, nil
}
func (c *appOneLinkMockClient) QuerySimSession(ctx context.Context, card *appCardQueryRow) (*appOneLinkMockSimSessionItem, error) {
if c == nil || c.cfg == nil {
return nil, gerror.New("OneLink挡板客户端未初始化")
}
if card == nil {
return nil, gerror.New("卡片信息为空,不能查询会话")
}
params := url.Values{}
params.Set("appid", c.cfg.AppID)
params.Set("transid", appOneLinkMockTransID(c.cfg.AppID))
if token, err := c.ensureToken(ctx); err != nil {
return nil, err
} else {
params.Set("token", token)
}
if value := strings.TrimSpace(card.Msisdn); value != "" {
params.Set("msisdn", value)
}
if value := strings.TrimSpace(card.Iccid); value != "" {
params.Set("iccid", value)
}
if value := strings.TrimSpace(card.Imsi); value != "" {
params.Set("imsi", value)
}
if params.Get("msisdn") == "" && params.Get("iccid") == "" && params.Get("imsi") == "" {
return nil, gerror.New("卡片缺少查询会话所需标识")
}
body, err := c.get(ctx, c.cfg.BaseURL+"/ec/query/sim-session?"+params.Encode())
if err != nil {
return nil, err
}
var response appOneLinkMockSimSessionResponse
if err = json.Unmarshal(body, &response); err != nil {
return nil, gerror.Wrap(err, "解析OneLink挡板会话响应失败")
}
if strings.TrimSpace(response.Status) != "" && strings.TrimSpace(response.Status) != "0" {
message := strings.TrimSpace(response.Message)
if message == "" {
message = "OneLink挡板会话查询失败"
}
return nil, gerror.New(message)
}
if response.Code != 0 {
message := strings.TrimSpace(response.Message)
if message == "" {
message = "OneLink挡板会话查询失败"
}
return nil, gerror.New(message)
}
for _, item := range appOneLinkMockParseSessionResult(response.Result) {
if !item.isZero() {
return &item, nil
}
}
for _, item := range response.Data {
if item.Code == 0 && !item.ResData.isZero() {
session := item.ResData
return &session, nil
}
}
return nil, nil
}
func appOneLinkMockAddBasicInfoQueryParam(params url.Values, input appSimBasicInfoInput) {
if params == nil {
return
}
if value := strings.TrimSpace(input.Msisdn); value != "" {
params.Set("msisdn", value)
}
if value := strings.TrimSpace(input.Iccid); value != "" {
params.Set("iccid", value)
}
if value := strings.TrimSpace(input.Imsi); value != "" {
params.Set("imsi", value)
}
if value := strings.TrimSpace(input.Imei); value != "" {
params.Set("imei", value)
}
if params.Get("msisdn") == "" && params.Get("iccid") == "" && params.Get("imsi") == "" && params.Get("imei") == "" {
if value := strings.TrimSpace(input.CardValue); value != "" {
params.Set("msisdn", value)
}
}
}
func appOneLinkMockParseBasicInfoOutput(raw json.RawMessage, input appSimBasicInfoInput) *appSimBasicInfoOutput {
payload := appOneLinkMockFirstPayload(raw)
if len(payload) == 0 {
return nil
}
output := &appSimBasicInfoOutput{
Provider: appUpstreamProviderMock,
Iccid: appFirstNotEmpty(appOneLinkMockMapString(payload, "iccid", "Iccid"), strings.TrimSpace(input.Iccid)),
Msisdn: appFirstNotEmpty(appOneLinkMockMapString(payload, "msisdn", "Msisdn"), strings.TrimSpace(input.Msisdn), strings.TrimSpace(input.CardValue)),
Imsi: appFirstNotEmpty(appOneLinkMockMapString(payload, "imsi", "Imsi"), strings.TrimSpace(input.Imsi)),
Imei: appFirstNotEmpty(appOneLinkMockMapString(payload, "imei", "Imei"), strings.TrimSpace(input.Imei)),
OpenDate: appOneLinkMockMapString(payload, "openDate", "open_date", "OpenDate"),
ActiveDate: appOneLinkMockMapString(payload, "activeDate", "active_date", "ActiveDate"),
Remark: appOneLinkMockMapString(payload, "remark", "Remark"),
LastChangeDate: appOneLinkMockMapString(payload, "lastChangeDate", "last_change_date", "LastChangeDate"),
}
statusText := appOneLinkMockMapString(payload, "cardStatus", "card_status", "status", "Status")
if statusText != "" {
if status, err := strconv.Atoi(statusText); err == nil {
output.CardStatus = status
}
if output.CardStatus == 0 {
switch strings.ToLower(statusText) {
case "active", "activated", "normal", "ok", "success":
output.CardStatus = consts.V3ApiSimBossCardStatusActivated
case "stop", "stopped", "disable", "disabled":
output.CardStatus = consts.V3ApiSimBossCardStatusStopped
}
}
}
if output.Iccid == "" && output.Msisdn == "" && output.Imsi == "" && output.Imei == "" && output.OpenDate == "" && output.ActiveDate == "" && output.Remark == "" && output.LastChangeDate == "" && output.CardStatus == 0 {
return nil
}
return output
}
func appOneLinkMockFirstPayload(raw json.RawMessage) map[string]any {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err == nil && len(payload) > 0 {
if nested, ok := payload["resData"]; ok && nested != nil {
if nestedRaw, err := json.Marshal(nested); err == nil {
if nestedPayload := appOneLinkMockFirstPayload(nestedRaw); len(nestedPayload) > 0 {
return nestedPayload
}
}
}
if nested, ok := payload["data"]; ok && nested != nil {
if nestedRaw, err := json.Marshal(nested); err == nil {
if nestedPayload := appOneLinkMockFirstPayload(nestedRaw); len(nestedPayload) > 0 {
return nestedPayload
}
}
}
return payload
}
var payloadList []map[string]any
if err := json.Unmarshal(raw, &payloadList); err == nil {
for _, item := range payloadList {
if len(item) > 0 {
if nested, ok := item["resData"]; ok && nested != nil {
if nestedRaw, err := json.Marshal(nested); err == nil {
if nestedPayload := appOneLinkMockFirstPayload(nestedRaw); len(nestedPayload) > 0 {
return nestedPayload
}
}
}
return item
}
}
}
var payloadWrapper struct {
ResData json.RawMessage `json:"resData"`
}
if err := json.Unmarshal(raw, &payloadWrapper); err == nil && len(payloadWrapper.ResData) > 0 {
return appOneLinkMockFirstPayload(payloadWrapper.ResData)
}
return nil
}
func appOneLinkMockMapString(data map[string]any, keys ...string) string {
if len(data) == 0 {
return ""
}
for _, key := range keys {
if value, ok := data[key]; ok && value != nil {
text := strings.TrimSpace(fmt.Sprint(value))
if text != "" && text != "<nil>" {
return text
}
}
}
return ""
}
func (c *appOneLinkMockClient) ensureToken(ctx context.Context) (string, error) {
if strings.TrimSpace(c.token) != "" {
return c.token, nil
}
params := url.Values{}
params.Set("appid", c.cfg.AppID)
params.Set("password", c.cfg.Password)
params.Set("transid", appOneLinkMockTransID(c.cfg.AppID))
body, err := c.get(ctx, c.cfg.BaseURL+"/ec/get/token?"+params.Encode())
if err != nil {
return "", err
}
var response appOneLinkMockTokenResponse
if err = json.Unmarshal(body, &response); err != nil {
return "", gerror.Wrap(err, "解析OneLink挡板token响应失败")
}
if strings.TrimSpace(response.Status) != "0" || len(response.Result) == 0 || strings.TrimSpace(response.Result[0].Token) == "" {
message := strings.TrimSpace(response.Message)
if message == "" {
message = "获取OneLink挡板token失败"
}
return "", gerror.New(message)
}
c.token = strings.TrimSpace(response.Result[0].Token)
return c.token, nil
}
func (c *appOneLinkMockClient) get(ctx context.Context, requestURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, gerror.Wrap(err, "请求OneLink挡板失败")
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, gerror.Wrap(err, "读取OneLink挡板响应失败")
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, gerror.Newf("OneLink挡板HTTP状态异常:%d", resp.StatusCode)
}
return body, nil
}
func appOneLinkMockParseSessionResult(raw json.RawMessage) []appOneLinkMockSimSessionItem {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var directList []appOneLinkMockSimSessionItem
if err := json.Unmarshal(raw, &directList); err == nil && len(directList) > 0 {
return directList
}
var direct appOneLinkMockSimSessionItem
if err := json.Unmarshal(raw, &direct); err == nil && !direct.isZero() {
return []appOneLinkMockSimSessionItem{direct}
}
var wrappedList []struct {
Code int `json:"code"`
ResData appOneLinkMockSimSessionItem `json:"resData"`
}
if err := json.Unmarshal(raw, &wrappedList); err == nil && len(wrappedList) > 0 {
items := make([]appOneLinkMockSimSessionItem, 0, len(wrappedList))
for _, wrapped := range wrappedList {
if wrapped.Code == 0 && !wrapped.ResData.isZero() {
items = append(items, wrapped.ResData)
}
}
return items
}
var wrapped struct {
Code int `json:"code"`
ResData appOneLinkMockSimSessionItem `json:"resData"`
}
if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Code == 0 && !wrapped.ResData.isZero() {
return []appOneLinkMockSimSessionItem{wrapped.ResData}
}
return nil
}
func (item appOneLinkMockSimSessionItem) isZero() bool {
return strings.TrimSpace(item.ApnId) == "" &&
strings.TrimSpace(item.Rat) == "" &&
strings.TrimSpace(item.Imei) == "" &&
strings.TrimSpace(item.Ipv4) == "" &&
strings.TrimSpace(item.Ipv6) == "" &&
strings.TrimSpace(item.Ipv6Prefix) == "" &&
strings.TrimSpace(item.Status) == "" &&
strings.TrimSpace(item.CreateTime) == ""
}
func appOneLinkMockOperType(operation appCardLifecycleOperation) (string, error) {
switch operation.Code {
case "stop":
return consts.SimCardStatusOperTypeStop, nil
case "start":
return consts.SimCardStatusOperTypeStart, nil
default:
return "", gerror.New("OneLink挡板暂不支持该卡片操作")
}
}
func appOneLinkMockTransID(appID string) string {
random := time.Now().UnixNano() % 100000000
return fmt.Sprintf("%s%s%08d", strings.TrimSpace(appID), gtime.Now().Format("YmdHis"), random)
}
func appOneLinkMockSuccessMessage(message string) string {
message = strings.TrimSpace(message)
if message == "" {
return "OneLink挡板处理成功"
}
return message
}
@@ -0,0 +1,80 @@
package app
import (
"context"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
const (
appUpstreamProviderMock = "onelink_mock"
)
type appSimUpstreamProvider interface {
QuerySimBasicInfo(ctx context.Context, input appSimBasicInfoInput) (*appSimBasicInfoOutput, error)
ChangeSimStatus(ctx context.Context, operation appCardLifecycleOperation, row *appCardLifecycleExecutionRow) (appSimStatusChangeOutcome, error)
}
type appSimBasicInfoInput struct {
CardValue string
Iccid string
Msisdn string
Imsi string
Imei string
}
type appSimBasicInfoOutput struct {
Provider string
Iccid string
Msisdn string
Imsi string
Imei string
OpenDate string
ActiveDate string
Remark string
CardStatus int
LastChangeDate string
}
type appSimStatusChangeOutcome struct {
Success bool
Code int
Message string
OrderNum string
}
func newAppSimUpstreamProvider(ctx context.Context) (appSimUpstreamProvider, error) {
provider := strings.TrimSpace(g.Cfg().MustGet(ctx, "upstream.provider", appUpstreamProviderMock).String())
if provider == "" {
provider = appUpstreamProviderMock
}
switch provider {
case appUpstreamProviderMock:
return newAppOneLinkMockClient(ctx)
default:
return nil, gerror.Newf("不支持的上游服务提供方:%s", provider)
}
}
func appSimBasicInfoInputFromCardValue(cardValue string) appSimBasicInfoInput {
value := strings.TrimSpace(cardValue)
input := appSimBasicInfoInput{CardValue: value}
switch {
case len(value) == 20:
input.Iccid = value
case len(value) == 15:
input.Imsi = value
default:
input.Msisdn = value
}
return input
}
func appErrUpstreamUnavailable(err error) error {
if err == nil {
return nil
}
return gerror.Wrap(err, "上游挡板暂不可用")
}
+343
View File
@@ -0,0 +1,343 @@
package app
import (
"context"
"encoding/json"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
type sAppOrder struct{}
func NewAppOrder() *sAppOrder {
return &sAppOrder{}
}
func init() {
service.RegisterAppOrder(NewAppOrder())
}
func (s *sAppOrder) List(ctx context.Context, param *model.AppOrderListParam) (items []*model.AppOrderModel, total int, err error) {
if param == nil {
param = &model.AppOrderListParam{}
}
if param.Page <= 0 {
param.Page = 1
}
if param.PageSize <= 0 {
param.PageSize = 10
}
if param.PageSize > 100 {
param.PageSize = 100
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, 0, err
}
mod := s.ownerOrderModel(ctx, user)
mod = s.applyOrderFilters(mod, param)
total, err = mod.Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "查询订单数量失败")
}
if total == 0 {
return []*model.AppOrderModel{}, 0, nil
}
var rows []*entity.TOrder
if err = mod.
Page(param.Page, param.PageSize).
OrderDesc(dao.TOrder.Columns().CreatedAt).
OrderDesc(dao.TOrder.Columns().Id).
Scan(&rows); err != nil {
return nil, 0, gerror.Wrap(err, "查询订单列表失败")
}
orderSns := make([]string, 0, len(rows))
for _, row := range rows {
if row != nil && strings.TrimSpace(row.OrderSn) != "" {
orderSns = append(orderSns, row.OrderSn)
}
}
itemMap, err := s.orderItemMap(ctx, user.TenantId, orderSns)
if err != nil {
return nil, 0, err
}
items = make([]*model.AppOrderModel, 0, len(rows))
for _, row := range rows {
items = append(items, s.mapOrder(ctx, row, itemMap[row.OrderSn], false))
}
return items, total, nil
}
func (s *sAppOrder) Detail(ctx context.Context, param *model.AppOrderDetailParam) (*model.AppOrderModel, error) {
if param == nil {
param = &model.AppOrderDetailParam{}
}
orderSn := strings.TrimSpace(param.OrderSn)
if orderSn == "" {
return nil, gerror.New("订单号不能为空")
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
var order *entity.TOrder
if err = s.ownerOrderModel(ctx, user).
Where(dao.TOrder.Columns().OrderSn, orderSn).
Limit(1).
Scan(&order); err != nil {
return nil, gerror.Wrap(err, "查询订单详情失败")
}
if order == nil || order.Id == 0 {
return nil, gerror.New("订单不存在或无权查看")
}
itemMap, err := s.orderItemMap(ctx, user.TenantId, []string{order.OrderSn})
if err != nil {
return nil, err
}
return s.mapOrder(ctx, order, itemMap[order.OrderSn], true), nil
}
func (s *sAppOrder) ownerOrderModel(ctx context.Context, user *entity.SysUser) *gdb.Model {
orderTable := dao.TOrder.Table()
mod := dao.TOrder.Ctx(ctx).
Where(orderTable+"."+dao.TOrder.Columns().TenantId, user.TenantId)
if appIsTerminalUser(ctx, user) {
return mod.Where(orderTable+"."+dao.TOrder.Columns().CreatedBy, user.UserId)
}
return appApplyEnterpriseDataScope(
ctx,
user,
mod,
orderTable+"."+dao.TOrder.Columns().CreatedDept,
orderTable+"."+dao.TOrder.Columns().CreatedBy,
func(legacyMod *gdb.Model) *gdb.Model {
return legacyMod.Where(orderTable+"."+dao.TOrder.Columns().CreatedBy, user.UserId)
},
)
}
func (s *sAppOrder) applyOrderFilters(mod *gdb.Model, param *model.AppOrderListParam) *gdb.Model {
if param.OrderType > 0 {
mod = mod.Where(dao.TOrder.Columns().OrderType, param.OrderType)
}
if param.PayStatus != nil && *param.PayStatus >= 0 {
mod = mod.Where(dao.TOrder.Columns().PayStatus, *param.PayStatus)
}
keyword := strings.TrimSpace(param.Keyword)
if keyword == "" {
keyword = strings.TrimSpace(param.PageInfo.Keyword)
}
if keyword != "" {
like := "%" + keyword + "%"
orderTable := dao.TOrder.Table()
itemTable := dao.TOrderItem.Table()
mod = mod.Where(
"("+orderTable+"."+dao.TOrder.Columns().OrderSn+" LIKE ? OR "+
orderTable+"."+dao.TOrder.Columns().PayOrderSn+" LIKE ? OR "+
orderTable+"."+dao.TOrder.Columns().Remark+" LIKE ? OR EXISTS (SELECT 1 FROM "+itemTable+" oi WHERE oi."+dao.TOrderItem.Columns().OrderSn+" = "+orderTable+"."+dao.TOrder.Columns().OrderSn+" AND oi."+dao.TOrderItem.Columns().ProductName+" LIKE ?))",
like, like, like, like,
)
}
return mod
}
func (s *sAppOrder) orderItemMap(ctx context.Context, tenantId string, orderSns []string) (map[string][]*entity.TOrderItem, error) {
result := make(map[string][]*entity.TOrderItem)
if len(orderSns) == 0 {
return result, nil
}
var rows []*entity.TOrderItem
if err := dao.TOrderItem.Ctx(ctx).
Where(dao.TOrderItem.Columns().TenantId, tenantId).
WhereIn(dao.TOrderItem.Columns().OrderSn, orderSns).
OrderAsc(dao.TOrderItem.Columns().ItemId).
Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "查询订单项失败")
}
for _, row := range rows {
if row == nil {
continue
}
result[row.OrderSn] = append(result[row.OrderSn], row)
}
return result, nil
}
func (s *sAppOrder) mapOrder(ctx context.Context, row *entity.TOrder, items []*entity.TOrderItem, includeItems bool) *model.AppOrderModel {
if row == nil {
return nil
}
mappedItems := make([]*model.AppOrderItemModel, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
mappedItems = append(mappedItems, &model.AppOrderItemModel{
ItemId: item.ItemId,
ProductName: item.ProductName,
ProductSn: item.ProductSn,
Price: appRoundAmount(item.Price),
PriceText: appFormatMoney(item.Price),
Quantity: item.Quantity,
OriginPrice: appRoundAmount(item.OriginPrice),
OriginPriceText: appFormatMoney(item.OriginPrice),
ProductType: item.ProductType,
ServiceBeginTime: item.ServiceBeginTime,
ServiceEndTime: item.ServiceEndTime,
})
}
itemSummary := "订单商品"
if len(mappedItems) > 0 && strings.TrimSpace(mappedItems[0].ProductName) != "" {
itemSummary = strings.TrimSpace(mappedItems[0].ProductName)
if len(mappedItems) > 1 {
itemSummary += " 等"
}
}
orderTypeText := appOrderTypeText(row.OrderType)
switch appOrderBusinessFromItems(items) {
case "miniapp_card_change_batch":
orderTypeText = "套餐变更"
case "miniapp_card_renew_batch":
orderTypeText = "套餐续费"
}
paymentExpireAt, paymentRemainingSeconds, paymentExpired := appBalanceRechargePaymentTiming(ctx, row)
res := &model.AppOrderModel{
OrderId: row.Id,
OrderSn: row.OrderSn,
OrderType: row.OrderType,
OrderTypeText: orderTypeText,
OrderStatus: row.OrderStatus,
OrderStatusText: appOrderStatusText(row.OrderStatus),
PayType: row.PayType,
PayTypeText: appOrderPayTypeText(row.PayType),
PayStatus: row.PayStatus,
PayStatusText: appOrderPayStatusText(row.PayStatus),
PayOrderSn: row.PayOrderSn,
ReturnStatus: row.ReturnStatus,
ReturnStatusText: appOrderReturnStatusText(row.ReturnStatus),
PaymentExpireAt: paymentExpireAt,
PaymentRemainingSeconds: paymentRemainingSeconds,
PaymentExpired: paymentExpired,
TotalAmount: appRoundAmount(row.TotalAmount),
TotalAmountText: appFormatMoney(row.TotalAmount),
PaidAmount: appRoundAmount(row.PaidAmount),
PaidAmountText: appFormatMoney(row.PaidAmount),
OnlinePaidAmount: appRoundAmount(row.OnlinePaidAmount),
OnlinePaidAmountText: appFormatMoney(row.OnlinePaidAmount),
Balance: appRoundAmount(row.Balance),
BalanceText: appFormatMoney(row.Balance),
UnpaidAmount: appRoundAmount(row.UnpaidAmount),
UnpaidAmountText: appFormatMoney(row.UnpaidAmount),
UnrefundAmount: appRoundAmount(row.UnrefundAmount),
UnrefundAmountText: appFormatMoney(row.UnrefundAmount),
BuyerNote: row.BuyerNote,
Remark: row.Remark,
CreatedAt: row.CreatedAt,
PayTime: row.PayTime,
ItemSummary: itemSummary,
ItemCount: len(mappedItems),
}
if includeItems {
res.Items = mappedItems
}
return res
}
func appOrderBusinessFromItems(items []*entity.TOrderItem) string {
for _, item := range items {
if item == nil || strings.TrimSpace(item.ExtraSkuData) == "" {
continue
}
var extra struct {
Business string `json:"business"`
}
if err := json.Unmarshal([]byte(item.ExtraSkuData), &extra); err != nil {
continue
}
if strings.TrimSpace(extra.Business) != "" {
return strings.TrimSpace(extra.Business)
}
}
return ""
}
func appOrderTypeText(orderType int) string {
switch orderType {
case consts.OrderTypeCardSubscribe:
return "套餐续费"
case consts.OrderTypeBalanceRecharge:
return "余额充值"
default:
return "其他订单"
}
}
func appOrderStatusText(status int) string {
switch status {
case consts.OrderStatusPendingPay:
return "待支付"
case consts.OrderStatusCompleted:
return "已完成"
default:
return "处理中"
}
}
func appOrderPayTypeText(payType int) string {
switch payType {
case consts.PayTypeBalance:
return "余额"
case consts.PayTypeOnline:
return "线上支付"
default:
return "未支付"
}
}
func appOrderPayStatusText(payStatus int) string {
switch payStatus {
case consts.PayStatusUnpaid:
return "待支付"
case consts.PayStatusPaid:
return "已支付"
default:
return "处理中"
}
}
func appOrderReturnStatusText(status int) string {
switch status {
case consts.OrderReturnStatusPending:
return "退款申请中"
case consts.OrderReturnStatusRunning:
return "退款处理中"
case consts.OrderReturnStatusPartial:
return "退款异常"
case consts.OrderReturnStatusSuccess:
return "已退款"
default:
return "未退款"
}
}
@@ -0,0 +1,906 @@
package app
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"math"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth"
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
)
const (
appWechatPayMethodH5 = "wechat_h5"
appWechatPayMethodMini = "wechat_mini"
appWechatNotifyPath = "/api/v1/payment/wechat/notify"
appWechatRefundPath = "/api/v1/payment/wechat/refund-notify"
)
type appWechatConfig struct {
Enabled bool `json:"enabled"`
EnvFile string `json:"envFile"`
ApiBase string `json:"apiBase"`
MchId string `json:"mchId"`
MchSerialNo string `json:"mchSerialNo"`
ApiV3Key string `json:"apiV3Key"`
PrivateKeyPath string `json:"privateKeyPath"`
PrivateKey string `json:"privateKey"`
NotifyUrl string `json:"notifyUrl"`
NotifyBaseUrl string `json:"notifyBaseUrl"`
RefundNotifyUrl string `json:"refundNotifyUrl"`
TimeoutMinutes int `json:"timeoutMinutes"`
PlatformPublicKeyId string `json:"platformPublicKeyId"`
PlatformPublicKeyPath string `json:"platformPublicKeyPath"`
PlatformPublicKey string `json:"platformPublicKey"`
H5 appWechatH5Config `json:"h5"`
Mini appWechatMiniConfig `json:"mini"`
}
type appWechatH5Config struct {
Enabled bool `json:"enabled"`
AppId string `json:"appId"`
AppName string `json:"appName"`
AppUrl string `json:"appUrl"`
PayerClientIp string `json:"payerClientIp"`
}
type appWechatMiniConfig struct {
Enabled bool `json:"enabled"`
AppId string `json:"appId"`
AppSecret string `json:"appSecret"`
}
type appWechatPayRequest struct {
PayUrl string
PayMethod string
PayParams map[string]string
}
func appWechatPayUrl(payRequest *appWechatPayRequest) string {
if payRequest == nil {
return "物联网卡管理平台"
}
return payRequest.PayUrl
}
func appWechatPayMethod(payRequest *appWechatPayRequest) string {
if payRequest == nil {
return ""
}
return payRequest.PayMethod
}
func appWechatPayParams(payRequest *appWechatPayRequest) map[string]string {
if payRequest == nil || len(payRequest.PayParams) == 0 {
return nil
}
copied := make(map[string]string, len(payRequest.PayParams))
for key, value := range payRequest.PayParams {
copied[key] = value
}
return copied
}
func appLoadWechatConfig(ctx context.Context) (*appWechatConfig, error) {
cfg := &appWechatConfig{}
if err := g.Cfg().MustGet(ctx, "payment.wechat").Scan(cfg); err != nil {
return nil, gerror.Wrap(err, "读取微信支付配置失败")
}
envDir, err := appLoadWechatEnvFile(ctx, cfg.EnvFile)
if err != nil {
return nil, err
}
appApplyWechatEnvOverrides(cfg, envDir)
appNormalizeWechatConfig(cfg)
return cfg, nil
}
func appLoadWechatEnvFile(ctx context.Context, configured string) (string, error) {
envFile := strings.TrimSpace(os.Getenv("WECHAT_PAY_ENV_FILE"))
if envFile == "" {
envFile = strings.TrimSpace(configured)
}
if envFile == "" {
return "", nil
}
absPath, err := filepath.Abs(filepath.Clean(envFile))
if err != nil {
return "", gerror.Wrap(err, "解析微信支付环境配置路径失败")
}
data, err := os.ReadFile(absPath)
if errors.Is(err, os.ErrNotExist) {
g.Log().Warningf(ctx, "微信支付环境配置文件不存在,将继续读取系统环境变量: %s", absPath)
return filepath.Dir(absPath), nil
}
if err != nil {
return filepath.Dir(absPath), gerror.Wrap(err, "读取微信支付环境配置失败")
}
values := appParseDotenv(string(data))
for key, value := range values {
if _, exists := os.LookupEnv(key); exists {
continue
}
if err = os.Setenv(key, value); err != nil {
return filepath.Dir(absPath), gerror.Wrapf(err, "写入微信支付环境变量失败: %s", key)
}
}
g.Log().Debugf(ctx, "微信支付环境配置已加载: %s", absPath)
return filepath.Dir(absPath), nil
}
func appParseDotenv(content string) map[string]string {
result := make(map[string]string)
content = strings.TrimPrefix(content, "\ufeff")
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
if line == "" || strings.HasPrefix(line, "#") {
continue
}
index := strings.Index(line, "=")
if index <= 0 {
continue
}
key := strings.TrimSpace(line[:index])
value := strings.TrimSpace(line[index+1:])
value = strings.Trim(value, `"'`)
if key != "" {
result[key] = value
}
}
return result
}
func appApplyWechatEnvOverrides(cfg *appWechatConfig, envDir string) {
cfg.Enabled = appEnvBool("WECHAT_PAY_ENABLED", cfg.Enabled)
cfg.H5.Enabled = appEnvBool("WECHAT_PAY_H5_ENABLED", cfg.H5.Enabled)
cfg.Mini.Enabled = appEnvBool("WECHAT_PAY_MINI_ENABLED", cfg.Mini.Enabled)
cfg.MchId = appEnvValue("WECHAT_PAY_MCH_ID", cfg.MchId)
cfg.MchSerialNo = appEnvValue("WECHAT_PAY_MCH_SERIAL_NO", cfg.MchSerialNo)
cfg.ApiV3Key = appEnvValue("WECHAT_PAY_API_V3_KEY", cfg.ApiV3Key)
cfg.PrivateKey = appEnvValue("WECHAT_PAY_MCH_PRIVATE_KEY", cfg.PrivateKey)
cfg.PrivateKeyPath = appEnvPath("WECHAT_PAY_MCH_PRIVATE_KEY_PATH", cfg.PrivateKeyPath, envDir)
cfg.PlatformPublicKey = appEnvValue("WECHAT_PAY_PUBLIC_KEY", cfg.PlatformPublicKey)
cfg.PlatformPublicKeyId = appEnvValue("WECHAT_PAY_PUBLIC_KEY_ID", cfg.PlatformPublicKeyId)
cfg.PlatformPublicKeyPath = appEnvPath("WECHAT_PAY_PUBLIC_KEY_PATH", cfg.PlatformPublicKeyPath, envDir)
cfg.NotifyUrl = appEnvValue("WECHAT_PAY_NOTIFY_URL", cfg.NotifyUrl)
cfg.NotifyBaseUrl = appEnvValue("WECHAT_PAY_NOTIFY_BASE_URL", cfg.NotifyBaseUrl)
cfg.RefundNotifyUrl = appEnvValue("WECHAT_PAY_REFUND_NOTIFY_URL", cfg.RefundNotifyUrl)
if timeout := strings.TrimSpace(os.Getenv("WECHAT_PAY_TIMEOUT_MINUTES")); timeout != "" {
if value, err := strconv.Atoi(timeout); err == nil {
cfg.TimeoutMinutes = value
}
}
// AppID 优先使用本项目配置,避免模板 .env 中的旧 AppID 覆盖当前小程序配置。
if value := appEnvFirst("APP_WECHAT_H5_APP_ID", "WECHAT_PAY_H5_APP_ID"); value != "" {
cfg.H5.AppId = value
} else if cfg.H5.AppId == "" {
cfg.H5.AppId = appEnvFirst("WECHAT_PAY_SERVICE_APP_ID", "WECHAT_PAY_APP_ID")
}
cfg.H5.AppName = appEnvValue("WECHAT_PAY_H5_APP_NAME", cfg.H5.AppName)
cfg.H5.AppUrl = appEnvValue("WECHAT_PAY_H5_APP_URL", cfg.H5.AppUrl)
cfg.H5.PayerClientIp = appEnvValue("WECHAT_PAY_H5_PAYER_CLIENT_IP", cfg.H5.PayerClientIp)
if value := appEnvFirst("APP_WECHAT_MINI_APP_ID", "WECHAT_PAY_MINI_APP_ID"); value != "" {
cfg.Mini.AppId = value
} else if cfg.Mini.AppId == "" {
cfg.Mini.AppId = strings.TrimSpace(os.Getenv("WECHAT_PAY_MINI_PROGRAM_APP_ID"))
}
if value := appEnvFirst("APP_WECHAT_MINI_APP_SECRET", "WECHAT_MINI_APP_SECRET", "WECHAT_PAY_MINI_APP_SECRET", "WECHAT_PAY_MINI_PROGRAM_APP_SECRET"); value != "" {
cfg.Mini.AppSecret = value
}
}
func appNormalizeWechatConfig(cfg *appWechatConfig) {
cfg.EnvFile = strings.TrimSpace(cfg.EnvFile)
cfg.ApiBase = strings.TrimRight(strings.TrimSpace(cfg.ApiBase), "/")
cfg.MchId = strings.TrimSpace(cfg.MchId)
cfg.MchSerialNo = strings.TrimSpace(cfg.MchSerialNo)
cfg.ApiV3Key = strings.TrimSpace(cfg.ApiV3Key)
cfg.PrivateKeyPath = strings.TrimSpace(cfg.PrivateKeyPath)
cfg.PrivateKey = strings.TrimSpace(cfg.PrivateKey)
cfg.NotifyUrl = strings.TrimSpace(cfg.NotifyUrl)
cfg.NotifyBaseUrl = strings.TrimSpace(cfg.NotifyBaseUrl)
cfg.RefundNotifyUrl = strings.TrimSpace(cfg.RefundNotifyUrl)
cfg.PlatformPublicKeyId = strings.TrimSpace(cfg.PlatformPublicKeyId)
cfg.PlatformPublicKeyPath = strings.TrimSpace(cfg.PlatformPublicKeyPath)
cfg.PlatformPublicKey = strings.TrimSpace(cfg.PlatformPublicKey)
cfg.H5.AppId = strings.TrimSpace(cfg.H5.AppId)
cfg.H5.AppName = strings.TrimSpace(cfg.H5.AppName)
cfg.H5.AppUrl = strings.TrimSpace(cfg.H5.AppUrl)
cfg.H5.PayerClientIp = strings.TrimSpace(cfg.H5.PayerClientIp)
cfg.Mini.AppId = strings.TrimSpace(cfg.Mini.AppId)
cfg.Mini.AppSecret = strings.TrimSpace(cfg.Mini.AppSecret)
if cfg.ApiBase == "" {
cfg.ApiBase = "https://api.mch.weixin.qq.com"
}
if cfg.TimeoutMinutes <= 0 {
cfg.TimeoutMinutes = 5
}
if cfg.NotifyBaseUrl != "" {
cfg.NotifyUrl = appWechatBuildNotifyUrl(cfg.NotifyBaseUrl)
if cfg.RefundNotifyUrl == "" {
cfg.RefundNotifyUrl = appWechatBuildUrl(cfg.NotifyBaseUrl, appWechatRefundPath)
}
} else {
cfg.NotifyUrl = appWechatNormalizeNotifyUrl(cfg.NotifyUrl)
}
if cfg.RefundNotifyUrl != "" {
cfg.RefundNotifyUrl = appWechatNormalizePathUrl(cfg.RefundNotifyUrl, appWechatRefundPath)
}
}
func appEnvValue(key string, current string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return current
}
func appEnvFirst(keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
}
return ""
}
func appEnvBool(key string, current bool) bool {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return current
}
switch strings.ToLower(value) {
case "1", "true", "yes", "y", "on", "enabled":
return true
case "0", "false", "no", "n", "off", "disabled":
return false
default:
return current
}
}
func appEnvPath(key string, current string, envDir string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return current
}
if filepath.IsAbs(value) || envDir == "" {
return value
}
return filepath.Join(envDir, value)
}
func appWechatBuildNotifyUrl(base string) string {
return appWechatBuildUrl(base, appWechatNotifyPath)
}
func appWechatBuildUrl(base string, path string) string {
base = strings.TrimSpace(base)
if base == "" {
return ""
}
parsed, err := url.Parse(base)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return strings.TrimRight(base, "/") + path
}
parsed.Path = path
parsed.RawPath = ""
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func appWechatNormalizeNotifyUrl(raw string) string {
return appWechatNormalizePathUrl(raw, appWechatNotifyPath)
}
func appWechatNormalizePathUrl(raw string, path string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw
}
if strings.TrimSpace(parsed.Path) == "" || strings.TrimRight(parsed.Path, "/") == strings.TrimRight(path, "/") {
parsed.Path = path
parsed.RawPath = ""
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
return raw
}
func appWechatEnabled(ctx context.Context) bool {
cfg, err := appLoadWechatConfig(ctx)
return err == nil && cfg.Enabled
}
func appWechatPaymentTimeoutDuration(ctx context.Context) time.Duration {
minutes := g.Cfg().MustGet(ctx, "payment.wechat.timeoutMinutes", 5).Int()
if timeout := strings.TrimSpace(os.Getenv("WECHAT_PAY_TIMEOUT_MINUTES")); timeout != "" {
if value, err := strconv.Atoi(timeout); err == nil {
minutes = value
}
}
if minutes <= 0 {
minutes = 5
}
return time.Duration(minutes) * time.Minute
}
func appBalanceRechargePaymentTiming(ctx context.Context, order *entity.TOrder) (*gtime.Time, int64, bool) {
if order == nil || order.OrderType != consts.OrderTypeBalanceRecharge || order.PayStatus != consts.PayStatusUnpaid {
return nil, 0, false
}
if order.CreatedAt == nil || order.CreatedAt.IsZero() {
return nil, 0, false
}
expireAt := order.CreatedAt.Add(appWechatPaymentTimeoutDuration(ctx))
remaining := expireAt.Sub(gtime.Now())
if remaining <= 0 {
return expireAt, 0, true
}
return expireAt, int64(math.Ceil(remaining.Seconds())), false
}
func appWechatPrepayExpireTime(ctx context.Context, cfg *appWechatConfig, order *entity.TOrder) time.Time {
timeout := appWechatPaymentTimeoutDuration(ctx)
if cfg != nil && cfg.TimeoutMinutes > 0 {
timeout = time.Duration(cfg.TimeoutMinutes) * time.Minute
}
if order != nil && order.CreatedAt != nil && !order.CreatedAt.IsZero() {
return time.Unix(order.CreatedAt.Add(timeout).Timestamp(), 0)
}
return time.Now().Add(timeout)
}
func appMockPaymentEnabled(ctx context.Context) bool {
return g.Cfg().MustGet(ctx, "payment.mock.enabled", false).Bool()
}
func appBuildWechatPayRequest(ctx context.Context, order *entity.TOrder, payScene string, user *entity.SysUser) (*appWechatPayRequest, error) {
if order == nil || strings.TrimSpace(order.OrderSn) == "" {
return nil, gerror.New("微信支付下单订单信息不完整")
}
cfg, err := appLoadWechatConfig(ctx)
if err != nil {
return nil, err
}
if !cfg.Enabled {
return nil, nil
}
if err = appValidateWechatCommonConfig(cfg); err != nil {
return nil, err
}
if _, _, expired := appBalanceRechargePaymentTiming(ctx, order); expired {
return nil, gerror.New("支付订单已过期,请重新提交充值")
}
switch appNormalizeWechatPayScene(payScene) {
case "mini":
return appBuildWechatMiniPayRequest(ctx, cfg, order, user)
default:
return appBuildWechatH5PayRequest(ctx, cfg, order)
}
}
func appValidateWechatCommonConfig(cfg *appWechatConfig) error {
if cfg.MchId == "" {
return gerror.New("微信支付商户号未配置")
}
if cfg.MchSerialNo == "" {
return gerror.New("微信支付商户证书序列号未配置")
}
if cfg.ApiV3Key == "" {
return gerror.New("微信支付 API v3 密钥未配置")
}
if len([]byte(cfg.ApiV3Key)) != 32 {
return gerror.New("微信支付 API v3 密钥长度必须为 32 字节")
}
if cfg.NotifyUrl == "" {
return gerror.New("微信支付回调地址未配置")
}
if cfg.PrivateKey == "" && cfg.PrivateKeyPath == "" {
return gerror.New("微信支付商户私钥未配置")
}
if cfg.PlatformPublicKey == "" && cfg.PlatformPublicKeyPath == "" {
return gerror.New("微信支付平台公钥未配置")
}
if cfg.PlatformPublicKeyId == "" {
return gerror.New("微信支付平台公钥 ID 未配置")
}
return nil
}
func appBuildWechatH5PayRequest(ctx context.Context, cfg *appWechatConfig, order *entity.TOrder) (*appWechatPayRequest, error) {
if !cfg.H5.Enabled {
return nil, gerror.New("微信 H5 支付未启用")
}
appId := cfg.H5.AppId
if appId == "" {
appId = cfg.Mini.AppId
}
if appId == "" {
return nil, gerror.New("微信 H5 支付 AppID 未配置")
}
clientIp := appWechatClientIp(ctx, cfg)
if clientIp == "" {
return nil, gerror.New("微信 H5 支付终端 IP 未获取到")
}
expireAt := appWechatPrepayExpireTime(ctx, cfg, order)
prepayReq := h5.PrepayRequest{
Appid: core.String(appId),
Mchid: core.String(cfg.MchId),
Description: core.String(appWechatOrderDescription(order)),
OutTradeNo: core.String(order.OrderSn),
TimeExpire: &expireAt,
NotifyUrl: core.String(cfg.NotifyUrl),
Amount: &h5.Amount{
Total: core.Int64(appWechatAmountFen(order.TotalAmount)),
Currency: core.String("CNY"),
},
SceneInfo: &h5.SceneInfo{
PayerClientIp: core.String(clientIp),
H5Info: &h5.H5Info{
Type: core.String("Wap"),
AppName: appStringPtr(appWechatH5AppName(cfg)),
AppUrl: appStringPtr(cfg.H5.AppUrl),
},
},
}
client, err := appWechatClient(ctx, cfg)
if err != nil {
return nil, err
}
rsp, _, err := (&h5.H5ApiService{Client: client}).Prepay(ctx, prepayReq)
if err != nil {
return nil, gerror.Wrap(err, "微信 H5 支付预下单失败")
}
if rsp == nil || rsp.H5Url == nil || strings.TrimSpace(*rsp.H5Url) == "" {
return nil, gerror.New("微信 H5 支付未返回 h5_url")
}
return &appWechatPayRequest{
PayUrl: appWechatH5PayUrl(strings.TrimSpace(*rsp.H5Url), cfg, order),
PayMethod: appWechatPayMethodH5,
}, nil
}
func appWechatH5PayUrl(raw string, cfg *appWechatConfig, order *entity.TOrder) string {
raw = strings.TrimSpace(raw)
if raw == "" || cfg == nil || order == nil || strings.TrimSpace(order.OrderSn) == "" {
return raw
}
returnUrl := appWechatH5ReturnUrl(cfg, order.OrderSn)
if returnUrl == "" {
return raw
}
parsed, err := url.Parse(raw)
if err != nil {
return raw
}
query := parsed.Query()
if strings.TrimSpace(query.Get("redirect_url")) == "" {
query.Set("redirect_url", returnUrl)
parsed.RawQuery = query.Encode()
}
return parsed.String()
}
func appWechatH5ReturnUrl(cfg *appWechatConfig, orderSn string) string {
base := strings.TrimRight(strings.TrimSpace(cfg.H5.AppUrl), "/")
if base == "" {
base = strings.TrimRight(strings.TrimSpace(cfg.NotifyBaseUrl), "/")
}
if base == "" {
return ""
}
return fmt.Sprintf("%s/#/pages/business/views/balanceRecharge/index?orderSn=%s", base, url.QueryEscape(strings.TrimSpace(orderSn)))
}
func appBuildWechatMiniPayRequest(ctx context.Context, cfg *appWechatConfig, order *entity.TOrder, user *entity.SysUser) (*appWechatPayRequest, error) {
if !cfg.Mini.Enabled {
return nil, gerror.New("微信小程序支付未启用")
}
if cfg.Mini.AppId == "" {
return nil, gerror.New("微信小程序支付 AppID 未配置")
}
openId, err := appCurrentWechatOpenId(ctx, user)
if err != nil {
return nil, err
}
expireAt := appWechatPrepayExpireTime(ctx, cfg, order)
prepayReq := jsapi.PrepayRequest{
Appid: core.String(cfg.Mini.AppId),
Mchid: core.String(cfg.MchId),
Description: core.String(appWechatOrderDescription(order)),
OutTradeNo: core.String(order.OrderSn),
TimeExpire: &expireAt,
NotifyUrl: core.String(cfg.NotifyUrl),
Amount: &jsapi.Amount{
Total: core.Int64(appWechatAmountFen(order.TotalAmount)),
Currency: core.String("CNY"),
},
Payer: &jsapi.Payer{
Openid: core.String(openId),
},
}
client, err := appWechatClient(ctx, cfg)
if err != nil {
return nil, err
}
rsp, _, err := (&jsapi.JsapiApiService{Client: client}).PrepayWithRequestPayment(ctx, prepayReq)
if err != nil {
return nil, gerror.Wrap(err, "微信小程序支付预下单失败")
}
if rsp == nil || rsp.Package == nil || strings.TrimSpace(*rsp.Package) == "" {
return nil, gerror.New("微信小程序支付未返回调起支付参数")
}
return &appWechatPayRequest{
PayMethod: appWechatPayMethodMini,
PayParams: map[string]string{
"appId": appStringValue(rsp.Appid),
"timeStamp": appStringValue(rsp.TimeStamp),
"nonceStr": appStringValue(rsp.NonceStr),
"package": appStringValue(rsp.Package),
"signType": appStringValue(rsp.SignType),
"paySign": appStringValue(rsp.PaySign),
},
}, nil
}
func appWechatClient(ctx context.Context, cfg *appWechatConfig) (*core.Client, error) {
privateKey, err := appWechatPrivateKey(cfg)
if err != nil {
return nil, err
}
verifier, err := appWechatVerifier(cfg)
if err != nil {
return nil, err
}
client, err := core.NewClient(ctx,
option.WithMerchantCredential(cfg.MchId, cfg.MchSerialNo, privateKey),
option.WithVerifier(verifier),
)
if err != nil {
return nil, gerror.Wrap(err, "初始化微信支付 SDK 客户端失败")
}
return client, nil
}
func appWechatVerifier(cfg *appWechatConfig) (auth.Verifier, error) {
publicKey, err := appWechatPlatformPublicKey(cfg)
if err != nil {
return nil, err
}
return verifiers.NewSHA256WithRSAPubkeyVerifier(cfg.PlatformPublicKeyId, *publicKey), nil
}
func appCurrentWechatOpenId(ctx context.Context, user *entity.SysUser) (string, error) {
if user == nil || user.UserId <= 0 {
return "", gerror.New("当前用户信息不完整,无法发起微信小程序支付")
}
var social *entity.SysSocial
if err := dao.SysSocial.Ctx(ctx).
Where(dao.SysSocial.Columns().TenantId, user.TenantId).
Where(dao.SysSocial.Columns().UserId, user.UserId).
Where(dao.SysSocial.Columns().Source, consts.SourceWechatMp).
OrderDesc(dao.SysSocial.Columns().Id).
Limit(1).
Scan(&social); err != nil {
return "", gerror.Wrap(err, "查询微信 openid 失败")
}
if social == nil || strings.TrimSpace(social.OpenId) == "" {
return "", gerror.New("当前账号未绑定微信 openid,无法发起小程序支付")
}
return strings.TrimSpace(social.OpenId), nil
}
func (s *sAppCard) BalanceRechargeWechatNotify(ctx context.Context) error {
r := g.RequestFromCtx(ctx)
if r == nil || r.Request == nil {
return gerror.New("微信支付通知请求不存在")
}
cfg, err := appLoadWechatConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled {
return gerror.New("微信支付未启用")
}
if err = appValidateWechatCommonConfig(cfg); err != nil {
return err
}
verifier, err := appWechatVerifier(cfg)
if err != nil {
return err
}
handler := notify.NewNotifyHandler(cfg.ApiV3Key, verifier)
transaction := new(payments.Transaction)
notifyReq, err := handler.ParseNotifyRequest(ctx, r.Request, transaction)
if err != nil {
return gerror.Wrap(err, "解析微信支付通知失败")
}
tradeState := appStringValue(transaction.TradeState)
outTradeNo := appStringValue(transaction.OutTradeNo)
transactionId := appStringValue(transaction.TransactionId)
eventType := ""
if notifyReq != nil {
eventType = notifyReq.EventType
}
if notifyReq == nil || eventType != "TRANSACTION.SUCCESS" || tradeState != "SUCCESS" {
g.Log().Infof(ctx, "微信支付余额充值通知忽略非成功状态: order=%s event=%s status=%s", outTradeNo, eventType, tradeState)
return nil
}
if outTradeNo == "" {
return gerror.New("微信支付通知订单号为空")
}
g.Log().Infof(ctx, "微信支付余额充值通知开始处理: order=%s event=%s status=%s tradeNo=%s", outTradeNo, eventType, tradeState, transactionId)
paidAt := appParseWechatPayTime(appStringValue(transaction.SuccessTime))
if paidAt == nil {
paidAt = gtime.Now()
}
amountFen := appWechatTransactionAmountFen(transaction)
if amountFen <= 0 {
return gerror.New("微信支付通知金额不正确")
}
_, err = s.completeAppBalanceRechargePayment(
ctx,
outTradeNo,
nil,
transactionId,
"wechat",
consts.BalanceTransactionChannelWechat,
float64(amountFen)/100,
paidAt,
)
if err != nil {
return err
}
g.Log().Infof(ctx, "微信支付余额充值通知处理成功: order=%s tradeNo=%s amountFen=%d", outTradeNo, transactionId, amountFen)
return nil
}
func (s *sAppCard) BalanceRechargeWechatRefundNotify(ctx context.Context) error {
r := g.RequestFromCtx(ctx)
if r == nil || r.Request == nil {
return gerror.New("微信退款通知请求不存在")
}
cfg, err := appLoadWechatConfig(ctx)
if err != nil {
return err
}
if !cfg.Enabled {
return gerror.New("微信支付未启用")
}
if err = appValidateWechatCommonConfig(cfg); err != nil {
return err
}
verifier, err := appWechatVerifier(cfg)
if err != nil {
return err
}
handler := notify.NewNotifyHandler(cfg.ApiV3Key, verifier)
refund := new(refunddomestic.Refund)
notifyReq, err := handler.ParseNotifyRequest(ctx, r.Request, refund)
if err != nil {
return gerror.Wrap(err, "解析微信退款通知失败")
}
eventType := ""
summary := ""
if notifyReq != nil {
eventType = notifyReq.EventType
summary = notifyReq.Summary
}
refundStatus := ""
refundStatus = appWechatRefundStatus(refund)
refundAmountFen := appWechatRefundAmountFen(refund)
g.Log().Infof(
ctx,
"微信退款通知已验签: event=%s status=%s order=%s refundNo=%s refundId=%s amountFen=%d summary=%s",
eventType,
refundStatus,
appStringValue(refund.OutTradeNo),
appStringValue(refund.OutRefundNo),
appStringValue(refund.RefundId),
refundAmountFen,
summary,
)
return s.applyAppBalanceRechargeRefundNotify(ctx, refund)
}
func appWechatTransactionAmountFen(transaction *payments.Transaction) int64 {
if transaction == nil || transaction.Amount == nil {
return 0
}
if transaction.Amount.PayerTotal != nil && *transaction.Amount.PayerTotal > 0 {
return *transaction.Amount.PayerTotal
}
if transaction.Amount.Total != nil && *transaction.Amount.Total > 0 {
return *transaction.Amount.Total
}
return 0
}
func appWechatPrivateKey(cfg *appWechatConfig) (*rsa.PrivateKey, error) {
raw, err := appWechatKeyContent(cfg.PrivateKey, cfg.PrivateKeyPath)
if err != nil {
return nil, gerror.Wrap(err, "读取微信支付商户私钥失败")
}
der, err := appWechatDecodeKey(raw)
if err != nil {
return nil, gerror.Wrap(err, "解析微信支付商户私钥失败")
}
if key, err := x509.ParsePKCS8PrivateKey(der); err == nil {
if privateKey, ok := key.(*rsa.PrivateKey); ok {
return privateKey, nil
}
}
if privateKey, err := x509.ParsePKCS1PrivateKey(der); err == nil {
return privateKey, nil
}
return nil, gerror.New("微信支付商户私钥不是有效 RSA 私钥")
}
func appWechatPlatformPublicKey(cfg *appWechatConfig) (*rsa.PublicKey, error) {
raw, err := appWechatKeyContent(cfg.PlatformPublicKey, cfg.PlatformPublicKeyPath)
if err != nil {
return nil, gerror.Wrap(err, "读取微信支付平台公钥失败")
}
der, err := appWechatDecodeKey(raw)
if err != nil {
return nil, gerror.Wrap(err, "解析微信支付平台公钥失败")
}
if key, err := x509.ParsePKIXPublicKey(der); err == nil {
if publicKey, ok := key.(*rsa.PublicKey); ok {
return publicKey, nil
}
}
if publicKey, err := x509.ParsePKCS1PublicKey(der); err == nil {
return publicKey, nil
}
return nil, gerror.New("微信支付平台公钥不是有效 RSA 公钥")
}
func appWechatKeyContent(raw string, path string) (string, error) {
if strings.TrimSpace(raw) != "" {
return strings.TrimSpace(raw), nil
}
if strings.TrimSpace(path) == "" {
return "", gerror.New("密钥路径未配置")
}
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(content), nil
}
func appWechatDecodeKey(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, gerror.New("密钥内容为空")
}
if block, _ := pem.Decode([]byte(raw)); block != nil {
return block.Bytes, nil
}
compact := strings.Join(strings.Fields(raw), "")
der, err := base64.StdEncoding.DecodeString(compact)
if err != nil {
return nil, err
}
return der, nil
}
func appNormalizeWechatPayScene(payScene string) string {
switch strings.ToLower(strings.TrimSpace(payScene)) {
case "mini", "mp", "mp-weixin", "wechat_mini", "miniprogram":
return "mini"
default:
return "h5"
}
}
func appWechatAmountFen(amount float64) int64 {
return int64(math.Round(appRoundAmount(amount) * 100))
}
func appWechatClientIp(ctx context.Context, cfg *appWechatConfig) string {
clientIp := strings.TrimSpace(cfg.H5.PayerClientIp)
if clientIp == "" {
if r := g.RequestFromCtx(ctx); r != nil {
clientIp = strings.TrimSpace(r.GetClientIp())
}
}
if parsed := net.ParseIP(clientIp); parsed != nil {
return parsed.String()
}
return clientIp
}
func appWechatOrderDescription(order *entity.TOrder) string {
amount := appFormatMoney(order.TotalAmount)
return fmt.Sprintf("余额充值 %s", amount)
}
func appWechatH5AppName(cfg *appWechatConfig) string {
if cfg.H5.AppName != "" {
return cfg.H5.AppName
}
return "物联网卡管理平台"
}
func appParseWechatPayTime(value string) *gtime.Time {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil
}
return gtime.NewFromTime(parsed)
}
func appStringPtr(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return core.String(value)
}
func appStringValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
@@ -0,0 +1,142 @@
package app
import (
"context"
"strconv"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"github.com/gogf/gf/v2/errors/gerror"
)
type appRegisterSettings struct {
DeptId int64
RoleId int64
}
func appUserDeptOrDefault(user *entity.SysUser) int64 {
if user != nil && user.DeptId > 0 {
return user.DeptId
}
return 0
}
func appRegisterDeptOrDefault(ctx context.Context, tenantId string) int64 {
deptId, err := appLoadRegisterDept(ctx, tenantId)
if err != nil {
return 0
}
return deptId
}
func appLoadRegisterSettings(ctx context.Context, tenantId string) (*appRegisterSettings, error) {
if tenantId == "" {
tenantId = consts.DefaultSystemTenantCode
}
deptId, err := appLoadRegisterDept(ctx, tenantId)
if err != nil {
return nil, err
}
if err = appValidateRegisterDept(ctx, tenantId, deptId); err != nil {
return nil, err
}
roleValue, err := appTenantConfigValue(ctx, tenantId, consts.ConfigKeyAppRegisterRoleId)
if err != nil {
return nil, gerror.Wrap(err, "读取小程序默认角色失败")
}
roleId, err := appPositiveConfigInt64(roleValue, "小程序默认角色ID")
if err != nil {
return nil, err
}
if err = appValidateRegisterRole(ctx, tenantId, roleId); err != nil {
return nil, err
}
return &appRegisterSettings{
DeptId: deptId,
RoleId: roleId,
}, nil
}
func appLoadRegisterDept(ctx context.Context, tenantId string) (int64, error) {
if tenantId == "" {
tenantId = consts.DefaultSystemTenantCode
}
deptValue, err := appTenantConfigValue(ctx, tenantId, consts.ConfigKeyAppRegisterDeptId)
if err != nil {
return 0, gerror.Wrap(err, "读取小程序默认部门失败")
}
return appPositiveConfigInt64(deptValue, "小程序默认部门ID")
}
func appTenantConfigValue(ctx context.Context, tenantId string, key string) (string, error) {
var config *entity.SysConfig
if err := dao.SysConfig.Ctx(ctx).
Where(dao.SysConfig.Columns().TenantId, tenantId).
Where(dao.SysConfig.Columns().ConfigKey, key).
OrderDesc(dao.SysConfig.Columns().ConfigId).
Limit(1).
Scan(&config); err != nil {
return "", err
}
if config == nil || config.ConfigId == 0 {
return "", nil
}
return strings.TrimSpace(config.ConfigValue), nil
}
func appPositiveConfigInt64(value string, label string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, gerror.Newf("%s未配置", label)
}
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
return 0, gerror.Newf("%s配置不正确", label)
}
return id, nil
}
func appValidateRegisterDept(ctx context.Context, tenantId string, deptId int64) error {
var dept *entity.SysDept
if err := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, tenantId).
Where(dao.SysDept.Columns().DeptId, deptId).
WhereNull(dao.SysDept.Columns().DeletedAt).
Limit(1).
Scan(&dept); err != nil {
return gerror.Wrap(err, "校验小程序默认部门失败")
}
if dept == nil || dept.DeptId == 0 {
return gerror.New("小程序默认部门不存在")
}
if strings.TrimSpace(dept.Status) != consts.SysDeptStatusNormal {
return gerror.New("小程序默认部门已停用")
}
return nil
}
func appValidateRegisterRole(ctx context.Context, tenantId string, roleId int64) error {
var role *entity.SysRole
if err := dao.SysRole.Ctx(ctx).
Where(dao.SysRole.Columns().TenantId, tenantId).
Where(dao.SysRole.Columns().RoleId, roleId).
WhereNull(dao.SysRole.Columns().DeletedAt).
Limit(1).
Scan(&role); err != nil {
return gerror.Wrap(err, "校验小程序默认角色失败")
}
if role == nil || role.RoleId == 0 {
return gerror.New("小程序默认角色不存在")
}
if strings.TrimSpace(role.Status) != consts.SysDeptStatusNormal {
return gerror.New("小程序默认角色已停用")
}
return nil
}
@@ -0,0 +1,225 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/gclient"
"github.com/gogf/gf/v2/net/gsvc"
)
type appUpstreamDefaultConfig struct {
ClientName string `json:"clientName"`
Timeout string `json:"timeout"`
}
type appUpstreamSimKey struct {
ChanKey string `json:"chanKey,omitempty"`
ICCID string `json:"iccid,omitempty"`
IMSI string `json:"imsi,omitempty"`
MSISDN string `json:"msisdn,omitempty"`
}
type appUpstreamSimQueryInput struct {
SimKeys []*appUpstreamSimKey `json:"simKeys"`
ReqData any `json:"reqData"`
}
type appUpstreamSimQueryItem struct {
Code int `json:"code"`
Message string `json:"message"`
SimKey *appUpstreamSimKey `json:"simKey"`
ResData json.RawMessage `json:"resData"`
}
type appUpstreamSimQueryResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data []appUpstreamSimQueryItem `json:"data"`
}
func queryAppCardRealtimeSession(ctx context.Context, row *appCardQueryRow) (*appOneLinkMockSimSessionItem, string, error) {
simKey, err := appUpstreamSimKeyFromRow(row)
if err != nil {
return nil, "upstream", err
}
return queryAppCardRealtimeSessionWithKey(ctx, row, simKey)
}
func queryAppCardRealtimeSessionWithKey(ctx context.Context, row *appCardQueryRow, simKey *appUpstreamSimKey) (*appOneLinkMockSimSessionItem, string, error) {
session, err := queryAppCardRealtimeSessionByUpstreamWithKey(ctx, simKey)
if err == nil {
return session, "upstream", nil
}
if !appUpstreamMockFallbackEnabled(ctx) {
return nil, "upstream", err
}
g.Log().Debugf(ctx, "app card session upstream query failed, fallback to onelink mock, iccid:%s, err:%+v", appCardQueryLogIccid(row), err)
session, mockErr := queryAppCardRealtimeSessionByMock(ctx, row)
if mockErr != nil {
return nil, "onelink_mock", gerror.Wrapf(mockErr, "查询OneLink挡板会话失败,上游网关错误:%v", err)
}
return session, "onelink_mock", nil
}
func queryAppCardRealtimeSessionByUpstream(ctx context.Context, row *appCardQueryRow) (*appOneLinkMockSimSessionItem, error) {
simKey, err := appUpstreamSimKeyFromRow(row)
if err != nil {
return nil, err
}
return queryAppCardRealtimeSessionByUpstreamWithKey(ctx, simKey)
}
func queryAppCardRealtimeSessionByUpstreamWithKey(ctx context.Context, simKey *appUpstreamSimKey) (*appOneLinkMockSimSessionItem, error) {
items, err := queryAppUpstreamSimWithKey(ctx, simKey, "/sim/query/sim-session", map[string]any{})
if err != nil {
return nil, err
}
for _, item := range items {
if item.Code != 0 || len(item.ResData) == 0 {
continue
}
session := appOneLinkMockSimSessionItem{}
if err = json.Unmarshal(item.ResData, &session); err != nil {
return nil, gerror.Wrap(err, "解析上游会话响应失败")
}
if !session.isZero() {
return &session, nil
}
}
return nil, nil
}
func queryAppCardRealtimeSessionByMock(ctx context.Context, row *appCardQueryRow) (*appOneLinkMockSimSessionItem, error) {
client, err := newAppOneLinkMockClient(ctx)
if err != nil {
return nil, err
}
return client.QuerySimSession(ctx, row)
}
func appUpstreamMockFallbackEnabled(ctx context.Context) bool {
return g.Cfg().MustGet(ctx, "upstream.onelinkMock.enabled", false).Bool()
}
func appCardQueryLogIccid(row *appCardQueryRow) string {
if row == nil {
return ""
}
return row.Iccid
}
func queryAppUpstreamSim(ctx context.Context, row *appCardQueryRow, path string, reqData any) ([]appUpstreamSimQueryItem, error) {
simKey, err := appUpstreamSimKeyFromRow(row)
if err != nil {
return nil, err
}
return queryAppUpstreamSimWithKey(ctx, simKey, path, reqData)
}
func queryAppUpstreamSimWithKey(ctx context.Context, simKey *appUpstreamSimKey, path string, reqData any) ([]appUpstreamSimQueryItem, error) {
if simKey == nil {
return nil, gerror.New("卡片信息为空,不能查询上游")
}
cleanKey := &appUpstreamSimKey{
ChanKey: strings.TrimSpace(simKey.ChanKey),
ICCID: strings.TrimSpace(simKey.ICCID),
IMSI: strings.TrimSpace(simKey.IMSI),
MSISDN: strings.TrimSpace(simKey.MSISDN),
}
if cleanKey.ICCID == "" && cleanKey.IMSI == "" && cleanKey.MSISDN == "" {
return nil, gerror.New("卡片缺少上游查询标识")
}
cfg := appUpstreamDefaultConfig{}
if err := g.Cfg().MustGet(ctx, "upstream.default").Scan(&cfg); err != nil {
return nil, gerror.Wrap(err, "读取上游代理配置失败")
}
cfg.ClientName = strings.TrimSpace(cfg.ClientName)
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
if cfg.ClientName == "" {
return nil, gerror.New("上游代理地址未配置")
}
timeout := 10 * time.Second
if cfg.Timeout != "" {
if parsed, err := time.ParseDuration(cfg.Timeout); err == nil && parsed > 0 {
timeout = parsed
}
}
if reqData == nil {
reqData = map[string]any{}
}
input := appUpstreamSimQueryInput{
SimKeys: []*appUpstreamSimKey{cleanKey},
ReqData: reqData,
}
body, err := json.Marshal(input)
if err != nil {
return nil, gerror.Wrap(err, "组装上游查询参数失败")
}
requestURL := appUpstreamURL(cfg.ClientName, path)
client := g.Client()
client.SetDiscovery(gsvc.GetRegistry())
appSetUpstreamClientTimeout(client, timeout)
client.SetHeader("Content-Type", "application/json")
resp, err := client.Post(ctx, requestURL, string(body))
if err != nil {
return nil, gerror.Wrap(err, "请求上游信息失败")
}
defer resp.Close()
respBody := resp.ReadAll()
output := appUpstreamSimQueryResponse{}
if err = json.Unmarshal(respBody, &output); err != nil {
return nil, gerror.Wrap(err, "解析上游响应失败")
}
if output.Code != 0 {
message := strings.TrimSpace(output.Message)
if message == "" {
message = "上游查询失败"
}
return nil, gerror.New(message)
}
return output.Data, nil
}
func appSetUpstreamClientTimeout(client *gclient.Client, timeout time.Duration) {
client.SetTimeout(timeout)
if transport, ok := client.Transport.(*http.Transport); ok {
transport.ResponseHeaderTimeout = timeout
}
}
func appUpstreamSimKeyFromRow(row *appCardQueryRow) (*appUpstreamSimKey, error) {
if row == nil {
return nil, gerror.New("卡片信息为空,不能查询上游")
}
simKey := &appUpstreamSimKey{
ChanKey: strings.TrimSpace(row.ChanKey),
ICCID: strings.TrimSpace(row.Iccid),
IMSI: strings.TrimSpace(row.Imsi),
MSISDN: strings.TrimSpace(row.Msisdn),
}
if simKey.ICCID == "" && simKey.IMSI == "" && simKey.MSISDN == "" {
return nil, gerror.New("卡片缺少上游查询标识")
}
return simKey, nil
}
func appUpstreamURL(clientName string, path string) string {
clientName = strings.TrimRight(strings.TrimSpace(clientName), "/")
path = "/" + strings.TrimLeft(path, "/")
if strings.HasPrefix(clientName, "http://") || strings.HasPrefix(clientName, "https://") {
return clientName + path
}
return fmt.Sprintf("http://%s%s", clientName, path)
}
+589
View File
@@ -0,0 +1,589 @@
package app
import (
"context"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
type sAppUser struct{}
func NewAppUser() *sAppUser {
return &sAppUser{}
}
func init() {
service.RegisterAppUser(NewAppUser())
}
func (s *sAppUser) Info(ctx context.Context) (res *model.AppUserInfoModel, err error) {
userId := contexts.GetUserId(ctx)
if userId == 0 {
return nil, gerror.New("登录已过期")
}
return getAppUserInfoById(ctx, userId)
}
func (s *sAppUser) UpdateInfo(ctx context.Context, param *model.AppUpdateUserInfoParam) (res *model.AppUserInfoModel, err error) {
userId := contexts.GetUserId(ctx)
if userId == 0 {
return nil, gerror.New("登录已过期")
}
if param == nil {
return nil, gerror.New("请求参数不能为空")
}
now := gtime.Now()
err = dao.SysUser.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
var user *entity.SysUser
if err := dao.SysUser.Ctx(ctx).TX(tx).
Where(dao.SysUser.Columns().UserId, userId).
Scan(&user); err != nil {
return err
}
if user == nil {
return gerror.New("用户不存在")
}
if !appIsTerminalUser(ctx, user) {
return gerror.New("代理商账号仅支持查看资料")
}
phone := strings.TrimSpace(param.Phone)
if phone == "" {
phone = strings.TrimSpace(user.Phonenumber)
}
if phone == "" {
return gerror.New("联系电话不能为空")
}
if phone != "" && phone != strings.TrimSpace(user.Phonenumber) {
var exists *entity.SysUser
if err := dao.SysUser.Ctx(ctx).TX(tx).
Where(dao.SysUser.Columns().TenantId, user.TenantId).
Where(dao.SysUser.Columns().Phonenumber, phone).
WhereNot(dao.SysUser.Columns().UserId, userId).
WhereNull(dao.SysUser.Columns().DeletedAt).
Limit(1).
Scan(&exists); err != nil {
return err
}
if exists != nil && exists.UserId > 0 {
return gerror.New("手机号已被其他账号使用")
}
}
if _, err := dao.SysUser.Ctx(ctx).TX(tx).
Where(dao.SysUser.Columns().UserId, userId).
Data(do.SysUser{
NickName: param.NickName,
Avatar: param.Avatar,
Email: strings.TrimSpace(param.Email),
Phonenumber: phone,
Sex: param.Sex,
UpdatedBy: userId,
UpdatedAt: now,
}).
OmitNil().
Update(); err != nil {
return err
}
user.NickName = strings.TrimSpace(param.NickName)
user.Email = strings.TrimSpace(param.Email)
user.Phonenumber = phone
return syncTerminalUserProfile(ctx, tx, user, param, now)
})
if err != nil {
return nil, err
}
return getAppUserInfoById(ctx, userId)
}
func (s *sAppUser) BindWechat(ctx context.Context, param *model.AppWechatBindParam) (res *model.AppWechatBindModel, err error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
if err = service.AppAuth().BindWechatOpenIdByLoginCode(ctx, user, param.WxCode); err != nil {
return nil, err
}
userInfo, err := getAppUserInfoById(ctx, user.UserId)
if err != nil {
return nil, err
}
return &model.AppWechatBindModel{
Success: true,
UserInfo: userInfo,
}, nil
}
func syncTerminalUserProfile(ctx context.Context, tx gdb.TX, user *entity.SysUser, param *model.AppUpdateUserInfoParam, now *gtime.Time) error {
nickName := strings.TrimSpace(param.NickName)
terminalName := strings.TrimSpace(appFirstNotEmpty(param.TerminalName, nickName, user.NickName, user.UserName))
phone := strings.TrimSpace(appFirstNotEmpty(param.Phone, user.Phonenumber))
email := strings.TrimSpace(appFirstNotEmpty(param.Email, user.Email))
establishTime, err := appParseProfileDate(param.EstablishTime)
if err != nil {
return err
}
var terminal *entity.TTerminal
if err = dao.TTerminal.Ctx(ctx).TX(tx).
Where(dao.TTerminal.Columns().TerminalId, user.UserId).
Scan(&terminal); err != nil {
return err
}
if terminal == nil {
if _, err := dao.TTerminal.Ctx(ctx).TX(tx).Data(do.TTerminal{
TerminalId: user.UserId,
TerminalName: terminalName,
Phone: phone,
Email: email,
Status: consts.SysUserStatusNormal,
CreditCode: strings.TrimSpace(param.CreditCode),
AreaCode: strings.TrimSpace(param.AreaCode),
Address: strings.TrimSpace(param.Address),
FullName: strings.TrimSpace(appFirstNotEmpty(param.FullName, terminalName)),
Industry: strings.TrimSpace(param.Industry),
LegalPersonName: strings.TrimSpace(appFirstNotEmpty(param.LegalPersonName, terminalName)),
LegalPersonIdCard: strings.TrimSpace(appFirstNotEmpty(param.LegalPersonIdCard, "000000000000000000")),
CompanyType: strings.TrimSpace(param.CompanyType),
EstablishTime: establishTime,
BankName: strings.TrimSpace(param.BankName),
BankAccount: strings.TrimSpace(param.BankAccount),
Balance: 0,
TenantId: user.TenantId,
CreatedDept: appUserDeptOrDefault(user),
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Insert(); err != nil {
return err
}
} else {
if _, err := dao.TTerminal.Ctx(ctx).TX(tx).
Where(dao.TTerminal.Columns().TerminalId, user.UserId).
Data(do.TTerminal{
TerminalName: terminalName,
Phone: phone,
Email: email,
CreditCode: strings.TrimSpace(param.CreditCode),
AreaCode: strings.TrimSpace(param.AreaCode),
Address: strings.TrimSpace(param.Address),
FullName: strings.TrimSpace(param.FullName),
Industry: strings.TrimSpace(param.Industry),
LegalPersonName: strings.TrimSpace(param.LegalPersonName),
LegalPersonIdCard: strings.TrimSpace(param.LegalPersonIdCard),
CompanyType: strings.TrimSpace(param.CompanyType),
EstablishTime: establishTime,
BankName: strings.TrimSpace(param.BankName),
BankAccount: strings.TrimSpace(param.BankAccount),
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
OmitNil().
Update(); err != nil {
return err
}
}
if _, err := dao.TSimTerminal.Ctx(ctx).TX(tx).
Where(dao.TSimTerminal.Columns().TenantId, user.TenantId).
Where(dao.TSimTerminal.Columns().TerminalId, user.UserId).
Data(do.TSimTerminal{
TerminalName: terminalName,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
OmitNil().
Update(); err != nil {
return err
}
if _, err := dao.TPackageTerminal.Ctx(ctx).TX(tx).
Where(dao.TPackageTerminal.Columns().TenantId, user.TenantId).
Where(dao.TPackageTerminal.Columns().TerminalId, user.UserId).
Data(do.TPackageTerminal{
TerminalName: terminalName,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
OmitNil().
Update(); err != nil {
return err
}
return nil
}
func appParseProfileDate(value string) (*gtime.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
return nil, gerror.New("成立时间格式应为YYYY-MM-DD")
}
return gtime.NewFromTime(parsed), nil
}
func getAppUserInfoById(ctx context.Context, userId int64) (res *model.AppUserInfoModel, err error) {
var user *entity.SysUser
err = dao.SysUser.Ctx(ctx).
Where(dao.SysUser.Columns().UserId, userId).
Scan(&user)
if err != nil {
return nil, err
}
if user == nil {
return nil, gerror.New("用户不存在")
}
isTerminal := appIsTerminalUser(ctx, user)
canEnterpriseBusiness := !isTerminal && user.DeptId > 0
balance := float64(0)
role := "企业用户"
phoneText := appMaskPhone(user.Phonenumber)
var enterpriseProfile *model.AppEnterpriseProfileModel
var terminalProfile *model.AppTerminalProfileModel
if isTerminal {
role = "终端用户"
phoneText = strings.TrimSpace(user.Phonenumber)
var terminal *entity.TTerminal
if err = dao.TTerminal.Ctx(ctx).
Where(dao.TTerminal.Columns().TenantId, user.TenantId).
Where(dao.TTerminal.Columns().TerminalId, user.UserId).
Limit(1).
Scan(&terminal); err != nil {
return nil, err
}
if terminal != nil {
balance = terminal.Balance
terminalProfile = appTerminalProfileModel(terminal)
}
} else if user.DeptId > 0 {
var agentBalance *entity.TAgentBalance
if err = dao.TAgentBalance.Ctx(ctx).
Where(dao.TAgentBalance.Columns().TenantId, user.TenantId).
Where(dao.TAgentBalance.Columns().DeptId, user.DeptId).
Scan(&agentBalance); err != nil {
return nil, err
}
if agentBalance != nil {
balance = agentBalance.Balance
}
enterpriseProfile, err = appEnterpriseProfileByDept(ctx, user)
if err != nil {
return nil, err
}
}
roleName, roleKey, roleNames := appLoadBoundRoleNames(ctx, user.UserId)
wechatBound, wechatBindText, err := appUserWechatBindingStatus(ctx, user)
if err != nil {
return nil, err
}
return &model.AppUserInfoModel{
UserId: user.UserId,
TenantId: user.TenantId,
DeptId: user.DeptId,
Username: user.UserName,
NickName: user.NickName,
Phone: phoneText,
Avatar: user.Avatar,
Sex: user.Sex,
UserType: user.UserType,
IsTerminal: isTerminal,
CanEnterpriseBusiness: canEnterpriseBusiness,
WechatBound: wechatBound,
WechatBindText: wechatBindText,
Balance: balance,
Role: role,
RoleName: roleName,
RoleKey: roleKey,
RoleNames: roleNames,
EnterpriseProfile: enterpriseProfile,
TerminalProfile: terminalProfile,
}, nil
}
// appLoadBoundRoleNames 读取账户在 sys_user_role 中绑定的角色,并关联 sys_role 返回真实角色名称。
// 这样既保留原有账号类型 role,又能让前端按真实的角色表名称展示账户身份标识。
func appLoadBoundRoleNames(ctx context.Context, userId int64) (roleName string, roleKey string, roleNames []string) {
roleNames = make([]string, 0)
var userRoles []*entity.SysUserRole
if err := dao.SysUserRole.Ctx(ctx).
Where(dao.SysUserRole.Columns().UserId, userId).
Scan(&userRoles); err != nil || len(userRoles) == 0 {
return "", "", roleNames
}
roleIds := make([]int64, 0, len(userRoles))
for _, ur := range userRoles {
if ur != nil && ur.RoleId > 0 {
roleIds = append(roleIds, ur.RoleId)
}
}
if len(roleIds) == 0 {
return "", "", roleNames
}
var roles []*entity.SysRole
if err := dao.SysRole.Ctx(ctx).
WhereIn(dao.SysRole.Columns().RoleId, roleIds).
Scan(&roles); err != nil || len(roles) == 0 {
return "", "", roleNames
}
for _, r := range roles {
if r == nil {
continue
}
if name := strings.TrimSpace(r.RoleName); name != "" {
roleNames = append(roleNames, name)
}
}
if roles[0] != nil {
roleName = strings.TrimSpace(roles[0].RoleName)
roleKey = strings.TrimSpace(roles[0].RoleKey)
}
return roleName, roleKey, roleNames
}
func appUserWechatBindingStatus(ctx context.Context, user *entity.SysUser) (bool, string, error) {
if user == nil || user.UserId <= 0 {
return false, "未绑定", nil
}
var social *entity.SysSocial
err := dao.SysSocial.Ctx(ctx).
Fields(dao.SysSocial.Columns().Id).
Where(dao.SysSocial.Columns().TenantId, user.TenantId).
Where(dao.SysSocial.Columns().UserId, user.UserId).
Where(dao.SysSocial.Columns().Source, consts.SourceWechatMp).
Where(dao.SysSocial.Columns().OpenId+" IS NOT NULL").
Where(dao.SysSocial.Columns().OpenId+" <> ?", "").
WhereNull(dao.SysSocial.Columns().DeletedAt).
OrderDesc(dao.SysSocial.Columns().Id).
Limit(1).
Scan(&social)
if err != nil {
return false, "", gerror.Wrap(err, "查询微信绑定状态失败")
}
if social == nil || social.Id == 0 {
return false, "未绑定", nil
}
return true, "已绑定", nil
}
func appEnterpriseProfileByDept(ctx context.Context, user *entity.SysUser) (*model.AppEnterpriseProfileModel, error) {
if user == nil || user.DeptId <= 0 {
return nil, nil
}
var dept *entity.SysDept
if err := dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, user.TenantId).
Where(dao.SysDept.Columns().DeptId, user.DeptId).
Limit(1).
Scan(&dept); err != nil {
return nil, gerror.Wrap(err, "查询企业基础信息失败")
}
if dept == nil || dept.DeptId == 0 {
return nil, nil
}
var enterprise *entity.TAgentEnterprise
if err := dao.TAgentEnterprise.Ctx(ctx).
Where(dao.TAgentEnterprise.Columns().TenantId, user.TenantId).
Where(dao.TAgentEnterprise.Columns().DeptId, user.DeptId).
Limit(1).
Scan(&enterprise); err != nil {
return nil, gerror.Wrap(err, "查询企业客户档案失败")
}
profile := &model.AppEnterpriseProfileModel{
DeptId: dept.DeptId,
DeptName: strings.TrimSpace(dept.DeptName),
DeptKey: strings.TrimSpace(dept.DeptKey),
Phone: appMaskPhone(dept.Phone),
Email: appMaskEmail(dept.Email),
StatusText: appDeptStatusText(dept.Status),
CreditCode: appMaskCreditCode(dept.CreditCode),
AreaCode: strings.TrimSpace(dept.AreaCode),
Address: appMaskAddress(dept.Address),
FullName: strings.TrimSpace(dept.DeptName),
RealNameStatus: "实名资料待完善",
}
if enterprise != nil && enterprise.Id > 0 {
profile.FullName = appFirstNotEmpty(enterprise.FullName, dept.DeptName)
profile.Industry = strings.TrimSpace(enterprise.Industry)
profile.LegalPersonName = appMaskName(enterprise.LegalPersonName)
profile.LegalPersonIdCard = appMaskIdCard(enterprise.LegalPersonIdCard)
profile.CompanyType = strings.TrimSpace(enterprise.CompanyType)
profile.EstablishTime = appProfileDate(enterprise.EstablishTime)
profile.BankName = strings.TrimSpace(enterprise.BankName)
profile.BankAccount = appMaskBankAccount(enterprise.BankAccount)
profile.RealNameStatus = appRealNameStatus(enterprise.FullName, enterprise.LegalPersonName, enterprise.LegalPersonIdCard)
}
return profile, nil
}
func appTerminalProfileModel(terminal *entity.TTerminal) *model.AppTerminalProfileModel {
if terminal == nil || terminal.TerminalId == 0 {
return nil
}
return &model.AppTerminalProfileModel{
TerminalId: terminal.TerminalId,
TerminalName: strings.TrimSpace(terminal.TerminalName),
Phone: strings.TrimSpace(terminal.Phone),
Email: strings.TrimSpace(terminal.Email),
Status: strings.TrimSpace(terminal.Status),
StatusText: appTerminalStatusText(terminal.Status),
CreditCode: strings.TrimSpace(terminal.CreditCode),
AreaCode: strings.TrimSpace(terminal.AreaCode),
Address: strings.TrimSpace(terminal.Address),
FullName: strings.TrimSpace(terminal.FullName),
Industry: strings.TrimSpace(terminal.Industry),
LegalPersonName: strings.TrimSpace(terminal.LegalPersonName),
LegalPersonIdCard: strings.TrimSpace(terminal.LegalPersonIdCard),
CompanyType: strings.TrimSpace(terminal.CompanyType),
EstablishTime: appProfileDate(terminal.EstablishTime),
BankName: strings.TrimSpace(terminal.BankName),
BankAccount: strings.TrimSpace(terminal.BankAccount),
Balance: appRoundAmount(terminal.Balance),
RealNameStatus: appRealNameStatus(terminal.FullName, terminal.LegalPersonName, terminal.LegalPersonIdCard),
}
}
func appProfileDate(value *gtime.Time) string {
if value == nil || value.IsZero() {
return ""
}
return value.Format("Y-m-d")
}
func appTerminalStatusText(status string) string {
switch strings.TrimSpace(status) {
case consts.SysUserStatusNormal:
return "正常"
case "1":
return "停用"
case "":
return "未设置"
default:
return "未知"
}
}
func appRealNameStatus(fullName string, legalPersonName string, legalPersonIdCard string) string {
if strings.TrimSpace(fullName) == "" && strings.TrimSpace(legalPersonName) == "" {
return "资料待完善"
}
idCard := strings.TrimSpace(legalPersonIdCard)
if idCard == "" || idCard == "000000000000000000" {
return "实名资料待完善"
}
return "已录入实名资料"
}
func appMaskIdCard(value string) string {
value = strings.TrimSpace(value)
if value == "" || value == "000000000000000000" {
return ""
}
runes := []rune(value)
if len(runes) <= 8 {
return value
}
return string(runes[:4]) + strings.Repeat("*", len(runes)-8) + string(runes[len(runes)-4:])
}
func appMaskBankAccount(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) <= 8 {
return value
}
return string(runes[:4]) + strings.Repeat("*", len(runes)-8) + string(runes[len(runes)-4:])
}
func appMaskPhone(value string) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) < 7 {
return value
}
return string(runes[:3]) + "****" + string(runes[len(runes)-4:])
}
func appMaskEmail(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
parts := strings.SplitN(value, "@", 2)
if len(parts) != 2 {
return appMaskMiddle(value, 1, 1)
}
name := []rune(parts[0])
if len(name) <= 1 {
return "*" + "@" + parts[1]
}
return string(name[:1]) + strings.Repeat("*", len(name)-1) + "@" + parts[1]
}
func appMaskCreditCode(value string) string {
return appMaskMiddle(value, 4, 4)
}
func appMaskAddress(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) <= 6 {
return strings.Repeat("*", len(runes))
}
return string(runes[:6]) + strings.Repeat("*", len(runes)-6)
}
func appMaskName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) <= 1 {
return "*"
}
return string(runes[:1]) + strings.Repeat("*", len(runes)-1)
}
func appMaskMiddle(value string, prefix int, suffix int) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
runes := []rune(value)
if len(runes) <= prefix+suffix {
if len(runes) <= 2 {
return strings.Repeat("*", len(runes))
}
return string(runes[:1]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1:])
}
return string(runes[:prefix]) + strings.Repeat("*", len(runes)-prefix-suffix) + string(runes[len(runes)-suffix:])
}
+758
View File
@@ -0,0 +1,758 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"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"
)
const (
appWarningItemTypeCard = 1
appWarningUnitMb = 1
appWarningTypeCardTraffic = 1
appWarningMessageMethod = "3"
// 微信订阅消息模板 ID 默认值:物联卡预警提醒
appWarningWechatTemplateIdDefault = "DMDP2PKktdtG1ES_3Ic1JcR0LGELQmWE6LunYyerHCY"
)
type sAppWarning struct{}
type appWarningConfigRow = entity.TWarnConfig
func NewAppWarning() *sAppWarning {
return &sAppWarning{}
}
func init() {
service.RegisterAppWarning(NewAppWarning())
}
func (s *sAppWarning) ConfigList(ctx context.Context, param *model.AppWarningConfigListParam) (res *model.AppWarningConfigListModel, err error) {
if param == nil {
return nil, gerror.New("预警配置查询参数不能为空")
}
param.CardValue = strings.TrimSpace(param.CardValue)
user, cardRow, cardModel, err := s.resolveWarningCard(ctx, param.CardValue)
if err != nil {
return nil, err
}
var rows []*appWarningConfigRow
err = s.configModel(ctx).
Where(dao.TWarnConfig.Columns().TenantId, user.TenantId).
Where(dao.TWarnConfig.Columns().ItemType, appWarningItemTypeCard).
Where(dao.TWarnConfig.Columns().ItemId, cardRow.Id).
Where(dao.TWarnConfig.Columns().WarnUnit, appWarningUnitMb).
OrderAsc(dao.TWarnConfig.Columns().WarnUnit).
Scan(&rows)
if err != nil {
return nil, gerror.Wrap(err, "查询预警配置失败,请稍后重试")
}
wechatBound, wechatBindText, err := appUserWechatBindingStatus(ctx, user)
if err != nil {
return nil, err
}
return &model.AppWarningConfigListModel{
Card: cardModel,
Items: s.configRowsToModels(rows),
TemplateId: appWarningWechatTemplateId(ctx),
TemplateReady: appWarningTemplateReady(ctx),
WechatBound: wechatBound,
WechatBindText: wechatBindText,
}, nil
}
func (s *sAppWarning) ConfigMeta(ctx context.Context) (res *model.AppWarningConfigMetaModel, err error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
wechatBound, wechatBindText, err := appUserWechatBindingStatus(ctx, user)
if err != nil {
return nil, err
}
return &model.AppWarningConfigMetaModel{
TemplateId: appWarningWechatTemplateId(ctx),
TemplateReady: appWarningTemplateReady(ctx),
WechatBound: wechatBound,
WechatBindText: wechatBindText,
AppNotifyEnabled: appWarningNotifyEnabledValue(nil),
}, nil
}
func (s *sAppWarning) ConfigSave(ctx context.Context, param *model.AppWarningConfigSaveParam) (res *model.AppWarningConfigSaveModel, err error) {
if err = normalizeAppWarningSaveParam(param); err != nil {
return nil, err
}
user, cardRow, _, err := s.resolveWarningCard(ctx, param.CardValue)
if err != nil {
return nil, err
}
saveParam, err := s.prepareWarningSaveParam(ctx, nil, user, cardRow, param)
if err != nil {
return nil, err
}
now := gtime.Now()
batchNo, err := appNextBatchNo(ctx, consts.BatchNoPrefixWarning)
if err != nil {
return nil, err
}
data := appWarningBuildSaveRow(ctx, user, cardRow, saveParam, batchNo, now)
_, err = dao.TWarnConfig.Ctx(ctx).
Data(data).
OnConflict(
dao.TWarnConfig.Columns().TenantId,
dao.TWarnConfig.Columns().ItemType,
dao.TWarnConfig.Columns().ItemId,
dao.TWarnConfig.Columns().WarnUnit,
).
OnDuplicateEx(appWarningSaveDuplicateExcludeFields()).
Save()
if err != nil {
return nil, gerror.Wrap(err, "保存预警配置失败,请稍后重试")
}
config, err := s.findConfigByItem(ctx, user.TenantId, cardRow.Id, param.WarnUnit)
if err != nil {
return nil, err
}
wechatBound, wechatBindText, err := appUserWechatBindingStatus(ctx, user)
if err != nil {
return nil, err
}
if config == nil || config.Id == 0 {
return nil, gerror.New("保存预警配置后未查询到配置")
}
return &model.AppWarningConfigSaveModel{
Id: config.Id,
Config: s.configRowToModel(config),
TemplateId: appWarningWechatTemplateId(ctx),
TemplateReady: appWarningTemplateReady(ctx),
WechatBound: wechatBound,
WechatBindText: wechatBindText,
}, nil
}
func (s *sAppWarning) ConfigBatchSave(ctx context.Context, param *model.AppWarningConfigBatchSaveParam) (res *model.AppWarningConfigBatchSaveModel, err error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
cardValues, configs, err := normalizeAppWarningBatchSaveParam(param)
if err != nil {
return nil, err
}
cardLogic := NewAppCard()
type validCard struct {
Value string
Row *appCardQueryRow
}
validCards := make([]validCard, 0, len(cardValues))
failItems := make([]*model.AppWarningConfigBatchFailItem, 0)
seenCardIds := make(map[int64]struct{}, len(cardValues))
for _, cardValue := range cardValues {
cardRow, err := cardLogic.findVisibleCard(ctx, user, cardValue)
if err != nil {
failItems = append(failItems, &model.AppWarningConfigBatchFailItem{CardValue: cardValue, Reason: "查询卡片失败"})
continue
}
if cardRow == nil || cardRow.Id == 0 {
failItems = append(failItems, &model.AppWarningConfigBatchFailItem{CardValue: cardValue, Reason: "卡片不存在或无权配置"})
continue
}
if _, exists := seenCardIds[cardRow.Id]; exists {
continue
}
if err = cardLogic.ensureOperateCard(ctx, user, cardRow, "配置预警"); err != nil {
failItems = append(failItems, &model.AppWarningConfigBatchFailItem{CardValue: cardValue, Reason: err.Error()})
continue
}
seenCardIds[cardRow.Id] = struct{}{}
validCards = append(validCards, validCard{Value: cardValue, Row: cardRow})
}
if len(validCards) == 0 {
return nil, gerror.New("没有可配置预警的卡片")
}
now := gtime.Now()
batchNo, err := appNextBatchNo(ctx, consts.BatchNoPrefixWarning)
if err != nil {
return nil, err
}
err = dao.TWarnConfig.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, card := range validCards {
for _, config := range configs {
saveParam, err := s.prepareWarningSaveParam(ctx, tx, user, card.Row, config)
if err != nil {
return err
}
data := appWarningBuildSaveRow(ctx, user, card.Row, saveParam, batchNo, now)
if _, err = dao.TWarnConfig.Ctx(ctx).TX(tx).
Data(data).
OnConflict(
dao.TWarnConfig.Columns().TenantId,
dao.TWarnConfig.Columns().ItemType,
dao.TWarnConfig.Columns().ItemId,
dao.TWarnConfig.Columns().WarnUnit,
).
OnDuplicateEx(appWarningSaveDuplicateExcludeFields()).
Save(); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return nil, gerror.Wrap(err, "批量保存预警配置失败,请稍后重试")
}
wechatBound, wechatBindText, err := appUserWechatBindingStatus(ctx, user)
if err != nil {
return nil, err
}
return &model.AppWarningConfigBatchSaveModel{
BatchNo: batchNo,
TotalCount: len(cardValues),
SuccessCount: len(validCards),
FailCount: len(failItems),
FailItems: failItems,
TemplateId: appWarningWechatTemplateId(ctx),
TemplateReady: appWarningTemplateReady(ctx),
WechatBound: wechatBound,
WechatBindText: wechatBindText,
}, nil
}
func (s *sAppWarning) ConfigNotify(ctx context.Context, param *model.AppWarningConfigNotifyParam) (res *model.AppWarningConfigNotifyModel, err error) {
if err = normalizeAppWarningNotifyParam(param); err != nil {
return nil, err
}
user, cardRow, _, err := s.resolveWarningCard(ctx, param.CardValue)
if err != nil {
return nil, err
}
config, err := s.findConfigByItem(ctx, user.TenantId, cardRow.Id, param.WarnUnit)
if err != nil {
return nil, err
}
if config == nil || config.Id == 0 {
return nil, gerror.New("请先保存预警阈值后再设置小程序提醒")
}
now := gtime.Now()
_, err = dao.TWarnConfig.Ctx(ctx).
Where(dao.TWarnConfig.Columns().TenantId, user.TenantId).
Where(dao.TWarnConfig.Columns().ItemType, appWarningItemTypeCard).
Where(dao.TWarnConfig.Columns().ItemId, cardRow.Id).
Where(dao.TWarnConfig.Columns().WarnUnit, param.WarnUnit).
Data(do.TWarnConfig{
AppNotifyEnabled: appWarningNotifyEnabledValue(param.AppNotifyEnabled),
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update()
if err != nil {
return nil, gerror.Wrap(err, "设置小程序预警通知失败,请稍后重试")
}
config, err = s.findConfigByItem(ctx, user.TenantId, cardRow.Id, param.WarnUnit)
if err != nil {
return nil, err
}
wechatBound, wechatBindText, err := appUserWechatBindingStatus(ctx, user)
if err != nil {
return nil, err
}
return &model.AppWarningConfigNotifyModel{
AppNotifyEnabled: appWarningNotifyEnabledValue(param.AppNotifyEnabled),
Config: s.configRowToModel(config),
TemplateId: appWarningWechatTemplateId(ctx),
TemplateReady: appWarningTemplateReady(ctx),
WechatBound: wechatBound,
WechatBindText: wechatBindText,
}, nil
}
func (s *sAppWarning) ConfigBatchNotify(ctx context.Context, param *model.AppWarningConfigBatchNotifyParam) (res *model.AppWarningConfigBatchNotifyModel, err error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
cardValues, err := normalizeAppWarningBatchNotifyParam(param)
if err != nil {
return nil, err
}
cardLogic := NewAppCard()
type validCard struct {
Value string
Row *appCardQueryRow
}
validCards := make([]validCard, 0, len(cardValues))
failItems := make([]*model.AppWarningConfigBatchFailItem, 0)
seenCardIds := make(map[int64]struct{}, len(cardValues))
for _, cardValue := range cardValues {
cardRow, err := cardLogic.findVisibleCard(ctx, user, cardValue)
if err != nil {
failItems = append(failItems, &model.AppWarningConfigBatchFailItem{CardValue: cardValue, Reason: "查询卡片失败"})
continue
}
if cardRow == nil || cardRow.Id == 0 {
failItems = append(failItems, &model.AppWarningConfigBatchFailItem{CardValue: cardValue, Reason: "卡片不存在或无权配置"})
continue
}
if _, exists := seenCardIds[cardRow.Id]; exists {
continue
}
if err = cardLogic.ensureOperateCard(ctx, user, cardRow, "配置预警"); err != nil {
failItems = append(failItems, &model.AppWarningConfigBatchFailItem{CardValue: cardValue, Reason: err.Error()})
continue
}
config, err := s.findConfigByItem(ctx, user.TenantId, cardRow.Id, param.WarnUnit)
if err != nil {
failItems = append(failItems, &model.AppWarningConfigBatchFailItem{CardValue: cardValue, Reason: "查询预警配置失败"})
continue
}
if config == nil || config.Id == 0 {
failItems = append(failItems, &model.AppWarningConfigBatchFailItem{CardValue: cardValue, Reason: "请先保存预警配置"})
continue
}
seenCardIds[cardRow.Id] = struct{}{}
validCards = append(validCards, validCard{Value: cardValue, Row: cardRow})
}
now := gtime.Now()
err = dao.TWarnConfig.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, card := range validCards {
if _, err = dao.TWarnConfig.Ctx(ctx).TX(tx).
Where(dao.TWarnConfig.Columns().TenantId, user.TenantId).
Where(dao.TWarnConfig.Columns().ItemType, appWarningItemTypeCard).
Where(dao.TWarnConfig.Columns().ItemId, card.Row.Id).
Where(dao.TWarnConfig.Columns().WarnUnit, param.WarnUnit).
Data(do.TWarnConfig{
AppNotifyEnabled: appWarningNotifyEnabledValue(param.AppNotifyEnabled),
UpdatedBy: user.UserId,
UpdatedAt: now,
}).
Update(); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, gerror.Wrap(err, "批量设置小程序预警通知失败,请稍后重试")
}
wechatBound, wechatBindText, err := appUserWechatBindingStatus(ctx, user)
if err != nil {
return nil, err
}
return &model.AppWarningConfigBatchNotifyModel{
AppNotifyEnabled: appWarningNotifyEnabledValue(param.AppNotifyEnabled),
TotalCount: len(cardValues),
SuccessCount: len(validCards),
FailCount: len(failItems),
FailItems: failItems,
TemplateId: appWarningWechatTemplateId(ctx),
TemplateReady: appWarningTemplateReady(ctx),
WechatBound: wechatBound,
WechatBindText: wechatBindText,
}, nil
}
func (s *sAppWarning) resolveWarningCard(ctx context.Context, cardValue string) (*entity.SysUser, *appCardQueryRow, *model.AppCardModel, error) {
user, err := appCurrentUser(ctx)
if err != nil {
return nil, nil, nil, err
}
cardLogic := NewAppCard()
cardRow, err := cardLogic.findVisibleCard(ctx, user, cardValue)
if err != nil {
return nil, nil, nil, gerror.Wrap(err, "查询卡片失败,请稍后重试")
}
if cardRow == nil || cardRow.Id == 0 {
return nil, nil, nil, gerror.New("卡片不存在或不在当前账号可配置范围内")
}
if err = cardLogic.ensureOperateCard(ctx, user, cardRow, "配置预警"); err != nil {
return nil, nil, nil, err
}
return user, cardRow, cardLogic.cardRowToModel(ctx, cardRow, user, cardLogic.ownerType(ctx, user), true), nil
}
func (s *sAppWarning) configModel(ctx context.Context) *gdb.Model {
return dao.TWarnConfig.Ctx(ctx)
}
func (s *sAppWarning) findConfigByItem(ctx context.Context, tenantId string, itemId int64, warnUnit int) (*appWarningConfigRow, error) {
return s.findConfigByItemWithModel(
dao.TWarnConfig.Ctx(ctx),
tenantId,
itemId,
warnUnit,
)
}
func (s *sAppWarning) findConfigByItemWithTx(ctx context.Context, tx gdb.TX, tenantId string, itemId int64, warnUnit int) (*appWarningConfigRow, error) {
return s.findConfigByItemWithModel(
dao.TWarnConfig.Ctx(ctx).TX(tx),
tenantId,
itemId,
warnUnit,
)
}
func (s *sAppWarning) findConfigByItemWithModel(mod *gdb.Model, tenantId string, itemId int64, warnUnit int) (*appWarningConfigRow, error) {
var row *appWarningConfigRow
err := mod.
Where(dao.TWarnConfig.Columns().TenantId, tenantId).
Where(dao.TWarnConfig.Columns().ItemType, appWarningItemTypeCard).
Where(dao.TWarnConfig.Columns().ItemId, itemId).
Where(dao.TWarnConfig.Columns().WarnUnit, warnUnit).
Limit(1).
Scan(&row)
if err != nil {
return nil, gerror.Wrap(err, "查询预警配置失败,请稍后重试")
}
return row, nil
}
func (s *sAppWarning) prepareWarningSaveParam(
ctx context.Context,
tx gdb.TX,
user *entity.SysUser,
cardRow *appCardQueryRow,
param *model.AppWarningConfigSaveParam,
) (*model.AppWarningConfigSaveParam, error) {
if param == nil {
return nil, gerror.New("预警配置参数不能为空")
}
saveParam := *param
var (
config *appWarningConfigRow
err error
)
if tx != nil {
config, err = s.findConfigByItemWithTx(ctx, tx, user.TenantId, cardRow.Id, param.WarnUnit)
} else {
config, err = s.findConfigByItem(ctx, user.TenantId, cardRow.Id, param.WarnUnit)
}
if err != nil {
return nil, err
}
if config != nil {
if saveParam.IsEnabled == nil {
value := config.IsEnabled
saveParam.IsEnabled = &value
}
if saveParam.AppNotifyEnabled == nil {
value := config.AppNotifyEnabled
saveParam.AppNotifyEnabled = &value
}
}
return &saveParam, nil
}
func (s *sAppWarning) configRowsToModels(rows []*appWarningConfigRow) []*model.AppWarningConfigModel {
items := make([]*model.AppWarningConfigModel, 0, len(rows))
for _, row := range rows {
items = append(items, s.configRowToModel(row))
}
return items
}
func (s *sAppWarning) configRowToModel(row *appWarningConfigRow) *model.AppWarningConfigModel {
if row == nil {
return nil
}
warnType := appWarningTypeByUnit(row.WarnUnit)
return &model.AppWarningConfigModel{
Id: row.Id,
ItemType: row.ItemType,
ItemId: row.ItemId,
ItemCode: row.ItemCode,
WarnType: warnType,
WarnUnit: row.WarnUnit,
WarnTitle: appWarningTitle(row.WarnUnit),
UnitText: appWarningUnitText(row.WarnUnit),
FirstWarn: row.FirstWarn,
SecondWarn: row.SecondWarn,
ThirdWarn: row.ThirdWarn,
AutoAction: row.AutoAction,
IsEnabled: row.IsEnabled,
AppNotifyEnabled: row.AppNotifyEnabled,
WxTemplateId: row.WxTemplateId,
Remark: row.Remark,
UpdatedAt: row.UpdatedAt,
}
}
func normalizeAppWarningSaveParam(param *model.AppWarningConfigSaveParam) error {
if param == nil {
return gerror.New("预警配置参数不能为空")
}
param.CardValue = strings.TrimSpace(param.CardValue)
param.Remark = strings.TrimSpace(param.Remark)
if param.IsEnabled != nil && *param.IsEnabled != 0 && *param.IsEnabled != 1 {
return gerror.New("启停状态无效")
}
if param.AppNotifyEnabled != nil && *param.AppNotifyEnabled != 0 && *param.AppNotifyEnabled != 1 {
return gerror.New("小程序预警通知状态无效")
}
switch param.WarnUnit {
case appWarningUnitMb:
return validateAppWarningTrafficMb(param.FirstWarn, param.SecondWarn, param.ThirdWarn)
default:
return gerror.New("小程序仅支持按MB配置流量预警")
}
}
func normalizeAppWarningBatchSaveParam(param *model.AppWarningConfigBatchSaveParam) ([]string, []*model.AppWarningConfigSaveParam, error) {
if param == nil {
return nil, nil, gerror.New("批量预警配置参数不能为空")
}
cardValues, err := normalizeAppWarningCardValues(param.CardValues)
if err != nil {
return nil, nil, err
}
if len(cardValues) > 100 {
return nil, nil, gerror.New("单次批量预警配置最多支持100张卡")
}
configs := make([]*model.AppWarningConfigSaveParam, 0, len(param.Configs))
seenUnits := make(map[int]struct{}, len(param.Configs))
for _, config := range param.Configs {
if config == nil {
continue
}
copied := model.AppWarningConfigSaveParam{
WarnUnit: config.WarnUnit,
FirstWarn: config.FirstWarn,
SecondWarn: config.SecondWarn,
ThirdWarn: config.ThirdWarn,
IsEnabled: config.IsEnabled,
AppNotifyEnabled: config.AppNotifyEnabled,
Remark: config.Remark,
}
if _, exists := seenUnits[copied.WarnUnit]; exists {
return nil, nil, gerror.New("同一预警类型不能重复配置")
}
if err := normalizeAppWarningSaveParam(&copied); err != nil {
return nil, nil, err
}
seenUnits[copied.WarnUnit] = struct{}{}
configs = append(configs, &copied)
}
if len(configs) == 0 {
return nil, nil, gerror.New("请至少填写一项预警配置")
}
return cardValues, configs, nil
}
func normalizeAppWarningNotifyParam(param *model.AppWarningConfigNotifyParam) error {
if param == nil {
return gerror.New("小程序预警通知参数不能为空")
}
param.CardValue = strings.TrimSpace(param.CardValue)
if param.WarnUnit == 0 {
param.WarnUnit = appWarningUnitMb
}
if param.AppNotifyEnabled == nil {
return gerror.New("请选择是否开启小程序预警通知")
}
if *param.AppNotifyEnabled != 0 && *param.AppNotifyEnabled != 1 {
return gerror.New("小程序预警通知状态无效")
}
if param.WarnUnit != appWarningUnitMb {
return gerror.New("小程序仅支持配置MB流量预警通知")
}
return nil
}
func normalizeAppWarningBatchNotifyParam(param *model.AppWarningConfigBatchNotifyParam) ([]string, error) {
if param == nil {
return nil, gerror.New("批量小程序预警通知参数不能为空")
}
if param.WarnUnit == 0 {
param.WarnUnit = appWarningUnitMb
}
if param.AppNotifyEnabled == nil {
return nil, gerror.New("请选择是否开启小程序预警通知")
}
if *param.AppNotifyEnabled != 0 && *param.AppNotifyEnabled != 1 {
return nil, gerror.New("小程序预警通知状态无效")
}
if param.WarnUnit != appWarningUnitMb {
return nil, gerror.New("小程序仅支持配置MB流量预警通知")
}
cardValues, err := normalizeAppWarningCardValues(param.CardValues)
if err != nil {
return nil, err
}
if len(cardValues) > 100 {
return nil, gerror.New("单次批量预警通知最多支持100张卡")
}
return cardValues, nil
}
func normalizeAppWarningCardValues(values []string) ([]string, error) {
cardValues := make([]string, 0, len(values))
seenCards := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seenCards[value]; exists {
continue
}
seenCards[value] = struct{}{}
cardValues = append(cardValues, value)
}
if len(cardValues) == 0 {
return nil, gerror.New("请选择需要配置预警的卡片")
}
return cardValues, nil
}
func appWarningBuildSaveRow(ctx context.Context, user *entity.SysUser, cardRow *appCardQueryRow, param *model.AppWarningConfigSaveParam, batchNo string, now *gtime.Time) do.TWarnConfig {
return do.TWarnConfig{
TenantId: user.TenantId,
BatchNo: batchNo,
ItemType: appWarningItemTypeCard,
ItemId: cardRow.Id,
ItemCode: appWarningCardCode(cardRow),
WarnUnit: param.WarnUnit,
FirstWarn: param.FirstWarn,
SecondWarn: param.SecondWarn,
ThirdWarn: param.ThirdWarn,
WarnMethods: appWarningMessageMethod,
WarnPhones: "",
WarnEmails: "",
AutoAction: 0,
IsEnabled: appWarningEnabledValue(param.IsEnabled),
AppNotifyEnabled: appWarningNotifyEnabledValue(param.AppNotifyEnabled),
WxTemplateId: appWarningWechatTemplateId(ctx),
Remark: strings.TrimSpace(param.Remark),
CreatedDept: appUserDeptOrDefault(user),
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}
}
func appWarningSaveDuplicateExcludeFields() string {
columns := dao.TWarnConfig.Columns()
return strings.Join([]string{
columns.Id,
columns.TenantId,
columns.ItemType,
columns.ItemId,
columns.WarnUnit,
columns.CreatedDept,
columns.CreatedBy,
columns.CreatedAt,
}, ",")
}
func validateAppWarningTrafficMb(first, second, third float64) error {
if first <= 0 {
return gerror.New("第一档阈值必须大于0")
}
if second < 0 || third < 0 {
return gerror.New("预警阈值不能小于0")
}
if second > 0 && second <= first {
return gerror.New("第二档阈值必须大于第一档")
}
if third > 0 {
if second <= 0 {
return gerror.New("配置第三档时必须先配置第二档")
}
if third <= second {
return gerror.New("第三档阈值必须大于第二档")
}
}
return nil
}
func appWarningEnabledValue(value *int) int {
if value == nil {
return 1
}
return *value
}
func appWarningNotifyEnabledValue(value *int) int {
if value == nil {
return 0
}
return *value
}
func appWarningTypeByUnit(warnUnit int) int {
switch warnUnit {
case appWarningUnitMb:
return appWarningTypeCardTraffic
default:
return 0
}
}
func appWarningTitle(warnUnit int) string {
switch warnUnit {
case appWarningUnitMb:
return "流量使用预警"
default:
return "预警配置"
}
}
func appWarningUnitText(warnUnit int) string {
switch warnUnit {
case appWarningUnitMb:
return "MB"
default:
return ""
}
}
func appWarningCardCode(card *appCardQueryRow) string {
if card == nil {
return ""
}
return appFirstNotEmpty(card.Iccid, card.Msisdn, card.Imsi, card.Imei)
}
func appWarningWechatTemplateId(ctx context.Context) string {
return strings.TrimSpace(g.Cfg().MustGet(ctx, "appWarning.wechatSubscribeTemplateId", appWarningWechatTemplateIdDefault).String())
}
func appWarningTemplateReady(ctx context.Context) bool {
value := appWarningWechatTemplateId(ctx)
return value != "" && !strings.Contains(strings.ToUpper(value), "TODO") && !strings.Contains(strings.ToUpper(value), "REPLACE")
}
@@ -0,0 +1,107 @@
package app
import (
"context"
"testing"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
)
func TestValidateAppWarningTrafficMbAllowsLargeThreshold(t *testing.T) {
if err := validateAppWarningTrafficMb(1024, 2048, 4096); err != nil {
t.Fatalf("MB warning threshold should allow values greater than 100: %v", err)
}
}
func TestNormalizeAppWarningSaveParamOnlyAllowsMb(t *testing.T) {
for _, warnUnit := range []int{2, 3, 4} {
err := normalizeAppWarningSaveParam(&model.AppWarningConfigSaveParam{
WarnUnit: warnUnit,
FirstWarn: 80,
})
if err == nil {
t.Fatalf("warnUnit %d should be rejected for mini-program warning config", warnUnit)
}
}
}
func TestAppWarningUnitTextForMb(t *testing.T) {
if got := appWarningUnitText(appWarningUnitMb); got != "MB" {
t.Fatalf("MB warning unit text = %q, want MB", got)
}
}
func TestAppWarningNotifyEnabledValueDefaultsToDisabled(t *testing.T) {
if got := appWarningNotifyEnabledValue(nil); got != 0 {
t.Fatalf("nil app notify enabled = %d, want 0", got)
}
enabled := 1
if got := appWarningNotifyEnabledValue(&enabled); got != 1 {
t.Fatalf("enabled app notify value = %d, want 1", got)
}
}
func TestNormalizeAppWarningNotifyParam(t *testing.T) {
enabled := 0
param := &model.AppWarningConfigNotifyParam{
CardValue: " 8986040000000000000 ",
AppNotifyEnabled: &enabled,
}
if err := normalizeAppWarningNotifyParam(param); err != nil {
t.Fatalf("valid notify param should pass: %v", err)
}
if param.CardValue != "8986040000000000000" {
t.Fatalf("card value was not trimmed: %q", param.CardValue)
}
if param.WarnUnit != appWarningUnitMb {
t.Fatalf("empty warn unit = %d, want MB", param.WarnUnit)
}
}
func TestNormalizeAppWarningNotifyParamRejectsInvalidStatus(t *testing.T) {
invalid := 2
err := normalizeAppWarningNotifyParam(&model.AppWarningConfigNotifyParam{
CardValue: "8986040000000000000",
WarnUnit: appWarningUnitMb,
AppNotifyEnabled: &invalid,
})
if err == nil {
t.Fatal("invalid app notify status should be rejected")
}
}
func TestNormalizeAppWarningNotifyParamRejectsNonMbUnit(t *testing.T) {
enabled := 1
err := normalizeAppWarningNotifyParam(&model.AppWarningConfigNotifyParam{
CardValue: "8986040000000000000",
WarnUnit: 2,
AppNotifyEnabled: &enabled,
})
if err == nil {
t.Fatal("non-MB warning notify unit should be rejected")
}
}
func TestNormalizeAppWarningBatchNotifyParamRejectsEmptyCards(t *testing.T) {
enabled := 1
_, err := normalizeAppWarningBatchNotifyParam(&model.AppWarningConfigBatchNotifyParam{
CardValues: []string{" ", ""},
AppNotifyEnabled: &enabled,
})
if err == nil {
t.Fatal("empty batch notify cards should be rejected")
}
}
func TestConfigNotifyRejectsNilParamBeforeCardLookup(t *testing.T) {
defer func() {
if value := recover(); value != nil {
t.Fatalf("ConfigNotify should not panic for nil param: %v", value)
}
}()
_, err := NewAppWarning().ConfigNotify(context.Background(), nil)
if err == nil {
t.Fatal("nil notify param should be rejected")
}
}
+448
View File
@@ -0,0 +1,448 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
"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"
)
const (
appWorkOrderCreatorTerminal = 1
appWorkOrderCreatorEnterprise = 2
appWorkOrderStatusAssigned = 1
appWorkOrderStatusProcessing = 2
appWorkOrderStatusResolved = 3
appWorkOrderStatusClosed = 4
appWorkOrderStatusCanceled = 5
appWorkOrderOperationCreate = 0
)
type sAppWorkOrder struct{}
func NewAppWorkOrder() *sAppWorkOrder {
return &sAppWorkOrder{}
}
func init() {
service.RegisterAppWorkOrder(NewAppWorkOrder())
}
func (s *sAppWorkOrder) Upload(ctx context.Context) (output *model.SysOssUploadModel, err error) {
if err = appEnsureFeatureAccess(ctx, "workorder:create"); err != nil {
return nil, err
}
return service.SysOss().Upload(ctx, &model.SysOssUploadParam{FileType: "file"})
}
func (s *sAppWorkOrder) List(ctx context.Context, param *model.AppWorkOrderListParam) (items []*model.AppWorkOrderModel, total int, err error) {
if err = appEnsureFeatureAccess(ctx, "workorder:list"); err != nil {
return nil, 0, err
}
if param == nil {
param = &model.AppWorkOrderListParam{}
}
if param.Page <= 0 {
param.Page = 1
}
if param.PageSize <= 0 {
param.PageSize = 20
}
if param.PageSize > 100 {
param.PageSize = 100
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, 0, err
}
mod := s.ownerModel(ctx, user)
if param.CurrentStatus > 0 {
mod = mod.Where(dao.TWoMain.Columns().CurrentStatus, param.CurrentStatus)
}
if param.WoType > 0 {
mod = mod.Where(dao.TWoMain.Columns().WoType, param.WoType)
}
keyword := strings.TrimSpace(param.Keyword)
if keyword == "" {
keyword = strings.TrimSpace(param.PageInfo.Keyword)
}
if keyword != "" {
like := "%" + keyword + "%"
columns := dao.TWoMain.Columns()
mod = mod.Where(
"("+columns.WoNo+" LIKE ? OR "+columns.Title+" LIKE ? OR "+columns.Content+" LIKE ?)",
like, like, like,
)
}
total, err = mod.Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "查询工单数量失败")
}
if total == 0 {
return []*model.AppWorkOrderModel{}, 0, nil
}
var rows []*entity.TWoMain
if err = mod.
OrderDesc(dao.TWoMain.Columns().CreatedAt).
OrderDesc(dao.TWoMain.Columns().Id).
Page(param.Page, param.PageSize).
Scan(&rows); err != nil {
return nil, 0, gerror.Wrap(err, "查询工单列表失败")
}
items = make([]*model.AppWorkOrderModel, 0, len(rows))
for _, row := range rows {
if row != nil {
items = append(items, appWorkOrderToModel(row))
}
}
return items, total, nil
}
func (s *sAppWorkOrder) Detail(ctx context.Context, param *model.AppWorkOrderDetailParam) (res *model.AppWorkOrderModel, err error) {
if err = appEnsureFeatureAccess(ctx, "workorder:detail"); err != nil {
return nil, err
}
if param == nil || strings.TrimSpace(param.WoNo) == "" {
return nil, gerror.New("工单编号不能为空")
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
var row *entity.TWoMain
if err = s.ownerModel(ctx, user).
Where(dao.TWoMain.Columns().WoNo, strings.TrimSpace(param.WoNo)).
Limit(1).
Scan(&row); err != nil {
return nil, gerror.Wrap(err, "查询工单详情失败")
}
if row == nil || row.Id == 0 {
return nil, gerror.New("工单不存在或无权查看")
}
res = appWorkOrderToModel(row)
var communications []*entity.TWoCommunication
if err = dao.TWoCommunication.Ctx(ctx).
Where(dao.TWoCommunication.Columns().TenantId, user.TenantId).
Where(dao.TWoCommunication.Columns().WoNo, row.WoNo).
OrderAsc(dao.TWoCommunication.Columns().Id).
Scan(&communications); err != nil {
return nil, gerror.Wrap(err, "查询工单沟通记录失败")
}
res.Communications = appWorkOrderCommunicationsToModels(communications)
return res, nil
}
func (s *sAppWorkOrder) Create(ctx context.Context, param *model.AppWorkOrderCreateParam) (res *model.AppWorkOrderModel, err error) {
if err = appEnsureFeatureAccess(ctx, "workorder:create"); err != nil {
return nil, err
}
if param == nil {
return nil, gerror.New("工单参数不能为空")
}
title := strings.TrimSpace(param.Title)
content := strings.TrimSpace(param.Content)
if title == "" {
return nil, gerror.New("工单标题不能为空")
}
if content == "" {
return nil, gerror.New("问题描述不能为空")
}
if param.WoType < 0 || param.WoType > 5 {
return nil, gerror.New("工单类型不正确")
}
user, err := appCurrentUser(ctx)
if err != nil {
return nil, err
}
now := gtime.Now()
woNo := appCardBatchNo("WO", user.UserId, now)
createdDept := user.DeptId
if createdDept <= 0 {
createdDept = consts.AppTerminalDeptId
}
creatorType := appWorkOrderCreatorEnterprise
if appIsTerminalUser(ctx, user) {
creatorType = appWorkOrderCreatorTerminal
}
creatorName := strings.TrimSpace(appFirstNotEmpty(user.NickName, user.UserName))
handlerDeptId, handlerUserId, err := appWorkOrderHandler(ctx, user, createdDept)
if err != nil {
return nil, err
}
err = dao.TWoMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
_, err := dao.TWoMain.Ctx(ctx).TX(tx).Data(do.TWoMain{
WoNo: woNo,
WoType: param.WoType,
RelatedId: param.RelatedId,
Title: title,
Content: content,
Attachment: firstAttachment(param.AttachmentUrls),
CreatorType: creatorType,
CreatorDeptId: createdDept,
CreatorUserId: user.UserId,
CreatorName: creatorName,
CreatorPhone: strings.TrimSpace(user.Phonenumber),
AssignDeptId: handlerDeptId,
AssignUserId: handlerUserId,
CurrentStatus: appWorkOrderStatusProcessing,
HandlerDeptId: handlerDeptId,
HandlerUserId: handlerUserId,
TenantId: user.TenantId,
CreatedDept: createdDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Insert()
if err != nil {
return gerror.Wrap(err, "创建工单失败")
}
communicationData := do.TWoCommunication{
WoNo: woNo,
CommType: 1,
SenderDeptId: createdDept,
SenderUserId: user.UserId,
SenderName: creatorName,
Content: content,
TenantId: user.TenantId,
CreatedDept: createdDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}
if len(param.AttachmentUrls) > 0 {
communicationData.AttachmentUrls = gjson.New(param.AttachmentUrls)
}
if _, err = dao.TWoCommunication.Ctx(ctx).TX(tx).Data(communicationData).Insert(); err != nil {
return gerror.Wrap(err, "创建工单沟通记录失败")
}
if _, err = dao.TWoRecord.Ctx(ctx).TX(tx).Data(do.TWoRecord{
WoNo: woNo,
OperationType: appWorkOrderOperationCreate,
OperationDeptId: createdDept,
OperationUserId: user.UserId,
BeforeStatus: 0,
AfterStatus: appWorkOrderStatusProcessing,
TenantId: user.TenantId,
CreatedDept: createdDept,
CreatedBy: user.UserId,
CreatedAt: now,
UpdatedBy: user.UserId,
UpdatedAt: now,
}).Insert(); err != nil {
return gerror.Wrap(err, "创建工单操作记录失败")
}
return nil
})
if err != nil {
return nil, err
}
return s.Detail(ctx, &model.AppWorkOrderDetailParam{WoNo: woNo})
}
func (s *sAppWorkOrder) ownerModel(ctx context.Context, user *entity.SysUser) *gdb.Model {
columns := dao.TWoMain.Columns()
mod := dao.TWoMain.Ctx(ctx).
Where(columns.TenantId, user.TenantId)
if appIsTerminalUser(ctx, user) {
return mod.Where(columns.CreatorUserId, user.UserId)
}
return appApplyEnterpriseDataScope(
ctx,
user,
mod,
columns.CreatorDeptId,
columns.CreatorUserId,
func(legacyMod *gdb.Model) *gdb.Model {
return legacyMod.Where(
"("+columns.CreatorDeptId+" = ? OR "+columns.CreatorUserId+" = ?)",
user.DeptId,
user.UserId,
)
},
)
}
func appWorkOrderHandler(ctx context.Context, user *entity.SysUser, createdDept int64) (deptId int64, userId int64, err error) {
if user == nil {
return createdDept, 0, nil
}
var topDept *entity.SysDept
if err = dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, user.TenantId).
Where(dao.SysDept.Columns().ParentId, 0).
OrderAsc(dao.SysDept.Columns().OrderNum).
OrderAsc(dao.SysDept.Columns().DeptId).
Limit(1).
Scan(&topDept); err != nil {
return 0, 0, gerror.Wrap(err, "查询顶级工单处理部门失败")
}
if topDept != nil && topDept.DeptId > 0 {
return topDept.DeptId, topDept.Leader, nil
}
deptId = createdDept
if deptId <= 0 {
deptId = appUserDeptOrDefault(user)
}
var dept *entity.SysDept
if err = dao.SysDept.Ctx(ctx).
Where(dao.SysDept.Columns().TenantId, user.TenantId).
Where(dao.SysDept.Columns().DeptId, deptId).
Limit(1).
Scan(&dept); err != nil {
return 0, 0, gerror.Wrap(err, "查询工单处理部门失败")
}
if dept != nil && dept.Leader > 0 {
userId = dept.Leader
}
return deptId, userId, nil
}
func appWorkOrderToModel(row *entity.TWoMain) *model.AppWorkOrderModel {
if row == nil {
return nil
}
return &model.AppWorkOrderModel{
Id: row.Id,
WoNo: row.WoNo,
WoType: row.WoType,
WoTypeText: appWorkOrderTypeText(row.WoType),
RelatedId: row.RelatedId,
Title: row.Title,
Content: row.Content,
AttachmentUrls: decodeAttachmentUrls(row.Attachment),
CreatorType: row.CreatorType,
CreatorName: row.CreatorName,
CreatorPhone: row.CreatorPhone,
CurrentStatus: row.CurrentStatus,
CurrentStatusText: appWorkOrderStatusText(row.CurrentStatus),
StatusType: appWorkOrderStatusType(row.CurrentStatus),
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}
func appWorkOrderCommunicationsToModels(rows []*entity.TWoCommunication) []*model.AppWorkOrderCommModel {
items := make([]*model.AppWorkOrderCommModel, 0, len(rows))
for _, row := range rows {
if row == nil {
continue
}
items = append(items, &model.AppWorkOrderCommModel{
Id: row.Id,
WoNo: row.WoNo,
CommType: row.CommType,
CommTypeText: appWorkOrderCommTypeText(row.CommType),
SenderName: row.SenderName,
Content: row.Content,
AttachmentUrls: row.AttachmentUrls,
CreatedAt: row.CreatedAt,
})
}
return items
}
func firstAttachment(urls []string) string {
if len(urls) == 0 {
return ""
}
return strings.TrimSpace(urls[0])
}
func decodeAttachmentUrls(value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
var urls []string
if err := gjson.DecodeTo(value, &urls); err == nil && len(urls) > 0 {
return urls
}
return []string{value}
}
func appWorkOrderTypeText(value int) string {
switch value {
case 0:
return "卡片"
case 1:
return "模组"
case 2:
return "设备"
case 3:
return "订单"
case 4:
return "余额"
case 5:
return "提现"
default:
return "其他"
}
}
func appWorkOrderStatusText(value int) string {
switch value {
case appWorkOrderStatusAssigned:
return "已分派"
case appWorkOrderStatusProcessing:
return "处理中"
case appWorkOrderStatusResolved:
return "已解决"
case appWorkOrderStatusClosed:
return "已完结"
case appWorkOrderStatusCanceled:
return "已取消"
default:
return "待处理"
}
}
func appWorkOrderStatusType(value int) string {
switch value {
case appWorkOrderStatusResolved, appWorkOrderStatusClosed:
return "success"
case appWorkOrderStatusCanceled:
return "danger"
case appWorkOrderStatusProcessing:
return "primary"
default:
return "warning"
}
}
func appWorkOrderCommTypeText(value int) string {
if value == 1 {
return "反馈"
}
return "回复"
}
@@ -0,0 +1,49 @@
package app
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/rabbitmq"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
const appWorkerBatchExecuteQueueDefault = "iotlcm.batch.execute"
type appWorkerBatchMessage struct {
BatchNo string `json:"batchNo"`
BatchType int `json:"batchType"`
}
func appWorkerBatchAsyncEnabled(ctx context.Context) bool {
return g.Cfg().MustGet(ctx, "workerBatchAsync.enabled", false).Bool()
}
func (s *sAppCard) publishWorkerBatchExecute(ctx context.Context, batchNo string, batchType int) error {
if strings.TrimSpace(batchNo) == "" {
return gerror.New("批次号不能为空")
}
if !appWorkerBatchAsyncEnabled(ctx) {
return nil
}
queueName := strings.TrimSpace(g.Cfg().MustGet(ctx, "workerBatchAsync.queueName", appWorkerBatchExecuteQueueDefault).String())
if queueName == "" {
queueName = appWorkerBatchExecuteQueueDefault
}
err := rabbitmq.Publish(ctx, queueName, appWorkerBatchMessage{
BatchNo: strings.TrimSpace(batchNo),
BatchType: batchType,
})
if err != nil {
g.Log().Errorf(ctx, "投递 worker 批次失败, queue:%s, batchNo:%s, batchType:%d, err:%+v", queueName, batchNo, batchType, err)
if g.Cfg().MustGet(ctx, "workerBatchAsync.failOnPublish", true).Bool() {
return gerror.Wrap(err, "批次已创建但投递 worker 失败")
}
return nil
}
g.Log().Infof(ctx, "投递 worker 批次成功, queue:%s, batchNo:%s, batchType:%d", queueName, batchNo, batchType)
return nil
}