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:
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "未知"
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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, "上游挡板暂不可用")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user