chore: 并入 iotlcm-app 历史到 app/(原提交 f40bda9)
This commit is contained in:
@@ -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/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:reply", Permissions: []string{"cpm:gen:tWoCommunication:message"}},
|
||||
{Feature: "workorder:resolve", Permissions: []string{"cpm:gen:tWoMain:resolve"}},
|
||||
{Feature: "workorder:transfer", Permissions: []string{"cpm:gen:tWoMain:transfer"}},
|
||||
{Feature: "workorder:close", Permissions: []string{"cpm:gen:tWoMain:close"}},
|
||||
{
|
||||
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,123 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkOrderHandlingRequiresDedicatedPermissions(t *testing.T) {
|
||||
for _, action := range []string{"reply", "resolve", "transfer", "close", "cancel", "confirm", "evaluate", "reopen"} {
|
||||
required := appWorkOrderHandlerPermission(action)
|
||||
if appAccessCodesMatchExpression([]string{"cpm:gen:tWoMain:list", "cpm:gen:tWoMain:view"}, required) {
|
||||
t.Fatalf("list/view granted %s", action)
|
||||
}
|
||||
if !appAccessCodesMatchExpression([]string{required}, required) {
|
||||
t.Fatalf("dedicated permission did not grant %s", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 TestPhoneRegisterParamRequiresSmsCode(t *testing.T) {
|
||||
err := g.Validator().Data(&model.AppPhoneRegisterParam{
|
||||
TenantId: "000000",
|
||||
Phone: "13800008000",
|
||||
Password: "123456",
|
||||
NickName: "测试用户",
|
||||
}).Run(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("AppPhoneRegisterParam should require smsCode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppSmsCodeHelpersUseRedisKeys(t *testing.T) {
|
||||
key := appSmsCacheKey("", "13800008000", "login")
|
||||
if key != "app:sms:000000:login:13800008000" {
|
||||
t.Fatalf("unexpected sms cache key: %s", key)
|
||||
}
|
||||
if key := appSmsCooldownCacheKey("", "13800008000"); key != "app:sms:cooldown:000000:13800008000" {
|
||||
t.Fatalf("unexpected sms cooldown key: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppSmsCooldownScopeAndDuration(t *testing.T) {
|
||||
if appSmsCooldownTTL != time.Minute {
|
||||
t.Fatalf("sms cooldown = %v, want one minute", appSmsCooldownTTL)
|
||||
}
|
||||
key := appSmsCooldownCacheKey("000000", "13800008000")
|
||||
if key != appSmsCooldownCacheKey("", "13800008000") {
|
||||
t.Fatal("default tenant should share the same cooldown key")
|
||||
}
|
||||
if key == appSmsCooldownCacheKey("000000", "13800008001") {
|
||||
t.Fatal("different phones should have independent cooldown keys")
|
||||
}
|
||||
if key == appSmsCooldownCacheKey("000001", "13800008000") {
|
||||
t.Fatal("different tenants should have independent cooldown keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedRegisterUserNameFitsSysUserUniqueColumn(t *testing.T) {
|
||||
name := deletedRegisterUserName(88, "18800004698")
|
||||
if name != "del88_314698" {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallAppCodeHTTPUsesSceneTemplate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Config struct {
|
||||
Scene string `json:"scene"`
|
||||
} `json:"config"`
|
||||
Params struct {
|
||||
PhoneNumber string `json:"phoneNumber"`
|
||||
MsgContent string `json:"msgContent"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if request.Config.Scene != "bind_phone" {
|
||||
t.Errorf("scene = %q, want bind_phone", request.Config.Scene)
|
||||
}
|
||||
if request.Params.PhoneNumber != "13800008000" {
|
||||
t.Errorf("phoneNumber = %q, want 13800008000", request.Params.PhoneNumber)
|
||||
}
|
||||
if request.Params.MsgContent != "123456" {
|
||||
t.Errorf("msgContent = %q, want raw verification code", request.Params.MsgContent)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":0,"message":"发送成功"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := callAppCodeHTTP(context.Background(), appCodeHttpSenderConfig{
|
||||
Url: server.URL,
|
||||
Method: http.MethodPost,
|
||||
BodyTemplate: `{"config":{"scene":"{scene}"},"params":{"phoneNumber":"{contact}","msgContent":"{code}"}}`,
|
||||
}, "13800008000", "123456", appSmsServiceScene("wechatBindPhone"))
|
||||
if err != nil {
|
||||
t.Fatalf("callAppCodeHTTP returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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,808 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchmetadata"
|
||||
|
||||
"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/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).
|
||||
Where(dao.SysDept.Columns().Status, "0").
|
||||
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
|
||||
}
|
||||
|
||||
if param.ExecutionMode == batchmetadata.Single && len(batch.Cards) != 1 {
|
||||
return nil, gerror.New("单卡划拨只能选择一张卡")
|
||||
}
|
||||
ctx = appContextWithUser(ctx, user)
|
||||
processor := newAppAllocateBatchProcessor(batch)
|
||||
rows := appAllocateBatchRows(batch)
|
||||
req := batchimport.SubmitRequest{
|
||||
BizParams: param,
|
||||
SourceType: consts.BatchSourceTypeBatchSelect,
|
||||
SourceChannel: batchmetadata.MiniProgram,
|
||||
ExecutionMode: appAllocateExecutionMode(param.ExecutionMode),
|
||||
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 确认预处理批次并提交 worker 执行。
|
||||
func (s *sAppCard) AllocateBatchSubmit(ctx context.Context, param *model.AppCardAllocateBatchSubmitParam) (res *model.AppCardAllocateBatchModel, err error) {
|
||||
if err := appEnsureFeatureAccess(ctx, "card:allocate"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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("终端用户不能使用卡划拨功能")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(param.BatchNo) == "" {
|
||||
if strings.TrimSpace(param.RequestId) != "" {
|
||||
return s.submitAppAllocateRequest(ctx, user, param)
|
||||
}
|
||||
return s.createAppAllocatePending(ctx, user, param)
|
||||
}
|
||||
if !appWorkerBatchAsyncEnabled(ctx) {
|
||||
return nil, gerror.New("划拨执行服务未启用,请联系管理员")
|
||||
}
|
||||
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
|
||||
}
|
||||
if prepareBatch.CreatedDept != user.DeptId {
|
||||
return gerror.New("当前机构与划拨批次创建机构不一致")
|
||||
}
|
||||
if prepareBatch.BatchType == consts.BatchTypeAllocate {
|
||||
if prepareBatch.BatchInfo.Get("jobRole").String() != "allocate_parent" {
|
||||
return gerror.New("旧划拨批次不能重新提交,请查看执行日志")
|
||||
}
|
||||
if err := appValidateAllocateRetry(prepareBatch, param); err != nil {
|
||||
return err
|
||||
}
|
||||
res = appAllocateQueuedResponse(prepareBatch)
|
||||
return nil
|
||||
}
|
||||
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.queueAppAllocateBatch(ctx, tx, user, prepareBatch, batch, strings.TrimSpace(param.Remark)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res == nil {
|
||||
if batch == nil {
|
||||
return nil, gerror.New("划拨提交结果不完整")
|
||||
}
|
||||
res = s.buildAllocateBatchModel(ctx, user, batch, batchNo, sourceBatchNo, consts.BatchTypeAllocate, "pending")
|
||||
}
|
||||
if res.Status == "pending" || res.Status == "processing" {
|
||||
if err = s.publishAppAllocateBatch(ctx, user, batchNo); err != nil {
|
||||
res.FailReason = "批次已创建,投递失败,请保留批次号并重试提交"
|
||||
}
|
||||
}
|
||||
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) 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).
|
||||
WhereIn(columns.BatchType, []int{consts.BatchTypePrepare, consts.BatchTypeAllocate}).
|
||||
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.BatchType == consts.BatchTypeAllocate {
|
||||
return batch, nil
|
||||
}
|
||||
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.BossCardStatus == 9 {
|
||||
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) 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.Status != "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,241 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/allocationunits"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchmetadata"
|
||||
"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"
|
||||
)
|
||||
|
||||
func appAllocateParentInfo(batch *appAllocateBatchContext, remark string) *gjson.Json {
|
||||
return gjson.New(map[string]any{
|
||||
"operation": "sim_card_allocate", "targetBatchType": consts.BatchTypeAllocate,
|
||||
"jobRole": "allocate_parent", "autoSplit": true, "splitCompleted": true,
|
||||
"chunkCount": 1, "chunkSize": 100, "parsedCount": len(batch.Cards),
|
||||
"targetDeptId": batch.Target.DeptId, "targetDeptName": batch.Target.DeptName,
|
||||
"cards": appAllocateCardSnapshotList(batch), "remark": remark, "resultFile": map[string]any{"status": "pending"},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sAppCard) queueAppAllocateBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, prepare *entity.BatchMain, batch *appAllocateBatchContext, remark string) error {
|
||||
units, err := allocationunits.Snapshot(ctx, user.TenantId, user.DeptId, batch.Target.DeptId, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
childNo, err := batchimport.NextBatchNoContext(ctx, consts.BatchNoPrefixAllocate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := batchmetadata.Bulk
|
||||
if prepare.ExecutionMode == batchmetadata.Single {
|
||||
mode = batchmetadata.Single
|
||||
}
|
||||
parentInfo := allocationunits.Merge(appAllocateParentInfo(batch, remark), units)
|
||||
if prepare.BatchInfo != nil {
|
||||
for _, key := range []string{"requestId", "requestHash"} {
|
||||
if value := prepare.BatchInfo.Get(key).String(); value != "" {
|
||||
parentInfo.Set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
childInfo := gjson.New(map[string]any{
|
||||
"operation": "sim_card_allocate", "jobRole": "allocate_chunk", "autoSplit": true,
|
||||
"targetBatchType": consts.BatchTypeAllocate, "targetDeptId": batch.Target.DeptId,
|
||||
"parentBatchNo": prepare.BatchNo, "chunkNo": 1, "remark": remark,
|
||||
})
|
||||
if _, err := dao.BatchMain.Ctx(ctx).TX(tx).Data(do.BatchMain{
|
||||
BatchNo: childNo, BatchType: consts.BatchTypeAllocate,
|
||||
BatchStatus: consts.BatchAsyncStatusPending, BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
SourceType: consts.BatchSourceTypeBatchSelect, SourceBatchNo: prepare.BatchNo,
|
||||
SourceChannel: batchmetadata.MiniProgram, ExecutionMode: mode,
|
||||
TargetDeptId: batch.Target.DeptId, DeptId: user.DeptId,
|
||||
TotalCount: len(batch.Cards), ValidCount: 0, SuccessCount: 0, FailCount: 0,
|
||||
RunResult: consts.BatchRunResultPending, BatchInfo: allocationunits.Merge(childInfo, units),
|
||||
TenantId: user.TenantId, CreatedDept: user.DeptId, CreatedBy: user.UserId, UpdatedBy: user.UserId,
|
||||
}).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "创建划拨执行子批次失败")
|
||||
}
|
||||
details := make([]do.BatchDetail, 0, len(batch.Cards))
|
||||
for index, item := range batch.Cards {
|
||||
details = append(details, do.BatchDetail{
|
||||
BatchNo: childNo, BatchType: consts.BatchTypeAllocate,
|
||||
DetailStatus: consts.BatchDetailStatusPending, BlankOutStatus: consts.BlankOutStatusNo,
|
||||
RowNo: index + 1, ItemId: item.Card.Iccid,
|
||||
Snapshot: gjson.New(map[string]any{
|
||||
"iccid": item.Card.Iccid, "msisdn": item.Card.Msisdn, "imsi": item.Card.Imsi,
|
||||
"targetDeptId": batch.Target.DeptId, "targetDeptName": units.Get("targetDeptName").String(), "targetDeptKey": units.Get("targetDeptKey").String(),
|
||||
"sourceDeptId": item.Card.DeptId, "sourceDeptName": units.Get("sourceDeptName").String(), "sourceDeptKey": units.Get("sourceDeptKey").String(), "remark": remark,
|
||||
}),
|
||||
TenantId: user.TenantId, CreatedDept: user.DeptId, CreatedBy: user.UserId, UpdatedBy: user.UserId,
|
||||
})
|
||||
}
|
||||
if _, err := dao.BatchDetail.Ctx(ctx).TX(tx).Data(details).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "创建划拨执行明细失败")
|
||||
}
|
||||
if _, err := dao.BatchDetail.Ctx(ctx).TX(tx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, prepare.BatchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Where(dao.BatchDetail.Columns().TenantId, user.TenantId).
|
||||
Where(dao.BatchDetail.Columns().CreatedBy, user.UserId).Delete(); err != nil {
|
||||
return gerror.Wrap(err, "清理划拨预处理明细失败")
|
||||
}
|
||||
_, err = dao.BatchMain.Ctx(ctx).TX(tx).Where(dao.BatchMain.Columns().Id, prepare.Id).Data(do.BatchMain{
|
||||
BatchType: consts.BatchTypeAllocate, SourceBatchNo: "", ResultOssId: 0,
|
||||
BatchStatus: consts.BatchAsyncStatusPending, BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
RunResult: consts.BatchRunResultPending, ValidCount: 0, SuccessCount: 0, FailCount: 0, FailReason: "",
|
||||
SourceChannel: batchmetadata.MiniProgram, ExecutionMode: mode,
|
||||
BatchInfo: parentInfo, UpdatedBy: user.UserId,
|
||||
StartTime: new(gtime.Time), EndTime: new(gtime.Time),
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "创建划拨父批次失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) createAppAllocatePending(ctx context.Context, user *entity.SysUser, param *model.AppCardAllocateBatchSubmitParam) (*model.AppCardAllocateBatchModel, error) {
|
||||
if !appWorkerBatchAsyncEnabled(ctx) {
|
||||
return nil, gerror.New("划拨执行服务未启用,请联系管理员")
|
||||
}
|
||||
if user.DeptId <= 0 || s.isTerminalUser(ctx, user) {
|
||||
return nil, gerror.New("当前账号不能使用卡划拨功能")
|
||||
}
|
||||
values := appNormalizeAllocateCardValues(param.CardValues)
|
||||
mode := appAllocateExecutionMode(param.ExecutionMode)
|
||||
if len(values) == 0 || len(values) > 100 || (mode == batchmetadata.Single && len(values) != 1) {
|
||||
return nil, gerror.New("请选择1至100张卡,单卡划拨只能选择一张卡")
|
||||
}
|
||||
target, err := s.loadAllocateTarget(ctx, user, param.TargetDeptId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
batch := &appAllocateBatchContext{Target: target, TotalCount: len(values)}
|
||||
for index, value := range values {
|
||||
card, err := s.findVisibleCard(ctx, user, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if card == nil || card.DeptId != user.DeptId || strings.TrimSpace(card.Iccid) == "" {
|
||||
return nil, gerror.Newf("第%d张卡不在当前企业可划拨范围内", index+1)
|
||||
}
|
||||
batch.Cards = append(batch.Cards, &appAllocateCardContext{Card: card, ItemId: card.Iccid, RowNo: index + 1, SourceDeptId: card.DeptId})
|
||||
}
|
||||
parentNo, err := batchimport.NextBatchNoContext(ctx, consts.BatchNoPrefixAllocate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parent, err := s.persistAppAllocatePending(ctx, user, parentNo, mode, batch, strings.TrimSpace(param.Remark))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := appAllocateQueuedResponse(parent)
|
||||
if err := s.publishAppAllocateBatch(ctx, user, parentNo); err != nil {
|
||||
response.FailReason = "任务已受理,投递尚未确认,请保留批次号并稍后刷新"
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) persistAppAllocatePending(ctx context.Context, user *entity.SysUser, parentNo string, mode int, batch *appAllocateBatchContext, remark string) (*entity.BatchMain, error) {
|
||||
parent := &entity.BatchMain{
|
||||
BatchNo: parentNo, BatchType: consts.BatchTypeAllocate, ExecutionMode: uint(mode),
|
||||
BatchStatus: consts.BatchAsyncStatusPending, BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
TotalCount: len(batch.Cards), TargetDeptId: batch.Target.DeptId, BatchInfo: appAllocateParentInfo(batch, remark),
|
||||
}
|
||||
err := dao.BatchMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
id, err := dao.BatchMain.Ctx(ctx).TX(tx).Data(do.BatchMain{
|
||||
BatchNo: parentNo, BatchType: consts.BatchTypeAllocate, SourceBatchNo: "",
|
||||
BatchStatus: consts.BatchAsyncStatusPending, BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
RunResult: consts.BatchRunResultPending, SourceType: consts.BatchSourceTypeBatchSelect,
|
||||
SourceChannel: batchmetadata.MiniProgram, ExecutionMode: mode,
|
||||
TotalCount: len(batch.Cards), TargetDeptId: batch.Target.DeptId, DeptId: user.DeptId,
|
||||
TenantId: user.TenantId, CreatedDept: user.DeptId, CreatedBy: user.UserId, UpdatedBy: user.UserId,
|
||||
}).InsertAndGetId()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "创建划拨待处理批次失败")
|
||||
}
|
||||
parent.Id = id
|
||||
return s.queueAppAllocateBatch(ctx, tx, user, parent, batch, remark)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parent, nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) publishAppAllocateBatch(ctx context.Context, user *entity.SysUser, batchNo string) error {
|
||||
var children []*entity.BatchMain
|
||||
columns := dao.BatchMain.Columns()
|
||||
if err := dao.BatchMain.Ctx(ctx).Where(columns.SourceBatchNo, batchNo).
|
||||
Where(columns.BatchType, consts.BatchTypeAllocate).Where(columns.TenantId, user.TenantId).
|
||||
Where(columns.CreatedBy, user.UserId).Scan(&children); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range children {
|
||||
if child.BatchAsyncStatus == consts.BatchAsyncStatusPending || child.BatchAsyncStatus == consts.BatchAsyncStatusProcessing {
|
||||
if err := s.publishWorkerBatchExecute(ctx, child.BatchNo, consts.BatchTypeAllocate, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.publishWorkerBatchExecute(ctx, batchNo, consts.BatchTypeAllocate, true)
|
||||
}
|
||||
|
||||
func appAllocateQueuedResponse(batch *entity.BatchMain) *model.AppCardAllocateBatchModel {
|
||||
status := appLifecycleBatchStatus(batch.BatchStatus, batch.BatchAsyncStatus)
|
||||
target := &model.AppCardAllocateTargetModel{DeptId: batch.TargetDeptId}
|
||||
if batch.BatchInfo != nil {
|
||||
target.DeptName = strings.TrimSpace(batch.BatchInfo.Get("targetDeptName").String())
|
||||
}
|
||||
return &model.AppCardAllocateBatchModel{
|
||||
BatchNo: batch.BatchNo, BatchType: consts.BatchTypeAllocate,
|
||||
Quantity: batch.TotalCount, TotalCount: batch.TotalCount,
|
||||
ValidCount: batch.ValidCount, FailCount: batch.FailCount,
|
||||
FailReason: appLifecycleFailReasonForResponse(status, batch.FailReason),
|
||||
Status: status, Target: target, Cards: []*model.AppCardModel{}, Details: []*model.AppCardAllocateBatchDetailModel{},
|
||||
}
|
||||
}
|
||||
|
||||
func appAllocateExecutionMode(requested int) int {
|
||||
if requested == batchmetadata.Single {
|
||||
return batchmetadata.Single
|
||||
}
|
||||
return batchmetadata.Bulk
|
||||
}
|
||||
|
||||
func appValidateAllocateRetry(batch *entity.BatchMain, param *model.AppCardAllocateBatchSubmitParam) error {
|
||||
if param.ExecutionMode > 0 && uint(param.ExecutionMode) != batch.ExecutionMode {
|
||||
return gerror.New("提交规模与原划拨批次不一致")
|
||||
}
|
||||
if strings.TrimSpace(param.Remark) != "" && strings.TrimSpace(param.Remark) != batch.BatchInfo.Get("remark").String() {
|
||||
return gerror.New("提交备注与原划拨批次不一致")
|
||||
}
|
||||
if strings.TrimSpace(param.RequestId) != "" && strings.TrimSpace(param.RequestId) != batch.BatchInfo.Get("requestId").String() {
|
||||
return gerror.New("请求标识与原划拨批次不一致")
|
||||
}
|
||||
|
||||
if param.TargetDeptId > 0 && param.TargetDeptId != batch.TargetDeptId {
|
||||
return gerror.New("提交目标客户与原划拨批次不一致")
|
||||
}
|
||||
if len(param.CardValues) > 0 {
|
||||
var expected []string
|
||||
for _, card := range batch.BatchInfo.Get("cards").Array() {
|
||||
expected = append(expected, gjson.New(card).Get("iccid").String())
|
||||
}
|
||||
if !appSameStringSet(appNormalizeAllocateCardValues(param.CardValues), expected) {
|
||||
return gerror.New("提交卡片与原划拨批次不一致")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"testing"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestAppAllocateParentMatchesWorkerContract(t *testing.T) {
|
||||
batch := &appAllocateBatchContext{Target: &entity.SysDept{DeptId: 2}, Cards: []*appAllocateCardContext{{Card: &appCardQueryRow{TSimCardInfo: entity.TSimCardInfo{Iccid: "A", DeptId: 1}}}}}
|
||||
info := appAllocateParentInfo(batch, "test")
|
||||
if info.Get("jobRole").String() != "allocate_parent" || !info.Get("splitCompleted").Bool() || info.Get("chunkCount").Int() != 1 || info.Get("targetBatchType").Int() != consts.BatchTypeAllocate {
|
||||
t.Fatal("worker parent contract mismatch", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAllocateRejectsCanceledCard(t *testing.T) {
|
||||
card := &appCardQueryRow{TSimCardInfo: entity.TSimCardInfo{Iccid: "A", BossCardStatus: 9}}
|
||||
if err := NewAppCard().validateAllocateCardBusinessRules(context.Background(), nil, card); err == nil {
|
||||
t.Fatal("canceled card accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAllocateQueuedResponseRetainsFinalStatus(t *testing.T) {
|
||||
batch := &entity.BatchMain{BatchNo: "ALC1", BatchType: 3, BatchStatus: 3, BatchAsyncStatus: 3, TotalCount: 2}
|
||||
if res := appAllocateQueuedResponse(batch); res.Status != "success" || res.BatchNo != batch.BatchNo || res.Quantity != 2 {
|
||||
t.Fatal("retry lost final result", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAllocateRetryRejectsChangedSelection(t *testing.T) {
|
||||
batch := &appAllocateBatchContext{Target: &entity.SysDept{DeptId: 2}, Cards: []*appAllocateCardContext{{Card: &appCardQueryRow{TSimCardInfo: entity.TSimCardInfo{Iccid: "A"}}}}}
|
||||
main := &entity.BatchMain{TargetDeptId: 2, BatchInfo: appAllocateParentInfo(batch, "")}
|
||||
for _, param := range []*model.AppCardAllocateBatchSubmitParam{{TargetDeptId: 3}, {CardValues: []string{"B"}}} {
|
||||
if appValidateAllocateRetry(main, param) == nil {
|
||||
t.Fatal("changed request accepted")
|
||||
}
|
||||
}
|
||||
if err := appValidateAllocateRetry(main, &model.AppCardAllocateBatchSubmitParam{CardValues: []string{"A"}, TargetDeptId: 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appAllocateExecutionMode(0) != batchmetadata.Bulk || appAllocateExecutionMode(2) != batchmetadata.Bulk || appAllocateExecutionMode(1) != batchmetadata.Single {
|
||||
t.Fatal("request shape lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAllocateQueuedResponsePreservesFailure(t *testing.T) {
|
||||
batch := &entity.BatchMain{BatchNo: "ALC1", BatchAsyncStatus: consts.BatchAsyncStatusFailed, FailReason: "卡已停机,不能划拨", TotalCount: 1, FailCount: 1}
|
||||
res := appAllocateQueuedResponse(batch)
|
||||
if res.Status != "failed" || res.FailReason != batch.FailReason || res.FailCount != 1 {
|
||||
t.Fatal("retry hid failure", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppAllocateRequestRejectsChangedHashAndScope(t *testing.T) {
|
||||
user := &entity.SysUser{DeptId: 1}
|
||||
batch := &entity.BatchMain{BatchType: 3, CreatedDept: 1, BatchInfo: gjson.New(map[string]any{"jobRole": "allocate_parent", "requestHash": "hash"})}
|
||||
if err := appValidateAllocateRequest(batch, user, "hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appValidateAllocateRequest(batch, user, "changed") == nil {
|
||||
t.Fatal("changed request accepted")
|
||||
}
|
||||
user.DeptId = 2
|
||||
if appValidateAllocateRequest(batch, user, "hash") == nil {
|
||||
t.Fatal("changed department accepted")
|
||||
}
|
||||
user.DeptId = 1
|
||||
batch.SourceBatchNo = "parent"
|
||||
if appValidateAllocateRequest(batch, user, "hash") == nil {
|
||||
t.Fatal("child accepted")
|
||||
}
|
||||
}
|
||||
@@ -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,261 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
_ "github.com/gogf/gf/contrib/nosql/redis/v2"
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/database/gredis"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"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/allocaterequest"
|
||||
"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"
|
||||
)
|
||||
|
||||
type allocateSequenceAdapter struct {
|
||||
gredis.Adapter
|
||||
sequence int
|
||||
}
|
||||
|
||||
func (adapter *allocateSequenceAdapter) Do(ctx context.Context, command string, args ...any) (*gvar.Var, error) {
|
||||
if command != "EVAL" {
|
||||
return nil, errors.New("unexpected Redis command")
|
||||
}
|
||||
adapter.sequence++
|
||||
return gvar.New(adapter.sequence), nil
|
||||
}
|
||||
|
||||
func TestAppAllocateQueuePersistsWorkerTaskWithoutChangingOwnership(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
gredis.SetConfig(&gredis.Config{Address: "127.0.0.1:1"})
|
||||
redis := g.Redis()
|
||||
original := redis.GetAdapter()
|
||||
redis.SetAdapter(&allocateSequenceAdapter{})
|
||||
t.Cleanup(func() { redis.SetAdapter(original) })
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database := g.DB()
|
||||
t.Cleanup(func() {
|
||||
if err := database.GetCore().ClearCacheAll(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := database.Close(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := gdb.SetConfigGroup("default", previous); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := database.GetCore().ClearCacheAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, value := range map[string]any{"batch_main": entity.BatchMain{}, "batch_detail": entity.BatchDetail{}, "t_sim_card_info": entity.TSimCardInfo{}} {
|
||||
typ := reflect.TypeOf(value)
|
||||
columns := make([]string, 0, typ.NumField())
|
||||
for index := 0; index < typ.NumField(); index++ {
|
||||
field := typ.Field(index)
|
||||
column := field.Tag.Get("orm")
|
||||
sqlType := "TEXT"
|
||||
if field.Type.Kind() >= reflect.Int && field.Type.Kind() <= reflect.Uint64 {
|
||||
sqlType = "INTEGER"
|
||||
}
|
||||
if column == "id" {
|
||||
sqlType = "INTEGER PRIMARY KEY"
|
||||
}
|
||||
if column == "batch_no" && name == "batch_main" {
|
||||
sqlType += " UNIQUE"
|
||||
}
|
||||
columns = append(columns, column+" "+sqlType)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "CREATE TABLE "+name+" ("+strings.Join(columns, ",")+")"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,dept_key TEXT,dept_name TEXT,deleted_at TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO sys_dept VALUES(1,'A','source','划拨单位',NULL),(2,'A','target','接收单位',NULL)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &entity.SysUser{UserId: 7, DeptId: 1, TenantId: "A"}
|
||||
prepare := &entity.BatchMain{BatchInfo: gjson.New(map[string]any{"requestId": "request", "requestHash": "hash"}), Id: 1, BatchNo: "ALC20261012000001", ExecutionMode: 2}
|
||||
if _, err := dao.BatchMain.Ctx(ctx).Data(do.BatchMain{Id: 1, BatchNo: prepare.BatchNo, BatchType: 4, TenantId: "A", CreatedBy: 7, StartTime: gtime.Now(), EndTime: gtime.Now(), TotalCount: 1, ResultOssId: 99}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := dao.BatchDetail.Ctx(ctx).Data(do.BatchDetail{BatchNo: prepare.BatchNo, BatchType: 4, ItemId: "CARD", TenantId: "A", CreatedBy: 7}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := dao.TSimCardInfo.Ctx(ctx).Data(do.TSimCardInfo{Id: 1, Iccid: "CARD", DeptId: 1, TenantId: "A", CreatedDept: 1}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := &appAllocateBatchContext{Target: &entity.SysDept{DeptId: 2, DeptName: "目标客户"}, Cards: []*appAllocateCardContext{{Card: &appCardQueryRow{TSimCardInfo: entity.TSimCardInfo{Id: 1, Iccid: "CARD", DeptId: 1}}}}}
|
||||
if err := dao.BatchMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return NewAppCard().queueAppAllocateBatch(ctx, tx, user, prepare, batch, "")
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parent *entity.BatchMain
|
||||
if err := dao.BatchMain.Ctx(ctx).Where("batch_no", prepare.BatchNo).Scan(&parent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parent.BatchType != consts.BatchTypeAllocate || parent.SourceBatchNo != "" || parent.BatchAsyncStatus != consts.BatchAsyncStatusPending || parent.ExecutionMode != 2 || parent.SourceChannel != "mini_program" {
|
||||
t.Fatal("invalid parent", parent)
|
||||
}
|
||||
if parent.BatchInfo.Get("requestHash").String() != "hash" || parent.BatchInfo.Get("requestId").String() != "request" {
|
||||
t.Fatal("request identity lost")
|
||||
}
|
||||
if parent.ResultOssId != 0 {
|
||||
t.Fatal("precheck result file leaked into execution")
|
||||
}
|
||||
if parent.StartTime != nil && !parent.StartTime.IsZero() || parent.EndTime != nil && !parent.EndTime.IsZero() {
|
||||
t.Fatal("prepare times leaked into execution", parent)
|
||||
}
|
||||
var children []*entity.BatchMain
|
||||
if err := dao.BatchMain.Ctx(ctx).Where("source_batch_no", prepare.BatchNo).Scan(&children); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(children) != 1 || !regexp.MustCompile(`^ALC[0-9]{17}$`).MatchString(children[0].BatchNo) || children[0].BatchInfo.Get("jobRole").String() != "allocate_chunk" {
|
||||
t.Fatal("invalid child", children)
|
||||
}
|
||||
var details []*entity.BatchDetail
|
||||
if err := dao.BatchDetail.Ctx(ctx).Scan(&details); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(details) != 1 || details[0].BatchNo != children[0].BatchNo || details[0].Snapshot.Get("targetDeptId").Int64() != 2 {
|
||||
t.Fatal("invalid execution details", details)
|
||||
}
|
||||
for _, snapshot := range []*gjson.Json{parent.BatchInfo, children[0].BatchInfo, details[0].Snapshot} {
|
||||
if snapshot.Get("sourceDeptName").String() != "划拨单位" || snapshot.Get("targetDeptName").String() != "接收单位" || snapshot.Get("sourceDeptId").Int64() != 1 || snapshot.Get("targetDeptId").Int64() != 2 {
|
||||
t.Fatal("父子批次和明细单位快照不一致", snapshot)
|
||||
}
|
||||
}
|
||||
var card *entity.TSimCardInfo
|
||||
if err := dao.TSimCardInfo.Ctx(ctx).Where("id", 1).Scan(&card); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card.DeptId != 1 || card.CreatedDept != 1 || card.AllocationBatchNo != "" {
|
||||
t.Fatal("queue changed ownership", card)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
value string
|
||||
allocate bool
|
||||
count int
|
||||
}{
|
||||
{" AR ", true, 1}, {"AR", false, 0}, {"CARD", true, 1}, {"missing", true, 0},
|
||||
} {
|
||||
query := NewAppCard().applyCardFilters(dao.TSimCardInfo.Ctx(ctx).Where("tenant_id", "A"), &model.AppCardListParam{CardValue: test.value, AllocateOnly: test.allocate})
|
||||
if count, err := query.Count(); err != nil || count != test.count {
|
||||
t.Fatalf("search %q allocate=%v count=%d error=%v", test.value, test.allocate, count, err)
|
||||
}
|
||||
}
|
||||
parentNo, err := batchimport.NextBatchNoContext(ctx, consts.BatchNoPrefixAllocate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending, err := NewAppCard().persistAppAllocatePending(ctx, user, parentNo, 2, batch, "直接异步")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pending.BatchType != 3 || pending.BatchAsyncStatus != 1 || pending.ValidCount != 0 || !regexp.MustCompile(`^ALC[0-9]{17}$`).MatchString(pending.BatchNo) {
|
||||
t.Fatal("direct submission did not create pending task", pending)
|
||||
}
|
||||
var directParent *entity.BatchMain
|
||||
if err := dao.BatchMain.Ctx(ctx).Where("batch_no", parentNo).Scan(&directParent); err != nil || directParent.SourceBatchNo != "" || directParent.BatchInfo.Get("jobRole").String() != "allocate_parent" || !directParent.BatchInfo.Get("splitCompleted").Bool() {
|
||||
t.Fatal("invalid direct parent protocol", directParent, err)
|
||||
}
|
||||
var directChild *entity.BatchMain
|
||||
if err := dao.BatchMain.Ctx(ctx).Where("source_batch_no", parentNo).Scan(&directChild); err != nil || directChild.BatchInfo.Get("jobRole").String() != "allocate_chunk" || directChild.BatchAsyncStatus != 1 {
|
||||
t.Fatal("invalid direct child protocol", directChild, err)
|
||||
}
|
||||
if err := dao.TSimCardInfo.Ctx(ctx).Where("id", 1).Scan(&card); err != nil || card.DeptId != 1 || card.AllocationBatchNo != "" {
|
||||
t.Fatal("direct submission changed card ownership", card, err)
|
||||
}
|
||||
if _, err := dao.BatchMain.DB().Exec(ctx, `CREATE TRIGGER reject_allocate_child BEFORE INSERT ON batch_main WHEN NEW.source_batch_no = 'ROLLBACK_PARENT' BEGIN SELECT RAISE(ABORT, 'injected child failure'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewAppCard().persistAppAllocatePending(ctx, user, "ROLLBACK_PARENT", 2, batch, ""); err == nil {
|
||||
t.Fatal("child creation failure was ignored")
|
||||
}
|
||||
if count, err := dao.BatchMain.Ctx(ctx).Where("batch_no", "ROLLBACK_PARENT").Count(); err != nil || count != 0 {
|
||||
t.Fatal("direct submission left orphan parent", count, err)
|
||||
}
|
||||
if _, err := dao.BatchMain.DB().Exec(ctx, `DROP TRIGGER reject_allocate_child`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := &entity.BatchMain{Id: 10, BatchNo: "ALC20261012000002", ExecutionMode: 2}
|
||||
if _, err := dao.BatchMain.Ctx(ctx).Data(do.BatchMain{Id: 10, BatchNo: second.BatchNo, BatchType: 4, TenantId: "A", CreatedBy: 7}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := dao.BatchDetail.Ctx(ctx).Data(do.BatchDetail{BatchNo: second.BatchNo, BatchType: 4, ItemId: "CARD", TenantId: "A", CreatedBy: 7}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
injected := errors.New("rollback test")
|
||||
err = dao.BatchMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if err := NewAppCard().queueAppAllocateBatch(ctx, tx, user, second, batch, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return injected
|
||||
})
|
||||
if !errors.Is(err, injected) {
|
||||
t.Fatal("rollback did not return injected failure", err)
|
||||
}
|
||||
|
||||
request := &model.AppCardAllocateBatchSubmitParam{RequestId: "alloc_test_0123456789", CardValues: []string{"CARD"}, TargetDeptId: 2, ExecutionMode: 2}
|
||||
requestKey, err := allocaterequest.Key(user.TenantId, user.UserId, request.RequestId)
|
||||
requestNo := "ALC" + requestKey[:60]
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash := allocaterequest.Fingerprint(request.CardValues, 1, 2, 2, "")
|
||||
if _, err := dao.BatchMain.Ctx(ctx).Data(do.BatchMain{Id: 20, BatchNo: requestNo, BatchType: 3, SourceBatchNo: "", BatchStatus: 3, BatchAsyncStatus: 3, TenantId: "A", CreatedBy: 7, CreatedDept: 1, TotalCount: 1, TargetDeptId: 2, BatchInfo: gjson.New(map[string]any{"jobRole": "allocate_parent", "requestHash": hash})}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := dao.TSimCardInfo.Ctx(ctx).Where("id", 1).Data(do.TSimCardInfo{DeptId: 2}).Update(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index := 0; index < 2; index++ {
|
||||
response, err := NewAppCard().submitAppAllocateRequest(ctx, user, request)
|
||||
if err != nil || response.BatchNo != requestNo || response.Status != "success" {
|
||||
t.Fatal("accepted request was revalidated or repeated", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
request.RequestId = "alloc_missing_0123456789"
|
||||
if _, err := NewAppCard().submitAppAllocateRequest(ctx, user, request); err == nil {
|
||||
t.Fatal("missing legacy request created new batch")
|
||||
}
|
||||
request.RequestId = "alloc_test_0123456789"
|
||||
request.TargetDeptId = 3
|
||||
if _, err := NewAppCard().submitAppAllocateRequest(ctx, user, request); err == nil {
|
||||
t.Fatal("changed request accepted")
|
||||
}
|
||||
if count, err := dao.BatchMain.Ctx(ctx).Where("batch_no", requestNo).Count(); err != nil || count != 1 {
|
||||
t.Fatal("duplicate request rows", count, err)
|
||||
}
|
||||
count, err := dao.BatchMain.Ctx(ctx).Where("source_batch_no", second.BatchNo).Count()
|
||||
if err != nil || count != 0 {
|
||||
t.Fatal("rollback left child", count, err)
|
||||
}
|
||||
var retained *entity.BatchMain
|
||||
if err := dao.BatchMain.Ctx(ctx).Where("id", second.Id).Scan(&retained); err != nil || retained.BatchType != 4 {
|
||||
t.Fatal("rollback changed parent", retained, err)
|
||||
}
|
||||
count, err = dao.BatchDetail.Ctx(ctx).Where("batch_no", second.BatchNo).Count()
|
||||
if err != nil || count != 1 {
|
||||
t.Fatal("rollback lost original detail", count, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/allocaterequest"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
)
|
||||
|
||||
func (s *sAppCard) submitAppAllocateRequest(ctx context.Context, user *entity.SysUser, param *model.AppCardAllocateBatchSubmitParam) (*model.AppCardAllocateBatchModel, error) {
|
||||
_, err := allocaterequest.Key(user.TenantId, user.UserId, param.RequestId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := appNormalizeAllocateCardValues(param.CardValues)
|
||||
if len(values) == 0 || len(values) > 100 || param.TargetDeptId <= 0 {
|
||||
return nil, gerror.New("请选择1至100张卡及目标客户")
|
||||
}
|
||||
mode := appAllocateExecutionMode(param.ExecutionMode)
|
||||
if mode == batchmetadata.Single && len(values) != 1 {
|
||||
return nil, gerror.New("单卡划拨只能选择一张卡")
|
||||
}
|
||||
hash := allocaterequest.Fingerprint(values, user.DeptId, param.TargetDeptId, mode, param.Remark)
|
||||
existing, err := allocaterequest.Find(ctx, user.TenantId, user.UserId, param.RequestId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return s.resumeAppAllocateRequest(ctx, user, existing, hash)
|
||||
}
|
||||
return nil, gerror.New("请先创建划拨批次,再按批次号提交;旧请求未受理,不能直接重试创建")
|
||||
}
|
||||
|
||||
func appValidateAllocateRequest(batch *entity.BatchMain, user *entity.SysUser, hash string) error {
|
||||
if batch.BatchType != consts.BatchTypeAllocate || batch.SourceBatchNo != "" || batch.BatchInfo == nil || batch.BatchInfo.Get("jobRole").String() != "allocate_parent" {
|
||||
return gerror.New("请求标识对应的批次不可重试")
|
||||
}
|
||||
if batch.CreatedDept != user.DeptId || batch.BatchInfo.Get("requestHash").String() != hash {
|
||||
return gerror.New("同一请求标识不能更改卡片、目标客户、规模或备注")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) resumeAppAllocateRequest(ctx context.Context, user *entity.SysUser, batch *entity.BatchMain, hash string) (*model.AppCardAllocateBatchModel, error) {
|
||||
if err := appValidateAllocateRequest(batch, user, hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := appAllocateQueuedResponse(batch)
|
||||
if response.Status == "pending" || response.Status == "processing" {
|
||||
if !appWorkerBatchAsyncEnabled(ctx) || s.publishAppAllocateBatch(ctx, user, batch.BatchNo) != nil {
|
||||
response.FailReason = "任务已受理,投递尚未确认,请保留批次号并稍后刷新"
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -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,228 @@
|
||||
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/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type appCardChannelInfo struct {
|
||||
ChanKey string
|
||||
ChanName string
|
||||
ChanAlias string
|
||||
TempKey string
|
||||
ChanStatus int
|
||||
}
|
||||
|
||||
func (s *sAppCard) findDiagnosisAssocChannel(ctx context.Context, tenantId string, iccid string) (*appCardChannelInfo, error) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
if iccid == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var assoc *struct {
|
||||
ChannelKey string `orm:"channel_id"`
|
||||
}
|
||||
base := g.DB().Model("card_channel_assoc").Ctx(ctx).Fields("channel_id").Where("status", 1)
|
||||
var err error
|
||||
err = base.Clone().Where("match_type", 1).Where("iccid COLLATE utf8mb4_general_ci = ?", iccid).
|
||||
OrderDesc("id").Limit(1).Scan(&assoc)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡通道单卡关联失败")
|
||||
}
|
||||
if assoc == nil || strings.TrimSpace(assoc.ChannelKey) == "" {
|
||||
err = base.Clone().Where("match_type", 2).
|
||||
Where("iccid_start COLLATE utf8mb4_general_ci <= ?", iccid).
|
||||
Where("iccid_end COLLATE utf8mb4_general_ci >= ?", iccid).
|
||||
OrderDesc("id").Limit(1).Scan(&assoc)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡通道号段关联失败")
|
||||
}
|
||||
}
|
||||
if assoc == nil || strings.TrimSpace(assoc.ChannelKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if tenantId = strings.TrimSpace(tenantId); tenantId == "" {
|
||||
tenantId = contexts.GetTenantId(ctx)
|
||||
}
|
||||
channel, err := s.findCardChannelEntityByKey(ctx, tenantId, assoc.ChannelKey)
|
||||
if err != nil || channel != nil || tenantId == "" {
|
||||
return appCardChannelInfoFromEntity(channel), err
|
||||
}
|
||||
channel, err = s.findCardChannelEntityByKey(ctx, "", assoc.ChannelKey)
|
||||
return appCardChannelInfoFromEntity(channel), err
|
||||
}
|
||||
|
||||
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) {
|
||||
card, err := queryLegacyDiagnosisCard(ctx, iccid)
|
||||
if err != nil || card == nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.findLegacyCardChannelInfo(ctx, tenantId, card.ChannelKey)
|
||||
}
|
||||
|
||||
// resolveLifecycleCardChannelInfo resolves a writable card operation channel.
|
||||
// Card ownership still comes from the current platform; the legacy database is
|
||||
// only a read-only fallback for the channel code.
|
||||
func (s *sAppCard) resolveLifecycleCardChannelInfo(ctx context.Context, tenantId string, iccid string, currentChanKey string) (*appCardChannelInfo, error) {
|
||||
currentChanKey = strings.TrimSpace(currentChanKey)
|
||||
if currentChanKey != "" {
|
||||
info, err := s.findLegacyCardChannelInfo(ctx, tenantId, currentChanKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if appCardChannelAvailable(info) {
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
|
||||
card, err := queryLegacyDiagnosisCard(ctx, strings.TrimSpace(iccid))
|
||||
if err != nil || card == nil || strings.TrimSpace(card.ICCID) != strings.TrimSpace(iccid) {
|
||||
return nil, err
|
||||
}
|
||||
info, err := s.findLegacyCardChannelInfo(ctx, tenantId, card.ChannelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !appCardChannelAvailable(info) {
|
||||
return nil, nil
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func appCardChannelAvailable(info *appCardChannelInfo) bool {
|
||||
return info != nil && strings.TrimSpace(info.ChanKey) != "" && info.ChanStatus == 0
|
||||
}
|
||||
|
||||
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, dao.TSimChannels.Columns().ChanStatus)
|
||||
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),
|
||||
ChanStatus: channel.ChanStatus,
|
||||
}
|
||||
}
|
||||
@@ -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,383 @@
|
||||
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)
|
||||
if param.AllocateOnly {
|
||||
if s.isTerminalUser(ctx, user) || user.DeptId <= 0 {
|
||||
return nil, 0, gerror.New("当前账号不能划拨卡片")
|
||||
}
|
||||
if err := appEnsureFeatureAccess(ctx, "card:allocate"); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
mod = mod.Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().DeptId, user.DeptId)
|
||||
}
|
||||
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) != "" {
|
||||
if param.AllocateOnly {
|
||||
// 划拨列表支持号码片段检索,详情与执行提交仍按完整号码定位。
|
||||
value := "%" + strings.TrimSpace(param.CardValue) + "%"
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
table := dao.TSimCardInfo.Table() + "."
|
||||
mod = mod.Where("("+table+columns.Iccid+" LIKE ? OR "+table+columns.Msisdn+" LIKE ? OR "+table+columns.Imsi+" LIKE ? OR "+table+columns.Imei+" LIKE ?)", value, value, value, value)
|
||||
} else {
|
||||
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,163 @@
|
||||
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 TestAppLifecycleFailReasonForResponseHidesInternalSQL(t *testing.T) {
|
||||
reason := "save data service detail 238726 result: UPDATE `t_sim_card_info` SET `traffic_cutoff_status`=null: Error 1048 (23000)"
|
||||
got := appLifecycleFailReasonForResponse("failed", reason)
|
||||
want := "后台执行失败,请稍后重试;如持续失败,请联系管理员并提供批次号"
|
||||
if got != want {
|
||||
t.Fatalf("appLifecycleFailReasonForResponse() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppLifecycleFailReasonForResponseKeepsBusinessReason(t *testing.T) {
|
||||
const reason = "当前卡片状态不允许停用数据服务"
|
||||
if got := appLifecycleFailReasonForResponse("failed", reason); got != reason {
|
||||
t.Fatalf("appLifecycleFailReasonForResponse() = %q, want %q", got, reason)
|
||||
}
|
||||
}
|
||||
|
||||
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,863 @@
|
||||
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/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
const (
|
||||
appCustomerTypeAll = "all"
|
||||
appCustomerTypeEnterprise = "enterprise"
|
||||
appCustomerTypeTerminal = "terminal"
|
||||
)
|
||||
|
||||
type appCustomerCardStat struct {
|
||||
PackageStatus int `orm:"package_status"`
|
||||
PackageStatusCounts []model.AppCustomerStatusCount
|
||||
BossCardStatus int `orm:"boss_card_status"`
|
||||
CardStatusCounts []model.AppCustomerStatusCount
|
||||
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, summary *model.AppCustomerSummary, err error) {
|
||||
if param == nil {
|
||||
param = &model.AppCustomerListParam{}
|
||||
}
|
||||
user, err := appCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
if err = appEnsureEnterpriseCustomerScope(ctx, user); err != nil {
|
||||
return nil, 0, nil, 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, nil, gerror.New("暂不支持查看终端客户")
|
||||
}
|
||||
|
||||
list, err := s.enterpriseCustomerList(ctx, user, param)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
total = len(list)
|
||||
summary = appCustomerSummary(list)
|
||||
items = appCustomerPage(list, param.Page, param.PageSize)
|
||||
if len(items) == 0 {
|
||||
return items, total, summary, nil
|
||||
}
|
||||
deptIds := make([]int64, 0, len(items))
|
||||
for _, item := range items {
|
||||
deptIds = append(deptIds, item.CustomerId)
|
||||
}
|
||||
stats, err := appCardStatsByDept(ctx, user.TenantId, deptIds)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if stat := stats[item.CustomerId]; stat != nil {
|
||||
item.CardCount = stat.CardCount
|
||||
item.NormalCardCount = stat.NormalCardCount
|
||||
item.CardStatusCounts = stat.CardStatusCounts
|
||||
item.PackageStatusCounts = stat.PackageStatusCounts
|
||||
}
|
||||
}
|
||||
return items, total, summary, nil
|
||||
}
|
||||
|
||||
func appCustomerPage(items []*model.AppCustomerModel, page, pageSize int) []*model.AppCustomerModel {
|
||||
if page <= 0 || pageSize <= 0 || len(items) == 0 || page > (len(items)-1)/pageSize+1 {
|
||||
return []*model.AppCustomerModel{}
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
remaining := len(items) - start
|
||||
if remaining < pageSize {
|
||||
return items[start:]
|
||||
}
|
||||
return items[start : start+pageSize]
|
||||
}
|
||||
|
||||
func appCustomerSummary(items []*model.AppCustomerModel) *model.AppCustomerSummary {
|
||||
summary := &model.AppCustomerSummary{}
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
summary.Total++
|
||||
if item.Status == consts.SysDeptStatusNormal {
|
||||
summary.NormalCount++
|
||||
}
|
||||
if item.RealNameStatus != "已录入实名资料" {
|
||||
summary.PendingProfileCount++
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
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 := appEnterpriseAuthorizedCustomerDepts(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
depts = appFilterEnterpriseDepts(depts, strings.TrimSpace(param.Keyword), strings.TrimSpace(param.Status))
|
||||
deptIds := appDeptIds(depts)
|
||||
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]
|
||||
item := appEnterpriseCustomerModel(dept, profile, 0, nil)
|
||||
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 || dept.DeptType != 1 {
|
||||
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).
|
||||
Where(dao.SysDept.Columns().DeptType, 1).
|
||||
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 appEnterpriseCustomerQuery(ctx context.Context, user *entity.SysUser) (*gdb.Model, error) {
|
||||
scope, err := appResolveEnterpriseDataScope(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mod := dao.SysDept.Ctx(ctx).
|
||||
Where(dao.SysDept.Columns().TenantId, user.TenantId).
|
||||
Where(dao.SysDept.Columns().DeptType, 1)
|
||||
if scope.Configured {
|
||||
if !scope.All {
|
||||
if len(scope.DeptIds) == 0 {
|
||||
return mod.Where("1 = 0"), nil
|
||||
}
|
||||
mod = mod.WhereIn(dao.SysDept.Columns().DeptId, scope.DeptIds)
|
||||
}
|
||||
} else {
|
||||
ids, err := appEnterpriseDeptAndSubIds(ctx, user.TenantId, user.DeptId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mod = mod.WhereIn(dao.SysDept.Columns().DeptId, ids)
|
||||
}
|
||||
excluded, err := appEnterpriseCustomerExcludedDeptIds(ctx, user.TenantId, user.DeptId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
excludedIds := []int64{user.DeptId}
|
||||
for id := range excluded {
|
||||
excludedIds = append(excludedIds, id)
|
||||
}
|
||||
mod = mod.WhereNotIn(dao.SysDept.Columns().DeptId, excludedIds)
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
func appEnterpriseAuthorizedCustomerDepts(ctx context.Context, user *entity.SysUser) ([]*entity.SysDept, error) {
|
||||
mod, err := appEnterpriseCustomerQuery(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []*entity.SysDept
|
||||
err = mod.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
|
||||
}
|
||||
mod, err := appEnterpriseCustomerQuery(ctx, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
count, err := mod.Where(dao.SysDept.Columns().DeptId, deptId).Count()
|
||||
if err != nil {
|
||||
return false, gerror.Wrap(err, "校验企业客户范围失败")
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
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 appAccumulateCustomerStatusCount(counts []model.AppCustomerStatusCount, status, count int) []model.AppCustomerStatusCount {
|
||||
for index := range counts {
|
||||
if counts[index].Status == status {
|
||||
counts[index].Count += count
|
||||
return counts
|
||||
}
|
||||
}
|
||||
return append(counts, model.AppCustomerStatusCount{Status: status, Count: count})
|
||||
}
|
||||
|
||||
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).
|
||||
Fields("COALESCE("+cardTable+"."+dao.TSimCardInfo.Columns().BossCardStatus+", 0) AS boss_card_status").
|
||||
Fields("COALESCE("+cardTable+"."+dao.TSimCardInfo.Columns().PackageStatus+", -1) AS package_status").
|
||||
Where(cardTable+"."+dao.TSimCardInfo.Columns().TenantId, tenantId).
|
||||
WhereIn(cardTable+"."+dao.TSimCardInfo.Columns().DeptId, deptIds).
|
||||
Group(cardTable + "." + dao.TSimCardInfo.Columns().DeptId + ", COALESCE(" + cardTable + "." + dao.TSimCardInfo.Columns().BossCardStatus + ", 0), COALESCE(" + cardTable + "." + dao.TSimCardInfo.Columns().PackageStatus + ", -1)").
|
||||
OrderAsc("boss_card_status").
|
||||
OrderAsc("package_status").
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "统计企业客户卡数量失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row != nil {
|
||||
stat := result[row.DeptId]
|
||||
if stat == nil {
|
||||
stat = &appCustomerCardStat{DeptId: row.DeptId}
|
||||
result[row.DeptId] = stat
|
||||
}
|
||||
stat.CardCount += row.CardCount
|
||||
stat.NormalCardCount += row.NormalCardCount
|
||||
stat.CardStatusCounts = appAccumulateCustomerStatusCount(stat.CardStatusCounts, row.BossCardStatus, row.CardCount)
|
||||
stat.PackageStatusCounts = appAccumulateCustomerStatusCount(stat.PackageStatusCounts, row.PackageStatus, row.CardCount)
|
||||
}
|
||||
}
|
||||
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
|
||||
item.CardStatusCounts = stat.CardStatusCounts
|
||||
item.PackageStatusCounts = stat.PackageStatusCounts
|
||||
}
|
||||
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
|
||||
item.CardStatusCounts = stat.CardStatusCounts
|
||||
item.PackageStatusCounts = stat.PackageStatusCounts
|
||||
}
|
||||
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,148 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"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/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestAppCustomerScopeSQL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database := g.DB()
|
||||
t.Cleanup(func() {
|
||||
if err := database.GetCore().ClearCacheAll(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := database.Close(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := gdb.SetConfigGroup("default", previous); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := database.GetCore().ClearCacheAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements := []string{
|
||||
"CREATE TABLE sys_menu (menu_id INTEGER PRIMARY KEY, perms TEXT, status TEXT, deleted_at DATETIME)",
|
||||
"INSERT INTO sys_menu VALUES (1,'agent:dept:list','0',NULL)",
|
||||
"CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY, tenant_id TEXT, config_key TEXT, config_value TEXT, deleted_at DATETIME)",
|
||||
"INSERT INTO sys_config VALUES (1,'A','iot.register.app.dept','900',NULL)",
|
||||
"CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_type INTEGER, order_num INTEGER, dept_name TEXT, dept_key TEXT, status TEXT, deleted_at DATETIME)",
|
||||
"INSERT INTO sys_dept VALUES (100,'A',0,1,0,'顶级','TOP','0',NULL),(101,'A',100,1,1,'客户甲','A101','0',NULL),(102,'A',101,1,2,'客户乙','A102','1',NULL),(103,'A',100,0,3,'普通部门','D103','0',NULL),(104,'A',100,1,4,'已删除','A104','0','2026-01-01'),(200,'B',0,1,5,'其他租户','B200','0',NULL),(900,'A',100,1,6,'注册组织','R900','0',NULL),(901,'A',900,1,7,'注册下级','R901','0',NULL)",
|
||||
"CREATE TABLE sys_role_dept (role_id INTEGER, dept_id INTEGER, tenant_id TEXT)",
|
||||
"INSERT INTO sys_role_dept VALUES (9,102,'A'),(9,200,'B')",
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := database.Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Run("卡统计包含全部状态并隔离租户与删除记录", func(t *testing.T) {
|
||||
statements := []string{
|
||||
"CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, boss_card_status INTEGER, package_status INTEGER, deleted_at DATETIME)",
|
||||
"INSERT INTO t_sim_card_info VALUES (1,'A',101,2,2,NULL),(2,'A',101,2,3,NULL),(3,'A',101,4,2,NULL),(4,'A',101,1,0,NULL),(5,'A',101,6,0,NULL),(6,'A',101,7,0,NULL),(7,'A',101,8,0,NULL),(8,'A',101,9,0,NULL),(9,'A',101,NULL,NULL,NULL),(10,'A',101,88,0,NULL),(11,'A',101,2,2,'2026-01-01'),(12,'B',101,2,2,NULL),(13,'A',102,4,2,NULL)",
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := database.Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
stats, err := appCardStatsByDept(ctx, "A", []int64{101, 999})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stat := stats[101]
|
||||
if stat == nil || stat.CardCount != 10 || stat.NormalCardCount != 1 || len(stats) != 1 {
|
||||
t.Fatalf("unexpected stats: stat=%+v, departments=%d", stat, len(stats))
|
||||
}
|
||||
expected := []model.AppCustomerStatusCount{{Status: 0, Count: 1}, {Status: 1, Count: 1}, {Status: 2, Count: 2}, {Status: 4, Count: 1}, {Status: 6, Count: 1}, {Status: 7, Count: 1}, {Status: 8, Count: 1}, {Status: 9, Count: 1}, {Status: 88, Count: 1}}
|
||||
if !reflect.DeepEqual(stat.CardStatusCounts, expected) {
|
||||
t.Fatalf("actual=%+v expected=%+v", stat.CardStatusCounts, expected)
|
||||
}
|
||||
item := appEnterpriseCustomerModel(&entity.SysDept{DeptId: 101}, nil, 0, stat)
|
||||
if !reflect.DeepEqual(item.CardStatusCounts, expected) || item.CardCount != 10 {
|
||||
t.Fatalf("model lost card status counts: %+v", item)
|
||||
}
|
||||
packageCounts := make(map[int]int)
|
||||
for _, count := range stat.PackageStatusCounts {
|
||||
packageCounts[count.Status] = count.Count
|
||||
}
|
||||
if !reflect.DeepEqual(packageCounts, map[int]int{-1: 1, 0: 6, 2: 2, 3: 1}) {
|
||||
t.Fatalf("unexpected package counts: %+v", packageCounts)
|
||||
}
|
||||
if !reflect.DeepEqual(item.PackageStatusCounts, stat.PackageStatusCounts) {
|
||||
t.Fatalf("model lost package counts: %+v", item)
|
||||
}
|
||||
if _, err := database.Exec(ctx, "INSERT INTO t_sim_card_info VALUES (14,'A',105,2,2,NULL),(15,'A',105,2,2,NULL),(16,'A',105,2,2,NULL),(17,'A',105,2,2,NULL),(18,'A',105,2,2,NULL),(19,'A',105,2,2,NULL),(20,'A',105,2,3,NULL),(21,'A',105,2,3,NULL)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activatedStats, err := appCardStatsByDept(ctx, "A", []int64{105})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activated := activatedStats[105]
|
||||
if activated == nil || activated.CardCount != 8 || activated.NormalCardCount != 6 || !reflect.DeepEqual(activated.CardStatusCounts, []model.AppCustomerStatusCount{{Status: 2, Count: 8}}) {
|
||||
t.Fatalf("unexpected activated stats: %+v", activated)
|
||||
}
|
||||
if !reflect.DeepEqual(activated.PackageStatusCounts, []model.AppCustomerStatusCount{{Status: 2, Count: 6}, {Status: 3, Count: 2}}) {
|
||||
t.Fatalf("expired packages must not be counted as stopped cards: %+v", activated)
|
||||
}
|
||||
})
|
||||
user := &entity.SysUser{UserId: 7, TenantId: "A", DeptId: 100}
|
||||
cases := []struct {
|
||||
name, scope string
|
||||
expected []int64
|
||||
}{
|
||||
{"全部仍限制租户和企业类型", consts.SysRoleDataScopeAll, []int64{101, 102}},
|
||||
{"直属下级不含孙级", consts.SysRoleDataScopeOrgNextLevel, []int64{101}},
|
||||
{"本级及下级包含孙级", consts.SysRoleDataScopeDeptAndBelow, []int64{101, 102}},
|
||||
{"自定义指定孙级", consts.SysRoleDataScopeCustom, []int64{102}},
|
||||
{"仅本级没有客户", consts.SysRoleDataScopeDept, []int64{}},
|
||||
{"仅本人没有组织权限", consts.SysRoleDataScopePersonal, []int64{}},
|
||||
{"未知范围拒绝", "invalid", []int64{}},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
scopeCtx := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: user.UserId, TenantId: user.TenantId, DeptId: user.DeptId}}})
|
||||
contexts.SetDataValue(scopeCtx, "currentRoleId", "9")
|
||||
contexts.SetDataValue(scopeCtx, "currentRoleDataScope", item.scope)
|
||||
rows, err := appEnterpriseAuthorizedCustomerDepts(scopeCtx, user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual := appDeptIds(rows)
|
||||
if !reflect.DeepEqual(actual, item.expected) {
|
||||
t.Fatalf("actual=%v expected=%v", actual, item.expected)
|
||||
}
|
||||
for _, candidate := range []int64{100, 101, 102, 103, 104, 200, 900, 901, 999} {
|
||||
visible, err := appEnterpriseCustomerDeptVisible(scopeCtx, user, candidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expected := false
|
||||
for _, id := range item.expected {
|
||||
if candidate == id {
|
||||
expected = true
|
||||
}
|
||||
}
|
||||
if visible != expected {
|
||||
t.Fatalf("detail visibility for %d: actual=%v expected=%v", candidate, visible, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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"
|
||||
)
|
||||
|
||||
func TestAppCustomerSummary(t *testing.T) {
|
||||
items := make([]*model.AppCustomerModel, 0, 25)
|
||||
for index := 0; index < 25; index++ {
|
||||
items = append(items, &model.AppCustomerModel{Status: "0", RealNameStatus: "已录入实名资料"})
|
||||
}
|
||||
items[24].Status = "1"
|
||||
items[24].RealNameStatus = "实名资料待完善"
|
||||
items = append(items, nil)
|
||||
summary := appCustomerSummary(items)
|
||||
if summary.Total != 25 || summary.NormalCount != 24 || summary.PendingProfileCount != 1 {
|
||||
t.Fatalf("unexpected summary: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCustomerSummaryEmpty(t *testing.T) {
|
||||
summary := appCustomerSummary(nil)
|
||||
if summary.Total != 0 || summary.NormalCount != 0 || summary.PendingProfileCount != 0 {
|
||||
t.Fatalf("unexpected empty summary: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCustomerFilterKeepsAuthorizedScope(t *testing.T) {
|
||||
depts := []*entity.SysDept{
|
||||
{DeptId: 101, DeptName: "企业甲", DeptKey: "A101", Status: "0"},
|
||||
{DeptId: 102, DeptName: "企业乙", DeptKey: "A102", Status: "1"},
|
||||
nil,
|
||||
}
|
||||
cases := []struct {
|
||||
name, keyword, status string
|
||||
want int64
|
||||
}{
|
||||
{"按编码搜索", "A101", "", 101},
|
||||
{"按企业名称搜索", "企业乙", "", 102},
|
||||
{"仅停用客户", "", "1", 102},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
rows := appFilterEnterpriseDepts(depts, item.keyword, item.status)
|
||||
if len(rows) != 1 || rows[0].DeptId != item.want {
|
||||
t.Fatalf("unexpected result: %+v", rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
if rows := appFilterEnterpriseDepts(depts, "A101", "1"); len(rows) != 0 {
|
||||
t.Fatalf("combined filters must intersect: %+v", rows)
|
||||
}
|
||||
if rows := appFilterEnterpriseDepts(depts, "未授权企业", ""); len(rows) != 0 {
|
||||
t.Fatalf("search must not expand candidate scope: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCustomerPage(t *testing.T) {
|
||||
items := make([]*model.AppCustomerModel, 25)
|
||||
for index := range items {
|
||||
items[index] = &model.AppCustomerModel{CustomerId: int64(index + 1)}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
page, size, count int
|
||||
first int64
|
||||
}{
|
||||
{"首页", 1, 20, 20, 1},
|
||||
{"末页", 2, 20, 5, 21},
|
||||
{"越界页", 3, 20, 0, 0},
|
||||
{"最大页码", int(^uint(0) >> 1), 20, 0, 0},
|
||||
{"最大页容量", 1, int(^uint(0) >> 1), 25, 1},
|
||||
{"无效页", 0, 20, 0, 0},
|
||||
{"无效容量", 1, 0, 0, 0},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
rows := appCustomerPage(items, item.page, item.size)
|
||||
if len(rows) != item.count {
|
||||
t.Fatalf("count=%d want=%d", len(rows), item.count)
|
||||
}
|
||||
if len(rows) > 0 && rows[0].CustomerId != item.first {
|
||||
t.Fatalf("first=%d want=%d", rows[0].CustomerId, item.first)
|
||||
}
|
||||
})
|
||||
}
|
||||
if rows := appCustomerPage(nil, 1, 20); rows == nil || len(rows) != 0 {
|
||||
t.Fatal("empty result must serialize as an array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCustomerRoleScope(t *testing.T) {
|
||||
user := &entity.SysUser{UserId: 7, DeptId: 101, TenantId: "A"}
|
||||
cases := []struct {
|
||||
name, scope string
|
||||
all bool
|
||||
deptCount, userCount int
|
||||
}{
|
||||
{"全部数据", consts.SysRoleDataScopeAll, true, 0, 0},
|
||||
{"仅本级", consts.SysRoleDataScopeDept, false, 1, 0},
|
||||
{"仅本人", consts.SysRoleDataScopePersonal, false, 0, 1},
|
||||
{"未知权限拒绝", "invalid", false, 0, 0},
|
||||
}
|
||||
for _, item := range cases {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
scope, err := appBuildEnterpriseDataScope(context.Background(), user, 1, item.scope, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scope.All != item.all || len(scope.DeptIds) != item.deptCount || len(scope.UserIds) != item.userCount {
|
||||
t.Fatalf("unexpected scope: %+v", scope)
|
||||
}
|
||||
if !scope.All && !scope.Restricted {
|
||||
t.Fatal("non-all scope must remain restricted")
|
||||
}
|
||||
if len(scope.DeptIds) > 0 && scope.DeptIds[0] != user.DeptId {
|
||||
t.Fatal("wrong dept scope")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,103 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestAppDiagnosisUpstreamContract(t *testing.T) {
|
||||
scenarios := []string{"success", "session failure with IMEI", "IMEI failure with session"}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
seen := map[string]int{}
|
||||
responses := map[string]string{
|
||||
"sim-basic-info": `{"simStatus":"1","iccid":"card","imsi":"imsi","msisdn":"msisdn"}`,
|
||||
"sim-status": `{"cardStatus":4}`,
|
||||
"sim-session": `{"status":"0","apnId":"CMIOT","ipv4":"192.0.2.10"}`,
|
||||
"sim-imei": `{"imei":"latest"}`,
|
||||
"sim-device-status": `{"deviceStatus":"1","operatingStatus":"1"}`,
|
||||
"sim-card-bind-status": `{"bindStatus":"1","orderStatus":"0"}`,
|
||||
"sim-region-limit-status": `{"regionLimitStatus":"0","orderStatus":"1"}`,
|
||||
"sim-data-diagnosis": `{"status":"0","message":"正确","result":[{"APNList":{"APNInfo":[{"APNName":"CMIOT","modifyTime":"20250526140211","APNStatus":"2","APNServiceUsageState":"1"}]}}]}`,
|
||||
"sim-stop-reason": `{"stopReason":"000000000000"}`,
|
||||
"sim-frozen-state": `{"frozenStatus":"0"}`,
|
||||
"sim-datausage": `{"dataUsage":0}`,
|
||||
"sim-apn-info": `{"apnList":[{"apnName":"CMIOT","status":"0"}]}`,
|
||||
}
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
const prefix = "/internal/app-diagnosis/sim/query/"
|
||||
if !strings.HasPrefix(request.URL.Path, prefix) || request.Header.Get("X-App-Diagnosis-Token") != "test-token" {
|
||||
t.Errorf("proxy contract mismatch: %s", request.URL.Path)
|
||||
}
|
||||
var input appUpstreamSimQueryInput
|
||||
if err := json.NewDecoder(request.Body).Decode(&input); err != nil || len(input.SimKeys) != 1 || input.SimKeys[0].ICCID != "card" || input.SimKeys[0].ChanKey != "test-channel" {
|
||||
t.Errorf("query key mismatch: %+v, %v", input, err)
|
||||
}
|
||||
endpoint := strings.TrimPrefix(request.URL.Path, prefix)
|
||||
mu.Lock()
|
||||
seen[endpoint]++
|
||||
mu.Unlock()
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
fail := scenario == "session failure with IMEI" && endpoint == "sim-session" || scenario == "IMEI failure with session" && endpoint == "sim-imei"
|
||||
if fail {
|
||||
_, _ = writer.Write([]byte(`{"code":0,"data":[{"code":500,"message":"query failed"}]}`))
|
||||
return
|
||||
}
|
||||
raw, ok := responses[endpoint]
|
||||
if !ok {
|
||||
t.Errorf("unknown endpoint: %s", endpoint)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(writer, `{"code":0,"data":[{"code":0,"resData":%s}]}`, raw)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"default":{"clientName":%q,"timeout":"2s"},"appDiagnosisToken":"test-token","onelinkMock":{"enabled":true}}}`, proxy.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
row := &appCardQueryRow{TSimCardInfo: entity.TSimCardInfo{Id: 1, Iccid: "card", Imsi: "imsi", Msisdn: "msisdn", ChanKey: "test-channel", BossCardStatus: 2, DataServiceStatus: 1, Imei: "local-old"}}
|
||||
result, err := NewAppDiagnosis().buildDiagnosisInfo(context.Background(), row, &model.AppCardModel{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Info.Status == nil || *result.Info.Status != "4" {
|
||||
t.Fatal("card status did not come from sim-status")
|
||||
}
|
||||
if result.Info.DataStatus == nil || *result.Info.DataStatus != "0" {
|
||||
t.Fatal("APN status did not come from sim-apn-info")
|
||||
}
|
||||
if result.Info.UsedFlowMb == nil || *result.Info.UsedFlowMb != 0 {
|
||||
t.Fatal("zero usage was lost")
|
||||
}
|
||||
if result.Info.RateLimitStatus == nil || *result.Info.RateLimitStatus != "1" || len(result.Info.RateLimitApnDetails) != 1 {
|
||||
t.Fatal("mobile APN policy response was not normalized")
|
||||
}
|
||||
if scenario == "session failure with IMEI" {
|
||||
if result.Modules["session"].Status != "error" || result.Info.OnlineStatus != nil || result.Session.Imei == nil || *result.Session.Imei != "latest" {
|
||||
t.Fatal("session failure was masked or IMEI was not independent")
|
||||
}
|
||||
} else if scenario == "IMEI failure with session" {
|
||||
if result.Modules["imei"].Status != "error" || result.Modules["session"].Status != "success" || result.Session.Imei != nil {
|
||||
t.Fatal("IMEI failure fell back to another source")
|
||||
}
|
||||
} else if result.Info.OnlineStatus == nil || *result.Info.OnlineStatus != "1" {
|
||||
t.Fatal("online status did not come from session")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestDiagnosisTopLevelEnterprise(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "diagnosis.db")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := g.DB()
|
||||
_ = db.GetCore().ClearCacheAll(ctx)
|
||||
t.Cleanup(func() {
|
||||
_ = db.GetCore().ClearCacheAll(ctx)
|
||||
_ = db.Close(ctx)
|
||||
_ = gdb.SetConfigGroup("default", previous)
|
||||
})
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE sys_dept (dept_id INTEGER, tenant_id TEXT, parent_id INTEGER, status TEXT, deleted_at DATETIME)",
|
||||
"INSERT INTO sys_dept VALUES (100,'A',0,'0',NULL),(200,'A',100,'0',NULL),(300,'A',200,'0',NULL),(400,'B',0,'0',NULL),(500,'A',0,'1',NULL),(600,'A',0,'0','2026-01-01')",
|
||||
} {
|
||||
if _, err := db.Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dept int64
|
||||
want bool
|
||||
}{
|
||||
{"顶级企业运营人员", 100, true}, {"下级管理员", 200, false}, {"多层企业", 300, false},
|
||||
{"跨租户机构", 400, false}, {"停用机构", 500, false}, {"删除机构", 600, false}, {"缺失机构", 700, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := appIsTopLevelEnterprise(ctx, &entity.SysUser{UserId: 20, TenantId: "A", DeptId: tc.dept, UserType: "sys_user"})
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("got %v, err %v; want %v", got, err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
got, err := appIsTopLevelEnterprise(ctx, &entity.SysUser{UserId: 20, TenantId: "A", DeptId: 100, UserType: "app_user"})
|
||||
if err != nil || got {
|
||||
t.Fatal("终端不能使用顶级企业展示", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type legacyDiagnosisCard struct {
|
||||
ICCID string `orm:"iccid"`
|
||||
IMSI string `orm:"imsi"`
|
||||
MSISDN string `orm:"msisdn"`
|
||||
ChannelKey string `orm:"channel_id"`
|
||||
}
|
||||
|
||||
// Legacy channel_id references yz_card_route.cd_id, which maps to chan_key.
|
||||
// The legacy route ID is not the new platform t_sim_channels.id.
|
||||
func queryLegacyDiagnosisCard(ctx context.Context, code string) (*legacyDiagnosisCard, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.TrimSpace(g.Cfg().MustGet(ctx, "database.legacyCard.link").String()) == "" {
|
||||
return nil, gerror.New("旧平台只读数据库未配置")
|
||||
}
|
||||
var cards []*legacyDiagnosisCard
|
||||
err := g.DB("legacyCard").Model("yz_card_info c").Ctx(ctx).
|
||||
InnerJoin("yz_card_route r", "r.cd_id = c.channel_id").
|
||||
Fields("c.iccid,c.imsi,c.msisdn,r.cd_id AS channel_id").
|
||||
Where("c.iccid = ? OR c.msisdn = ? OR c.imsi = ?", code, code, code).
|
||||
Limit(2).Scan(&cards)
|
||||
if err != nil {
|
||||
return nil, gerror.New("旧平台卡信息查询失败,请稍后重试")
|
||||
}
|
||||
if len(cards) > 1 {
|
||||
return nil, gerror.New("卡号对应多张卡,无法确定通道")
|
||||
}
|
||||
if len(cards) == 0 || strings.TrimSpace(cards[0].ChannelKey) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return cards[0], nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
)
|
||||
|
||||
func TestDiagnosisPrefersPlatformChannelWithoutLegacyDB(t *testing.T) {
|
||||
if os.Getenv("IOTLCM_TEST_LOCAL_READONLY") != "1" {
|
||||
t.Skip("set IOTLCM_TEST_LOCAL_READONLY=1 for the local read-only DB integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
link := g.Cfg().MustGet(ctx, "database.default.link").String()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"database":{"default":{"link":%q},"legacyCard":{"link":""}}}`, link))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
|
||||
row := &appCardQueryRow{TSimCardInfo: entity.TSimCardInfo{Id: 1, TenantId: "000000", Iccid: "898608771525C0625498", ChanKey: "129"}}
|
||||
if err = NewAppDiagnosis().resolveDiagnosisCardChannel(ctx, NewAppCard(), row, row.Iccid, "查询诊断卡片"); err != nil {
|
||||
t.Fatalf("platform channel should not need the legacy DB: %v", err)
|
||||
}
|
||||
if row.ChanKey != "129" || row.TempKey == "" {
|
||||
t.Fatalf("unexpected platform channel: key=%q template=%q", row.ChanKey, row.TempKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyDiagnosisCardRejectsMissingReadOnlyDatabase(t *testing.T) {
|
||||
adapter, err := gcfg.NewAdapterContent(`{"database":{"legacyCard":{"link":""}}}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
card, err := queryLegacyDiagnosisCard(context.Background(), "89860401788250435125")
|
||||
if card != nil || err == nil || !strings.Contains(err.Error(), "旧平台只读数据库未配置") {
|
||||
t.Fatalf("missing read-only config: card=%+v err=%v", card, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleChannelAvailability(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info *appCardChannelInfo
|
||||
want bool
|
||||
}{
|
||||
{name: "enabled channel", info: &appCardChannelInfo{ChanKey: "129", ChanStatus: 0}, want: true},
|
||||
{name: "disabled channel", info: &appCardChannelInfo{ChanKey: "129", ChanStatus: 1}},
|
||||
{name: "missing channel key", info: &appCardChannelInfo{ChanStatus: 0}},
|
||||
{name: "missing channel"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := appCardChannelAvailable(test.info); got != test.want {
|
||||
t.Fatalf("appCardChannelAvailable() = %t, want %t", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnosisRangeAssociationWithoutLegacyDatabase(t *testing.T) {
|
||||
if os.Getenv("IOTLCM_TEST_LOCAL_READONLY") != "1" {
|
||||
t.Skip("set IOTLCM_TEST_LOCAL_READONLY=1 for the local read-only DB integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
const iccid = "898608141623D0229706"
|
||||
cardLogic := NewAppCard()
|
||||
row, err := NewAppDiagnosis().resolveAssocOnlyDiagnosisCard(ctx, cardLogic, "000000", iccid, "查询诊断卡片")
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("range association should work without legacy database: row=%+v err=%v", row, err)
|
||||
}
|
||||
if row.ChanKey != "28" || row.TempKey != "sim_yd_v5" || row.Id != 0 {
|
||||
t.Fatalf("unexpected range association: %+v", row)
|
||||
}
|
||||
if err = NewAppDiagnosis().resolveDiagnosisCardChannel(ctx, cardLogic, row, iccid, "查询诊断卡片"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyDiagnosisCardReadOnlyIntegration(t *testing.T) {
|
||||
if os.Getenv("IOTLCM_TEST_LEGACY_READONLY") != "1" {
|
||||
t.Skip("set IOTLCM_TEST_LEGACY_READONLY=1 for the read-only legacy DB integration test")
|
||||
}
|
||||
for _, test := range []struct {
|
||||
iccid string
|
||||
chanKey string
|
||||
}{
|
||||
{iccid: "898608771525C0625498", chanKey: "129"},
|
||||
{iccid: "898604F4152391115474", chanKey: "112"},
|
||||
{iccid: "8986112223600594371", chanKey: "71"},
|
||||
} {
|
||||
t.Run(test.iccid, func(t *testing.T) {
|
||||
card, err := queryLegacyDiagnosisCard(context.Background(), test.iccid)
|
||||
if err != nil {
|
||||
t.Fatalf("read-only legacy card lookup: %v", err)
|
||||
}
|
||||
if card == nil || card.ICCID != test.iccid || card.ChannelKey != test.chanKey {
|
||||
t.Fatalf("unexpected legacy channel mapping: card=%+v, want channel %q", card, test.chanKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
)
|
||||
|
||||
func appDiagnosisApplyNetworkBlock(info *model.AppDiagnosisInfoItem, raw json.RawMessage) bool {
|
||||
var data struct {
|
||||
NetBlockStatus *string `json:"netBlockStatus"`
|
||||
NetBlockTypes []struct {
|
||||
BlockType string `json:"blockType"`
|
||||
BlockTypeName string `json:"blockTypeName"`
|
||||
} `json:"netBlockTypes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &data); err != nil || data.NetBlockStatus == nil {
|
||||
return false
|
||||
}
|
||||
status := strings.TrimSpace(*data.NetBlockStatus)
|
||||
if status != "0" && status != "1" {
|
||||
return false
|
||||
}
|
||||
info.NetBlockStatus = appDiagnosisStringPtr(status)
|
||||
info.NetBlockReason = nil
|
||||
if status == "1" {
|
||||
names := make([]string, 0, len(data.NetBlockTypes))
|
||||
seen := make(map[string]bool)
|
||||
for _, item := range data.NetBlockTypes {
|
||||
name := strings.TrimSpace(item.BlockTypeName)
|
||||
if item.BlockType != "0" && name != "" && !seen[name] {
|
||||
names = append(names, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
info.NetBlockReason = appDiagnosisStringPtr(strings.Join(names, "、"))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
)
|
||||
|
||||
func TestAppDiagnosisNetworkBlock(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw string
|
||||
status string
|
||||
reason string
|
||||
}{
|
||||
{raw: `{"netBlockStatus":"1","netBlockTypes":[{"blockType":"1","blockTypeName":"达量断网"},{"blockType":"5","blockTypeName":"区域限制断网"},{"blockType":"1","blockTypeName":"达量断网"}]}`, status: "1", reason: "达量断网、区域限制断网"},
|
||||
{raw: `{"netBlockStatus":"0","netBlockTypes":[{"blockType":"0","blockTypeName":"未断网"}]}`, status: "0"},
|
||||
{raw: `{"netBlockStatus":"1"}`, status: "1"},
|
||||
{raw: `{}`}, {raw: `{"netBlockStatus":"2"}`}, {raw: `{"netBlockStatus":null}`}, {raw: `invalid`},
|
||||
}
|
||||
for index, test := range tests {
|
||||
info := &model.AppDiagnosisInfoItem{DataServiceSource: "network_block"}
|
||||
ok := appDiagnosisApplyNetworkBlock(info, json.RawMessage(test.raw))
|
||||
if ok != (test.status != "") || info.DataStatus != nil {
|
||||
t.Fatalf("case %d: ok=%v, info=%+v", index, ok, info)
|
||||
}
|
||||
if test.status == "" {
|
||||
if info.NetBlockStatus != nil {
|
||||
t.Fatalf("case %d: unexpected status", index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if info.NetBlockStatus == nil || *info.NetBlockStatus != test.status {
|
||||
t.Fatalf("case %d: status = %v", index, info.NetBlockStatus)
|
||||
}
|
||||
if test.reason == "" {
|
||||
if info.NetBlockReason != nil {
|
||||
t.Fatalf("case %d: unexpected reason", index)
|
||||
}
|
||||
} else if info.NetBlockReason == nil || *info.NetBlockReason != test.reason {
|
||||
t.Fatalf("case %d: reason=%v", index, info.NetBlockReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
)
|
||||
|
||||
type appYdDataDiagnosisResp struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []appYdDataDiagnosisResult `json:"result"`
|
||||
}
|
||||
|
||||
type appYdDataDiagnosisResult struct {
|
||||
ApnList json.RawMessage `json:"apnList"`
|
||||
}
|
||||
|
||||
type appYdDataDiagnosisApnInfo struct {
|
||||
ApnName string `json:"apnName"`
|
||||
ApnStatus string `json:"apnStatus"`
|
||||
ModifyTime string `json:"modifyTime"`
|
||||
OverUseDetails string `json:"overUseDetails"`
|
||||
ApnServiceUsageState string `json:"apnServiceUsageState"`
|
||||
PccList json.RawMessage `json:"pccList"`
|
||||
}
|
||||
|
||||
type appYdDataDiagnosisPccInfo struct {
|
||||
PccServiceCode string `json:"pccServiceCode"`
|
||||
PccName string `json:"pccName"`
|
||||
PccNameLower string `json:"pccname"`
|
||||
PccPolicyStatus string `json:"pccPolicyStatus"`
|
||||
PccServiceUsageState string `json:"pccServiceUsageState"`
|
||||
ModifyTime string `json:"modifyTime"`
|
||||
}
|
||||
|
||||
func appParseYdDataDiagnosisBody(body json.RawMessage) (appYdDataDiagnosisItem, error) {
|
||||
var ydResp appYdDataDiagnosisResp
|
||||
if err := json.Unmarshal(body, &ydResp); err != nil {
|
||||
return appYdDataDiagnosisItem{}, err
|
||||
}
|
||||
return appBuildYdDataDiagnosisItem(ydResp.Result), nil
|
||||
}
|
||||
|
||||
func appBuildYdDataDiagnosisItem(results []appYdDataDiagnosisResult) appYdDataDiagnosisItem {
|
||||
apnInfos := make([]appYdDataDiagnosisApnInfo, 0)
|
||||
for _, result := range results {
|
||||
apnInfos = append(apnInfos, appDecodeYdDataDiagnosisApnInfos(result.ApnList)...)
|
||||
}
|
||||
if len(apnInfos) == 0 {
|
||||
return appYdDataDiagnosisItem{
|
||||
RateLimitStatus: "2",
|
||||
RateLimitReason: "上游未返回达量限速明细",
|
||||
}
|
||||
}
|
||||
|
||||
triggered := false
|
||||
reasons := make([]string, 0)
|
||||
details := make([]string, 0, len(apnInfos))
|
||||
apnDetails := make([]model.AppDiagnosisRateLimitApnItem, 0, len(apnInfos))
|
||||
|
||||
for _, apn := range apnInfos {
|
||||
apnName := strings.TrimSpace(apn.ApnName)
|
||||
displayApn := apnName
|
||||
if displayApn == "" {
|
||||
displayApn = "未命名APN"
|
||||
}
|
||||
|
||||
if overUseText := appMapYdOverUseDetailsText(apn.OverUseDetails); overUseText != "" {
|
||||
triggered = true
|
||||
reasons = append(reasons, fmt.Sprintf("%s:%s", displayApn, overUseText))
|
||||
}
|
||||
if stateText, limited := appMapYdUsageStateText(apn.ApnServiceUsageState); limited {
|
||||
triggered = true
|
||||
reasons = append(reasons, fmt.Sprintf("%s:%s", displayApn, stateText))
|
||||
}
|
||||
|
||||
pccInfos := appDecodeYdDataDiagnosisPccInfos(apn.PccList)
|
||||
pccDetails := make([]model.AppDiagnosisRateLimitPccItem, 0, len(pccInfos))
|
||||
pccText := make([]string, 0, len(pccInfos))
|
||||
for _, pcc := range pccInfos {
|
||||
pccName := appFirstNonEmpty(pcc.PccName, pcc.PccNameLower, pcc.PccServiceCode)
|
||||
if pccName == "" {
|
||||
pccName = "未命名PCC策略"
|
||||
}
|
||||
if stateText, limited := appMapYdUsageStateText(pcc.PccServiceUsageState); limited {
|
||||
triggered = true
|
||||
reasons = append(reasons, fmt.Sprintf("%s/%s:%s", displayApn, pccName, stateText))
|
||||
}
|
||||
pccText = append(pccText, fmt.Sprintf("%s %s %s", pccName, appMapYdPccPolicyStatusText(pcc.PccPolicyStatus), appMapYdUsageStateTextForDetail(pcc.PccServiceUsageState)))
|
||||
pccDetails = append(pccDetails, model.AppDiagnosisRateLimitPccItem{
|
||||
PccServiceCode: strings.TrimSpace(pcc.PccServiceCode),
|
||||
PccName: appFirstNonEmpty(pcc.PccName, pcc.PccNameLower),
|
||||
PccPolicyStatus: strings.TrimSpace(pcc.PccPolicyStatus),
|
||||
PccServiceUsageState: strings.TrimSpace(pcc.PccServiceUsageState),
|
||||
ModifyTime: strings.TrimSpace(pcc.ModifyTime),
|
||||
})
|
||||
}
|
||||
|
||||
detailParts := []string{fmt.Sprintf("%s %s %s", displayApn, appMapYdApnStatusText(apn.ApnStatus), appMapYdUsageStateTextForDetail(apn.ApnServiceUsageState))}
|
||||
if overUseText := appMapYdOverUseDetailsText(apn.OverUseDetails); overUseText != "" {
|
||||
detailParts = append(detailParts, "超停原因:"+overUseText)
|
||||
}
|
||||
if len(pccText) > 0 {
|
||||
detailParts = append(detailParts, "PCC:"+strings.Join(pccText, ";"))
|
||||
}
|
||||
details = append(details, strings.Join(detailParts, ";"))
|
||||
apnDetails = append(apnDetails, model.AppDiagnosisRateLimitApnItem{
|
||||
ApnName: apnName,
|
||||
ApnStatus: strings.TrimSpace(apn.ApnStatus),
|
||||
ModifyTime: strings.TrimSpace(apn.ModifyTime),
|
||||
OverUseDetails: strings.TrimSpace(apn.OverUseDetails),
|
||||
ApnServiceUsageState: strings.TrimSpace(apn.ApnServiceUsageState),
|
||||
PccDetails: pccDetails,
|
||||
})
|
||||
}
|
||||
|
||||
status := "1"
|
||||
reason := "未发现达量限速或流量关停"
|
||||
if triggered {
|
||||
status = "0"
|
||||
reason = strings.Join(appUniqueNonEmptyStrings(reasons), ";")
|
||||
}
|
||||
return appYdDataDiagnosisItem{
|
||||
RateLimitStatus: status,
|
||||
RateLimitReason: reason,
|
||||
RateLimitDetail: strings.Join(appUniqueNonEmptyStrings(details), ";"),
|
||||
ApnDetails: apnDetails,
|
||||
}
|
||||
}
|
||||
|
||||
func appDecodeYdDataDiagnosisApnInfos(raw json.RawMessage) []appYdDataDiagnosisApnInfo {
|
||||
raw = appTrimJSONRaw(raw)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '{' {
|
||||
var wrapper struct {
|
||||
ApnInfo json.RawMessage `json:"apnInfo"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapper) == nil && len(appTrimJSONRaw(wrapper.ApnInfo)) > 0 {
|
||||
return appDecodeYdDataDiagnosisApnInfoList(wrapper.ApnInfo)
|
||||
}
|
||||
var item appYdDataDiagnosisApnInfo
|
||||
if json.Unmarshal(raw, &item) == nil {
|
||||
return []appYdDataDiagnosisApnInfo{item}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var wrappers []struct {
|
||||
ApnInfo json.RawMessage `json:"apnInfo"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrappers) == nil {
|
||||
items := make([]appYdDataDiagnosisApnInfo, 0)
|
||||
for _, wrapper := range wrappers {
|
||||
items = append(items, appDecodeYdDataDiagnosisApnInfoList(wrapper.ApnInfo)...)
|
||||
}
|
||||
if len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return appDecodeYdDataDiagnosisApnInfoList(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appDecodeYdDataDiagnosisApnInfoList(raw json.RawMessage) []appYdDataDiagnosisApnInfo {
|
||||
raw = appTrimJSONRaw(raw)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var items []appYdDataDiagnosisApnInfo
|
||||
if json.Unmarshal(raw, &items) == nil && len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
if raw[0] == '{' {
|
||||
var item appYdDataDiagnosisApnInfo
|
||||
if json.Unmarshal(raw, &item) == nil {
|
||||
return []appYdDataDiagnosisApnInfo{item}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appDecodeYdDataDiagnosisPccInfos(raw json.RawMessage) []appYdDataDiagnosisPccInfo {
|
||||
raw = appTrimJSONRaw(raw)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '{' {
|
||||
var wrapper struct {
|
||||
PccInfo json.RawMessage `json:"pccInfo"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapper) == nil && len(appTrimJSONRaw(wrapper.PccInfo)) > 0 {
|
||||
return appDecodeYdDataDiagnosisPccInfoList(wrapper.PccInfo)
|
||||
}
|
||||
var item appYdDataDiagnosisPccInfo
|
||||
if json.Unmarshal(raw, &item) == nil {
|
||||
return []appYdDataDiagnosisPccInfo{item}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var wrappers []struct {
|
||||
PccInfo json.RawMessage `json:"pccInfo"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrappers) == nil {
|
||||
items := make([]appYdDataDiagnosisPccInfo, 0)
|
||||
for _, wrapper := range wrappers {
|
||||
items = append(items, appDecodeYdDataDiagnosisPccInfoList(wrapper.PccInfo)...)
|
||||
}
|
||||
if len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return appDecodeYdDataDiagnosisPccInfoList(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appDecodeYdDataDiagnosisPccInfoList(raw json.RawMessage) []appYdDataDiagnosisPccInfo {
|
||||
raw = appTrimJSONRaw(raw)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if raw[0] == '[' {
|
||||
var items []appYdDataDiagnosisPccInfo
|
||||
if json.Unmarshal(raw, &items) == nil && len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
if raw[0] == '{' {
|
||||
var item appYdDataDiagnosisPccInfo
|
||||
if json.Unmarshal(raw, &item) == nil {
|
||||
return []appYdDataDiagnosisPccInfo{item}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appTrimJSONRaw(raw json.RawMessage) json.RawMessage {
|
||||
return json.RawMessage(strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
func appFirstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func appUniqueNonEmptyStrings(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func appMapYdOverUseDetailsText(value string) string {
|
||||
parts := strings.FieldsFunc(strings.TrimSpace(value), func(r rune) bool {
|
||||
return r == '|' || r == ',' || r == ';' || r == ',' || r == ';'
|
||||
})
|
||||
texts := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
switch strings.TrimSpace(part) {
|
||||
case "12":
|
||||
texts = append(texts, "流量用尽关停")
|
||||
case "17":
|
||||
texts = append(texts, "限额管控关停")
|
||||
case "18":
|
||||
texts = append(texts, "超量关停")
|
||||
case "":
|
||||
default:
|
||||
texts = append(texts, strings.TrimSpace(part))
|
||||
}
|
||||
}
|
||||
return strings.Join(appUniqueNonEmptyStrings(texts), ";")
|
||||
}
|
||||
|
||||
func appMapYdUsageStateText(value string) (text string, limited bool) {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "", "1":
|
||||
return "正常速率", false
|
||||
case "91":
|
||||
return "限速至2Mbps(月初不自动恢复)", true
|
||||
case "92":
|
||||
return "限速至2Mbps(月初自动恢复)", true
|
||||
case "95":
|
||||
return "限速至1Mbps(月初不自动恢复)", true
|
||||
case "96":
|
||||
return "限速至1Mbps(月初自动恢复)", true
|
||||
case "97":
|
||||
return "达量限速2M", true
|
||||
case "98":
|
||||
return "达量限速1M", true
|
||||
default:
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, value != "0"
|
||||
}
|
||||
}
|
||||
|
||||
func appMapYdUsageStateTextForDetail(value string) string {
|
||||
text, _ := appMapYdUsageStateText(value)
|
||||
return text
|
||||
}
|
||||
|
||||
func appMapYdApnStatusText(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "", "0", "1":
|
||||
return "APN已停用"
|
||||
case "2":
|
||||
return "APN已激活"
|
||||
case "3":
|
||||
return "APN已删除"
|
||||
case "4":
|
||||
return "APN已暂停"
|
||||
default:
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
func appMapYdPccPolicyStatusText(value string) string {
|
||||
switch strings.TrimSpace(value) {
|
||||
case "", "0", "1":
|
||||
return "策略已停用"
|
||||
case "2":
|
||||
return "策略已激活"
|
||||
case "3":
|
||||
return "策略已删除"
|
||||
case "4":
|
||||
return "策略已暂停"
|
||||
default:
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
|
||||
type appYdDataDiagnosisItem struct {
|
||||
RateLimitStatus string `json:"rateLimitStatus"`
|
||||
RateLimitReason string `json:"rateLimitReason"`
|
||||
RateLimitDetail string `json:"rateLimitDetail"`
|
||||
ApnDetails []model.AppDiagnosisRateLimitApnItem `json:"apnDetails"`
|
||||
}
|
||||
|
||||
func appDiagnosisNormalizeRateLimitBody(raw json.RawMessage) json.RawMessage {
|
||||
if appDiagnosisRawString(raw, "rateLimitStatus", "rate_limit_status") != "" || len(appDiagnosisRateLimitApnDetails(raw)) > 0 {
|
||||
return raw
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if json.Unmarshal(raw, &payload) != nil || len(payload["result"]) == 0 {
|
||||
return raw
|
||||
}
|
||||
parsed, err := appParseYdDataDiagnosisBody(raw)
|
||||
if err != nil || len(parsed.ApnDetails) == 0 {
|
||||
return raw
|
||||
}
|
||||
normalized, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
)
|
||||
|
||||
func TestAppDiagnosisDataServiceStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "disabled", raw: "0", want: "0"},
|
||||
{name: "enabled", raw: "1", want: "1"},
|
||||
{name: "unknown", raw: "2", want: "-1"},
|
||||
{name: "empty", raw: "", want: ""},
|
||||
{name: "invalid", raw: "unexpected", want: ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := appDiagnosisDataServiceStatus(test.raw)
|
||||
if test.want == "" {
|
||||
if got != nil {
|
||||
t.Fatalf("appDiagnosisDataServiceStatus(%q) = %q, want nil", test.raw, *got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || *got != test.want {
|
||||
t.Fatalf("appDiagnosisDataServiceStatus(%q) = %v, want %q", test.raw, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisDataServiceStatusFromApn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw json.RawMessage
|
||||
want string
|
||||
}{
|
||||
{name: "mobile APN enabled", raw: json.RawMessage(`{"apnList":[{"apnName":"CMIOT","status":"1"}]}`), want: "1"},
|
||||
{name: "mobile APN disabled", raw: json.RawMessage(`{"apnList":[{"apnName":"CMIOT","status":"0"}]}`), want: "0"},
|
||||
{name: "ignore unrelated APN", raw: json.RawMessage(`{"apnList":[{"apnName":"OTHER","status":"0"},{"apnName":"cmiot","status":"1"}]}`), want: "1"},
|
||||
{name: "no APN entry", raw: json.RawMessage(`{"status":"1","apnList":[]}`)},
|
||||
{name: "other APN only", raw: json.RawMessage(`{"apnList":[{"apnName":"OTHER","status":"1"}]}`)},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := appDiagnosisDataServiceStatusFromApn(test.raw)
|
||||
if test.want == "" {
|
||||
if got != nil {
|
||||
t.Fatalf("status = %q, want no data", *got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || *got != test.want {
|
||||
t.Fatalf("status = %v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisDataServiceStatusFromItems(t *testing.T) {
|
||||
items := []appUpstreamSimQueryItem{
|
||||
{Code: 0, ResData: json.RawMessage(`{"apnList":[{"apnName":"OTHER","status":"0"}]}`)},
|
||||
{Code: 5000, ResData: json.RawMessage(`{"apnList":[{"apnName":"CMIOT","status":"0"}]}`)},
|
||||
{Code: 0, ResData: json.RawMessage(`{"apnList":[{"apnName":"CMIOT","status":"1"}]}`)},
|
||||
}
|
||||
status := appDiagnosisDataServiceStatusFromItems(items)
|
||||
if status == nil || *status != "1" {
|
||||
t.Fatalf("status = %v, want enabled CMIOT from successful upstream item", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisDataServiceStatusForCarrier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
chanKey string
|
||||
raw json.RawMessage
|
||||
want string
|
||||
}{
|
||||
{name: "mobile only reads CMIOT", tempKey: "sim_yd_v5", raw: json.RawMessage(`{"apnList":[{"apnName":"OTHER","status":"1"}]}`)},
|
||||
{name: "telecom reads returned DNN", tempKey: "sim_dx_v1", raw: json.RawMessage(`{"apnList":[{"apnName":"ctnet","status":"1"}]}`), want: "1"},
|
||||
{name: "telecom reads disabled DNN", tempKey: "sim_dx_v1", raw: json.RawMessage(`{"apnList":[{"apnName":"ctnet","status":"0"}]}`), want: "0"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := appDiagnosisDataServiceStatusFromItemsForChannel([]appUpstreamSimQueryItem{{Code: 0, ResData: test.raw}}, test.tempKey, test.chanKey)
|
||||
if test.want == "" {
|
||||
if got != nil {
|
||||
t.Fatalf("status = %q, want no data", *got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil || *got != test.want {
|
||||
t.Fatalf("status = %v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestAppDiagnosisAPNStatusRequiresAgreement(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "all enabled", raw: `{"apnList":[{"apnName":"ctnet","status":"1"},{"apnName":"ctiot","status":"1"}]}`, want: "1"},
|
||||
{name: "all stopped", raw: `{"apnList":[{"apnName":"ctnet","status":"0"},{"apnName":"ctiot","status":"0"}]}`, want: "0"},
|
||||
{name: "mixed", raw: `{"apnList":[{"apnName":"ctnet","status":"1"},{"apnName":"ctiot","status":"0"}]}`},
|
||||
{name: "mixed reversed", raw: `{"apnList":[{"apnName":"ctiot","status":"0"},{"apnName":"ctnet","status":"1"}]}`},
|
||||
{name: "missing status", raw: `{"apnList":[{"apnName":"ctnet","status":"1"},{"apnName":"ctiot"}]}`},
|
||||
{name: "unknown status", raw: `{"apnList":[{"apnName":"ctnet","status":"1"},{"apnName":"ctiot","status":"-1"}]}`},
|
||||
{name: "missing name", raw: `{"apnList":[{"status":"1"}]}`},
|
||||
{name: "no mobile preference for telecom", raw: `{"apnList":[{"apnName":"CMIOT","status":"1"},{"apnName":"ctnet","status":"0"}]}`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := appDiagnosisDataServiceStatusFromItemsForChannel([]appUpstreamSimQueryItem{{Code: 0, ResData: json.RawMessage(test.raw)}}, "sim_dx_v1", "channel_dx")
|
||||
if test.want == "" {
|
||||
if got != nil {
|
||||
t.Fatalf("status = %q, want no data", *got)
|
||||
}
|
||||
} else if got == nil || *got != test.want {
|
||||
t.Fatalf("status = %v, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 TestValidateDiagnosisCardChannelInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info *appCardChannelInfo
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "missing channel", wantErr: true},
|
||||
{name: "empty channel key", info: &appCardChannelInfo{}, wantErr: true},
|
||||
{name: "disabled channel", info: &appCardChannelInfo{ChanKey: "channel-1", ChanStatus: 1}, wantErr: true},
|
||||
{name: "enabled channel", info: &appCardChannelInfo{ChanKey: "channel-1", ChanStatus: 0}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateDiagnosisCardChannelInfo(test.info)
|
||||
if test.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), appDiagnosisNotIncludedMessage) {
|
||||
t.Fatalf("error = %v, want %q", err, appDiagnosisNotIncludedMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisUnsupportedMessageIncludesUnsubscribedCapabilities(t *testing.T) {
|
||||
for _, message := range []string{"接口未订购", "能力未开通", "服务未开通", "未开通该接口", "通道未申请"} {
|
||||
if !appDiagnosisIsUnsupportedMessage(message) {
|
||||
t.Fatalf("message %q should be treated as unsupported", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisModuleReason(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status string
|
||||
message string
|
||||
want string
|
||||
}{
|
||||
{name: "unsubscribed", status: "skipped", message: "该卡未订购机卡绑定服务", want: "not_subscribed"},
|
||||
{name: "unsupported", status: "skipped", message: "运营商暂不支持", want: "unsupported"},
|
||||
{name: "empty skipped", status: "skipped", want: "unsupported"},
|
||||
{name: "no data", status: "skipped", message: "上游会话信息未返回有效结果", want: "no_data"},
|
||||
{name: "query error", status: "error", message: "查询超时", want: "query_error"},
|
||||
{name: "success", status: "success"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := appDiagnosisModuleReason(test.status, test.message); got != test.want {
|
||||
t.Fatalf("appDiagnosisModuleReason() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppSetDiagnosisModulePreservesStructuredReason(t *testing.T) {
|
||||
res := &model.AppDiagnosisInfoModel{}
|
||||
appSetDiagnosisModule(res, "cardBind", "skipped", "该卡未订购机卡绑定服务", false, "")
|
||||
module := res.Modules["cardBind"]
|
||||
if module == nil || module.Reason != "not_subscribed" || module.Message != "" {
|
||||
t.Fatalf("module = %+v, want hidden not_subscribed reason", module)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisDoesNotExposeUpstreamErrors(t *testing.T) {
|
||||
for _, message := range []string{"missing msisdn", "Missing MSISDN"} {
|
||||
res := &model.AppDiagnosisInfoModel{}
|
||||
status, displayMessage := appDiagnosisUpstreamFailure("上游机卡绑定状态查询失败", 9999, message)
|
||||
appSetDiagnosisModule(res, "cardBind", status, displayMessage, false, "")
|
||||
module := res.Modules["cardBind"]
|
||||
if module == nil || module.Status != "error" || module.Message != appDiagnosisRetryMessage {
|
||||
t.Fatalf("message %q: module = %+v", message, module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisNormalizesLegacyBusyMessage(t *testing.T) {
|
||||
status, displayMessage := appDiagnosisUpstreamFailure("上游实时流量查询失败", 13000, "系统忙,请稍后再试")
|
||||
if status != "error" || displayMessage != appDiagnosisRetryMessage {
|
||||
t.Fatalf("status = %q, message = %q", status, displayMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisHidesConnectivityFailureMessage(t *testing.T) {
|
||||
status, displayMessage := appDiagnosisUpstreamFailure("上游实时流量查询失败", 9999, "dial tcp 127.0.0.1:8510: connection refused")
|
||||
if status != "error" || displayMessage != "" {
|
||||
t.Fatalf("status = %q, message = %q", status, displayMessage)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestSanitizeOpenDiagnosisInfoKeepsImeiAndRemovesOtherNetworkData(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("192.0.2.10"),
|
||||
Ipv6: appDiagnosisStringPtr("2001:db8::1"),
|
||||
},
|
||||
Card: &model.AppCardModel{
|
||||
Iccid: "8986001234567890123",
|
||||
Msisdn: "13800008000",
|
||||
Imsi: "460001234567890",
|
||||
Imei: "860123456789012",
|
||||
Ipv4: "192.0.2.10",
|
||||
},
|
||||
}
|
||||
|
||||
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.Ipv4 != nil {
|
||||
t.Fatal("sensitive diagnosis fields were not removed")
|
||||
}
|
||||
if res.Session.Imei == nil || *res.Session.Imei != "860123456789012" {
|
||||
t.Fatalf("session IMEI = %#v, want public 30-day IMEI", res.Session.Imei)
|
||||
}
|
||||
if res.Card.Imsi != "" || res.Card.Imei != "860123456789012" || res.Card.Ipv4 != "" {
|
||||
t.Fatal("sensitive card fields were not removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisLatestImeiUsesSuccessfulGatewayResult(t *testing.T) {
|
||||
raw, err := json.Marshal(map[string]any{
|
||||
"resData": map[string]string{"imei": "861789024851199"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal IMEI response: %v", err)
|
||||
}
|
||||
items := []appUpstreamSimQueryItem{
|
||||
{Code: 5000, ResData: json.RawMessage(`{"imei":"ignored"}`)},
|
||||
{Code: 0, ResData: raw},
|
||||
}
|
||||
if got := appDiagnosisLatestImei(items); got != "861789024851199" {
|
||||
t.Fatalf("latest IMEI = %q, want 861789024851199", got)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestAppDiagnosisShouldCacheRegionLimitMatchesConsoleChannels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
chanKey string
|
||||
want bool
|
||||
}{
|
||||
{name: "mobile template", tempKey: "sim_yd", want: true},
|
||||
{name: "mobile mock channel", chanKey: "channel_yd_mock", want: true},
|
||||
{name: "onelink", chanKey: "onelink_shared", want: true},
|
||||
{name: "shared token", tempKey: "shared_token", want: true},
|
||||
{name: "telecom", tempKey: "sim_dx", chanKey: "channel_dx", want: false},
|
||||
{name: "unicom", tempKey: "sim_lt", chanKey: "channel_lt", want: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := appDiagnosisShouldCacheRegionLimit(test.tempKey, test.chanKey); got != test.want {
|
||||
t.Fatalf("appDiagnosisShouldCacheRegionLimit(%q, %q) = %v, want %v", test.tempKey, test.chanKey, 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,282 @@
|
||||
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
|
||||
}
|
||||
|
||||
func (cfg appDiagnosisTrafficConfig) databaseFor(stable string) string {
|
||||
switch stable {
|
||||
case appDiagnosisDailyStable:
|
||||
if cfg.DailyDbName != "" {
|
||||
return cfg.DailyDbName
|
||||
}
|
||||
case appDiagnosisMonthlyStable:
|
||||
if cfg.MonthlyDbName != "" {
|
||||
return cfg.MonthlyDbName
|
||||
}
|
||||
}
|
||||
return cfg.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 {
|
||||
g.Log().Errorf(ctx, "app diagnosis daily traffic query failed, iccid:%s, days:%d, err:%+v", appDiagnosisMaskIdentifier(iccid), days, err)
|
||||
return nil, gerror.New("流量趋势服务暂时不可用,请稍后重试")
|
||||
}
|
||||
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),
|
||||
FlowAvailable: make([]bool, 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"))
|
||||
usage, ok := usageMap[current.Format("2006-01-02")]
|
||||
res.Flow = append(res.Flow, usage)
|
||||
res.FlowAvailable = append(res.FlowAvailable, ok)
|
||||
}
|
||||
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 {
|
||||
g.Log().Errorf(ctx, "app diagnosis monthly traffic query failed, iccid:%s, months:%d, err:%+v", appDiagnosisMaskIdentifier(iccid), months, err)
|
||||
return nil, gerror.New("流量趋势服务暂时不可用,请稍后重试")
|
||||
}
|
||||
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),
|
||||
FlowAvailable: make([]bool, 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)
|
||||
usage, ok := usageMap[key]
|
||||
res.Flow = append(res.Flow, usage)
|
||||
res.FlowAvailable = append(res.FlowAvailable, ok)
|
||||
}
|
||||
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
|
||||
}
|
||||
database := cfg.databaseFor(stable)
|
||||
if err := appDiagnosisValidateTableConfig(database, 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(database, 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 {
|
||||
cfg := 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: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dbName", "iotlcm_daily").String()),
|
||||
DailyDbName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dailyDbName", "").String()),
|
||||
MonthlyDbName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.monthlyDbName", "").String()),
|
||||
MaxOpenConns: g.Cfg().MustGet(ctx, "tsdb.tdengine.maxOpenConns", 100).Int(),
|
||||
MaxIdleConns: g.Cfg().MustGet(ctx, "tsdb.tdengine.maxIdleConns", 10).Int(),
|
||||
}
|
||||
if cfg.DailyDbName == "" {
|
||||
cfg.DailyDbName = cfg.DbName
|
||||
}
|
||||
if cfg.MonthlyDbName == "" {
|
||||
cfg.MonthlyDbName = cfg.DbName
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
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,111 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
)
|
||||
|
||||
func TestAppDiagnosisTrafficDatabaseFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config appDiagnosisTrafficConfig
|
||||
stable string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "daily usage uses daily database",
|
||||
config: appDiagnosisTrafficConfig{DbName: "iotlcm", DailyDbName: "iotlcm_daily", MonthlyDbName: "iotlcm_monthly"},
|
||||
stable: appDiagnosisDailyStable,
|
||||
want: "iotlcm_daily",
|
||||
},
|
||||
{
|
||||
name: "monthly usage uses monthly database",
|
||||
config: appDiagnosisTrafficConfig{DbName: "iotlcm", DailyDbName: "iotlcm_daily", MonthlyDbName: "iotlcm_monthly"},
|
||||
stable: appDiagnosisMonthlyStable,
|
||||
want: "iotlcm_monthly",
|
||||
},
|
||||
{
|
||||
name: "daily usage falls back to legacy database",
|
||||
config: appDiagnosisTrafficConfig{DbName: "iotlcm"},
|
||||
stable: appDiagnosisDailyStable,
|
||||
want: "iotlcm",
|
||||
},
|
||||
{
|
||||
name: "monthly usage falls back to legacy database",
|
||||
config: appDiagnosisTrafficConfig{DbName: "iotlcm"},
|
||||
stable: appDiagnosisMonthlyStable,
|
||||
want: "iotlcm",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.config.databaseFor(test.stable); got != test.want {
|
||||
t.Fatalf("databaseFor(%q) = %q, want %q", test.stable, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisGetTrafficConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantDaily string
|
||||
wantMonthly string
|
||||
}{
|
||||
{
|
||||
name: "explicit split databases",
|
||||
content: `{"tsdb":{"tdengine":{"dbName":"iotlcm","dailyDbName":"iotlcm_daily","monthlyDbName":"iotlcm_monthly"}}}`,
|
||||
wantDaily: "iotlcm_daily",
|
||||
wantMonthly: "iotlcm_monthly",
|
||||
},
|
||||
{
|
||||
name: "custom database names are trimmed",
|
||||
content: `{"tsdb":{"tdengine":{"dbName":" legacy ","dailyDbName":" custom_daily ","monthlyDbName":" custom_monthly "}}}`,
|
||||
wantDaily: "custom_daily",
|
||||
wantMonthly: "custom_monthly",
|
||||
},
|
||||
{
|
||||
name: "missing split settings use configured database",
|
||||
content: `{"tsdb":{"tdengine":{"dbName":"iotlcm"}}}`,
|
||||
wantDaily: "iotlcm",
|
||||
wantMonthly: "iotlcm",
|
||||
},
|
||||
{
|
||||
name: "blank split settings use configured database",
|
||||
content: `{"tsdb":{"tdengine":{"dbName":"iotlcm","dailyDbName":" ","monthlyDbName":""}}}`,
|
||||
wantDaily: "iotlcm",
|
||||
wantMonthly: "iotlcm",
|
||||
},
|
||||
{
|
||||
name: "missing settings match console default database",
|
||||
content: `{"tsdb":{"tdengine":{}}}`,
|
||||
wantDaily: "iotlcm_daily",
|
||||
wantMonthly: "iotlcm_daily",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
adapter, err := gcfg.NewAdapterContent(test.content)
|
||||
if err != nil {
|
||||
t.Fatalf("configure traffic database: %v", err)
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
|
||||
config := appDiagnosisGetTrafficConfig(context.Background())
|
||||
if got := config.databaseFor(appDiagnosisDailyStable); got != test.wantDaily {
|
||||
t.Fatalf("daily database = %q, want %q", got, test.wantDaily)
|
||||
}
|
||||
if got := config.databaseFor(appDiagnosisMonthlyStable); got != test.wantMonthly {
|
||||
t.Fatalf("monthly database = %q, want %q", got, test.wantMonthly)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/batchmetadata"
|
||||
)
|
||||
|
||||
func TestLifecycleExecutionModeUsesRequestShape(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cardValue string
|
||||
cardValues []string
|
||||
wantMode int
|
||||
wantCount int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "single", cardValue: "card1", wantMode: batchmetadata.Single, wantCount: 1},
|
||||
{name: "bulk with one card", cardValues: []string{"card1"}, wantMode: batchmetadata.Bulk, wantCount: 1},
|
||||
{name: "bulk deduplicated to one", cardValues: []string{"card1", "card1"}, wantMode: batchmetadata.Bulk, wantCount: 1},
|
||||
{name: "multiple cards in single input", cardValue: "card1,card2", wantMode: batchmetadata.Single, wantError: true},
|
||||
{name: "multiple lines in single input", cardValue: "card1\ncard2", wantMode: batchmetadata.Single, wantError: true},
|
||||
{name: "bulk input", cardValues: []string{"card1,card2"}, wantMode: batchmetadata.Bulk, wantCount: 2},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
values, mode, err := appNormalizeLifecycleCardValues(test.cardValue, test.cardValues)
|
||||
if (err != nil) != test.wantError || mode != test.wantMode || len(values) != test.wantCount {
|
||||
t.Fatalf("values=%v mode=%d err=%v", values, mode, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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,
|
||||
}).
|
||||
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").
|
||||
InnerJoin(dao.TMessageLog.Table()+" ml", "ml."+dao.TMessageLog.Columns().MessageLogId+" = um."+dao.TUserMessage.Columns().MessageLogId+" AND ml."+dao.TMessageLog.Columns().TenantId+" = um."+dao.TUserMessage.Columns().TenantId).
|
||||
Where("um."+dao.TUserMessage.Columns().TenantId, user.TenantId).
|
||||
Where("um."+dao.TUserMessage.Columns().UserId, user.UserId).
|
||||
Where("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,137 @@
|
||||
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"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
func warningPreferenceKey(userID int64) string {
|
||||
return fmt.Sprintf("app.wechat.warning.enabled.%d", userID)
|
||||
}
|
||||
|
||||
func (s *sAppUser) WarningPreference(ctx context.Context) (*model.AppWarningPreferenceModel, error) {
|
||||
user, err := appCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var config *entity.SysConfig
|
||||
err = dao.SysConfig.Ctx(ctx).Where("tenant_id", user.TenantId).Where("config_key", warningPreferenceKey(user.UserId)).OrderDesc("config_id").Scan(&config)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "读取消息提醒设置失败")
|
||||
}
|
||||
bound, _, err := appUserWechatBindingStatus(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.AppWarningPreferenceModel{Enabled: bound && (config == nil || config.ConfigValue == "1"), WechatBound: bound}, nil
|
||||
}
|
||||
|
||||
func saveWarningPreference(ctx context.Context, tx gdb.TX, user *entity.SysUser, enabled bool) error {
|
||||
value := "0"
|
||||
if enabled {
|
||||
value = "1"
|
||||
}
|
||||
var config *entity.SysConfig
|
||||
if err := dao.SysConfig.Ctx(ctx).TX(tx).Where("tenant_id", user.TenantId).Where("config_key", warningPreferenceKey(user.UserId)).OrderDesc("config_id").Scan(&config); err != nil {
|
||||
return gerror.Wrap(err, "读取消息提醒设置失败")
|
||||
}
|
||||
data := do.SysConfig{ConfigValue: value, UpdatedBy: user.UserId}
|
||||
if config != nil {
|
||||
_, err := dao.SysConfig.Ctx(ctx).TX(tx).Where("tenant_id", user.TenantId).Where("config_key", warningPreferenceKey(user.UserId)).Data(data).Update()
|
||||
return gerror.Wrap(err, "保存消息提醒设置失败")
|
||||
}
|
||||
data.TenantId = user.TenantId
|
||||
data.ConfigKey = warningPreferenceKey(user.UserId)
|
||||
data.ConfigName = "小程序用户微信预警提醒偏好"
|
||||
data.ConfigType = "N"
|
||||
data.CreatedBy = user.UserId
|
||||
data.CreatedDept = user.DeptId
|
||||
_, err := dao.SysConfig.Ctx(ctx).TX(tx).Data(data).Insert()
|
||||
return gerror.Wrap(err, "保存消息提醒设置失败")
|
||||
}
|
||||
|
||||
func (s *sAppUser) SaveWarningPreference(ctx context.Context, param *model.AppWarningPreferenceParam) (*model.AppWarningPreferenceModel, error) {
|
||||
if param == nil || param.Enabled == nil {
|
||||
return nil, gerror.New("请选择是否开启预警订阅")
|
||||
}
|
||||
user, err := appCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = dao.SysUser.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
var locked *entity.SysUser
|
||||
lockModel := dao.SysUser.Ctx(ctx).TX(tx).WherePri(user.UserId).Where("tenant_id", user.TenantId)
|
||||
if dao.SysUser.DB().GetConfig().Type != "sqlite" {
|
||||
lockModel = lockModel.LockUpdate()
|
||||
}
|
||||
if err := lockModel.Scan(&locked); err != nil {
|
||||
return err
|
||||
}
|
||||
if locked == nil {
|
||||
return gerror.New("账号不存在")
|
||||
}
|
||||
if *param.Enabled {
|
||||
count, err := dao.SysSocial.Ctx(ctx).TX(tx).Where("tenant_id", user.TenantId).Where("user_id", user.UserId).Where("source", consts.SourceWechatMp).Where("open_id <> ?", "").Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return gerror.New("请先在个人资料中绑定微信")
|
||||
}
|
||||
}
|
||||
return saveWarningPreference(ctx, tx, user, *param.Enabled)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "保存消息提醒设置失败")
|
||||
}
|
||||
return s.WarningPreference(ctx)
|
||||
}
|
||||
|
||||
func (s *sAppUser) UnbindWechat(ctx context.Context) (*model.AppWechatBindModel, error) {
|
||||
user, err := appCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(user.UserName) == "" || strings.TrimSpace(user.Password) == "" {
|
||||
return nil, gerror.New("请先设置可用的账号密码,再解除微信绑定")
|
||||
}
|
||||
err = unbindWechatBindings(ctx, user)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "解除微信绑定失败")
|
||||
}
|
||||
info, err := getAppUserInfoById(ctx, user.UserId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model.AppWechatBindModel{Success: true, UserInfo: info}, nil
|
||||
}
|
||||
|
||||
func unbindWechatBindings(ctx context.Context, user *entity.SysUser) error {
|
||||
return dao.SysUser.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
var locked *entity.SysUser
|
||||
lockModel := dao.SysUser.Ctx(ctx).TX(tx).WherePri(user.UserId).Where("tenant_id", user.TenantId)
|
||||
if dao.SysUser.DB().GetConfig().Type != "sqlite" {
|
||||
lockModel = lockModel.LockUpdate()
|
||||
}
|
||||
if err := lockModel.Scan(&locked); err != nil {
|
||||
return err
|
||||
}
|
||||
if locked == nil || strings.TrimSpace(locked.UserName) == "" || strings.TrimSpace(locked.Password) == "" {
|
||||
return gerror.New("请先设置可用的账号密码")
|
||||
}
|
||||
if err := saveWarningPreference(ctx, tx, user, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := dao.SysSocial.Ctx(ctx).TX(tx).Where("tenant_id", user.TenantId).Where("user_id", user.UserId).Where("source", consts.SourceWechatMp).Delete()
|
||||
return err
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"testing"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestWarningPreferenceIsolationAndBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := g.DB()
|
||||
statements := []string{
|
||||
"CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, tenant_id TEXT, user_name TEXT, password TEXT, deleted_at DATETIME)",
|
||||
"CREATE TABLE sys_social (id INTEGER PRIMARY KEY, tenant_id TEXT, user_id INTEGER, source TEXT, open_id TEXT, deleted_at DATETIME)",
|
||||
"CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, config_key TEXT, config_value TEXT, config_name TEXT, config_type TEXT, created_dept INTEGER, created_by INTEGER, updated_by INTEGER, created_at DATETIME, updated_at DATETIME)",
|
||||
}
|
||||
for _, table := range []string{"sys_user", "sys_social", "sys_config"} {
|
||||
_, _ = db.Exec(ctx, "DROP TABLE IF EXISTS "+table)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, table := range []string{"sys_user", "sys_social", "sys_config"} {
|
||||
_, _ = db.Exec(ctx, "DROP TABLE IF EXISTS "+table)
|
||||
}
|
||||
_ = db.GetCore().ClearCacheAll(ctx)
|
||||
})
|
||||
for _, statement := range statements {
|
||||
if _, err := db.Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, userID := range []int64{101, 102} {
|
||||
if _, err := db.Model("sys_user").Data(do.SysUser{UserId: userID, TenantId: "tenant", UserName: "account", Password: "hash"}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Model("sys_social").Data(do.SysSocial{Id: 1, TenantId: "tenant", UserId: 101, Source: "wechat_mp", OpenId: "openid"}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userCtx := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 101, TenantId: "tenant"}}})
|
||||
service := NewAppUser()
|
||||
preference, err := service.WarningPreference(userCtx)
|
||||
if err != nil || !preference.Enabled {
|
||||
t.Fatalf("legacy bound user should remain enabled: %v %v", preference, err)
|
||||
}
|
||||
enabled := false
|
||||
preference, err = service.SaveWarningPreference(userCtx, &model.AppWarningPreferenceParam{Enabled: &enabled})
|
||||
if err != nil || preference.Enabled || !preference.WechatBound {
|
||||
t.Fatalf("disable must preserve binding: %v %v", preference, err)
|
||||
}
|
||||
enabled = true
|
||||
preference, err = service.SaveWarningPreference(userCtx, &model.AppWarningPreferenceParam{Enabled: &enabled})
|
||||
if err != nil || !preference.Enabled {
|
||||
t.Fatalf("enable failed: %v %v", preference, err)
|
||||
}
|
||||
otherCtx := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 102, TenantId: "tenant"}}})
|
||||
if _, err := service.SaveWarningPreference(otherCtx, &model.AppWarningPreferenceParam{Enabled: &enabled}); err == nil {
|
||||
t.Fatal("unbound account must not enable")
|
||||
}
|
||||
if _, err := service.SaveWarningPreference(userCtx, &model.AppWarningPreferenceParam{}); err == nil {
|
||||
t.Fatal("missing enabled must be rejected")
|
||||
}
|
||||
if _, err := service.WarningPreference(context.Background()); err == nil {
|
||||
t.Fatal("anonymous access must fail")
|
||||
}
|
||||
count, err := db.Model("sys_config").Where("tenant_id", "tenant").Where("config_key", warningPreferenceKey(101)).Count()
|
||||
if err != nil || count != 1 {
|
||||
t.Fatalf("expected one persisted preference: %d %v", count, err)
|
||||
}
|
||||
if _, err := db.Model("sys_social").Data(do.SysSocial{Id: 2, TenantId: "other", UserId: 101, Source: "wechat_mp", OpenId: "other"}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, "ALTER TABLE sys_social RENAME TO sys_social_unavailable"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := unbindWechatBindings(userCtx, &entity.SysUser{UserId: 101, TenantId: "tenant"}); err == nil {
|
||||
t.Fatal("failed binding deletion must fail transaction")
|
||||
}
|
||||
persisted, err := db.Model("sys_config").Where("tenant_id", "tenant").Where("config_key", warningPreferenceKey(101)).Value("config_value")
|
||||
if err != nil || persisted.String() != "1" {
|
||||
t.Fatalf("failed unbind changed preference: %v %v", persisted, err)
|
||||
}
|
||||
if _, err := db.Exec(ctx, "ALTER TABLE sys_social_unavailable RENAME TO sys_social"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := unbindWechatBindings(userCtx, &entity.SysUser{UserId: 101, TenantId: "tenant"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remaining, err := db.Model("sys_social").Where("tenant_id", "tenant").Count()
|
||||
if err != nil || remaining != 0 {
|
||||
t.Fatalf("unbind did not delete current binding: %d %v", remaining, err)
|
||||
}
|
||||
remaining, err = db.Model("sys_social").Where("tenant_id", "other").Count()
|
||||
if err != nil || remaining != 1 {
|
||||
t.Fatalf("unbind changed other tenant: %d %v", remaining, err)
|
||||
}
|
||||
preference, err = service.WarningPreference(userCtx)
|
||||
if err != nil || preference.Enabled || preference.WechatBound {
|
||||
t.Fatalf("unbind must disable notifications: %v %v", preference, err)
|
||||
}
|
||||
if _, err := db.Model("sys_social").Data(do.SysSocial{Id: 3, TenantId: "tenant", UserId: 101, Source: "wechat_mp", OpenId: "new"}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preference, err = service.WarningPreference(userCtx)
|
||||
if err != nil || preference.Enabled || !preference.WechatBound {
|
||||
t.Fatalf("rebind must not silently enable: %v %v", preference, err)
|
||||
}
|
||||
}
|
||||
@@ -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://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,275 @@
|
||||
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 appDiagnosisProxyContextKey struct{}
|
||||
|
||||
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 ctx.Value(appDiagnosisProxyContextKey{}) == true || !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, "解析上游会话响应失败")
|
||||
}
|
||||
appNormalizeUpstreamSession(&session, item.ResData)
|
||||
if !session.isZero() {
|
||||
return &session, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func appNormalizeUpstreamSession(session *appOneLinkMockSimSessionItem, raw json.RawMessage) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
if session.ApnId == "" {
|
||||
session.ApnId = appDiagnosisRawString(raw, "apnId", "apn")
|
||||
}
|
||||
if session.Rat == "" {
|
||||
session.Rat = appDiagnosisRawString(raw, "rat", "accessType", "netModel")
|
||||
}
|
||||
if session.Imei == "" {
|
||||
session.Imei = appDiagnosisRawString(raw, "imei", "IMEI")
|
||||
}
|
||||
if session.Ipv4 == "" {
|
||||
session.Ipv4 = appDiagnosisRawString(raw, "ipv4", "ipv4Address", "ip")
|
||||
}
|
||||
if session.Ipv6 == "" {
|
||||
session.Ipv6 = appDiagnosisRawString(raw, "ipv6", "ipv6Address")
|
||||
}
|
||||
if session.Ipv6Prefix == "" {
|
||||
session.Ipv6Prefix = appDiagnosisRawString(raw, "ipv6Prefix")
|
||||
}
|
||||
if session.CreateTime == "" {
|
||||
session.CreateTime = appDiagnosisRawString(raw, "createTime", "startTime")
|
||||
}
|
||||
if session.Status == "" {
|
||||
session.Status = appDiagnosisRawString(raw, "status", "onlineStatus", "sessionStatus")
|
||||
}
|
||||
if session.Status == "1" && appDiagnosisRawString(raw, "onlineStatus", "sessionStatus") != "" {
|
||||
session.Status = "-1"
|
||||
}
|
||||
if session.Status == "2" {
|
||||
session.Status = "-2"
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
if ctx.Value(appDiagnosisProxyContextKey{}) == true {
|
||||
token := strings.TrimSpace(g.Cfg().MustGet(ctx, "upstream.appDiagnosisToken").String())
|
||||
if token == "" {
|
||||
return nil, gerror.New("诊断代理内部令牌未配置")
|
||||
}
|
||||
requestURL = appUpstreamURL(cfg.ClientName, "/internal/app-diagnosis"+path)
|
||||
client.SetHeader("X-App-Diagnosis-Token", token)
|
||||
}
|
||||
resp, err := client.Post(ctx, requestURL, string(body))
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "请求上游信息失败")
|
||||
}
|
||||
defer resp.Close()
|
||||
respBody := resp.ReadAll()
|
||||
if ctx.Value(appDiagnosisProxyContextKey{}) == true && resp.StatusCode == http.StatusNotFound && strings.Contains(string(respBody), "诊断通道不可用") {
|
||||
return nil, gerror.New(appDiagnosisNotIncludedMessage)
|
||||
}
|
||||
|
||||
output := appUpstreamSimQueryResponse{}
|
||||
if err = json.Unmarshal(respBody, &output); err != nil {
|
||||
return nil, gerror.Wrap(err, "解析上游响应失败")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || output.Code == nil || *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,132 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
)
|
||||
|
||||
func TestAppNormalizeUpstreamSessionSupportsTelecomFields(t *testing.T) {
|
||||
session := &appOneLinkMockSimSessionItem{}
|
||||
raw := json.RawMessage(`{"onlineStatus":"0","accessType":"5G","ipv4Address":"192.0.2.10","ipv6Address":"2001:db8::1","apn":"CMIOT","startTime":"2026-10-08 10:00:00"}`)
|
||||
appNormalizeUpstreamSession(session, raw)
|
||||
if session.Status != "0" || session.Rat != "5G" || session.Ipv4 != "192.0.2.10" || session.Ipv6 != "2001:db8::1" || session.ApnId != "CMIOT" {
|
||||
t.Fatalf("normalized session = %+v", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisUpstreamUsesProxy(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/sim/query/sim-basic-info" {
|
||||
t.Errorf("request = %s %s", request.Method, request.URL.Path)
|
||||
}
|
||||
var input appUpstreamSimQueryInput
|
||||
if err := json.NewDecoder(request.Body).Decode(&input); err != nil {
|
||||
t.Errorf("decode proxy request: %v", err)
|
||||
} else if len(input.SimKeys) != 1 || input.SimKeys[0].ChanKey != "test-channel" || input.SimKeys[0].ICCID != "test-iccid" || input.SimKeys[0].IMSI != "test-imsi" || input.SimKeys[0].MSISDN != "test-msisdn" {
|
||||
t.Errorf("proxy sim key = %+v", input.SimKeys)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"simStatus":"0"}}]}`))
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
config, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"default":{"clientName":%q,"timeout":"2s"},"gateway":{"clientName":"http://127.0.0.1:1"}}}`, proxy.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("configure upstream: %v", err)
|
||||
}
|
||||
original := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(config)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(original) })
|
||||
|
||||
items, err := queryAppUpstreamSimWithKey(context.Background(), &appUpstreamSimKey{
|
||||
ChanKey: "test-channel", ICCID: "test-iccid", IMSI: "test-imsi", MSISDN: "test-msisdn",
|
||||
}, "/sim/query/sim-basic-info", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("query upstream through proxy: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Code != 0 {
|
||||
t.Fatalf("proxy response = %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisUsesDedicatedProxyRoute(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path != "/internal/app-diagnosis/sim/query/sim-basic-info" {
|
||||
t.Errorf("unexpected path: %s", request.URL.Path)
|
||||
}
|
||||
if request.Header.Get("X-App-Diagnosis-Token") != "test-internal-token" {
|
||||
t.Errorf("missing internal token")
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"code":0,"data":[{"code":0,"resData":{"simStatus":"0"}}]}`))
|
||||
}))
|
||||
defer proxy.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"default":{"clientName":%q,"timeout":"2s"},"appDiagnosisToken":"test-internal-token"}}`, proxy.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
ctx := context.WithValue(context.Background(), appDiagnosisProxyContextKey{}, true)
|
||||
items, err := queryAppUpstreamSimWithKey(ctx, &appUpstreamSimKey{ChanKey: "test-channel", ICCID: "test-iccid"}, "/sim/query/sim-basic-info", nil)
|
||||
if err != nil || len(items) != 1 || items[0].Code != 0 {
|
||||
t.Fatalf("dedicated proxy request: items=%+v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisDoesNotFallbackToMockSession(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"code":500,"message":"upstream unavailable"}`))
|
||||
}))
|
||||
defer proxy.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"default":{"clientName":%q,"timeout":"2s"},"appDiagnosisToken":"test-token","onelinkMock":{"enabled":true}}}`, proxy.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
ctx := context.WithValue(context.Background(), appDiagnosisProxyContextKey{}, true)
|
||||
session, source, err := queryAppCardRealtimeSessionWithKey(ctx, &appCardQueryRow{}, &appUpstreamSimKey{ChanKey: "test-channel", ICCID: "test-iccid"})
|
||||
if err == nil || session != nil || source != "upstream" {
|
||||
t.Fatalf("session = %+v, source = %q, err = %v; want upstream failure without mock fallback", session, source, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppDiagnosisTreatsMissingProxyChannelAsNotIncluded(t *testing.T) {
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusNotFound)
|
||||
_, _ = writer.Write([]byte(`Not Found{"code":404,"message":"诊断通道不可用"}`))
|
||||
}))
|
||||
defer proxy.Close()
|
||||
adapter, err := gcfg.NewAdapterContent(fmt.Sprintf(`{"upstream":{"default":{"clientName":%q,"timeout":"2s"},"appDiagnosisToken":"test-internal-token"}}`, proxy.URL))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
ctx := context.WithValue(context.Background(), appDiagnosisProxyContextKey{}, true)
|
||||
_, err = queryAppUpstreamSimWithKey(ctx, &appUpstreamSimKey{ChanKey: "missing-channel", ICCID: "test-iccid"}, "/sim/query/sim-basic-info", nil)
|
||||
if err == nil || err.Error() != appDiagnosisNotIncludedMessage {
|
||||
t.Fatalf("missing proxy channel error = %v", err)
|
||||
}
|
||||
row := &appCardQueryRow{TSimCardInfo: entity.TSimCardInfo{
|
||||
Iccid: "test-iccid", Imsi: "test-imsi", Msisdn: "test-msisdn", ChanKey: "missing-channel",
|
||||
}}
|
||||
_, err = (&sAppDiagnosis{}).prepareDiagnosisSimKey(ctx, row)
|
||||
if err == nil || err.Error() != appDiagnosisNotIncludedMessage {
|
||||
t.Fatalf("legacy diagnosis preflight error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
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) {
|
||||
if param == nil || strings.TrimSpace(param.WxCode) == "" {
|
||||
return nil, gerror.New("微信登录凭证不能为空")
|
||||
}
|
||||
config, err := appLoadWechatConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !config.Mini.Enabled {
|
||||
return nil, gerror.New("微信小程序绑定暂未启用")
|
||||
}
|
||||
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
|
||||
}
|
||||
if !userInfo.WechatBound {
|
||||
return nil, gerror.New("微信绑定未完成,请检查小程序配置后重试")
|
||||
}
|
||||
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)
|
||||
isTopLevelEnterprise, err := appIsTopLevelEnterprise(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wechatBound, wechatBindText, err := appUserWechatBindingStatus(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.AppUserInfoModel{
|
||||
IsTopLevelEnterprise: isTopLevelEnterprise,
|
||||
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
|
||||
}
|
||||
|
||||
// 顶级企业按机构层级识别,不依赖运营、管理员等角色名称。
|
||||
func appIsTopLevelEnterprise(ctx context.Context, user *entity.SysUser) (bool, error) {
|
||||
if user == nil || user.UserId <= 0 || user.UserType == consts.SysUserTypeAPP || appIsTerminalUser(ctx, user) {
|
||||
return false, nil
|
||||
}
|
||||
if user.UserId == 1 {
|
||||
return true, nil
|
||||
}
|
||||
if user.DeptId <= 0 {
|
||||
return false, nil
|
||||
}
|
||||
var dept *entity.SysDept
|
||||
err := dao.SysDept.Ctx(ctx).Fields("dept_id", "parent_id", "status", "deleted_at").Where("tenant_id", user.TenantId).Where("dept_id", user.DeptId).Scan(&dept)
|
||||
if err != nil {
|
||||
return false, gerror.Wrap(err, "查询诊断企业层级失败")
|
||||
}
|
||||
return dept != nil && dept.ParentId == 0 && dept.Status == "0" && (dept.DeletedAt == nil || dept.DeletedAt.IsZero()), 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,780 @@
|
||||
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
|
||||
}
|
||||
var summary appWarningNotifySummary
|
||||
if err = appWarningNotifySummaryModel(s.configModel(ctx), user).Scan(&summary); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询当前账号预警提醒配置失败")
|
||||
}
|
||||
return &model.AppWarningConfigMetaModel{
|
||||
TemplateId: appWarningWechatTemplateId(ctx),
|
||||
TemplateReady: appWarningTemplateReady(ctx),
|
||||
WechatBound: wechatBound,
|
||||
WechatBindText: wechatBindText,
|
||||
AppNotifyEnabled: appWarningNotifyEnabledValue(nil),
|
||||
ActiveConfigCount: summary.ActiveConfigCount,
|
||||
NotifyConfigCount: summary.NotifyConfigCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type appWarningNotifySummary struct {
|
||||
ActiveConfigCount int `orm:"active_config_count"`
|
||||
NotifyConfigCount int `orm:"notify_config_count"`
|
||||
}
|
||||
|
||||
func appWarningNotifySummaryModel(mod *gdb.Model, user *entity.SysUser) *gdb.Model {
|
||||
columns := dao.TWarnConfig.Columns()
|
||||
return mod.
|
||||
Where(columns.TenantId, user.TenantId).
|
||||
Where(columns.CreatedBy, user.UserId).
|
||||
Where(columns.ItemType, appWarningItemTypeCard).
|
||||
Where(columns.WarnUnit, appWarningUnitMb).
|
||||
Where(columns.IsEnabled, 1).
|
||||
Fields("COUNT(*) AS active_config_count, COALESCE(SUM(CASE WHEN " + columns.AppNotifyEnabled + " = 1 THEN 1 ELSE 0 END), 0) AS notify_config_count")
|
||||
}
|
||||
|
||||
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,53 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
)
|
||||
|
||||
func TestAppWarningNotifySummaryScopesConfiguration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Name: filepath.Join(t.TempDir(), "warning.db")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close(ctx) })
|
||||
_, err = db.Exec(ctx, "CREATE TABLE t_warn_config (id INTEGER PRIMARY KEY, tenant_id TEXT, created_by INTEGER, item_type INTEGER, warn_unit INTEGER, is_enabled INTEGER, app_notify_enabled INTEGER)")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configs := []do.TWarnConfig{
|
||||
{TenantId: "tenant-a", CreatedBy: 11, ItemType: 1, WarnUnit: 1, IsEnabled: 1, AppNotifyEnabled: 1},
|
||||
{TenantId: "tenant-a", CreatedBy: 11, ItemType: 1, WarnUnit: 1, IsEnabled: 1, AppNotifyEnabled: 0},
|
||||
{TenantId: "tenant-b", CreatedBy: 11, ItemType: 1, WarnUnit: 1, IsEnabled: 1, AppNotifyEnabled: 1},
|
||||
{TenantId: "tenant-a", CreatedBy: 12, ItemType: 1, WarnUnit: 1, IsEnabled: 1, AppNotifyEnabled: 1},
|
||||
{TenantId: "tenant-a", CreatedBy: 11, ItemType: 1, WarnUnit: 1, IsEnabled: 0, AppNotifyEnabled: 1},
|
||||
{TenantId: "tenant-a", CreatedBy: 11, ItemType: 1, WarnUnit: 3, IsEnabled: 1, AppNotifyEnabled: 1},
|
||||
{TenantId: "tenant-a", CreatedBy: 11, ItemType: 2, WarnUnit: 1, IsEnabled: 1, AppNotifyEnabled: 1},
|
||||
}
|
||||
if _, err = db.Model("t_warn_config").Ctx(ctx).Data(configs).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var summary appWarningNotifySummary
|
||||
user := &entity.SysUser{TenantId: "tenant-a", UserId: 11}
|
||||
if err = appWarningNotifySummaryModel(db.Model("t_warn_config").Ctx(ctx), user).Scan(&summary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.ActiveConfigCount != 2 || summary.NotifyConfigCount != 1 {
|
||||
t.Fatalf("summary = %+v, expected active=2, notify=1", summary)
|
||||
}
|
||||
user.UserId = 99
|
||||
if err = appWarningNotifySummaryModel(db.Model("t_warn_config").Ctx(ctx), user).Scan(&summary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.ActiveConfigCount != 0 || summary.NotifyConfigCount != 0 {
|
||||
t.Fatalf("empty summary = %+v, expected zero counts", summary)
|
||||
}
|
||||
}
|
||||
@@ -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,595 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/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/frame/g"
|
||||
"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 {
|
||||
if err = appEnsureFeatureAccess(ctx, "workorder:detail"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
request := g.RequestFromCtx(ctx)
|
||||
if request == nil {
|
||||
return nil, gerror.New("上传请求无效")
|
||||
}
|
||||
file := request.GetUploadFile("file")
|
||||
if file == nil {
|
||||
return nil, gerror.New("请选择附件")
|
||||
}
|
||||
if file.Size > 10*1024*1024 {
|
||||
return nil, gerror.New("单个附件不能超过10MB")
|
||||
}
|
||||
originalName, err := appWorkOrderOriginalName(request.GetForm("originalName").String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err = service.SysOss().Upload(ctx, &model.SysOssUploadParam{FileType: "file"})
|
||||
if err != nil || originalName == "" {
|
||||
return output, err
|
||||
}
|
||||
columns := dao.SysOss.Columns()
|
||||
if _, err = dao.SysOss.Ctx(ctx).
|
||||
Where(columns.OssId, output.OssId).
|
||||
Where(columns.TenantId, output.TenantId).
|
||||
Data(do.SysOss{OriginalName: originalName}).Update(); err != nil {
|
||||
return nil, gerror.Wrap(err, "保存工单附件原名失败")
|
||||
}
|
||||
output.OriginalName = originalName
|
||||
return output, nil
|
||||
}
|
||||
|
||||
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.Scope != "" && param.Scope != "all" && param.Scope != "submitted" && param.Scope != "received" {
|
||||
return nil, 0, gerror.New("工单范围不正确")
|
||||
}
|
||||
if param.Scope == "submitted" {
|
||||
mod = mod.Where(dao.TWoMain.Columns().CreatorUserId, user.UserId)
|
||||
}
|
||||
if param.Scope == "received" {
|
||||
if appIsTerminalUser(ctx, user) {
|
||||
return nil, 0, gerror.New("终端账号不能查看待处理工单")
|
||||
}
|
||||
columns := dao.TWoMain.Columns()
|
||||
mod = mod.WhereNot(columns.CreatorUserId, user.UserId).Where("("+columns.HandlerUserId+" = ? OR ("+columns.HandlerDeptId+" = ? AND ? > 0))", user.UserId, user.DeptId, user.DeptId)
|
||||
}
|
||||
if param.CurrentStatus < 0 || param.CurrentStatus > 5 {
|
||||
return nil, 0, gerror.New("工单状态不正确")
|
||||
}
|
||||
if param.CurrentStatus > 0 {
|
||||
mod = mod.Where(dao.TWoMain.Columns().CurrentStatus, param.CurrentStatus)
|
||||
}
|
||||
if param.WoType != nil {
|
||||
if *param.WoType < 0 || *param.WoType > 5 {
|
||||
return nil, 0, gerror.New("工单类型不正确")
|
||||
}
|
||||
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
|
||||
communicationModel := dao.TWoCommunication.Ctx(ctx).
|
||||
Where(dao.TWoCommunication.Columns().TenantId, user.TenantId).
|
||||
Where(dao.TWoCommunication.Columns().WoNo, row.WoNo)
|
||||
if param.BeforeId > 0 {
|
||||
communicationModel = communicationModel.WhereLT(dao.TWoCommunication.Columns().Id, param.BeforeId)
|
||||
}
|
||||
if err = communicationModel.OrderDesc(dao.TWoCommunication.Columns().Id).Limit(21).
|
||||
Scan(&communications); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询工单沟通记录失败")
|
||||
}
|
||||
res.HasMoreCommunications = len(communications) > 20
|
||||
if res.HasMoreCommunications {
|
||||
communications = communications[:20]
|
||||
}
|
||||
for left, right := 0, len(communications)-1; left < right; left, right = left+1, right-1 {
|
||||
communications[left], communications[right] = communications[right], communications[left]
|
||||
}
|
||||
res.Communications = appWorkOrderCommunicationsToModels(communications)
|
||||
urls := append([]string{}, res.AttachmentUrls...)
|
||||
for _, communication := range communications {
|
||||
if communication != nil && communication.AttachmentUrls != nil {
|
||||
urls = append(urls, communication.AttachmentUrls.Var().Strings()...)
|
||||
}
|
||||
}
|
||||
res.AttachmentNames, err = appWorkOrderAttachmentNames(ctx, user.TenantId, urls)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Actions = appAvailableWorkOrderActions(ctx, row, user)
|
||||
res.IsCreator = row.CreatorUserId == user.UserId
|
||||
var records []*entity.TWoRecord
|
||||
if err = dao.TWoRecord.Ctx(ctx).Where(dao.TWoRecord.Columns().TenantId, user.TenantId).Where(dao.TWoRecord.Columns().WoNo, row.WoNo).OrderAsc(dao.TWoRecord.Columns().Id).Scan(&records); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询工单进度失败")
|
||||
}
|
||||
for _, record := range records {
|
||||
res.Records = append(res.Records, &model.AppWorkOrderProgressModel{Id: record.Id, OperationType: record.OperationType, OperationText: appWorkOrderOperationText(record), BeforeStatus: record.BeforeStatus, AfterStatus: record.AfterStatus, CreatedAt: record.CreatedAt})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
var appWorkOrderPhonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`)
|
||||
|
||||
func appWorkOrderContactPhone(phone string) (string, error) {
|
||||
value := strings.TrimSpace(phone)
|
||||
if value == "" {
|
||||
return "", gerror.New("请输入创建人手机号")
|
||||
}
|
||||
if !appWorkOrderPhonePattern.MatchString(value) {
|
||||
return "", gerror.New("请输入正确的手机号")
|
||||
}
|
||||
return value, 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
|
||||
}
|
||||
|
||||
creatorPhone, err := appWorkOrderContactPhone(param.CreatorPhone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if utf8.RuneCountInString(title) > 100 || utf8.RuneCountInString(content) > 2000 {
|
||||
return nil, gerror.New("标题最多100字,问题描述最多2000字")
|
||||
}
|
||||
if err = s.validateRelatedWorkOrder(ctx, user, param); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = appValidateWorkOrderAttachments(param.AttachmentUrls); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.validateAttachmentOwnership(ctx, user, param.AttachmentUrls); 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
|
||||
}
|
||||
|
||||
var created *entity.TWoMain
|
||||
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: appWorkOrderEncodeAttachments(param.AttachmentUrls),
|
||||
CreatorType: creatorType,
|
||||
CreatorDeptId: createdDept,
|
||||
CreatorUserId: user.UserId,
|
||||
CreatorName: creatorName,
|
||||
CreatorPhone: creatorPhone,
|
||||
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, "创建工单操作记录失败")
|
||||
}
|
||||
if err := dao.TWoMain.Ctx(ctx).TX(tx).
|
||||
Where(dao.TWoMain.Columns().TenantId, user.TenantId).
|
||||
Where(dao.TWoMain.Columns().CreatorUserId, user.UserId).
|
||||
Where(dao.TWoMain.Columns().WoNo, woNo).
|
||||
Scan(&created); err != nil {
|
||||
return gerror.Wrap(err, "读取新建工单失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return appWorkOrderToModel(created), nil
|
||||
}
|
||||
|
||||
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)
|
||||
terminal, identityErr := appidentity.IsTerminalUser(ctx, user)
|
||||
if identityErr != nil {
|
||||
g.Log().Errorf(ctx, "工单用户身份查询失败: %v", identityErr)
|
||||
return mod.Where("1 = 0")
|
||||
}
|
||||
if terminal {
|
||||
return mod.Where(columns.CreatorUserId, user.UserId)
|
||||
}
|
||||
scope, err := appResolveEnterpriseDataScope(ctx, user)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "work order data scope: %v", err)
|
||||
return mod.Where("1 = 0")
|
||||
}
|
||||
if scope.All {
|
||||
return mod
|
||||
}
|
||||
condition := ""
|
||||
args := []any{}
|
||||
if !scope.Configured {
|
||||
condition = columns.CreatorDeptId + " = ? OR " + columns.CreatorUserId + " = ?"
|
||||
args = append(args, user.DeptId, user.UserId)
|
||||
} else {
|
||||
if len(scope.DeptIds) > 0 {
|
||||
condition = columns.CreatorDeptId + " IN (?)"
|
||||
args = append(args, scope.DeptIds)
|
||||
}
|
||||
if len(scope.UserIds) > 0 {
|
||||
if condition != "" {
|
||||
condition += " OR "
|
||||
}
|
||||
condition += columns.CreatorUserId + " IN (?)"
|
||||
args = append(args, scope.UserIds)
|
||||
}
|
||||
}
|
||||
if condition != "" {
|
||||
condition += " OR "
|
||||
}
|
||||
condition += columns.HandlerUserId + " = ?"
|
||||
args = append(args, user.UserId)
|
||||
if user.DeptId > 0 {
|
||||
condition += " OR " + columns.HandlerDeptId + " = ?"
|
||||
args = append(args, user.DeptId)
|
||||
}
|
||||
return mod.Where("("+condition+")", args...)
|
||||
}
|
||||
|
||||
func appWorkOrderHandler(ctx context.Context, user *entity.SysUser, createdDept int64) (deptId int64, userId int64, err error) {
|
||||
if user == nil || strings.TrimSpace(user.TenantId) == "" || createdDept <= 0 {
|
||||
return 0, 0, gerror.New("当前账号未配置有效的工单所属部门")
|
||||
}
|
||||
columns := dao.SysDept.Columns()
|
||||
var currentDept *entity.SysDept
|
||||
if err = dao.SysDept.Ctx(ctx).
|
||||
Fields(columns.DeptId, columns.ParentId, columns.Leader, columns.Status).
|
||||
Where(columns.TenantId, user.TenantId).
|
||||
Where(columns.DeptId, createdDept).
|
||||
WhereNull(columns.DeletedAt).
|
||||
Limit(1).
|
||||
Scan(¤tDept); err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "查询工单所属部门失败")
|
||||
}
|
||||
if currentDept == nil || currentDept.DeptId <= 0 || currentDept.Status != "0" {
|
||||
return 0, 0, gerror.New("工单所属部门不存在或已停用")
|
||||
}
|
||||
if currentDept.ParentId == 0 {
|
||||
return currentDept.DeptId, currentDept.Leader, nil
|
||||
}
|
||||
if currentDept.ParentId == currentDept.DeptId {
|
||||
return 0, 0, gerror.New("工单所属部门的上级配置异常")
|
||||
}
|
||||
var parentDept *entity.SysDept
|
||||
if err = dao.SysDept.Ctx(ctx).
|
||||
Fields(columns.DeptId, columns.Leader, columns.Status).
|
||||
Where(columns.TenantId, user.TenantId).
|
||||
Where(columns.DeptId, currentDept.ParentId).
|
||||
WhereNull(columns.DeletedAt).
|
||||
Limit(1).
|
||||
Scan(&parentDept); err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "查询上级工单处理部门失败")
|
||||
}
|
||||
if parentDept == nil || parentDept.DeptId <= 0 || parentDept.Status != "0" {
|
||||
return 0, 0, gerror.New("上级工单处理部门不存在或已停用,请联系管理员")
|
||||
}
|
||||
return parentDept.DeptId, parentDept.Leader, nil
|
||||
}
|
||||
|
||||
func appWorkOrderToModel(row *entity.TWoMain) *model.AppWorkOrderModel {
|
||||
if row == nil {
|
||||
return nil
|
||||
}
|
||||
return &model.AppWorkOrderModel{
|
||||
Id: row.Id,
|
||||
Actions: []string{},
|
||||
ResolveTime: row.ResolveTime,
|
||||
CloseTime: row.CloseTime,
|
||||
SatisfactionLevel: row.SatisfactionLevel,
|
||||
EvalContent: row.EvalContent,
|
||||
EvalTime: row.EvalTime,
|
||||
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 decodeAttachmentUrls(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var urls []string
|
||||
if err := gjson.DecodeTo(value, &urls); err == nil && (strings.HasPrefix(value, "[") || value == "null") {
|
||||
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,378 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/rabbitmq"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/appidentity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/mcache"
|
||||
"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"
|
||||
)
|
||||
|
||||
func appValidateWorkOrderAttachments(urls []string) error {
|
||||
if len(urls) > 6 {
|
||||
return gerror.New("最多上传6个附件")
|
||||
}
|
||||
for _, value := range urls {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed == nil || len(value) > 2048 || strings.TrimSpace(value) == "" {
|
||||
return gerror.New("附件地址不正确")
|
||||
}
|
||||
if parsed.User != nil || ((parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host == "") {
|
||||
return gerror.New("附件地址不正确")
|
||||
}
|
||||
if parsed.Scheme != "" && parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return gerror.New("附件地址不正确")
|
||||
}
|
||||
if parsed.Scheme == "" && (!strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//")) {
|
||||
return gerror.New("附件地址不正确")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appWorkOrderActions(row *entity.TWoMain, userID int64) []string {
|
||||
actions := []string{}
|
||||
if row == nil || row.CreatorUserId != userID || userID == 0 {
|
||||
return actions
|
||||
}
|
||||
switch row.CurrentStatus {
|
||||
case appWorkOrderStatusAssigned, appWorkOrderStatusProcessing:
|
||||
return []string{"reply", "cancel"}
|
||||
case appWorkOrderStatusResolved:
|
||||
return []string{"reopen", "confirm"}
|
||||
case appWorkOrderStatusClosed:
|
||||
if row.EvalTime == nil && row.SatisfactionLevel == 0 {
|
||||
return []string{"evaluate"}
|
||||
}
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
func appWorkOrderHandlerActions(row *entity.TWoMain, user *entity.SysUser) []string {
|
||||
if row == nil || user == nil || user.UserId <= 0 || row.TenantId != user.TenantId || row.CreatorUserId == user.UserId {
|
||||
return []string{}
|
||||
}
|
||||
if row.HandlerUserId != user.UserId && (user.DeptId <= 0 || row.HandlerDeptId != user.DeptId) {
|
||||
return []string{}
|
||||
}
|
||||
switch row.CurrentStatus {
|
||||
case appWorkOrderStatusAssigned, appWorkOrderStatusProcessing:
|
||||
return []string{"reply", "resolve", "transfer"}
|
||||
case appWorkOrderStatusResolved:
|
||||
return []string{"reply", "close"}
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func appWorkOrderHandlerPermission(action string) string {
|
||||
if action == "reply" || action == "reopen" {
|
||||
return "cpm:gen:tWoCommunication:message"
|
||||
}
|
||||
if action == "confirm" {
|
||||
action = "close"
|
||||
}
|
||||
return "cpm:gen:tWoMain:" + action
|
||||
}
|
||||
|
||||
func appEnsureWorkOrderHandlerPermission(ctx context.Context, action string) error {
|
||||
if contexts.IsSuperAdmin(ctx) {
|
||||
return nil
|
||||
}
|
||||
codes, _, err := mcache.GetUserAccessCodeList(ctx, contexts.GetUserId(ctx))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "读取工单办理权限失败")
|
||||
}
|
||||
if !appAccessCodesMatchExpression(codes, appWorkOrderHandlerPermission(action)) {
|
||||
return gerror.New("暂无工单办理权限")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appAvailableWorkOrderActions(ctx context.Context, row *entity.TWoMain, user *entity.SysUser) []string {
|
||||
creatorActions := appWorkOrderActions(row, user.UserId)
|
||||
terminal, err := appidentity.IsTerminalUser(ctx, user)
|
||||
if err != nil {
|
||||
return []string{}
|
||||
}
|
||||
if terminal {
|
||||
return creatorActions
|
||||
}
|
||||
actions := []string{}
|
||||
for _, action := range append(creatorActions, appWorkOrderHandlerActions(row, user)...) {
|
||||
if appEnsureWorkOrderHandlerPermission(ctx, action) == nil {
|
||||
actions = append(actions, action)
|
||||
}
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
func appWorkOrderOperationText(row *entity.TWoRecord) string {
|
||||
if row.BeforeStatus == appWorkOrderStatusResolved && row.AfterStatus == appWorkOrderStatusProcessing {
|
||||
return "用户反馈未解决,继续处理"
|
||||
}
|
||||
switch row.OperationType {
|
||||
case 0:
|
||||
return "提交工单"
|
||||
case 1:
|
||||
return "工单已分派"
|
||||
case 2:
|
||||
return "跟进处理"
|
||||
case 3:
|
||||
return "已解决,等待确认"
|
||||
case 4:
|
||||
return "工单已完结"
|
||||
case 5:
|
||||
return "工单已取消"
|
||||
default:
|
||||
return "工单进度更新"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sAppWorkOrder) Action(ctx context.Context, param *model.AppWorkOrderActionParam) error {
|
||||
if err := appEnsureFeatureAccess(ctx, "workorder:detail"); err != nil {
|
||||
return err
|
||||
}
|
||||
if param == nil || strings.TrimSpace(param.WoNo) == "" {
|
||||
return gerror.New("工单编号不能为空")
|
||||
}
|
||||
user, err := appCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
param.Content = strings.TrimSpace(param.Content)
|
||||
if utf8.RuneCountInString(param.Content) > 2000 {
|
||||
return gerror.New("内容最多2000字")
|
||||
}
|
||||
if err = appValidateWorkOrderAttachments(param.AttachmentUrls); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.validateAttachmentOwnership(ctx, user, param.AttachmentUrls); err != nil {
|
||||
return err
|
||||
}
|
||||
var current *entity.TWoMain
|
||||
if err := s.ownerModel(ctx, user).Where(dao.TWoMain.Columns().WoNo, strings.TrimSpace(param.WoNo)).Scan(¤t); err != nil {
|
||||
return gerror.Wrap(err, "读取工单失败")
|
||||
}
|
||||
if current == nil {
|
||||
return gerror.New("工单不存在或无权访问")
|
||||
}
|
||||
terminal, identityErr := appidentity.IsTerminalUser(ctx, user)
|
||||
if identityErr != nil {
|
||||
return gerror.Wrap(identityErr, "读取工单用户身份失败")
|
||||
}
|
||||
if terminal && current.CreatorUserId != user.UserId {
|
||||
return gerror.New("终端账号不能办理其他用户工单")
|
||||
}
|
||||
if !terminal {
|
||||
if err := appEnsureWorkOrderHandlerPermission(ctx, param.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err = s.applyWorkOrderAction(ctx, user, param)
|
||||
if err == nil && param.Action == "resolve" && rabbitmq.Enabled(ctx) {
|
||||
var resolved *entity.TWoMain
|
||||
if scanErr := dao.TWoMain.Ctx(ctx).Fields(dao.TWoMain.Columns().ResolveTime).Where(dao.TWoMain.Columns().TenantId, user.TenantId).Where(dao.TWoMain.Columns().WoNo, param.WoNo).Scan(&resolved); scanErr == nil && resolved != nil && resolved.ResolveTime != nil {
|
||||
if publishErr := rabbitmq.PublishDelayed(ctx, consts.QueueWorkOrderAutoCloseDelay, consts.QueueWorkOrderAutoClose, 72*time.Hour, model.WorkOrderAutoCloseMessage{WoNo: param.WoNo, ResolveTimeTs: resolved.ResolveTime.Timestamp()}); publishErr != nil {
|
||||
g.Log().Errorf(ctx, "工单延时关闭消息发送失败,补偿扫描仍可处理: %v", publishErr)
|
||||
}
|
||||
} else if scanErr != nil {
|
||||
g.Log().Errorf(ctx, "读取工单解决时间失败: %v", scanErr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// applyWorkOrderAction 在事务内验证操作人、租户和状态。
|
||||
func (s *sAppWorkOrder) applyWorkOrderAction(ctx context.Context, user *entity.SysUser, param *model.AppWorkOrderActionParam) error {
|
||||
return dao.TWoMain.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
var current *entity.TWoMain
|
||||
query := dao.TWoMain.Ctx(ctx).TX(tx).Where(dao.TWoMain.Columns().TenantId, user.TenantId).Where(dao.TWoMain.Columns().WoNo, strings.TrimSpace(param.WoNo))
|
||||
// SQLite 通过事务串行化写入,不支持 SELECT FOR UPDATE。
|
||||
if g.DB().GetConfig().Type != "sqlite" {
|
||||
query = query.LockUpdate()
|
||||
}
|
||||
if err := query.Scan(¤t); err != nil {
|
||||
return gerror.Wrap(err, "读取工单失败")
|
||||
}
|
||||
if current != nil && current.CreatorUserId != user.UserId {
|
||||
if err := appEnsureWorkOrderHandlerPermission(ctx, param.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
allowed := false
|
||||
for _, action := range append(appWorkOrderActions(current, user.UserId), appWorkOrderHandlerActions(current, user)...) {
|
||||
if action == param.Action {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return gerror.New("工单状态已变化或无权执行此操作,请刷新后重试")
|
||||
}
|
||||
now := gtime.Now()
|
||||
update := do.TWoMain{UpdatedBy: user.UserId}
|
||||
after, operation := current.CurrentStatus, -1
|
||||
switch param.Action {
|
||||
case "reply", "reopen":
|
||||
if param.Content == "" && len(param.AttachmentUrls) == 0 {
|
||||
return gerror.New("请输入回复内容或上传附件")
|
||||
}
|
||||
if param.Action == "reopen" && param.Content == "" {
|
||||
return gerror.New("请说明问题尚未解决的原因")
|
||||
}
|
||||
if param.Action == "reopen" {
|
||||
after, operation = appWorkOrderStatusProcessing, 2
|
||||
update.CurrentStatus = after
|
||||
}
|
||||
commType := 1
|
||||
if current.CreatorUserId != user.UserId {
|
||||
commType = 0
|
||||
}
|
||||
communication := do.TWoCommunication{WoNo: current.WoNo, CommType: commType, SenderDeptId: user.DeptId, SenderUserId: user.UserId, SenderName: appFirstNotEmpty(user.NickName, user.UserName), Content: param.Content, TenantId: user.TenantId, CreatedDept: user.DeptId, CreatedBy: user.UserId, UpdatedBy: user.UserId}
|
||||
if len(param.AttachmentUrls) > 0 {
|
||||
communication.AttachmentUrls = gjson.New(param.AttachmentUrls)
|
||||
}
|
||||
if current.CreatorUserId != user.UserId && current.FirstResponseTime == nil {
|
||||
update.FirstResponseTime = now
|
||||
}
|
||||
if _, err := dao.TWoCommunication.Ctx(ctx).TX(tx).Data(communication).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "保存工单回复失败")
|
||||
}
|
||||
case "transfer":
|
||||
var topDept *entity.SysDept
|
||||
if err := dao.SysDept.Ctx(ctx).TX(tx).Where(dao.SysDept.Columns().TenantId, user.TenantId).Where(dao.SysDept.Columns().ParentId, 0).Where(dao.SysDept.Columns().Status, "0").Fields(dao.SysDept.Columns().DeptId, dao.SysDept.Columns().Leader, dao.SysDept.Columns().Status).OrderAsc(dao.SysDept.Columns().OrderNum).OrderAsc(dao.SysDept.Columns().DeptId).Scan(&topDept); err != nil {
|
||||
return gerror.Wrap(err, "查询顶级部门失败")
|
||||
}
|
||||
if topDept == nil || topDept.DeptId <= 0 {
|
||||
return gerror.New("未找到启用的同租户顶级部门")
|
||||
}
|
||||
if topDept.DeptId == current.HandlerDeptId && topDept.Leader == current.HandlerUserId {
|
||||
return gerror.New("工单已由顶级部门处理,无需重复转单")
|
||||
}
|
||||
after, operation = appWorkOrderStatusProcessing, 1
|
||||
update.AssignDeptId, update.AssignUserId = topDept.DeptId, topDept.Leader
|
||||
update.HandlerDeptId, update.HandlerUserId, update.CurrentStatus = topDept.DeptId, topDept.Leader, after
|
||||
case "resolve":
|
||||
after, operation = appWorkOrderStatusResolved, 3
|
||||
update.CurrentStatus, update.ResolveTime = after, now
|
||||
update.HandlerDeptId, update.HandlerUserId = user.DeptId, user.UserId
|
||||
case "confirm", "close":
|
||||
after, operation = appWorkOrderStatusClosed, 4
|
||||
update.CurrentStatus, update.CloseTime = after, now
|
||||
case "cancel":
|
||||
after, operation = appWorkOrderStatusCanceled, 5
|
||||
update.CurrentStatus, update.CloseTime = after, now
|
||||
case "evaluate":
|
||||
if param.SatisfactionLevel < 1 || param.SatisfactionLevel > 5 {
|
||||
return gerror.New("请选择1至5星评价")
|
||||
}
|
||||
update.SatisfactionLevel, update.EvalContent, update.EvalTime = param.SatisfactionLevel, param.Content, now
|
||||
}
|
||||
result, err := dao.TWoMain.Ctx(ctx).TX(tx).Where(dao.TWoMain.Columns().Id, current.Id).Where(dao.TWoMain.Columns().TenantId, user.TenantId).Where(dao.TWoMain.Columns().CurrentStatus, current.CurrentStatus).Data(update).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新工单失败")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 && param.Action != "reply" {
|
||||
return gerror.New("工单已更新,请刷新后重试")
|
||||
}
|
||||
if operation >= 0 {
|
||||
if _, err := dao.TWoRecord.Ctx(ctx).TX(tx).Data(do.TWoRecord{WoNo: current.WoNo, TargetDeptId: update.HandlerDeptId, TargetUserId: update.HandlerUserId, OperationType: operation, OperationDeptId: user.DeptId, OperationUserId: user.UserId, BeforeStatus: current.CurrentStatus, AfterStatus: after, TenantId: user.TenantId, CreatedDept: user.DeptId, CreatedBy: user.UserId, UpdatedBy: user.UserId}).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "保存工单进度失败")
|
||||
}
|
||||
}
|
||||
if current.CreatorUserId != user.UserId && (param.Action == "reply" || param.Action == "resolve") {
|
||||
title := "工单收到回复"
|
||||
content := param.Content
|
||||
if param.Action == "resolve" {
|
||||
title, content = "工单待确认解决", "问题已标记解决,请在72小时内确认或反馈未解决。"
|
||||
}
|
||||
if err := appSaveWorkOrderNotice(ctx, tx, current, user.UserId, title, content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func appSaveWorkOrderNotice(ctx context.Context, tx gdb.TX, current *entity.TWoMain, operator int64, title, content string) error {
|
||||
if current == nil || current.CreatorUserId <= 0 || current.CreatorUserId == operator {
|
||||
return nil
|
||||
}
|
||||
id, err := dao.TMessageLog.Ctx(ctx).TX(tx).Data(do.TMessageLog{MessageType: 1, SendUserType: current.CreatorType, UserIds: gjson.New([]int64{current.CreatorUserId}), MessageTitle: title, MessageContent: content, MessageLink: "/pages/business/views/workOrderDetail/index?woNo=" + url.QueryEscape(current.WoNo), SendTime: gtime.Now(), IsRecall: 0, TenantId: current.TenantId, CreatedDept: current.CreatorDeptId, CreatedBy: operator}).InsertAndGetId()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "保存工单通知失败")
|
||||
}
|
||||
_, err = dao.TUserMessage.Ctx(ctx).TX(tx).Data(do.TUserMessage{MessageLogId: id, UserId: current.CreatorUserId, IsRead: 0, TenantId: current.TenantId, CreatedDept: current.CreatorDeptId, CreatedBy: operator}).Insert()
|
||||
return gerror.Wrap(err, "保存工单用户通知失败")
|
||||
}
|
||||
|
||||
// validateRelatedWorkOrder 复用卡片和订单的数据范围,禁止关联其他用户的业务。
|
||||
func (s *sAppWorkOrder) validateRelatedWorkOrder(ctx context.Context, user *entity.SysUser, param *model.AppWorkOrderCreateParam) error {
|
||||
if param.RelatedId == 0 {
|
||||
return nil
|
||||
}
|
||||
if param.RelatedId < 0 {
|
||||
return gerror.New("关联业务不存在或无权访问")
|
||||
}
|
||||
var count int
|
||||
var err error
|
||||
switch param.WoType {
|
||||
case 0:
|
||||
count, err = NewAppCard().visibleCardModel(ctx, user).Where(dao.TSimCardInfo.Table()+"."+dao.TSimCardInfo.Columns().Id, param.RelatedId).Count()
|
||||
case 3:
|
||||
count, err = NewAppOrder().ownerOrderModel(ctx, user).Where(dao.TOrder.Table()+"."+dao.TOrder.Columns().Id, param.RelatedId).Count()
|
||||
default:
|
||||
return gerror.New("当前类型不支持关联业务,请在描述中提供相关信息")
|
||||
}
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "查询关联业务失败")
|
||||
}
|
||||
if count == 0 {
|
||||
return gerror.New("关联业务不存在或无权访问")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appWorkOrderEncodeAttachments(urls []string) string {
|
||||
if len(urls) == 0 {
|
||||
return ""
|
||||
}
|
||||
return gjson.MustEncodeString(urls)
|
||||
}
|
||||
|
||||
// validateAttachmentOwnership 只接受当前用户在本租户上传的附件。
|
||||
func (s *sAppWorkOrder) validateAttachmentOwnership(ctx context.Context, user *entity.SysUser, urls []string) error {
|
||||
columns := dao.SysOss.Columns()
|
||||
for _, value := range urls {
|
||||
var file *entity.SysOss
|
||||
if err := dao.SysOss.Ctx(ctx).Where(columns.TenantId, user.TenantId).Where(columns.CreatedBy, user.UserId).Where(columns.Url, value).Limit(1).Scan(&file); err != nil {
|
||||
return gerror.Wrap(err, "校验工单附件失败")
|
||||
}
|
||||
if file == nil || file.OssId == 0 {
|
||||
return gerror.New("附件不存在或不属于当前用户,请重新上传")
|
||||
}
|
||||
if file.FileSize > 10*1024*1024 {
|
||||
return gerror.New("单个附件不能超过10MB")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"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/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestWorkOrderCreatorActions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
row *entity.TWoMain
|
||||
user int64
|
||||
want []string
|
||||
}{
|
||||
{"other user cannot operate", &entity.TWoMain{CreatorUserId: 1, CurrentStatus: 2}, 2, []string{}},
|
||||
{"anonymous cannot operate", &entity.TWoMain{CreatorUserId: 0, CurrentStatus: 2}, 0, []string{}},
|
||||
{"missing order", nil, 1, []string{}},
|
||||
{"processing", &entity.TWoMain{CreatorUserId: 1, CurrentStatus: 2}, 1, []string{"reply", "cancel"}},
|
||||
{"resolved", &entity.TWoMain{CreatorUserId: 1, CurrentStatus: 3}, 1, []string{"reopen", "confirm"}},
|
||||
{"closed", &entity.TWoMain{CreatorUserId: 1, CurrentStatus: 4}, 1, []string{"evaluate"}},
|
||||
{"canceled cannot evaluate", &entity.TWoMain{CreatorUserId: 1, CurrentStatus: 5}, 1, []string{}},
|
||||
{"evaluated once", &entity.TWoMain{CreatorUserId: 1, CurrentStatus: 4, EvalTime: gtime.Now()}, 1, []string{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := appWorkOrderActions(tc.row, tc.user); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("actions = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkOrderHandlerActions(t *testing.T) {
|
||||
user := &entity.SysUser{UserId: 20, DeptId: 30, TenantId: "tenant-a"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
row *entity.TWoMain
|
||||
want []string
|
||||
}{
|
||||
{"assignee", &entity.TWoMain{TenantId: "tenant-a", CreatorUserId: 10, HandlerUserId: 20, CurrentStatus: 1}, []string{"reply", "resolve", "transfer"}},
|
||||
{"department", &entity.TWoMain{TenantId: "tenant-a", CreatorUserId: 10, HandlerDeptId: 30, CurrentStatus: 2}, []string{"reply", "resolve", "transfer"}},
|
||||
{"resolved", &entity.TWoMain{TenantId: "tenant-a", CreatorUserId: 10, HandlerUserId: 20, CurrentStatus: 3}, []string{"reply", "close"}},
|
||||
{"creator cannot handle", &entity.TWoMain{TenantId: "tenant-a", CreatorUserId: 20, HandlerUserId: 20, CurrentStatus: 2}, []string{}},
|
||||
{"other department", &entity.TWoMain{TenantId: "tenant-a", CreatorUserId: 10, HandlerDeptId: 40, CurrentStatus: 2}, []string{}},
|
||||
{"other tenant", &entity.TWoMain{TenantId: "tenant-b", CreatorUserId: 10, HandlerUserId: 20, CurrentStatus: 2}, []string{}},
|
||||
{"closed", &entity.TWoMain{TenantId: "tenant-a", CreatorUserId: 10, HandlerUserId: 20, CurrentStatus: 4}, []string{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := appWorkOrderHandlerActions(tc.row, user); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("actions=%v want=%v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkOrderHandlerTransactionAndNotification(t *testing.T) {
|
||||
ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 20, Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}}}}})
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "handler.db")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database := g.DB()
|
||||
if err := database.GetCore().ClearCacheAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.GetCore().ClearCacheAll(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := database.Close(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := gdb.SetConfigGroup("default", previous); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
for _, sql := range []string{
|
||||
`CREATE TABLE t_wo_main (id INTEGER PRIMARY KEY, wo_no TEXT, tenant_id TEXT, creator_type INTEGER, creator_dept_id INTEGER, creator_user_id INTEGER, handler_dept_id INTEGER, handler_user_id INTEGER, assign_dept_id INTEGER, assign_user_id INTEGER, current_status INTEGER, first_response_time DATETIME, resolve_time DATETIME, close_time DATETIME, eval_time DATETIME, satisfaction_level INTEGER DEFAULT 0, eval_content TEXT, updated_by INTEGER, updated_at DATETIME)`,
|
||||
`CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, leader INTEGER, status TEXT, order_num INTEGER, deleted_at DATETIME)`,
|
||||
`CREATE TABLE t_wo_communication (id INTEGER PRIMARY KEY, wo_no TEXT, comm_type INTEGER, sender_dept_id INTEGER, sender_user_id INTEGER, sender_name TEXT, content TEXT, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, updated_by INTEGER, created_at DATETIME, updated_at DATETIME)`,
|
||||
`CREATE TABLE t_wo_record (id INTEGER PRIMARY KEY, wo_no TEXT, operation_type INTEGER, operation_dept_id INTEGER, operation_user_id INTEGER, target_dept_id INTEGER, target_user_id INTEGER, before_status INTEGER, after_status INTEGER, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, updated_by INTEGER, created_at DATETIME, updated_at DATETIME)`,
|
||||
`CREATE TABLE t_message_log (message_log_id INTEGER PRIMARY KEY, message_type INTEGER, send_user_type INTEGER, user_ids TEXT, message_title TEXT, message_content TEXT, message_link TEXT, send_time DATETIME, is_recall INTEGER, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME)`,
|
||||
`CREATE TABLE t_user_message (message_id INTEGER PRIMARY KEY, message_log_id INTEGER, user_id INTEGER, is_read INTEGER, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME)`,
|
||||
`INSERT INTO t_wo_main(id,wo_no,tenant_id,creator_type,creator_dept_id,creator_user_id,handler_dept_id,handler_user_id,current_status) VALUES(1,'WO-1','tenant-a',2,10,10,30,20,1)`,
|
||||
`INSERT INTO sys_dept(dept_id,tenant_id,parent_id,leader,status,order_num) VALUES(50,'tenant-a',0,51,'0',1)`,
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := database.GetCore().GetTablesWithCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewAppWorkOrder()
|
||||
handler := &entity.SysUser{UserId: 20, DeptId: 30, TenantId: "tenant-a"}
|
||||
for _, action := range []string{"reply", "transfer", "resolve", "close"} {
|
||||
if err := service.applyWorkOrderAction(ctx, handler, &model.AppWorkOrderActionParam{WoNo: "WO-1", Action: action, Content: "已收到,正在排查"}); action == "resolve" || action == "close" {
|
||||
if err == nil {
|
||||
t.Fatalf("transferred handler unexpectedly performed %s", action)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("%s: %v", action, err)
|
||||
}
|
||||
}
|
||||
status, err := g.DB().GetValue(ctx, "SELECT current_status FROM t_wo_main WHERE id=1")
|
||||
if err != nil || status.Int() != 2 {
|
||||
t.Fatalf("transfer status=%v err=%v", status, err)
|
||||
}
|
||||
handler = &entity.SysUser{UserId: 51, DeptId: 50, TenantId: "tenant-a"}
|
||||
if err := service.applyWorkOrderAction(ctx, handler, &model.AppWorkOrderActionParam{WoNo: "WO-1", Action: "resolve"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notice, err := g.DB().GetValue(ctx, "SELECT COUNT(*) FROM t_user_message WHERE user_id=10")
|
||||
if err != nil || notice.Int() != 2 {
|
||||
t.Fatalf("notification count=%v err=%v", notice, err)
|
||||
}
|
||||
if err := service.applyWorkOrderAction(ctx, handler, &model.AppWorkOrderActionParam{WoNo: "WO-1", Action: "close"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.applyWorkOrderAction(ctx, handler, &model.AppWorkOrderActionParam{WoNo: "WO-1", Action: "reply", Content: "closed"}); err == nil {
|
||||
t.Fatal("replied after close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkOrderAttachmentCompatibility(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want []string
|
||||
}{
|
||||
{"", nil}, {"[]", []string{}}, {"null", nil}, {"/files/a.png", []string{"/files/a.png"}},
|
||||
{"[\"/a.png\",\"/b.pdf\"]", []string{"/a.png", "/b.pdf"}},
|
||||
} {
|
||||
if got := decodeAttachmentUrls(tc.value); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("decode %q = %v, want %v", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"javascript:alert(1)", "file:///etc/passwd", "//other/a", "", "not-a-url"} {
|
||||
if appValidateWorkOrderAttachments([]string{value}) == nil {
|
||||
t.Fatalf("accepted invalid URL %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkOrderActionTransactions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "workorder.db")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database := g.DB()
|
||||
if err := database.GetCore().ClearCacheAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.GetCore().ClearCacheAll(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := database.Close(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := gdb.SetConfigGroup("default", previous); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
for _, sql := range []string{
|
||||
`CREATE TABLE t_wo_main (id INTEGER PRIMARY KEY, wo_no TEXT, tenant_id TEXT, creator_type INTEGER, creator_dept_id INTEGER, creator_user_id INTEGER, assign_dept_id INTEGER, assign_user_id INTEGER, handler_dept_id INTEGER, handler_user_id INTEGER, first_response_time DATETIME, current_status INTEGER, updated_by INTEGER, updated_at DATETIME, close_time DATETIME, eval_time DATETIME, satisfaction_level INTEGER DEFAULT 0, eval_content TEXT, resolve_time DATETIME)`,
|
||||
`CREATE TABLE t_wo_communication (id INTEGER PRIMARY KEY, wo_no TEXT, comm_type INTEGER, sender_dept_id INTEGER, sender_user_id INTEGER, sender_name TEXT, content TEXT, attachment_urls TEXT, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, updated_by INTEGER, created_at DATETIME, updated_at DATETIME)`,
|
||||
`CREATE TABLE t_wo_record (id INTEGER PRIMARY KEY, wo_no TEXT, operation_type INTEGER, operation_dept_id INTEGER, operation_user_id INTEGER, before_status INTEGER, after_status INTEGER, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, updated_by INTEGER, created_at DATETIME, updated_at DATETIME)`,
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO t_wo_main(id,wo_no,tenant_id,creator_user_id,current_status) VALUES(1,'WO-1','tenant-a',10,3),(2,'WO-2','tenant-a',10,3)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := &entity.SysUser{UserId: 10, TenantId: "tenant-a", DeptId: 20}
|
||||
if _, err := database.GetCore().GetTablesWithCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewAppWorkOrder()
|
||||
run := func(wo, action, content string, who *entity.SysUser, rating int) error {
|
||||
return service.applyWorkOrderAction(ctx, who, &model.AppWorkOrderActionParam{WoNo: wo, Action: action, Content: content, SatisfactionLevel: rating})
|
||||
}
|
||||
if err := run("WO-1", "confirm", "", &entity.SysUser{UserId: 11, TenantId: "tenant-a"}, 0); err == nil {
|
||||
t.Fatal("other user confirmed order")
|
||||
}
|
||||
if err := run("WO-1", "confirm", "", &entity.SysUser{UserId: 10, TenantId: "tenant-b"}, 0); err == nil {
|
||||
t.Fatal("other tenant confirmed order")
|
||||
}
|
||||
if err := run("WO-1", "reopen", "仍无法上网", user, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, err := g.DB().GetValue(ctx, "SELECT current_status FROM t_wo_main WHERE id=1")
|
||||
if err != nil || status.Int() != 2 {
|
||||
t.Fatalf("reopen status=%v err=%v", status, err)
|
||||
}
|
||||
if err := run("WO-1", "confirm", "", user, 0); err == nil {
|
||||
t.Fatal("confirmed processing order")
|
||||
}
|
||||
if err := run("WO-1", "cancel", "", user, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run("WO-1", "reply", "cancelled reply", user, 0); err == nil {
|
||||
t.Fatal("replied to canceled order")
|
||||
}
|
||||
if err := run("WO-1", "evaluate", "", user, 5); err == nil {
|
||||
t.Fatal("evaluated canceled order")
|
||||
}
|
||||
if err := run("WO-2", "confirm", "", user, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run("WO-2", "evaluate", "满意", user, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run("WO-2", "evaluate", "重复评价", user, 1); err == nil {
|
||||
t.Fatal("evaluated twice")
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO t_wo_main(id,wo_no,tenant_id,creator_user_id,current_status) VALUES(3,'WO-3','tenant-a',10,3)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "DROP TABLE t_wo_record"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := run("WO-3", "reopen", "需要继续排查", user, 0); err == nil {
|
||||
t.Fatal("expected record insert failure")
|
||||
}
|
||||
status, err = g.DB().GetValue(ctx, "SELECT current_status FROM t_wo_main WHERE id=3")
|
||||
if err != nil || status.Int() != 3 {
|
||||
t.Fatalf("status did not roll back: %v %v", status, err)
|
||||
}
|
||||
count, err := g.DB().GetValue(ctx, "SELECT COUNT(*) FROM t_wo_communication WHERE wo_no='WO-3'")
|
||||
if err != nil || count.Int() != 0 {
|
||||
t.Fatalf("communication did not roll back: %v %v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func appWorkOrderOriginalName(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
name := path.Base(strings.ReplaceAll(value, "\\", "/"))
|
||||
if name == "." || name == ".." || name == "/" || utf8.RuneCountInString(name) > 255 || strings.ContainsFunc(name, unicode.IsControl) {
|
||||
return "", gerror.New("附件文件名无效")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func appWorkOrderAttachmentNames(ctx context.Context, tenantID string, urls []string) (map[string]string, error) {
|
||||
names := make(map[string]string)
|
||||
if len(urls) == 0 || strings.TrimSpace(tenantID) == "" {
|
||||
return names, nil
|
||||
}
|
||||
columns := dao.SysOss.Columns()
|
||||
var files []*entity.SysOss
|
||||
if err := dao.SysOss.Ctx(ctx).
|
||||
Fields(columns.Url, columns.Path, columns.OriginalName).
|
||||
Where(columns.TenantId, tenantID).
|
||||
Where("("+columns.Url+" IN (?) OR "+columns.Path+" IN (?))", urls, urls).
|
||||
OrderAsc(columns.OssId).
|
||||
Scan(&files); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询工单附件原名失败")
|
||||
}
|
||||
requested := make(map[string]bool, len(urls))
|
||||
for _, url := range urls {
|
||||
requested[url] = true
|
||||
}
|
||||
for _, file := range files {
|
||||
name, err := appWorkOrderOriginalName(file.OriginalName)
|
||||
if err != nil || name == "" {
|
||||
continue
|
||||
}
|
||||
for _, url := range []string{file.Url, file.Path} {
|
||||
if requested[url] {
|
||||
names[url] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestWorkOrderOriginalName(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
invalid bool
|
||||
}{
|
||||
{"", "", false},
|
||||
{" 排查说明.pdf ", "排查说明.pdf", false},
|
||||
{"C:\\fakepath\\资费表.xlsx", "资费表.xlsx", false},
|
||||
{"../../说明.docx", "说明.docx", false},
|
||||
{"..", "", true},
|
||||
{"bad\nname.pdf", "", true},
|
||||
{strings.Repeat("字", 256), "", true},
|
||||
}
|
||||
for _, item := range cases {
|
||||
name, err := appWorkOrderOriginalName(item.input)
|
||||
if name != item.want || (err != nil) != item.invalid {
|
||||
t.Fatalf("input=%q, name=%q, err=%v", item.input, name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkOrderAttachmentNamesTenantAndLegacy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "attachment.db")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database := g.DB()
|
||||
if err := database.GetCore().ClearTableFields(ctx, "sys_oss"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = database.GetCore().ClearTableFields(ctx, "sys_oss")
|
||||
_ = database.Close(ctx)
|
||||
_ = gdb.SetConfigGroup("default", previous)
|
||||
})
|
||||
statements := []string{
|
||||
`CREATE TABLE sys_oss (oss_id INTEGER, tenant_id TEXT, url TEXT, path TEXT, original_name TEXT)`,
|
||||
`INSERT INTO sys_oss VALUES (1,'A','https://files/upload/random.pdf','resource/upload/random.pdf','排查说明.pdf'),(2,'B','https://files/upload/other.xlsx','resource/upload/other.xlsx','其他租户.xlsx'),(3,'A','https://files/upload/empty.pdf','resource/upload/empty.pdf','')`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := database.Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
names, err := appWorkOrderAttachmentNames(ctx, "A", []string{"https://files/upload/random.pdf", "resource/upload/random.pdf", "https://files/upload/other.xlsx", "https://files/upload/empty.pdf", "missing.pdf"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(names) != 2 || names["https://files/upload/random.pdf"] != "排查说明.pdf" || names["resource/upload/random.pdf"] != "排查说明.pdf" {
|
||||
t.Fatalf("names=%v", names)
|
||||
}
|
||||
for _, tenant := range []string{"", "B"} {
|
||||
names, err = appWorkOrderAttachmentNames(ctx, tenant, []string{"https://files/upload/random.pdf"})
|
||||
if err != nil || len(names) != 0 {
|
||||
t.Fatalf("tenant=%q, names=%v, err=%v", tenant, names, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
)
|
||||
|
||||
func TestWorkOrderContactPhone(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
phone string
|
||||
want string
|
||||
wantError bool
|
||||
}{
|
||||
{"使用填写的手机号", "13900009000", "13900009000", false},
|
||||
{"去除首尾空格", " 13800008000 ", "13800008000", false},
|
||||
{"空手机号", "", "", true},
|
||||
{"仅空格", " ", "", true},
|
||||
{"位数不正确", "1380013800", "", true},
|
||||
{"前缀不正确", "12800138000", "", true},
|
||||
{"包含非数字", "1380013800a", "", true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
got, err := appWorkOrderContactPhone(testCase.phone)
|
||||
if got != testCase.want || (err != nil) != testCase.wantError {
|
||||
t.Fatalf("phone = %q, error = %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkOrderContactPhoneRequired(t *testing.T) {
|
||||
for _, payload := range []string{`{}`, `{"creatorPhone":null}`, `{"creatorPhone":""}`, `{"creatorPhone":" "}`} {
|
||||
var param model.AppWorkOrderCreateParam
|
||||
if err := json.Unmarshal([]byte(payload), ¶m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := appWorkOrderContactPhone(param.CreatorPhone); err == nil {
|
||||
t.Fatalf("未拦截未填写手机号的请求: %s", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestWorkOrderHandlerUsesDirectParent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Name: filepath.Join(t.TempDir(), "handler.db")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
database := g.DB()
|
||||
if err := database.GetCore().ClearTableFields(ctx, "sys_dept"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = database.GetCore().ClearTableFields(ctx, "sys_dept")
|
||||
_ = database.Close(ctx)
|
||||
_ = gdb.SetConfigGroup("default", previous)
|
||||
})
|
||||
statements := []string{
|
||||
"CREATE TABLE sys_dept (dept_id INTEGER, tenant_id TEXT, parent_id INTEGER, leader INTEGER, status TEXT, deleted_at DATETIME)",
|
||||
"INSERT INTO sys_dept VALUES (100,'A',0,1,'0',NULL),(200,'A',100,2,'0',NULL),(300,'A',200,3,'0',NULL),(400,'A',300,4,'0',NULL)",
|
||||
"INSERT INTO sys_dept VALUES (500,'A',999,5,'0',NULL),(999,'B',0,99,'0',NULL),(600,'A',700,6,'0',NULL),(700,'A',100,7,'1',NULL)",
|
||||
"INSERT INTO sys_dept VALUES (800,'A',900,8,'0',NULL),(900,'A',100,9,'0','2026-01-01'),(1000,'A',1000,10,'0',NULL),(1100,'A',1200,11,'0',NULL)",
|
||||
"INSERT INTO sys_dept VALUES (1200,'A',100,0,'0',NULL),(1300,'A',100,13,'1',NULL),(1400,'A',100,14,'0','2026-01-01')",
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := database.Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
dept int64
|
||||
wantDept int64
|
||||
wantUser int64
|
||||
wantError bool
|
||||
}{
|
||||
{"直属上级而非顶级", 300, 200, 2, false},
|
||||
{"多层代理只向上一级", 400, 300, 3, false},
|
||||
{"顶级部门由本部门处理", 100, 100, 1, false},
|
||||
{"一级代理分派给父级100及其负责人", 200, 100, 1, false},
|
||||
{"无负责人仍归属处理部门", 1100, 1200, 0, false},
|
||||
{"跨租户父级不能接单", 500, 0, 0, true},
|
||||
{"停用父级不能接单", 600, 0, 0, true},
|
||||
{"删除父级不能接单", 800, 0, 0, true},
|
||||
{"部门不能指向自身", 1000, 0, 0, true},
|
||||
{"停用所属部门不能提交", 1300, 0, 0, true},
|
||||
{"删除所属部门不能提交", 1400, 0, 0, true},
|
||||
{"缺失所属部门不能提交", 1500, 0, 0, true},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
user := &entity.SysUser{UserId: 10, TenantId: "A", DeptId: testCase.dept}
|
||||
deptID, userID, err := appWorkOrderHandler(ctx, user, user.DeptId)
|
||||
if (err != nil) != testCase.wantError {
|
||||
t.Fatalf("error = %v, wantError = %v", err, testCase.wantError)
|
||||
}
|
||||
if deptID != testCase.wantDept || userID != testCase.wantUser {
|
||||
t.Fatalf("handler = (%d, %d), want (%d, %d)", deptID, userID, testCase.wantDept, testCase.wantUser)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, user := range []*entity.SysUser{nil, {DeptId: 200}, {TenantId: "A"}} {
|
||||
var deptID int64
|
||||
if user != nil {
|
||||
deptID = user.DeptId
|
||||
}
|
||||
if _, _, err := appWorkOrderHandler(ctx, user, deptID); err == nil {
|
||||
t.Fatal("invalid user was assigned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkOrderParentDepartmentCanReply(t *testing.T) {
|
||||
row := &entity.TWoMain{
|
||||
TenantId: "A", CreatorDeptId: 175, CreatorUserId: 133,
|
||||
HandlerDeptId: 100, HandlerUserId: 36, CurrentStatus: appWorkOrderStatusProcessing,
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
user entity.SysUser
|
||||
allowed bool
|
||||
}{
|
||||
{"父级部门管理员无需为部门负责人", entity.SysUser{UserId: 1, DeptId: 100, TenantId: "A"}, true},
|
||||
{"父级负责人可以办理", entity.SysUser{UserId: 36, DeptId: 100, TenantId: "A"}, true},
|
||||
{"下级其他用户不能办理", entity.SysUser{UserId: 134, DeptId: 175, TenantId: "A"}, false},
|
||||
{"无关部门不能办理", entity.SysUser{UserId: 200, DeptId: 200, TenantId: "A"}, false},
|
||||
{"跨租户不能办理", entity.SysUser{UserId: 1, DeptId: 100, TenantId: "B"}, false},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
allowed := false
|
||||
for _, action := range appWorkOrderHandlerActions(row, &testCase.user) {
|
||||
if action == "reply" {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if allowed != testCase.allowed {
|
||||
t.Fatalf("reply=%v, expected=%v", allowed, testCase.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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, requirePublication ...bool) 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 (len(requirePublication) > 0 && requirePublication[0]) || 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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestMain(tests *testing.M) {
|
||||
if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
g.DB()
|
||||
os.Exit(tests.Run())
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/genin"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility/excel"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sGenTestDemo struct{}
|
||||
|
||||
func NewGenTestDemo() *sGenTestDemo {
|
||||
return &sGenTestDemo{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterGenTestDemo(NewGenTestDemo())
|
||||
}
|
||||
|
||||
// Model 测试单表ORM模型
|
||||
func (s *sGenTestDemo) Model(ctx context.Context, option ...*handler.Option) *gdb.Model {
|
||||
if len(option) == 0 {
|
||||
option = append(option, &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
})
|
||||
}
|
||||
return handler.Model(dao.TestDemo.Ctx(ctx), option...)
|
||||
}
|
||||
|
||||
// List 获取测试单表列表
|
||||
func (s *sGenTestDemo) List(ctx context.Context, in *genin.TestDemoListParam) (list []*genin.TestDemoListModel, totalCount int, err error) {
|
||||
mod := s.Model(ctx)
|
||||
|
||||
// 字段过滤
|
||||
mod = mod.FieldsPrefix(dao.TestDemo.Table(), genin.TestDemoListModel{})
|
||||
mod = mod.Fields(xgorm.JoinFields(ctx, genin.TestDemoListModel{}, &dao.SysDept, "dept"))
|
||||
|
||||
// 关联表字段
|
||||
mod = mod.LeftJoinOnFields(dao.SysDept.Table(), dao.TestDemo.Columns().DeptId, "=", dao.SysDept.Columns().DeptId)
|
||||
|
||||
// 查询主键
|
||||
if in.Id > 0 {
|
||||
mod = mod.Where(dao.TestDemo.Columns().Id, in.Id)
|
||||
}
|
||||
|
||||
// 查询创建时间
|
||||
if len(in.CreatedAt) == 2 {
|
||||
in.CreatedAt[1] = in.CreatedAt[1].EndOfDay()
|
||||
}
|
||||
if len(in.CreatedAt) == 2 {
|
||||
mod = mod.WhereBetween(dao.TestDemo.Columns().CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
|
||||
}
|
||||
|
||||
// 查询部门名称
|
||||
if in.DeptDeptName != "" {
|
||||
mod = mod.WherePrefixLike(dao.SysDept.Table(), dao.SysDept.Columns().DeptName, in.DeptDeptName)
|
||||
}
|
||||
|
||||
// 分页
|
||||
mod = mod.Page(in.Page, in.PageSize)
|
||||
|
||||
// 排序
|
||||
mod = mod.OrderDesc(dao.TestDemo.Table() + "." + dao.TestDemo.Columns().Id)
|
||||
|
||||
// 操作人摘要信息
|
||||
mod = mod.Hook(hook.MemberSummary)
|
||||
|
||||
// 查询数据
|
||||
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
|
||||
err = gerror.Wrap(err, "获取测试单表列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出测试单表
|
||||
func (s *sGenTestDemo) Export(ctx context.Context, in *genin.TestDemoListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(genin.TestDemoExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出测试单表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []genin.TestDemoExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
}
|
||||
|
||||
// Edit 修改/新增测试单表
|
||||
func (s *sGenTestDemo) Edit(ctx context.Context, in *genin.TestDemoEditParam) (err error) {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
|
||||
|
||||
// 修改
|
||||
if in.Id > 0 {
|
||||
in.UpdatedAt = gtime.Now()
|
||||
in.UpdatedBy = contexts.GetUserId(ctx)
|
||||
data := do.TestDemo{}
|
||||
gconv.Struct(in, &data)
|
||||
data.Id = nil
|
||||
if _, err = s.Model(ctx).TX(tx).
|
||||
Fields(genin.TestDemoUpdateFields{}).
|
||||
WherePri(in.Id).Data(data).Update(); err != nil {
|
||||
err = gerror.Wrap(err, "修改测试单表失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 新增
|
||||
in.TenantId = contexts.GetTenantId(ctx)
|
||||
in.CreatedDept = contexts.GetDeptId(ctx)
|
||||
in.CreatedAt = gtime.Now()
|
||||
in.CreatedBy = contexts.GetUserId(ctx)
|
||||
data := do.TestDemo{}
|
||||
gconv.Struct(in, &data)
|
||||
data.Id = nil
|
||||
if _, err = s.Model(ctx, &handler.Option{FilterAuth: false}).TX(tx).
|
||||
Fields(genin.TestDemoInsertFields{}).
|
||||
Data(data).OmitEmptyData().Insert(); err != nil {
|
||||
err = gerror.Wrap(err, "新增测试单表失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
// Delete 删除测试单表
|
||||
func (s *sGenTestDemo) Delete(ctx context.Context, in *genin.TestDemoDeleteParam) (err error) {
|
||||
|
||||
data := do.TestDemo{
|
||||
DeletedBy: contexts.GetUserId(ctx),
|
||||
DeletedAt: gtime.Now(),
|
||||
}
|
||||
if _, err = s.Model(ctx).WherePri(in.Id).Data(data).Unscoped().Update(); err != nil {
|
||||
err = gerror.Wrap(err, "删除测试单表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// View 获取测试单表指定信息
|
||||
func (s *sGenTestDemo) View(ctx context.Context, in *genin.TestDemoViewParam) (res *genin.TestDemoViewModel, err error) {
|
||||
if err = s.Model(ctx).WherePri(in.Id).Hook(hook.MemberSummary).Scan(&res); err != nil {
|
||||
err = gerror.Wrap(err, "获取测试单表信息,请稍后重试!")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package gen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/genin"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility/excel"
|
||||
|
||||
"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/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility/tree"
|
||||
)
|
||||
|
||||
type sGenTestTree struct{}
|
||||
|
||||
func NewGenTestTree() *sGenTestTree {
|
||||
return &sGenTestTree{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterGenTestTree(NewGenTestTree())
|
||||
}
|
||||
|
||||
// Model 测试树表ORM模型
|
||||
func (s *sGenTestTree) Model(ctx context.Context, option ...*handler.Option) *gdb.Model {
|
||||
if len(option) == 0 {
|
||||
option = append(option, &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
})
|
||||
}
|
||||
return handler.Model(dao.TestTree.Ctx(ctx), option...)
|
||||
}
|
||||
|
||||
// List 获取测试树表列表
|
||||
func (s *sGenTestTree) List(ctx context.Context, in *genin.TestTreeListParam) (list []*genin.TestTreeListModel, totalCount int, err error) {
|
||||
mod := s.Model(ctx)
|
||||
|
||||
// 字段过滤
|
||||
mod = mod.Fields(genin.TestTreeListModel{})
|
||||
|
||||
// 查询主键
|
||||
if in.Id > 0 {
|
||||
mod = mod.Where(dao.TestTree.Columns().Id, in.Id)
|
||||
}
|
||||
|
||||
// 查询父id
|
||||
if in.ParentId > 0 {
|
||||
mod = mod.Where(dao.TestTree.Columns().ParentId, in.ParentId)
|
||||
}
|
||||
|
||||
// 查询创建时间
|
||||
if len(in.CreatedAt) == 2 {
|
||||
in.CreatedAt[1] = in.CreatedAt[1].EndOfDay()
|
||||
}
|
||||
if len(in.CreatedAt) == 2 {
|
||||
mod = mod.WhereBetween(dao.TestTree.Columns().CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
|
||||
}
|
||||
|
||||
// 树形列表判断是否需要分页
|
||||
if in.Page > 0 && in.PageSize > 0 {
|
||||
mod = mod.Page(in.Page, in.PageSize)
|
||||
}
|
||||
|
||||
// 排序
|
||||
mod = mod.OrderDesc(dao.TestTree.Columns().Id)
|
||||
|
||||
// 操作人摘要信息
|
||||
mod = mod.Hook(hook.MemberSummary)
|
||||
|
||||
// 查询数据
|
||||
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
|
||||
err = gerror.Wrap(err, "获取测试树表列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出测试树表
|
||||
func (s *sGenTestTree) Export(ctx context.Context, in *genin.TestTreeListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(genin.TestTreeExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出测试树表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []genin.TestTreeExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
}
|
||||
|
||||
// Edit 修改/新增测试树表
|
||||
func (s *sGenTestTree) Edit(ctx context.Context, in *genin.TestTreeEditParam) (err error) {
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) (err error) {
|
||||
// 修改
|
||||
if in.Id > 0 {
|
||||
in.UpdatedAt = gtime.Now()
|
||||
updatedBy := contexts.GetUserId(ctx)
|
||||
in.UpdatedBy = &updatedBy
|
||||
data := do.TestTree{}
|
||||
gconv.Struct(in, &data)
|
||||
data.Id = nil
|
||||
if _, err = s.Model(ctx).TX(tx).
|
||||
Fields(genin.TestTreeUpdateFields{}).
|
||||
WherePri(in.Id).Data(data).Update(); err != nil {
|
||||
err = gerror.Wrap(err, "修改测试树表失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 新增
|
||||
tenantId := contexts.GetTenantId(ctx)
|
||||
in.TenantId = &tenantId
|
||||
createdDept := contexts.GetDeptId(ctx)
|
||||
in.CreatedDept = &createdDept
|
||||
in.CreatedAt = gtime.Now()
|
||||
createdBy := contexts.GetUserId(ctx)
|
||||
in.CreatedBy = &createdBy
|
||||
data := do.TestTree{}
|
||||
gconv.Struct(in, &data)
|
||||
data.Id = nil
|
||||
if _, err = s.Model(ctx, &handler.Option{FilterAuth: false}).TX(tx).
|
||||
Fields(genin.TestTreeInsertFields{}).
|
||||
Data(data).Insert(); err != nil {
|
||||
err = gerror.Wrap(err, "新增测试树表失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
})
|
||||
}
|
||||
|
||||
// Delete 删除测试树表
|
||||
func (s *sGenTestTree) Delete(ctx context.Context, in *genin.TestTreeDeleteParam) (err error) {
|
||||
count, err := dao.TestTree.Ctx(ctx).Where(dao.TestTree.Columns().ParentId, in.Id).Count()
|
||||
if err != nil {
|
||||
err = gerror.Wrap(err, "查询测试树表下级失败,请稍后重试!")
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return gerror.New("请先删除该测试树表下的所有下级!")
|
||||
}
|
||||
|
||||
data := do.TestTree{
|
||||
DeletedBy: contexts.GetUserId(ctx),
|
||||
DeletedAt: gtime.Now(),
|
||||
}
|
||||
if _, err = s.Model(ctx).WherePri(in.Id).Data(data).Unscoped().Update(); err != nil {
|
||||
err = gerror.Wrap(err, "删除测试树表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// View 获取测试树表指定信息
|
||||
func (s *sGenTestTree) View(ctx context.Context, in *genin.TestTreeViewParam) (res *genin.TestTreeViewModel, err error) {
|
||||
if err = s.Model(ctx).WherePri(in.Id).Hook(hook.MemberSummary).Scan(&res); err != nil {
|
||||
err = gerror.Wrap(err, "获取测试树表信息,请稍后重试!")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TreeOption 获取测试树表关系树选项
|
||||
func (s *sGenTestTree) TreeOption(ctx context.Context) (nodes []tree.Node, err error) {
|
||||
var models []*genin.TestTreeTreeOption
|
||||
if err = s.Model(ctx).Fields(genin.TestTreeTreeOption{}).OrderAsc(dao.TestTree.Columns().ParentId).OrderDesc(dao.TestTree.Columns().Id).Scan(&models); err != nil {
|
||||
err = gerror.Wrap(err, "获取测试树表关系树选项失败!")
|
||||
return
|
||||
}
|
||||
nodes = make([]tree.Node, len(models))
|
||||
for i, v := range models {
|
||||
nodes[i] = v
|
||||
}
|
||||
return tree.ListToTree(0, nodes), nil
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// package gen_codes
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package gen_codes
|
||||
@@ -0,0 +1,218 @@
|
||||
// package gen_codes
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package gen_codes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/library/xgen"
|
||||
genconsts "git.5iot.com/iotlcm-public/iotlcm-app/internal/library/xgen/gen_consts"
|
||||
"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/do"
|
||||
"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"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sSysGenTable struct {
|
||||
}
|
||||
|
||||
func SysGenTableNew() *sSysGenTable {
|
||||
return &sSysGenTable{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterSysGenTable(SysGenTableNew())
|
||||
}
|
||||
func (l *sSysGenTable) Model(ctx context.Context, option ...*handler.Option) *gdb.Model {
|
||||
if len(option) == 0 {
|
||||
option = append(option, &handler.Option{
|
||||
FilterTenant: false,
|
||||
FilterAuth: true,
|
||||
})
|
||||
}
|
||||
return handler.Model(dao.SysGenTable.Ctx(ctx), option...)
|
||||
}
|
||||
|
||||
// 列表
|
||||
func (s *sSysGenTable) List(ctx context.Context, param *model.SysGenTableListParam) (output []*model.SysGenTableListModel, total int, err error) {
|
||||
m := s.Model(ctx)
|
||||
if param.GenType != 0 {
|
||||
m = m.Where(dao.SysGenTable.Columns().GenType, param.GenType)
|
||||
}
|
||||
if param.VarName != "" {
|
||||
m = m.Where(dao.SysGenTable.Columns().VarName, param.VarName)
|
||||
}
|
||||
if param.Status != "" {
|
||||
m = m.Where(dao.SysGenTable.Columns().Status, param.Status)
|
||||
}
|
||||
total, err = m.Count()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output = make([]*model.SysGenTableListModel, 0)
|
||||
err = m.Page(param.Page, param.PageSize).OrderDesc(dao.SysGenTable.Columns().TableId).Scan(&output)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 详情
|
||||
func (s *sSysGenTable) View(ctx context.Context, param *model.SysGenTableViewParam) (output *model.SysGenTableViewModel, err error) {
|
||||
if param.TableId == 0 {
|
||||
err = gerror.New("参数错误")
|
||||
return
|
||||
}
|
||||
m := s.Model(ctx)
|
||||
err = m.Where(dao.SysGenTable.Columns().TableId, param.TableId).Scan(&output)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 新增
|
||||
func (s *sSysGenTable) Add(ctx context.Context, param *model.SysGenTableAddParam) (output *model.SysGenTableAddModel, err error) {
|
||||
data := do.SysGenTable{}
|
||||
gconv.Struct(param, &data)
|
||||
data.Options = "{}"
|
||||
data.MasterColumns = "[]"
|
||||
data.Status = "1"
|
||||
data.CreatedDept = contexts.GetDeptId(ctx)
|
||||
data.CreatedBy = contexts.GetUserId(ctx)
|
||||
data.CreatedAt = gtime.Now()
|
||||
m := s.Model(ctx)
|
||||
id, err := m.Data(data).OmitNil().InsertAndGetId()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output = &model.SysGenTableAddModel{
|
||||
TableId: id,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 修改
|
||||
func (s *sSysGenTable) Edit(ctx context.Context, param *model.SysGenTableEditParam) (output *model.SysGenTableEditModel, err error) {
|
||||
if param == nil || param.TableId == 0 {
|
||||
err = gerror.New("参数错误")
|
||||
return
|
||||
}
|
||||
data := do.SysGenTable{}
|
||||
gconv.Struct(param, &data)
|
||||
if param.Options != nil {
|
||||
data.Options = gjson.New(*param.Options).MustToJsonString()
|
||||
}
|
||||
if param.MasterColumns != nil {
|
||||
data.MasterColumns = gjson.New(*param.MasterColumns).MustToJsonString()
|
||||
}
|
||||
|
||||
data.UpdatedBy = contexts.GetUserId(ctx)
|
||||
data.UpdatedAt = gtime.Now()
|
||||
m := s.Model(ctx)
|
||||
_, err = m.Data(data).OmitNil().Where(dao.SysGenTable.Columns().TableId, param.TableId).Update()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output = &model.SysGenTableEditModel{
|
||||
TableId: param.TableId,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (s *sSysGenTable) Delete(ctx context.Context, param *model.SysGenTableDeleteParam) (output *model.SysGenTableDeleteModel, err error) {
|
||||
if len(param.TableIds) == 0 {
|
||||
err = gerror.New("参数错误")
|
||||
return
|
||||
}
|
||||
m := s.Model(ctx)
|
||||
_, err = m.WhereIn(dao.SysGenTable.Columns().TableId, param.TableIds).Delete()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output = &model.SysGenTableDeleteModel{
|
||||
TableIds: param.TableIds,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 获取选择项
|
||||
func (s *sSysGenTable) Selects(ctx context.Context) (output *model.SelectsModel, err error) {
|
||||
output, err = xgen.GenCodesSelects(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 获取表选择项
|
||||
func (s *sSysGenTable) TableSelect(ctx context.Context, param *model.GenCodesTableSelectParam) (output []*model.GenCodesTableSelectModel, err error) {
|
||||
output, err = xgen.GenCodesTableSelect(ctx, param)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 获取字段选择项
|
||||
func (s *sSysGenTable) ColumnSelect(ctx context.Context, param *model.GenCodesColumnSelectParam) (output []*model.GenCodesColumnSelectModel, err error) {
|
||||
output, err = xgen.GenCodesColumnSelect(ctx, param)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 获取字段列表
|
||||
func (s *sSysGenTable) ColumnList(ctx context.Context, param *model.GenCodesColumnListParam) (output []*model.GenCodesColumnListModel, err error) {
|
||||
output, err = xgen.GenCodesColumnList(ctx, param)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 预览
|
||||
func (s *sSysGenTable) Preview(ctx context.Context, param *model.GenCodesPreviewParam) (output *model.GenCodesPreviewModel, err error) {
|
||||
output, err = xgen.GenCodesPreview(ctx, param)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 构建
|
||||
func (s *sSysGenTable) Build(ctx context.Context, param *model.GenCodesBuildParam) (output *model.GenCodesBuildModel, err error) {
|
||||
editParam := &model.SysGenTableEditParam{}
|
||||
gconv.Struct(param, editParam)
|
||||
status := genconsts.GenCodesStatusOk
|
||||
editParam.Status = &status
|
||||
_, err = s.Edit(ctx, editParam)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output, err = xgen.GenCodesBuild(ctx, param)
|
||||
if err != nil {
|
||||
status = genconsts.GenCodesStatusFail
|
||||
editParam.Status = &status
|
||||
_, err = s.Edit(ctx, &model.SysGenTableEditParam{
|
||||
TableId: editParam.TableId,
|
||||
Status: &status,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package info
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/glog"
|
||||
)
|
||||
|
||||
type sInfo struct {
|
||||
Info model.InfoModel
|
||||
}
|
||||
|
||||
func NewInfo() *sInfo {
|
||||
return &sInfo{
|
||||
Info: model.InfoModel{
|
||||
Data: make(map[string]interface{}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterInfo(NewInfo())
|
||||
}
|
||||
func toLogLevelStr(level int) string {
|
||||
if level == glog.LEVEL_NONE {
|
||||
return "NONE"
|
||||
}
|
||||
if level == glog.LEVEL_ALL {
|
||||
return "ALL"
|
||||
}
|
||||
if level == glog.LEVEL_DEV {
|
||||
return "DEV"
|
||||
}
|
||||
if level == glog.LEVEL_PROD {
|
||||
return "PROD"
|
||||
}
|
||||
str := ""
|
||||
if level&glog.LEVEL_DEBU != 0 {
|
||||
str += "DEBUG "
|
||||
}
|
||||
if level&glog.LEVEL_INFO != 0 {
|
||||
str += "INFO "
|
||||
}
|
||||
if level&glog.LEVEL_NOTI != 0 {
|
||||
str += "NOTI "
|
||||
}
|
||||
if level&glog.LEVEL_WARN != 0 {
|
||||
str += "WARN "
|
||||
}
|
||||
if level&glog.LEVEL_ERRO != 0 {
|
||||
str += "ERRO "
|
||||
}
|
||||
if level&glog.LEVEL_CRIT != 0 {
|
||||
str += "CRIT "
|
||||
}
|
||||
if level&glog.LEVEL_PANI != 0 {
|
||||
str += "PANI "
|
||||
}
|
||||
if level&glog.LEVEL_FATA != 0 {
|
||||
str += "FATA "
|
||||
}
|
||||
return str
|
||||
}
|
||||
func (s *sInfo) GetInfo() *model.InfoModel {
|
||||
ctx := context.Background()
|
||||
logLevel := g.Cfg().MustGet(ctx, "logger.level", "").String()
|
||||
if logLevel != "" {
|
||||
g.Log().SetLevelStr(logLevel)
|
||||
}
|
||||
s.AddInfoData("log_level_conf", logLevel)
|
||||
s.AddInfoData("log_level_real", toLogLevelStr(g.Log().GetLevel()))
|
||||
s.AddInfoData("Goroutines", runtime.NumGoroutine())
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
s.AddInfoData("MemAllocKB", m.Alloc/1024)
|
||||
s.AddInfoData("MemTotalAllocKB", m.TotalAlloc/1024)
|
||||
s.AddInfoData("MemSysKB", m.Sys/1024)
|
||||
s.AddInfoData("MemHeapAllocKB", m.HeapAlloc/1024)
|
||||
s.AddInfoData("MemHeapSysKB", m.HeapSys/1024)
|
||||
s.AddInfoData("MemHeapIdleKB", m.HeapIdle/1024)
|
||||
s.AddInfoData("MemHeapInuseKB", m.HeapInuse/1024)
|
||||
s.AddInfoData("MemHeapReleasedKB", m.HeapReleased/1024)
|
||||
s.AddInfoData("MemHeapObjects", m.HeapObjects)
|
||||
s.AddInfoData("MemStackInuseKB", m.StackInuse/1024)
|
||||
s.AddInfoData("MemStackSysKB", m.StackSys/1024)
|
||||
s.AddInfoData("MemNumGC", m.NumGC)
|
||||
return &s.Info
|
||||
}
|
||||
|
||||
func (s *sInfo) SetInfoName(Type, Name, Title, Description string) {
|
||||
if s.Info.Name == "" {
|
||||
s.Info.Type = Type
|
||||
s.Info.Name = Name
|
||||
s.Info.Title = Title
|
||||
s.Info.Description = Description
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sInfo) SetInfoBuild(buildVersion, buildTime, commitID string) {
|
||||
s.Info.Version = buildVersion
|
||||
s.Info.Build = buildTime
|
||||
s.Info.Commit = commitID
|
||||
}
|
||||
|
||||
func (s *sInfo) SetInfoData(data map[string]interface{}) {
|
||||
s.Info.Data = data
|
||||
}
|
||||
|
||||
func (s *sInfo) GetInfoData() map[string]interface{} {
|
||||
return s.Info.Data
|
||||
}
|
||||
|
||||
func (s *sInfo) AddInfoData(key string, value interface{}) {
|
||||
if s.Info.Data == nil {
|
||||
s.Info.Data = make(map[string]interface{})
|
||||
}
|
||||
s.Info.Data[key] = value
|
||||
}
|
||||
|
||||
func (s *sInfo) DelInfoData(key string) {
|
||||
delete(s.Info.Data, key)
|
||||
}
|
||||
|
||||
func (s *sInfo) GetInfoDataByKey(key string) interface{} {
|
||||
if s.Info.Data == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := s.Info.Data[key]; !ok {
|
||||
return nil
|
||||
}
|
||||
return s.Info.Data[key]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package logic
|
||||
|
||||
import (
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-app/internal/logic/app"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-app/internal/logic/gen"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-app/internal/logic/gen_codes"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-app/internal/logic/info"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-app/internal/logic/memorydb"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-app/internal/logic/middleware"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-app/internal/logic/monitor"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-app/internal/logic/system"
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
package memorydb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/cmd/inithttp"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var db gdb.DB
|
||||
|
||||
type memDB interface {
|
||||
Init(ctx context.Context) error
|
||||
EventHandler(ctx context.Context) error
|
||||
TableName(ctx context.Context) string
|
||||
CreateTable(ctx context.Context) error
|
||||
LoadData(ctx context.Context, pk int64) error
|
||||
DeleteData(ctx context.Context, ids []int64) error
|
||||
}
|
||||
|
||||
var memDBMap = make(map[string]memDB)
|
||||
|
||||
func RegisterMemDB(name string, memDB memDB) {
|
||||
memDBMap[name] = memDB
|
||||
}
|
||||
|
||||
type sMemoryDB struct {
|
||||
memDBMap map[string]interface{}
|
||||
}
|
||||
|
||||
func NewMemoryDB() *sMemoryDB {
|
||||
return &sMemoryDB{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterMemoryDB(NewMemoryDB())
|
||||
inithttp.RegisterHttpInitFunc("memorydb", func(ctx context.Context) error {
|
||||
return service.MemoryDB().Init(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *sMemoryDB) Init(ctx context.Context) error {
|
||||
link := g.Cfg().MustGet(ctx, "database.memorydb.link", "").String()
|
||||
debug := g.Cfg().MustGet(ctx, "database.memorydb.debug", "false").Bool()
|
||||
if link == "" {
|
||||
g.Log().Warning(ctx, "sMemoryDB.Init link is empty")
|
||||
return nil
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemoryDB.Init link", link)
|
||||
var err error
|
||||
db, err = gdb.New(gdb.ConfigNode{
|
||||
Link: link,
|
||||
MaxIdleConnCount: 10,
|
||||
MaxOpenConnCount: 1,
|
||||
MaxConnLifeTime: 24 * 3650 * time.Hour,
|
||||
Debug: debug,
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemoryDB.Init get db err: %v", err)
|
||||
return err
|
||||
}
|
||||
if db == nil {
|
||||
g.Log().Errorf(ctx, "sMemoryDB.Init not found memorydb")
|
||||
return errors.New("sMemoryDB.Init get db err")
|
||||
}
|
||||
for _, memDB := range memDBMap {
|
||||
err = memDB.Init(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemoryDB.Init end.", db.GetConfig())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemoryDB) EventHandler(ctx context.Context, chg *model.BroadcastDbChgCache) error {
|
||||
memDB, ok := memDBMap[chg.TableName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if chg.Type == "add" || chg.Type == "edit" {
|
||||
for _, pk := range chg.PK {
|
||||
memDB.LoadData(ctx, pk)
|
||||
}
|
||||
} else if chg.Type == "del" {
|
||||
memDB.DeleteData(ctx, chg.PK)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemoryDB) DB(ctx context.Context) gdb.DB {
|
||||
link := g.Cfg().MustGet(ctx, "database.memorydb.link", "sqlite::@file(:memory:)").String()
|
||||
if link == "" {
|
||||
return g.DB()
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (s *sMemoryDB) Ctx(ctx context.Context) gdb.DB {
|
||||
return s.DB(ctx).Ctx(ctx)
|
||||
}
|
||||
|
||||
func entityListToDo[E any, D any](source []*E) ([]*D, error) {
|
||||
target := make([]*D, 0, len(source))
|
||||
for _, item := range source {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
value := new(D)
|
||||
if err := gconv.Struct(item, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target = append(target, value)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package memorydb
|
||||
|
||||
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/event"
|
||||
"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/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sMemDBSysConfig struct {
|
||||
}
|
||||
|
||||
var instanceMemDBSysConfig *sMemDBSysConfig
|
||||
|
||||
func NewMemDBSysConfig() *sMemDBSysConfig {
|
||||
if instanceMemDBSysConfig != nil {
|
||||
return instanceMemDBSysConfig
|
||||
}
|
||||
instanceMemDBSysConfig = &sMemDBSysConfig{}
|
||||
initLoadMemoryDB := g.Cfg().MustGet(context.TODO(), "server.initLoadMemoryDB").Bool()
|
||||
if initLoadMemoryDB {
|
||||
instanceMemDBSysConfig.Init(context.TODO())
|
||||
}
|
||||
return instanceMemDBSysConfig
|
||||
}
|
||||
|
||||
func init() {
|
||||
s := NewMemDBSysConfig()
|
||||
service.RegisterMemDBSysConfig(s)
|
||||
RegisterMemDB(s.TableName(context.TODO()), s)
|
||||
}
|
||||
|
||||
func (s *sMemDBSysConfig) Init(ctx context.Context) error {
|
||||
err := s.CreateTable(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.EventHandler(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysConfig) EventHandler(ctx context.Context) error {
|
||||
eventList := []string{
|
||||
consts.EventKeyDBSysConfigCreate,
|
||||
consts.EventKeyDBSysConfigUpdate,
|
||||
consts.EventKeyDBSysConfigDelete,
|
||||
}
|
||||
event.EventsInstance().Register(eventList, func(ctx context.Context, eventKey string, args ...interface{}) {
|
||||
if len(args) < 1 {
|
||||
g.Log().Errorf(ctx, "memorydb/sys_config.go event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList, ok := args[0].([]int64)
|
||||
if !ok {
|
||||
pk, ok := args[0].(int64)
|
||||
if !ok {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList = []int64{pk}
|
||||
}
|
||||
if len(pkList) == 0 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
if eventKey == consts.EventKeyDBSysConfigDelete {
|
||||
s.DeleteData(ctx, pkList)
|
||||
} else {
|
||||
for _, pk := range pkList {
|
||||
s.LoadData(ctx, pk)
|
||||
}
|
||||
}
|
||||
|
||||
dbType := "edit"
|
||||
if eventKey == consts.EventKeyDBSysConfigCreate {
|
||||
dbType = "add"
|
||||
} else if eventKey == consts.EventKeyDBSysConfigDelete {
|
||||
dbType = "del"
|
||||
} else {
|
||||
dbType = "edit"
|
||||
}
|
||||
queue.Push(ctx, consts.BroadcastDbChg, &model.BroadcastDbChgCache{
|
||||
TableName: s.TableName(ctx),
|
||||
PK: pkList,
|
||||
Type: dbType,
|
||||
Data: nil,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysConfig) TableName(ctx context.Context) string {
|
||||
return dao.SysConfig.Table()
|
||||
}
|
||||
|
||||
func (s *sMemDBSysConfig) CreateTable(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, "DROP TABLE IF EXISTS "+s.TableName(ctx))
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.CreateTable delete table err: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := db.Model("sqlite_master").Where("type", "table").Where("name", dao.SysConfig.Table()).Count()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.CreateTable count err: %v", err)
|
||||
return err
|
||||
}
|
||||
if total == 0 {
|
||||
g.Log().Debug(ctx, "sMemDBSysConfig.List create table ", s.TableName(ctx))
|
||||
res, err := db.Exec(ctx, `
|
||||
CREATE TABLE "sys_config" (
|
||||
"config_id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" TEXT DEFAULT '000000' CHECK(length("tenant_id") <= 20),
|
||||
"config_name" TEXT DEFAULT '' CHECK(length("config_name") <= 100),
|
||||
"config_key" TEXT DEFAULT '' CHECK(length("config_key") <= 100),
|
||||
"config_value" TEXT DEFAULT '' CHECK(length("config_value") <= 1024),
|
||||
"config_type" TEXT DEFAULT 'N',
|
||||
"created_dept" INTEGER,
|
||||
"created_by" INTEGER,
|
||||
"created_at" TEXT,
|
||||
"updated_by" INTEGER,
|
||||
"updated_at" TEXT,
|
||||
"remark" TEXT CHECK(length("remark") <= 500)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.CreateTable create table err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemDBSysConfig.CreateTable create table success", res)
|
||||
// 加载数据
|
||||
s.LoadData(ctx, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysConfig) LoadData(ctx context.Context, pk int64) error {
|
||||
count := 0
|
||||
if pk == 0 {
|
||||
maxConfigId := int64(0)
|
||||
for {
|
||||
page := 1
|
||||
pageSize := 500
|
||||
dataList := make([]*entity.SysConfig, 0)
|
||||
dao.SysConfig.Ctx(ctx).Page(page, pageSize).WhereGT(dao.SysConfig.Columns().ConfigId, maxConfigId).
|
||||
OrderAsc(dao.SysConfig.Columns().ConfigId).Scan(&dataList)
|
||||
if len(dataList) == 0 {
|
||||
break
|
||||
}
|
||||
count += len(dataList)
|
||||
doList, err := entityListToDo[entity.SysConfig, do.SysConfig](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysConfig.Table()).Data(doList).OnConflict(dao.SysConfig.Columns().ConfigId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.Load data insert err: %v", err)
|
||||
return err
|
||||
}
|
||||
maxConfigId = dataList[len(dataList)-1].ConfigId
|
||||
if len(dataList) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataList := make([]*entity.SysConfig, 0)
|
||||
dao.SysConfig.Ctx(ctx).Where(dao.SysConfig.Columns().ConfigId, pk).Scan(&dataList)
|
||||
doList, err := entityListToDo[entity.SysConfig, do.SysConfig](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysConfig.Table()).Data(doList).OnConflict(dao.SysConfig.Columns().ConfigId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.Load data insert err: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Debugf(ctx, "sMemDBSysConfig.LoadData load data success, total: %d, pk: %d", count, pk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysConfig) DeleteData(ctx context.Context, ids []int64) error {
|
||||
_, err := db.Model(dao.SysConfig.Table()).WhereIn(dao.SysConfig.Columns().ConfigId, ids).Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysConfig.DeleteData err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysConfig.DeleteData delete data success, total: %d, ids: %+v", len(ids), ids)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package memorydb
|
||||
|
||||
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/event"
|
||||
"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/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sMemDBSysDept struct {
|
||||
}
|
||||
|
||||
var instanceMemDBSysDept *sMemDBSysDept
|
||||
|
||||
func NewMemDBSysDept() *sMemDBSysDept {
|
||||
if instanceMemDBSysDept != nil {
|
||||
return instanceMemDBSysDept
|
||||
}
|
||||
instanceMemDBSysDept = &sMemDBSysDept{}
|
||||
initLoadMemoryDB := g.Cfg().MustGet(context.TODO(), "server.initLoadMemoryDB").Bool()
|
||||
if initLoadMemoryDB {
|
||||
instanceMemDBSysDept.Init(context.TODO())
|
||||
}
|
||||
return instanceMemDBSysDept
|
||||
}
|
||||
|
||||
func init() {
|
||||
s := NewMemDBSysDept()
|
||||
service.RegisterMemDBSysDept(s)
|
||||
RegisterMemDB(s.TableName(context.TODO()), s)
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDept) Init(ctx context.Context) error {
|
||||
err := s.CreateTable(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.EventHandler(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDept) EventHandler(ctx context.Context) error {
|
||||
eventList := []string{
|
||||
consts.EventKeyDBSysDeptCreate,
|
||||
consts.EventKeyDBSysDeptUpdate,
|
||||
consts.EventKeyDBSysDeptDelete,
|
||||
}
|
||||
event.EventsInstance().Register(eventList, func(ctx context.Context, eventKey string, args ...interface{}) {
|
||||
if len(args) < 1 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList, ok := args[0].([]int64)
|
||||
if !ok {
|
||||
pk, ok := args[0].(int64)
|
||||
if !ok {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList = []int64{pk}
|
||||
}
|
||||
if len(pkList) == 0 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
if eventKey == consts.EventKeyDBSysDeptDelete {
|
||||
s.DeleteData(ctx, pkList)
|
||||
} else {
|
||||
for _, pk := range pkList {
|
||||
s.LoadData(ctx, pk)
|
||||
}
|
||||
}
|
||||
|
||||
dbType := "edit"
|
||||
if eventKey == consts.EventKeyDBSysDeptCreate {
|
||||
dbType = "add"
|
||||
} else if eventKey == consts.EventKeyDBSysDeptDelete {
|
||||
dbType = "del"
|
||||
} else {
|
||||
dbType = "edit"
|
||||
}
|
||||
queue.Push(ctx, consts.BroadcastDbChg, &model.BroadcastDbChgCache{
|
||||
TableName: s.TableName(ctx),
|
||||
PK: pkList,
|
||||
Type: dbType,
|
||||
Data: nil,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDept) TableName(ctx context.Context) string {
|
||||
return dao.SysDept.Table()
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDept) CreateTable(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, "DROP TABLE IF EXISTS "+s.TableName(ctx))
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.CreateTable delete table err: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := db.Model("sqlite_master").Where("type", "table").Where("name", dao.SysDept.Table()).Count()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.CreateTable count err: %v", err)
|
||||
return err
|
||||
}
|
||||
if total == 0 {
|
||||
g.Log().Debug(ctx, "sMemDBSysDept.List create table ", s.TableName(ctx))
|
||||
res, err := db.Exec(ctx, `
|
||||
CREATE TABLE "sys_dept" (
|
||||
"dept_id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" TEXT DEFAULT '000000' CHECK(length("tenant_id") <= 20),
|
||||
"parent_id" INTEGER DEFAULT 0,
|
||||
"ancestors" TEXT DEFAULT '' CHECK(length("ancestors") <= 500),
|
||||
"dept_name" TEXT DEFAULT '' CHECK(length("dept_name") <= 30),
|
||||
"dept_type" INTEGER DEFAULT 0,
|
||||
"dept_category" TEXT CHECK(length("dept_category") <= 100),
|
||||
"order_num" INTEGER DEFAULT 0,
|
||||
"leader" INTEGER,
|
||||
"phone" TEXT CHECK(length("phone") <= 11),
|
||||
"email" TEXT CHECK(length("email") <= 50),
|
||||
"status" TEXT DEFAULT '0' ,
|
||||
"created_dept" INTEGER,
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" INTEGER,
|
||||
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted_by" INTEGER,
|
||||
"deleted_at" DATETIME
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.CreateTable create table err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemDBSysDept.CreateTable create table success", res)
|
||||
// 加载数据
|
||||
s.LoadData(ctx, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDept) LoadData(ctx context.Context, pk int64) error {
|
||||
count := 0
|
||||
if pk == 0 {
|
||||
maxDeptId := int64(0)
|
||||
for {
|
||||
page := 1
|
||||
pageSize := 100
|
||||
dataList := make([]*entity.SysDept, 0)
|
||||
dao.SysDept.Ctx(ctx).Page(page, pageSize).WhereGT(dao.SysDept.Columns().DeptId, maxDeptId).
|
||||
OrderAsc(dao.SysDept.Columns().DeptId).Scan(&dataList)
|
||||
if len(dataList) == 0 {
|
||||
break
|
||||
}
|
||||
count += len(dataList)
|
||||
doList, err := entityListToDo[entity.SysDept, do.SysDept](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysDept.Table()).Data(doList).OnConflict(dao.SysDept.Columns().DeptId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.Load data insert err: %v", err)
|
||||
return err
|
||||
}
|
||||
maxDeptId = dataList[len(dataList)-1].DeptId
|
||||
if len(dataList) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataList := make([]*entity.SysDept, 0)
|
||||
dao.SysDept.Ctx(ctx).Where(dao.SysDept.Columns().DeptId, pk).Scan(&dataList)
|
||||
doList, err := entityListToDo[entity.SysDept, do.SysDept](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysDept.Table()).Data(doList).OnConflict(dao.SysDept.Columns().DeptId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.Load data insert err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Debugf(ctx, "sMemDBSysDept.LoadData load data success, total: %d, pk: %d", count, pk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDept) DeleteData(ctx context.Context, ids []int64) error {
|
||||
_, err := db.Model(dao.SysDept.Table()).WhereIn(dao.SysDept.Columns().DeptId, ids).Unscoped().Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDept.DeleteData err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysDept.DeleteData delete data success, total: %d, ids: %+v", len(ids), ids)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package memorydb
|
||||
|
||||
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/event"
|
||||
"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/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sMemDBSysDictData struct {
|
||||
}
|
||||
|
||||
var instanceMemDBSysDictData *sMemDBSysDictData
|
||||
|
||||
func NewMemDBSysDictData() *sMemDBSysDictData {
|
||||
if instanceMemDBSysDictData != nil {
|
||||
return instanceMemDBSysDictData
|
||||
}
|
||||
instanceMemDBSysDictData = &sMemDBSysDictData{}
|
||||
initLoadMemoryDB := g.Cfg().MustGet(context.TODO(), "server.initLoadMemoryDB").Bool()
|
||||
if initLoadMemoryDB {
|
||||
instanceMemDBSysDictData.Init(context.TODO())
|
||||
}
|
||||
return instanceMemDBSysDictData
|
||||
}
|
||||
|
||||
func init() {
|
||||
s := NewMemDBSysDictData()
|
||||
service.RegisterMemDBSysDictData(s)
|
||||
RegisterMemDB(s.TableName(context.TODO()), s)
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictData) Init(ctx context.Context) error {
|
||||
err := s.CreateTable(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.EventHandler(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictData) EventHandler(ctx context.Context) error {
|
||||
eventList := []string{
|
||||
consts.EventKeyDBSysDictDataCreate,
|
||||
consts.EventKeyDBSysDictDataUpdate,
|
||||
consts.EventKeyDBSysDictDataDelete,
|
||||
}
|
||||
event.EventsInstance().Register(eventList, func(ctx context.Context, eventKey string, args ...interface{}) {
|
||||
if len(args) < 1 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList, ok := args[0].([]int64)
|
||||
if !ok {
|
||||
pk, ok := args[0].(int64)
|
||||
if !ok {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList = []int64{pk}
|
||||
}
|
||||
if len(pkList) == 0 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
if eventKey == consts.EventKeyDBSysDictDataDelete {
|
||||
s.DeleteData(ctx, pkList)
|
||||
} else {
|
||||
for _, pk := range pkList {
|
||||
s.LoadData(ctx, pk)
|
||||
}
|
||||
}
|
||||
dbType := "edit"
|
||||
if eventKey == consts.EventKeyDBSysDictDataCreate {
|
||||
dbType = "add"
|
||||
} else if eventKey == consts.EventKeyDBSysDictDataDelete {
|
||||
dbType = "del"
|
||||
} else {
|
||||
dbType = "edit"
|
||||
}
|
||||
queue.Push(ctx, consts.BroadcastDbChg, &model.BroadcastDbChgCache{
|
||||
TableName: s.TableName(ctx),
|
||||
PK: pkList,
|
||||
Type: dbType,
|
||||
Data: nil,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictData) TableName(ctx context.Context) string {
|
||||
return dao.SysDictData.Table()
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictData) CreateTable(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, "DROP TABLE IF EXISTS "+s.TableName(ctx))
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.CreateTable delete table err: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := db.Model("sqlite_master").Where("type", "table").Where("name", dao.SysDictData.Table()).Count()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.CreateTable count err: %v", err)
|
||||
return err
|
||||
}
|
||||
if total == 0 {
|
||||
g.Log().Debug(ctx, "sMemDBSysDictData.List create table ", s.TableName(ctx))
|
||||
res, err := db.Exec(ctx, `
|
||||
CREATE TABLE "sys_dict_data" (
|
||||
"dict_code" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" TEXT DEFAULT '000000' CHECK(length("tenant_id") <= 20),
|
||||
"dict_sort" INTEGER DEFAULT 0,
|
||||
"dict_label" TEXT DEFAULT '' CHECK(length("dict_label") <= 100),
|
||||
"dict_value" TEXT DEFAULT '' CHECK(length("dict_value") <= 100),
|
||||
"dict_type" TEXT DEFAULT '' CHECK(length("dict_type") <= 100),
|
||||
"css_class" TEXT CHECK(length("css_class") <= 100),
|
||||
"list_class" TEXT CHECK(length("list_class") <= 100),
|
||||
"is_default" TEXT DEFAULT 'N' ,
|
||||
"created_dept" INTEGER,
|
||||
"created_by" INTEGER,
|
||||
"created_at" TEXT,
|
||||
"updated_by" INTEGER,
|
||||
"updated_at" TEXT,
|
||||
"remark" TEXT CHECK(length("remark") <= 500)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.CreateTable create table err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemDBSysDictData.CreateTable create table success", res)
|
||||
// 加载数据
|
||||
s.LoadData(ctx, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictData) LoadData(ctx context.Context, pk int64) error {
|
||||
count := 0
|
||||
if pk == 0 {
|
||||
maxDictCode := int64(0)
|
||||
for {
|
||||
page := 1
|
||||
pageSize := 500
|
||||
dataList := make([]*entity.SysDictData, 0)
|
||||
dao.SysDictData.Ctx(ctx).Page(page, pageSize).WhereGT(dao.SysDictData.Columns().DictCode, maxDictCode).
|
||||
OrderAsc(dao.SysDictData.Columns().DictCode).Scan(&dataList)
|
||||
if len(dataList) == 0 {
|
||||
break
|
||||
}
|
||||
count += len(dataList)
|
||||
doList, err := entityListToDo[entity.SysDictData, do.SysDictData](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysDictData.Table()).Data(doList).OnConflict(dao.SysDictData.Columns().DictCode).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.Load data insert err: %v", err)
|
||||
return err
|
||||
}
|
||||
maxDictCode = dataList[len(dataList)-1].DictCode
|
||||
if len(dataList) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataList := make([]*entity.SysDictData, 0)
|
||||
dao.SysDictData.Ctx(ctx).Where(dao.SysDictData.Columns().DictCode, pk).Scan(&dataList)
|
||||
doList, err := entityListToDo[entity.SysDictData, do.SysDictData](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysDictData.Table()).Data(doList).OnConflict(dao.SysDictData.Columns().DictCode).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.Load data insert err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Debugf(ctx, "sMemDBSysDictData.LoadData load data success, total: %d, pk: %d", count, pk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictData) DeleteData(ctx context.Context, ids []int64) error {
|
||||
_, err := db.Model(dao.SysDictData.Table()).WhereIn(dao.SysDictData.Columns().DictCode, ids).Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictData.DeleteData err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysDictData.DeleteData delete data success, total: %d, ids: %+v", len(ids), ids)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package memorydb
|
||||
|
||||
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/event"
|
||||
"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/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sMemDBSysDictType struct {
|
||||
}
|
||||
|
||||
var instanceMemDBSysDictType *sMemDBSysDictType
|
||||
|
||||
func NewMemDBSysDictType() *sMemDBSysDictType {
|
||||
if instanceMemDBSysDictType != nil {
|
||||
return instanceMemDBSysDictType
|
||||
}
|
||||
instanceMemDBSysDictType = &sMemDBSysDictType{}
|
||||
initLoadMemoryDB := g.Cfg().MustGet(context.TODO(), "server.initLoadMemoryDB").Bool()
|
||||
if initLoadMemoryDB {
|
||||
instanceMemDBSysDictType.Init(context.TODO())
|
||||
}
|
||||
return instanceMemDBSysDictType
|
||||
}
|
||||
|
||||
func init() {
|
||||
s := NewMemDBSysDictType()
|
||||
service.RegisterMemDBSysDictType(s)
|
||||
RegisterMemDB(s.TableName(context.TODO()), s)
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictType) Init(ctx context.Context) error {
|
||||
err := s.CreateTable(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.EventHandler(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictType) EventHandler(ctx context.Context) error {
|
||||
eventList := []string{
|
||||
consts.EventKeyDBSysDictTypeCreate,
|
||||
consts.EventKeyDBSysDictTypeUpdate,
|
||||
consts.EventKeyDBSysDictTypeDelete,
|
||||
}
|
||||
event.EventsInstance().Register(eventList, func(ctx context.Context, eventKey string, args ...interface{}) {
|
||||
if len(args) < 1 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList, ok := args[0].([]int64)
|
||||
if !ok {
|
||||
pk, ok := args[0].(int64)
|
||||
if !ok {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList = []int64{pk}
|
||||
}
|
||||
if len(pkList) == 0 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
if eventKey == consts.EventKeyDBSysDictTypeDelete {
|
||||
s.DeleteData(ctx, pkList)
|
||||
} else {
|
||||
for _, pk := range pkList {
|
||||
s.LoadData(ctx, pk)
|
||||
}
|
||||
}
|
||||
dbType := "edit"
|
||||
if eventKey == consts.EventKeyDBSysDictTypeCreate {
|
||||
dbType = "add"
|
||||
} else if eventKey == consts.EventKeyDBSysDictTypeDelete {
|
||||
dbType = "del"
|
||||
} else {
|
||||
dbType = "edit"
|
||||
}
|
||||
queue.Push(ctx, consts.BroadcastDbChg, &model.BroadcastDbChgCache{
|
||||
TableName: s.TableName(ctx),
|
||||
PK: pkList,
|
||||
Type: dbType,
|
||||
Data: nil,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictType) TableName(ctx context.Context) string {
|
||||
return dao.SysDictType.Table()
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictType) CreateTable(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, "DROP TABLE IF EXISTS "+s.TableName(ctx))
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.CreateTable delete table err: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := db.Model("sqlite_master").Where("type", "table").Where("name", dao.SysDictType.Table()).Count()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.CreateTable count err: %v", err)
|
||||
return err
|
||||
}
|
||||
if total == 0 {
|
||||
g.Log().Debug(ctx, "sMemDBSysDictType.List create table ", s.TableName(ctx))
|
||||
res, err := db.Exec(ctx, `
|
||||
CREATE TABLE "sys_dict_type" (
|
||||
"dict_id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" TEXT DEFAULT '000000' CHECK(length("tenant_id") <= 20),
|
||||
"dict_name" TEXT DEFAULT '' CHECK(length("dict_name") <= 100),
|
||||
"dict_type" TEXT DEFAULT '' CHECK(length("dict_type") <= 100),
|
||||
"is_sys" TEXT DEFAULT '1' ,
|
||||
"created_dept" INTEGER,
|
||||
"created_by" INTEGER,
|
||||
"created_at" TEXT,
|
||||
"updated_by" INTEGER,
|
||||
"updated_at" TEXT,
|
||||
"remark" TEXT CHECK(length("remark") <= 500)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.CreateTable create table err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemDBSysDictType.CreateTable create table success", res)
|
||||
// 加载数据
|
||||
s.LoadData(ctx, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictType) LoadData(ctx context.Context, pk int64) error {
|
||||
count := 0
|
||||
if pk == 0 {
|
||||
maxDictId := int64(0)
|
||||
for {
|
||||
page := 1
|
||||
pageSize := 500
|
||||
dataList := make([]*entity.SysDictType, 0)
|
||||
dao.SysDictType.Ctx(ctx).Page(page, pageSize).WhereGT(dao.SysDictType.Columns().DictId, maxDictId).
|
||||
OrderAsc(dao.SysDictType.Columns().DictId).Scan(&dataList)
|
||||
if len(dataList) == 0 {
|
||||
break
|
||||
}
|
||||
count += len(dataList)
|
||||
doList, err := entityListToDo[entity.SysDictType, do.SysDictType](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysDictType.Table()).Data(doList).OnConflict(dao.SysDictType.Columns().DictId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.Load data insert err: %v", err)
|
||||
return err
|
||||
}
|
||||
maxDictId = dataList[len(dataList)-1].DictId
|
||||
if len(dataList) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataList := make([]*entity.SysDictType, 0)
|
||||
dao.SysDictType.Ctx(ctx).Where(dao.SysDictType.Columns().DictId, pk).Scan(&dataList)
|
||||
doList, err := entityListToDo[entity.SysDictType, do.SysDictType](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysDictType.Table()).Data(doList).OnConflict(dao.SysDictType.Columns().DictId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.Load data insert err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
g.Log().Debugf(ctx, "sMemDBSysDictType.LoadData load data success, total: %d, pk: %d", count, pk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysDictType) DeleteData(ctx context.Context, ids []int64) error {
|
||||
_, err := db.Model(dao.SysDictType.Table()).WhereIn(dao.SysDictType.Columns().DictId, ids).Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysDictType.DeleteData err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysDictType.DeleteData delete data success, total: %d, ids: %+v", len(ids), ids)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package memorydb
|
||||
|
||||
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/event"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/mcache"
|
||||
"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/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sMemDBSysMenu struct {
|
||||
}
|
||||
|
||||
var instanceMemDBSysMenu *sMemDBSysMenu
|
||||
|
||||
func NewMemDBSysMenu() *sMemDBSysMenu {
|
||||
if instanceMemDBSysMenu != nil {
|
||||
return instanceMemDBSysMenu
|
||||
}
|
||||
instanceMemDBSysMenu = &sMemDBSysMenu{}
|
||||
initLoadMemoryDB := g.Cfg().MustGet(context.TODO(), "server.initLoadMemoryDB").Bool()
|
||||
if initLoadMemoryDB {
|
||||
instanceMemDBSysMenu.Init(context.TODO())
|
||||
}
|
||||
return instanceMemDBSysMenu
|
||||
}
|
||||
|
||||
func init() {
|
||||
s := NewMemDBSysMenu()
|
||||
service.RegisterMemDBSysMenu(s)
|
||||
RegisterMemDB(s.TableName(context.TODO()), s)
|
||||
}
|
||||
|
||||
func (s *sMemDBSysMenu) Init(ctx context.Context) error {
|
||||
err := s.CreateTable(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.EventHandler(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysMenu) EventHandler(ctx context.Context) error {
|
||||
eventList := []string{
|
||||
consts.EventKeyDBSysMenuCreate,
|
||||
consts.EventKeyDBSysMenuUpdate,
|
||||
consts.EventKeyDBSysMenuDelete,
|
||||
}
|
||||
event.EventsInstance().Register(eventList, func(ctx context.Context, eventKey string, args ...interface{}) {
|
||||
if len(args) < 1 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList, ok := args[0].([]int64)
|
||||
if !ok {
|
||||
pk, ok := args[0].(int64)
|
||||
if !ok {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList = []int64{pk}
|
||||
}
|
||||
if len(pkList) == 0 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
if eventKey == consts.EventKeyDBSysMenuDelete {
|
||||
s.DeleteData(ctx, pkList)
|
||||
} else {
|
||||
for _, pk := range pkList {
|
||||
s.LoadData(ctx, pk)
|
||||
}
|
||||
}
|
||||
|
||||
roleIds := make([]int64, 0)
|
||||
if err := dao.SysRoleMenu.Ctx(ctx).
|
||||
Fields(dao.SysRoleMenu.Columns().RoleId).
|
||||
WhereIn(dao.SysRoleMenu.Columns().MenuId, pkList).
|
||||
Group(dao.SysRoleMenu.Columns().RoleId).
|
||||
Scan(&roleIds); err == nil && len(roleIds) > 0 {
|
||||
userIds := make([]int64, 0)
|
||||
if err := dao.SysUserRole.Ctx(ctx).
|
||||
Fields(dao.SysUserRole.Columns().UserId).
|
||||
WhereIn(dao.SysUserRole.Columns().RoleId, roleIds).
|
||||
Group(dao.SysUserRole.Columns().UserId).
|
||||
Scan(&userIds); err == nil {
|
||||
for _, userId := range userIds {
|
||||
_ = mcache.RemoveUserAccessCodeList(ctx, userId)
|
||||
_ = mcache.RemoveUserRoleDataAccessCodeList(ctx, userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbType := "edit"
|
||||
if eventKey == consts.EventKeyDBSysMenuCreate {
|
||||
dbType = "add"
|
||||
} else if eventKey == consts.EventKeyDBSysMenuDelete {
|
||||
dbType = "del"
|
||||
} else {
|
||||
dbType = "edit"
|
||||
}
|
||||
queue.Push(ctx, consts.BroadcastDbChg, &model.BroadcastDbChgCache{
|
||||
TableName: s.TableName(ctx),
|
||||
PK: pkList,
|
||||
Type: dbType,
|
||||
Data: nil,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysMenu) TableName(ctx context.Context) string {
|
||||
return dao.SysMenu.Table()
|
||||
}
|
||||
|
||||
func (s *sMemDBSysMenu) CreateTable(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, "DROP TABLE IF EXISTS "+s.TableName(ctx))
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.CreateTable delete table err: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := db.Model("sqlite_master").Where("type", "table").Where("name", dao.SysMenu.Table()).Count()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.CreateTable count err: %v", err)
|
||||
return err
|
||||
}
|
||||
if total == 0 {
|
||||
g.Log().Debug(ctx, "sMemDBSysMenu.List create table ", s.TableName(ctx))
|
||||
res, err := db.Exec(ctx, `
|
||||
CREATE TABLE "sys_menu" (
|
||||
"menu_id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"menu_name" TEXT NOT NULL CHECK(length("menu_name") <= 50),
|
||||
"parent_id" INTEGER DEFAULT 0,
|
||||
"level" INTEGER DEFAULT 1,
|
||||
"tree" TEXT CHECK(length("tree") <= 255),
|
||||
"order_num" INTEGER DEFAULT 0,
|
||||
"path" TEXT DEFAULT '' CHECK(length("path") <= 200),
|
||||
"component" TEXT CHECK(length("component") <= 255),
|
||||
"query_param" TEXT CHECK(length("query_param") <= 255),
|
||||
"is_frame" INTEGER DEFAULT 1 ,
|
||||
"is_cache" INTEGER DEFAULT 0 ,
|
||||
"menu_type" TEXT DEFAULT '' ,
|
||||
"visible" TEXT DEFAULT '0',
|
||||
"status" TEXT DEFAULT '0' ,
|
||||
"perms" TEXT CHECK(length("perms") <= 100),
|
||||
"icon" TEXT DEFAULT '#' CHECK(length("icon") <= 100),
|
||||
"created_dept" INTEGER,
|
||||
"created_by" INTEGER,
|
||||
"created_at" TEXT,
|
||||
"updated_by" INTEGER,
|
||||
"updated_at" TEXT,
|
||||
"remark" TEXT DEFAULT '' CHECK(length("remark") <= 500)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.CreateTable create table err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemDBSysMenu.CreateTable create table success", res)
|
||||
s.LoadData(ctx, 0)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysMenu) LoadData(ctx context.Context, pk int64) error {
|
||||
count := 0
|
||||
if pk == 0 {
|
||||
maxMenuId := int64(0)
|
||||
for {
|
||||
page := 1
|
||||
pageSize := 500
|
||||
dataList := make([]*entity.SysMenu, 0)
|
||||
dao.SysMenu.Ctx(ctx).Page(page, pageSize).WhereGT(dao.SysMenu.Columns().MenuId, maxMenuId).
|
||||
OrderAsc(dao.SysMenu.Columns().MenuId).Scan(&dataList)
|
||||
if len(dataList) == 0 {
|
||||
break
|
||||
}
|
||||
count += len(dataList)
|
||||
doList, err := entityListToDo[entity.SysMenu, do.SysMenu](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysMenu.Table()).Data(doList).OnConflict(dao.SysMenu.Columns().MenuId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.Load data insert err: %v", err)
|
||||
return err
|
||||
}
|
||||
maxMenuId = dataList[len(dataList)-1].MenuId
|
||||
if len(dataList) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataList := make([]*entity.SysMenu, 0)
|
||||
dao.SysMenu.Ctx(ctx).Where(dao.SysMenu.Columns().MenuId, pk).Scan(&dataList)
|
||||
doList, err := entityListToDo[entity.SysMenu, do.SysMenu](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysMenu.Table()).Data(doList).OnConflict(dao.SysMenu.Columns().MenuId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.Load data insert err: %v", err)
|
||||
}
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysMenu.LoadData load data success, total: %d, pk: %d", count, pk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysMenu) DeleteData(ctx context.Context, ids []int64) error {
|
||||
_, err := db.Model(dao.SysMenu.Table()).WhereIn(dao.SysMenu.Columns().MenuId, ids).Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysMenu.DeleteData err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysMenu.DeleteData delete data success, total: %d, ids: %+v", len(ids), ids)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package memorydb
|
||||
|
||||
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/event"
|
||||
"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/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sMemDBSysPost struct {
|
||||
}
|
||||
|
||||
var instanceMemDBSysPost *sMemDBSysPost
|
||||
|
||||
func NewMemDBSysPost() *sMemDBSysPost {
|
||||
if instanceMemDBSysPost != nil {
|
||||
return instanceMemDBSysPost
|
||||
}
|
||||
instanceMemDBSysPost = &sMemDBSysPost{}
|
||||
initLoadMemoryDB := g.Cfg().MustGet(context.TODO(), "server.initLoadMemoryDB").Bool()
|
||||
if initLoadMemoryDB {
|
||||
instanceMemDBSysPost.Init(context.TODO())
|
||||
}
|
||||
return instanceMemDBSysPost
|
||||
}
|
||||
|
||||
func init() {
|
||||
s := NewMemDBSysPost()
|
||||
service.RegisterMemDBSysPost(s)
|
||||
RegisterMemDB(s.TableName(context.TODO()), s)
|
||||
}
|
||||
|
||||
func (s *sMemDBSysPost) Init(ctx context.Context) error {
|
||||
err := s.CreateTable(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.EventHandler(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysPost) EventHandler(ctx context.Context) error {
|
||||
eventList := []string{
|
||||
consts.EventKeyDBSysPostCreate,
|
||||
consts.EventKeyDBSysPostUpdate,
|
||||
consts.EventKeyDBSysPostDelete,
|
||||
}
|
||||
event.EventsInstance().Register(eventList, func(ctx context.Context, eventKey string, args ...interface{}) {
|
||||
if len(args) < 1 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList, ok := args[0].([]int64)
|
||||
if !ok {
|
||||
pk, ok := args[0].(int64)
|
||||
if !ok {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList = []int64{pk}
|
||||
}
|
||||
if len(pkList) == 0 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
if eventKey == consts.EventKeyDBSysPostDelete {
|
||||
s.DeleteData(ctx, pkList)
|
||||
} else {
|
||||
for _, pk := range pkList {
|
||||
s.LoadData(ctx, pk)
|
||||
}
|
||||
}
|
||||
dbType := "edit"
|
||||
if eventKey == consts.EventKeyDBSysPostCreate {
|
||||
dbType = "add"
|
||||
} else if eventKey == consts.EventKeyDBSysPostDelete {
|
||||
dbType = "del"
|
||||
} else {
|
||||
dbType = "edit"
|
||||
}
|
||||
queue.Push(ctx, consts.BroadcastDbChg, &model.BroadcastDbChgCache{
|
||||
TableName: s.TableName(ctx),
|
||||
PK: pkList,
|
||||
Type: dbType,
|
||||
Data: nil,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysPost) TableName(ctx context.Context) string {
|
||||
return dao.SysPost.Table()
|
||||
}
|
||||
|
||||
func (s *sMemDBSysPost) CreateTable(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, "DROP TABLE IF EXISTS "+s.TableName(ctx))
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.CreateTable delete table err: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := db.Model("sqlite_master").Where("type", "table").Where("name", dao.SysPost.Table()).Count()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.CreateTable count err: %v", err)
|
||||
return err
|
||||
}
|
||||
if total == 0 {
|
||||
g.Log().Debug(ctx, "sMemDBSysPost.List create table ", s.TableName(ctx))
|
||||
res, err := db.Exec(ctx, `
|
||||
CREATE TABLE "sys_post" (
|
||||
"post_id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" TEXT DEFAULT '000000' CHECK(length("tenant_id") <= 20),
|
||||
"dept_id" INTEGER NOT NULL,
|
||||
"post_code" TEXT NOT NULL CHECK(length("post_code") <= 64),
|
||||
"post_category" TEXT CHECK(length("post_category") <= 100),
|
||||
"post_name" TEXT NOT NULL CHECK(length("post_name") <= 50),
|
||||
"post_sort" INTEGER NOT NULL,
|
||||
"status" TEXT NOT NULL ,
|
||||
"created_dept" INTEGER,
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" INTEGER,
|
||||
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted_by" INTEGER,
|
||||
"deleted_at" DATETIME,
|
||||
"remark" TEXT CHECK(length("remark") <= 500)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.CreateTable create table err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemDBSysPost.CreateTable create table success", res)
|
||||
// 加载数据
|
||||
s.LoadData(ctx, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysPost) LoadData(ctx context.Context, pk int64) error {
|
||||
count := 0
|
||||
if pk == 0 {
|
||||
maxPostId := int64(0)
|
||||
for {
|
||||
page := 1
|
||||
pageSize := 500
|
||||
dataList := make([]*entity.SysPost, 0)
|
||||
dao.SysPost.Ctx(ctx).Page(page, pageSize).WhereGT(dao.SysPost.Columns().PostId, maxPostId).
|
||||
OrderAsc(dao.SysPost.Columns().PostId).Scan(&dataList)
|
||||
if len(dataList) == 0 {
|
||||
break
|
||||
}
|
||||
count += len(dataList)
|
||||
doList, err := entityListToDo[entity.SysPost, do.SysPost](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysPost.Table()).Data(doList).OnConflict(dao.SysPost.Columns().PostId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.Load data insert err: %v", err)
|
||||
return err
|
||||
}
|
||||
maxPostId = dataList[len(dataList)-1].PostId
|
||||
if len(dataList) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataList := make([]*entity.SysPost, 0)
|
||||
dao.SysPost.Ctx(ctx).Where(dao.SysPost.Columns().PostId, pk).Scan(&dataList)
|
||||
doList, err := entityListToDo[entity.SysPost, do.SysPost](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysPost.Table()).Data(doList).OnConflict(dao.SysPost.Columns().PostId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.Load data insert err: %v", err)
|
||||
}
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysPost.LoadData load data success, total: %d, pk: %d", count, pk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysPost) DeleteData(ctx context.Context, ids []int64) error {
|
||||
_, err := db.Model(dao.SysPost.Table()).WhereIn(dao.SysPost.Columns().PostId, ids).Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysPost.DeleteData err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysPost.DeleteData delete data success, total: %d, ids: %+v", len(ids), ids)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package memorydb
|
||||
|
||||
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/event"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/mcache"
|
||||
"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/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sMemDBSysRole struct {
|
||||
}
|
||||
|
||||
var instanceMemDBSysRole *sMemDBSysRole
|
||||
|
||||
func NewMemDBSysRole() *sMemDBSysRole {
|
||||
if instanceMemDBSysRole != nil {
|
||||
return instanceMemDBSysRole
|
||||
}
|
||||
instanceMemDBSysRole = &sMemDBSysRole{}
|
||||
initLoadMemoryDB := g.Cfg().MustGet(context.TODO(), "server.initLoadMemoryDB").Bool()
|
||||
if initLoadMemoryDB {
|
||||
instanceMemDBSysRole.Init(context.TODO())
|
||||
}
|
||||
return instanceMemDBSysRole
|
||||
}
|
||||
|
||||
func init() {
|
||||
s := NewMemDBSysRole()
|
||||
service.RegisterMemDBSysRole(s)
|
||||
RegisterMemDB(s.TableName(context.TODO()), s)
|
||||
}
|
||||
|
||||
func (s *sMemDBSysRole) Init(ctx context.Context) error {
|
||||
err := s.CreateTable(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.EventHandler(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysRole) EventHandler(ctx context.Context) error {
|
||||
eventList := []string{
|
||||
consts.EventKeyDBSysRoleCreate,
|
||||
consts.EventKeyDBSysRoleUpdate,
|
||||
consts.EventKeyDBSysRoleDelete,
|
||||
}
|
||||
event.EventsInstance().Register(eventList, func(ctx context.Context, eventKey string, args ...interface{}) {
|
||||
if len(args) < 1 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList, ok := args[0].([]int64)
|
||||
if !ok {
|
||||
pk, ok := args[0].(int64)
|
||||
if !ok {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList = []int64{pk}
|
||||
}
|
||||
if len(pkList) == 0 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
if eventKey == consts.EventKeyDBSysRoleDelete {
|
||||
s.DeleteData(ctx, pkList)
|
||||
} else {
|
||||
for _, pk := range pkList {
|
||||
s.LoadData(ctx, pk)
|
||||
}
|
||||
}
|
||||
|
||||
userIds := make([]int64, 0)
|
||||
if err := dao.SysUserRole.Ctx(ctx).
|
||||
Fields(dao.SysUserRole.Columns().UserId).
|
||||
WhereIn(dao.SysUserRole.Columns().RoleId, pkList).
|
||||
Group(dao.SysUserRole.Columns().UserId).
|
||||
Scan(&userIds); err == nil {
|
||||
for _, userId := range userIds {
|
||||
_ = mcache.RemoveUserAccessCodeList(ctx, userId)
|
||||
_ = mcache.RemoveUserRoleDataAccessCodeList(ctx, userId)
|
||||
}
|
||||
}
|
||||
|
||||
dbType := "edit"
|
||||
if eventKey == consts.EventKeyDBSysRoleCreate {
|
||||
dbType = "add"
|
||||
} else if eventKey == consts.EventKeyDBSysRoleDelete {
|
||||
dbType = "del"
|
||||
} else {
|
||||
dbType = "edit"
|
||||
}
|
||||
queue.Push(ctx, consts.BroadcastDbChg, &model.BroadcastDbChgCache{
|
||||
TableName: s.TableName(ctx),
|
||||
PK: pkList,
|
||||
Type: dbType,
|
||||
Data: nil,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysRole) TableName(ctx context.Context) string {
|
||||
return dao.SysRole.Table()
|
||||
}
|
||||
|
||||
func (s *sMemDBSysRole) CreateTable(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, "DROP TABLE IF EXISTS "+s.TableName(ctx))
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.CreateTable delete table err: %v", err)
|
||||
return err
|
||||
}
|
||||
total, err := db.Model("sqlite_master").Where("type", "table").Where("name", dao.SysRole.Table()).Count()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.CreateTable count err: %v", err)
|
||||
return err
|
||||
}
|
||||
if total == 0 {
|
||||
g.Log().Debug(ctx, "sMemDBSysRole.List create table ", s.TableName(ctx))
|
||||
res, err := db.Exec(ctx, `
|
||||
CREATE TABLE "sys_role" (
|
||||
"role_id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" TEXT DEFAULT '000000' CHECK(length("tenant_id") <= 20),
|
||||
"dept_id" INTEGER DEFAULT 0,
|
||||
"role_name" TEXT NOT NULL CHECK(length("role_name") <= 30),
|
||||
"role_key" TEXT NOT NULL CHECK(length("role_key") <= 100),
|
||||
"role_sort" INTEGER NOT NULL,
|
||||
"data_scope" TEXT DEFAULT '1',
|
||||
"menu_check_strictly" INTEGER DEFAULT 1,
|
||||
"dept_check_strictly" INTEGER DEFAULT 1,
|
||||
"status" TEXT NOT NULL ,
|
||||
"created_dept" INTEGER,
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" INTEGER,
|
||||
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted_by" INTEGER,
|
||||
"deleted_at" DATETIME,
|
||||
"remark" TEXT CHECK(length("remark") <= 500)
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.CreateTable create table err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemDBSysRole.CreateTable create table success", res)
|
||||
s.LoadData(ctx, 0)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysRole) LoadData(ctx context.Context, pk int64) error {
|
||||
count := 0
|
||||
if pk == 0 {
|
||||
maxRoleId := int64(0)
|
||||
for {
|
||||
page := 1
|
||||
pageSize := 500
|
||||
dataList := make([]*entity.SysRole, 0)
|
||||
dao.SysRole.Ctx(ctx).Page(page, pageSize).WhereGT(dao.SysRole.Columns().RoleId, maxRoleId).
|
||||
OrderAsc(dao.SysRole.Columns().RoleId).Scan(&dataList)
|
||||
if len(dataList) == 0 {
|
||||
break
|
||||
}
|
||||
count += len(dataList)
|
||||
doList, err := entityListToDo[entity.SysRole, do.SysRole](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysRole.Table()).Data(doList).OnConflict(dao.SysRole.Columns().RoleId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.Load data insert err: %v", err)
|
||||
return err
|
||||
}
|
||||
maxRoleId = dataList[len(dataList)-1].RoleId
|
||||
if len(dataList) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataList := make([]*entity.SysRole, 0)
|
||||
dao.SysRole.Ctx(ctx).Where(dao.SysRole.Columns().RoleId, pk).Scan(&dataList)
|
||||
doList, err := entityListToDo[entity.SysRole, do.SysRole](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysRole.Table()).Data(doList).OnConflict(dao.SysRole.Columns().RoleId).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.Load data insert err: %v", err)
|
||||
}
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysRole.LoadData load data success, total: %d, pk: %d", count, pk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysRole) DeleteData(ctx context.Context, ids []int64) error {
|
||||
g.Log().Debugf(ctx, "sMemDBSysRole.DeleteData called with ids: %+v, len: %d", ids, len(ids))
|
||||
|
||||
if len(ids) == 0 {
|
||||
g.Log().Warningf(ctx, "sMemDBSysRole.DeleteData called with empty ids")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用 Unscoped() 来确保删除操作被执行
|
||||
_, err := db.Model(dao.SysRole.Table()).WhereIn(dao.SysRole.Columns().RoleId, ids).Unscoped().Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysRole.DeleteData err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysRole.DeleteData delete data success, total: %d, ids: %+v", len(ids), ids)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package memorydb
|
||||
|
||||
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/event"
|
||||
"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/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type sMemDBSysTenant struct {
|
||||
}
|
||||
|
||||
var instanceMemDBSysTenant *sMemDBSysTenant
|
||||
|
||||
func NewMemDBSysTenant() *sMemDBSysTenant {
|
||||
if instanceMemDBSysTenant != nil {
|
||||
return instanceMemDBSysTenant
|
||||
}
|
||||
instanceMemDBSysTenant = &sMemDBSysTenant{}
|
||||
initLoadMemoryDB := g.Cfg().MustGet(context.TODO(), "server.initLoadMemoryDB").Bool()
|
||||
if initLoadMemoryDB {
|
||||
instanceMemDBSysTenant.Init(context.TODO())
|
||||
}
|
||||
return instanceMemDBSysTenant
|
||||
}
|
||||
|
||||
func init() {
|
||||
s := NewMemDBSysTenant()
|
||||
service.RegisterMemDBSysTenant(s)
|
||||
RegisterMemDB(s.TableName(context.TODO()), s)
|
||||
}
|
||||
|
||||
func (s *sMemDBSysTenant) Init(ctx context.Context) error {
|
||||
err := s.CreateTable(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = s.EventHandler(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysTenant) EventHandler(ctx context.Context) error {
|
||||
eventList := []string{
|
||||
consts.EventKeyDBSysTenantCreate,
|
||||
consts.EventKeyDBSysTenantUpdate,
|
||||
consts.EventKeyDBSysTenantDelete,
|
||||
}
|
||||
event.EventsInstance().Register(eventList, func(ctx context.Context, eventKey string, args ...interface{}) {
|
||||
if len(args) < 1 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList, ok := args[0].([]int64)
|
||||
if !ok {
|
||||
pk, ok := args[0].(int64)
|
||||
if !ok {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
pkList = []int64{pk}
|
||||
}
|
||||
if len(pkList) == 0 {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.EventHandler event args error. eventKey: %s, args: %+v", eventKey, args)
|
||||
return
|
||||
}
|
||||
if eventKey == consts.EventKeyDBSysTenantDelete {
|
||||
s.DeleteData(ctx, pkList)
|
||||
} else {
|
||||
for _, pk := range pkList {
|
||||
s.LoadData(ctx, pk)
|
||||
}
|
||||
}
|
||||
dbType := "edit"
|
||||
if eventKey == consts.EventKeyDBSysTenantCreate {
|
||||
dbType = "add"
|
||||
} else if eventKey == consts.EventKeyDBSysTenantDelete {
|
||||
dbType = "del"
|
||||
} else {
|
||||
dbType = "edit"
|
||||
}
|
||||
queue.Push(ctx, consts.BroadcastDbChg, &model.BroadcastDbChgCache{
|
||||
TableName: s.TableName(ctx),
|
||||
PK: pkList,
|
||||
Type: dbType,
|
||||
Data: nil,
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysTenant) DB(ctx context.Context) gdb.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func (s *sMemDBSysTenant) TableName(ctx context.Context) string {
|
||||
return dao.SysTenant.Table()
|
||||
}
|
||||
|
||||
func (s *sMemDBSysTenant) CreateTable(ctx context.Context) error {
|
||||
_, err := db.Exec(ctx, "DROP TABLE IF EXISTS "+s.TableName(ctx))
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.CreateTable delete table err: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := db.Model("sqlite_master").Where("type", "table").Where("name", dao.SysTenant.Table()).Count()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.CreateTable count err: %v", err)
|
||||
return err
|
||||
}
|
||||
if total == 0 {
|
||||
g.Log().Debug(ctx, "sMemDBSysTenant.List create table ", s.TableName(ctx))
|
||||
res, err := db.Exec(ctx, `
|
||||
CREATE TABLE "sys_tenant" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"tenant_id" TEXT NOT NULL CHECK(length("tenant_id") <= 20),
|
||||
"contact_user_name" TEXT CHECK(length("contact_user_name") <= 20),
|
||||
"contact_phone" TEXT CHECK(length("contact_phone") <= 20),
|
||||
"company_name" TEXT CHECK(length("company_name") <= 50),
|
||||
"license_number" TEXT CHECK(length("license_number") <= 30),
|
||||
"address" TEXT CHECK(length("address") <= 200),
|
||||
"intro" TEXT CHECK(length("intro") <= 200),
|
||||
"domain" TEXT CHECK(length("domain") <= 200),
|
||||
"remark" TEXT CHECK(length("remark") <= 200),
|
||||
"package_id" INTEGER,
|
||||
"expire_time" TEXT,
|
||||
"account_count" INTEGER DEFAULT -1,
|
||||
"status" TEXT DEFAULT '0',
|
||||
"created_dept" INTEGER,
|
||||
"created_by" INTEGER NOT NULL,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" INTEGER,
|
||||
"updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"deleted_by" INTEGER,
|
||||
"deleted_at" DATETIME,
|
||||
CONSTRAINT "sys_tenant_tenant_id_unique" UNIQUE ("tenant_id")
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.CreateTable create table err: %v", err)
|
||||
return err
|
||||
}
|
||||
g.Log().Debug(ctx, "sMemDBSysTenant.CreateTable create table success", res)
|
||||
// 加载数据
|
||||
s.LoadData(ctx, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysTenant) LoadData(ctx context.Context, pk int64) error {
|
||||
count := 0
|
||||
if pk == 0 {
|
||||
maxId := int64(0)
|
||||
for {
|
||||
page := 1
|
||||
pageSize := 500
|
||||
dataList := make([]*entity.SysTenant, 0)
|
||||
dao.SysTenant.Ctx(ctx).Page(page, pageSize).WhereGT(dao.SysTenant.Columns().Id, maxId).
|
||||
OrderAsc(dao.SysTenant.Columns().Id).Scan(&dataList)
|
||||
if len(dataList) == 0 {
|
||||
break
|
||||
}
|
||||
count += len(dataList)
|
||||
doList, err := entityListToDo[entity.SysTenant, do.SysTenant](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysTenant.Table()).Data(doList).OnConflict(dao.SysTenant.Columns().Id).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.Load data insert err: %v, dataList: %+v", err, dataList)
|
||||
return err
|
||||
}
|
||||
maxId = dataList[len(dataList)-1].Id
|
||||
if len(dataList) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataList := make([]*entity.SysTenant, 0)
|
||||
dao.SysTenant.Ctx(ctx).Where(dao.SysTenant.Columns().Id, pk).Scan(&dataList)
|
||||
doList, err := entityListToDo[entity.SysTenant, do.SysTenant](dataList)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.Load convert data err: %v", err)
|
||||
return err
|
||||
}
|
||||
_, err = db.Model(dao.SysTenant.Table()).Data(doList).OnConflict(dao.SysTenant.Columns().Id).Save()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.Load data insert err: %v", err)
|
||||
}
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysTenant.LoadData load data success, total: %d, pk: %d", count, pk)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sMemDBSysTenant) DeleteData(ctx context.Context, ids []int64) error {
|
||||
_, err := db.Model(dao.SysTenant.Table()).WhereIn(dao.SysTenant.Columns().Id, ids).Delete()
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMemDBSysTenant.DeleteData err: %v, ids: %+v", err, ids)
|
||||
return err
|
||||
}
|
||||
g.Log().Debugf(ctx, "sMemDBSysTenant.DeleteData delete data success, total: %d, ids: %+v", len(ids), ids)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
// package middleware
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/util/gmeta"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/appidentity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/bcache"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/mcache"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/packed/response"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility/queue"
|
||||
)
|
||||
|
||||
type sMiddleware struct {
|
||||
}
|
||||
|
||||
func New() *sMiddleware {
|
||||
return &sMiddleware{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterMiddleware(New())
|
||||
}
|
||||
|
||||
func getContentType(r *ghttp.Request) (contentType string) {
|
||||
contentType = r.Response.Header().Get("Content-Type")
|
||||
if contentType != "" {
|
||||
return
|
||||
}
|
||||
|
||||
mime := gmeta.Get(r.GetHandlerResponse(), "mime").String()
|
||||
if mime == "" {
|
||||
contentType = consts.HTTPContentTypeJson
|
||||
} else {
|
||||
contentType = mime
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 响应处理中间件
|
||||
func (m *sMiddleware) ResponseHandler(r *ghttp.Request) {
|
||||
r.Middleware.Next()
|
||||
if r.Response.BufferLength() > 0 {
|
||||
return
|
||||
}
|
||||
if contexts.IsWebSocket(r.GetCtx()) {
|
||||
return
|
||||
}
|
||||
contentType := getContentType(r)
|
||||
if contentType == consts.HTTPContentTypeExcel {
|
||||
r.Response.Status = http.StatusOK
|
||||
return
|
||||
}
|
||||
// g.Log().Infof(r.GetCtx(), "sMiddleware.ResponseHandler,contentType %v", contentType)
|
||||
err := r.GetError()
|
||||
res := r.GetHandlerResponse()
|
||||
var code gcode.Code = gcode.CodeOK
|
||||
|
||||
if err != nil {
|
||||
code = gerror.Code(err)
|
||||
if code == gcode.CodeNil {
|
||||
code = gcode.CodeInternalError
|
||||
}
|
||||
response.JsonExit(r, code.Code(), err.Error())
|
||||
} else {
|
||||
response.Json(r, code.Code(), "", res)
|
||||
}
|
||||
}
|
||||
|
||||
// Ctx 自定义上下文对象
|
||||
func (s *sMiddleware) Ctx(r *ghttp.Request) {
|
||||
ctx := r.GetCtx()
|
||||
r.SetCtx(r.GetNeverDoneCtx())
|
||||
|
||||
// 初始化登录用户信息
|
||||
data, err := service.SysAuth().GetCurrentUser(ctx)
|
||||
if err != nil {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
if data != nil {
|
||||
contextModel := new(model.Context)
|
||||
contextModel.User = &model.Identity{
|
||||
BaseClaims: data.BaseClaims,
|
||||
}
|
||||
contexts.Init(r, contextModel)
|
||||
}
|
||||
r.Middleware.Next()
|
||||
}
|
||||
|
||||
// CORS 跨域处理
|
||||
func (s *sMiddleware) CORS(r *ghttp.Request) {
|
||||
r.SetCtx(r.GetNeverDoneCtx())
|
||||
if contexts.IsWebSocket(r.GetCtx()) {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
corsOptions := r.Response.DefaultCORSOptions()
|
||||
corsConfig := g.Cfg().MustGet(context.Background(), "server.allowedDomains").Strings()
|
||||
if len(corsConfig) == 0 {
|
||||
r.Response.CORSDefault()
|
||||
} else {
|
||||
corsOptions.AllowDomain = corsConfig
|
||||
r.Response.CORS(corsOptions)
|
||||
}
|
||||
r.Middleware.Next()
|
||||
}
|
||||
|
||||
// Auth 认证处理
|
||||
func (s *sMiddleware) Auth(r *ghttp.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
method = r.Method
|
||||
path = ""
|
||||
)
|
||||
if r.URL != nil {
|
||||
path = r.URL.Path
|
||||
}
|
||||
// 不需要验证登录的路由地址
|
||||
if s.IsExceptLogin(ctx, path) {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
|
||||
userId := contexts.GetUserId(ctx)
|
||||
tenantId := contexts.GetTenantId(ctx)
|
||||
g.Log().Debugf(ctx, "sMiddleware.Auth userId: %d tenantId: %s method: %s path: %s, ================>>", userId, tenantId, method, path)
|
||||
if userId == 0 {
|
||||
g.Log().Error(ctx, "sMiddleware.Auth userId is 0", "path", path, "clientIp", r.GetClientIp(), "User-Agent", utility.GetUserAgent(ctx))
|
||||
response.JsonExit(r, gcode.CodeNotAuthorized.Code(), consts.CodeLoginExpired.Message())
|
||||
// r.Response.WriteStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// 如果是websocket,则不进行权限验证
|
||||
// g.Log().Infof(ctx, "sMiddleware.Auth is websocket: %v", contexts.IsWebSocket(ctx))
|
||||
if contexts.IsWebSocket(ctx) {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
// 是否校验强行退出用户
|
||||
forceLogout, err := mcache.GetSystemConfig(ctx, consts.ConfigOnlineForceLogout, consts.ConfigOnlineForceLogoutFalse)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMiddleware.Auth GetSystemConfig error: %v", err)
|
||||
response.JsonExit(r, gcode.CodeNotAuthorized.Code(), consts.CodeLoginExpired.Message())
|
||||
return
|
||||
}
|
||||
if forceLogout == consts.ConfigOnlineForceLogoutTrue {
|
||||
uuid := contexts.GetUserUuid(ctx)
|
||||
token, err := bcache.GetSysAuthTokenReject(ctx, userId, uuid)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMiddleware.Auth GetSysAuthTokenReject error: %v", err)
|
||||
response.JsonExit(r, gcode.CodeNotAuthorized.Code(), consts.CodeLoginExpired.Message())
|
||||
return
|
||||
}
|
||||
if token != "" {
|
||||
g.Log().Infof(ctx, "sMiddleware.Auth token is reject, userId: %d, uuid: %s", userId, uuid)
|
||||
response.JsonExit(r, gcode.CodeNotAuthorized.Code(), consts.CodeLoginExpired.Message())
|
||||
return
|
||||
}
|
||||
}
|
||||
// 超级管理员
|
||||
if contexts.IsSuperAdmin(r.GetCtx()) {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
// 权限验证
|
||||
serveHandler := r.GetServeHandler()
|
||||
userAccessCodeList, menuRoleDataAccessCodeList, err := mcache.GetUserAccessCodeList(ctx, userId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMiddleware.Auth GetUserAccessCodeList error: %v", err)
|
||||
response.JsonExit(r, gcode.CodeNotAuthorized.Code(), consts.CodeLoginExpired.Message())
|
||||
return
|
||||
}
|
||||
if serveHandler != nil {
|
||||
g.Log().Infof(ctx, "sMiddleware.Auth serveHandler GetMetaTag(x-check-permission): %+v", serveHandler.GetMetaTag("x-check-permission"))
|
||||
accessCode := serveHandler.GetMetaTag("x-check-permission")
|
||||
if accessCode != "" {
|
||||
accessCodeList := strings.Split(accessCode, ",")
|
||||
hasPermission := false
|
||||
for _, code := range accessCodeList {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == consts.SysCheckPermissionTerminalUser {
|
||||
if !isTerminalAppUser(ctx, userId) {
|
||||
g.Log().Errorf(ctx, "sMiddleware.Auth terminal permission denied, userId: %d", userId)
|
||||
continue
|
||||
}
|
||||
hasPermission = true
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(code, consts.SysCheckPermissionPrefix) {
|
||||
accessCodeItemList := splitAccessCodeExpression(code)
|
||||
if len(accessCodeItemList) == 0 {
|
||||
continue
|
||||
}
|
||||
if missingItem, ok := missingAccessCodeExpressionItem(userAccessCodeList, accessCodeItemList); ok {
|
||||
g.Log().Errorf(ctx, "sMiddleware.Auth userAccessCodeList not contains item: %v, userAccessCodeList: %v", missingItem, userAccessCodeList)
|
||||
continue
|
||||
}
|
||||
applyMatchedPermissionDataScope(ctx, menuRoleDataAccessCodeList, accessCodeItemList)
|
||||
hasPermission = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasPermission {
|
||||
response.JsonExit(r, consts.CodeNoPermission.Code(), consts.CodeNoPermission.Message())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
r.Middleware.Next()
|
||||
}
|
||||
|
||||
// matchAccessCode 检查权限码是否匹配,支持通配符 *
|
||||
func matchAccessCode(pattern string, code string) bool {
|
||||
pattern = normalizeAccessCode(pattern)
|
||||
code = normalizeAccessCode(code)
|
||||
if pattern == code {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(pattern, "*") {
|
||||
prefix := pattern[:len(pattern)-1]
|
||||
return strings.HasPrefix(code, prefix)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeAccessCode(code string) string {
|
||||
code = strings.TrimSpace(code)
|
||||
for strings.HasPrefix(code, consts.SysCheckPermissionMenuPrefix+consts.SysCheckPermissionMenuPrefix) {
|
||||
code = strings.TrimPrefix(code, consts.SysCheckPermissionMenuPrefix)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func isTerminalAppUser(ctx context.Context, userId int64) bool {
|
||||
if userId <= 0 {
|
||||
return false
|
||||
}
|
||||
ok, err := appidentity.IsTerminalUserId(ctx, userId)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sMiddleware.Auth query app terminal identity failed, userId: %d, error: %v", userId, err)
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// hasAccessCode 检查用户权限码列表中是否存在匹配指定权限码的项。
|
||||
func hasAccessCode(userAccessCodeList []string, requiredCode string) bool {
|
||||
for _, userCode := range userAccessCodeList {
|
||||
if matchAccessCode(userCode, requiredCode) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func splitAccessCodeExpression(code string) []string {
|
||||
parts := strings.Split(code, "&")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, item := range parts {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasAccessCodeExpression(userAccessCodeList []string, accessCodeItemList []string) bool {
|
||||
if len(accessCodeItemList) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, item := range accessCodeItemList {
|
||||
if !hasAccessCode(userAccessCodeList, item) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func missingAccessCodeExpressionItem(userAccessCodeList []string, accessCodeItemList []string) (string, bool) {
|
||||
for _, item := range accessCodeItemList {
|
||||
if !hasAccessCode(userAccessCodeList, item) {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func applyMatchedPermissionDataScope(ctx context.Context, menuRoleDataAccessCodeList []string, accessCodeItemList []string) {
|
||||
roleId, roleDataScope, ok := matchedPermissionDataScope(menuRoleDataAccessCodeList, accessCodeItemList)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
contexts.SetDataValue(ctx, "currentRoleId", roleId)
|
||||
contexts.SetDataValue(ctx, "currentRoleDataScope", roleDataScope)
|
||||
g.Log().Debugf(ctx, "sMiddleware.Auth currentRoleId: %s, currentRoleDataScope: %s", roleId, roleDataScope)
|
||||
}
|
||||
|
||||
func matchedPermissionDataScope(menuRoleDataAccessCodeList []string, accessCodeItemList []string) (roleId string, roleDataScope string, ok bool) {
|
||||
for _, item := range accessCodeItemList {
|
||||
for _, roleDataAccessCode := range menuRoleDataAccessCodeList {
|
||||
roleDataAccessCodeItemList := strings.Split(roleDataAccessCode, "|")
|
||||
if len(roleDataAccessCodeItemList) != 3 {
|
||||
continue
|
||||
}
|
||||
if !matchAccessCode(roleDataAccessCodeItemList[0], item) {
|
||||
continue
|
||||
}
|
||||
return roleDataAccessCodeItemList[1], roleDataAccessCodeItemList[2], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// IsExceptLogin 判断是否需要验证登录
|
||||
func (s *sMiddleware) IsExceptLogin(ctx context.Context, path string) bool {
|
||||
pathList := g.Cfg().MustGet(ctx, "router.exceptLogin").Strings()
|
||||
return slices.Contains(pathList, path)
|
||||
}
|
||||
|
||||
// OperationLog 操作日志
|
||||
func (s *sMiddleware) OperationLog(r *ghttp.Request) {
|
||||
r.Middleware.Next()
|
||||
ctx := r.Context()
|
||||
enabledDBOperationLog := g.Cfg().MustGet(ctx, "server.enabledDBOperationLog").Bool()
|
||||
if !enabledDBOperationLog {
|
||||
return
|
||||
}
|
||||
data, err := service.SysOperLog().AnalysisLog(ctx)
|
||||
if err != nil {
|
||||
g.Log().Errorf(context.TODO(), "sMiddleware.OperationLog error:%v", err)
|
||||
return
|
||||
}
|
||||
// 写入队列
|
||||
// logData, _ := json.Marshal(data)
|
||||
queue.Push(r.GetCtx(), consts.QueueSysOptLog, data)
|
||||
}
|
||||
|
||||
// ResponseHandlerRaw 返回处理中间件
|
||||
func (s *sMiddleware) ResponseHandlerRaw(r *ghttp.Request) {
|
||||
r.Middleware.Next()
|
||||
// 如果已经有返回内容,那么该中间件什么也不做
|
||||
if r.Response.BufferLength() > 0 {
|
||||
g.Log().Infof(r.Context(), "ResponseHandler r.Response.BufferLength() %d buf:%s", r.Response.BufferLength(), r.Response.BufferString())
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
err = r.GetError()
|
||||
res = r.GetHandlerResponse()
|
||||
)
|
||||
if err != nil {
|
||||
r.Response.WriteJson(res)
|
||||
r.ExitAll()
|
||||
} else {
|
||||
r.Response.WriteJson(res)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasAccessCodeExpressionRequiresEveryPart(t *testing.T) {
|
||||
userAccessCodeList := []string{
|
||||
"cpm:iot:tSimCardInfo:edit",
|
||||
"cpm:agent:dept:list",
|
||||
}
|
||||
|
||||
if !hasAccessCodeExpression(userAccessCodeList, splitAccessCodeExpression("cpm:iot:tSimCardInfo:edit&cpm:agent:dept:list")) {
|
||||
t.Fatal("expected AND expression to match when every permission is granted")
|
||||
}
|
||||
|
||||
if hasAccessCodeExpression(userAccessCodeList, splitAccessCodeExpression("cpm:iot:tSimCardInfo:edit&cpm:agent:tAgentEnterprise:list")) {
|
||||
t.Fatal("expected AND expression to fail when any permission is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchedPermissionDataScopeUsesFirstExpressionPermission(t *testing.T) {
|
||||
menuRoleDataAccessCodeList := []string{
|
||||
"cpm:iot:tSimCardInfo:edit|10|4",
|
||||
"cpm:agent:dept:list|20|7",
|
||||
}
|
||||
|
||||
roleId, roleDataScope, ok := matchedPermissionDataScope(
|
||||
menuRoleDataAccessCodeList,
|
||||
splitAccessCodeExpression("cpm:iot:tSimCardInfo:edit&cpm:agent:dept:list"),
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected data scope to be found")
|
||||
}
|
||||
if roleId != "10" || roleDataScope != "4" {
|
||||
t.Fatalf("expected first permission data scope role=10 scope=4, got role=%s scope=%s", roleId, roleDataScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchedPermissionDataScopeFallsBackToFirstMenuBoundPermission(t *testing.T) {
|
||||
menuRoleDataAccessCodeList := []string{
|
||||
"cpm:system:sysNoticeUser:list|30|5",
|
||||
}
|
||||
|
||||
roleId, roleDataScope, ok := matchedPermissionDataScope(
|
||||
menuRoleDataAccessCodeList,
|
||||
splitAccessCodeExpression("cpc:current:user&cpm:system:sysNoticeUser:list"),
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected data scope to be found")
|
||||
}
|
||||
if roleId != "30" || roleDataScope != "5" {
|
||||
t.Fatalf("expected first menu-bound permission data scope role=30 scope=5, got role=%s scope=%s", roleId, roleDataScope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingAccessCodeExpressionItem(t *testing.T) {
|
||||
userAccessCodeList := []string{"cpm:iot:tSimCardInfo:edit"}
|
||||
|
||||
missingItem, ok := missingAccessCodeExpressionItem(
|
||||
userAccessCodeList,
|
||||
splitAccessCodeExpression("cpm:iot:tSimCardInfo:edit&cpm:agent:dept:list"),
|
||||
)
|
||||
if !ok {
|
||||
t.Fatal("expected a missing permission")
|
||||
}
|
||||
if missingItem != "cpm:agent:dept:list" {
|
||||
t.Fatalf("expected missing agent list permission, got %s", missingItem)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// package monitor
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
"github.com/shirou/gopsutil/v3/disk"
|
||||
"github.com/shirou/gopsutil/v3/host"
|
||||
"github.com/shirou/gopsutil/v3/load"
|
||||
"github.com/shirou/gopsutil/v3/mem"
|
||||
"github.com/shirou/gopsutil/v3/net"
|
||||
)
|
||||
|
||||
type sMonitorServer struct {
|
||||
}
|
||||
|
||||
func NewMonitorServer() *sMonitorServer {
|
||||
return &sMonitorServer{}
|
||||
}
|
||||
|
||||
var SysStartTime *gtime.Time
|
||||
var GoDiskSize string
|
||||
var SysRunDir string
|
||||
var LocalIP string
|
||||
var PublicIP string
|
||||
|
||||
func init() {
|
||||
service.RegisterMonitorServer(NewMonitorServer())
|
||||
SysStartTime = gtime.Now()
|
||||
SysRunDir, _ = os.Getwd()
|
||||
GoDiskSize = utility.DirSize(SysRunDir)
|
||||
LocalIP, _ = utility.GetLocalIP()
|
||||
PublicIP, _ = utility.GetPublicIP()
|
||||
}
|
||||
|
||||
func (l *sMonitorServer) GetGoInfo(ctx context.Context) (res *model.GoRunInfo) {
|
||||
SysRunDir, _ := os.Getwd()
|
||||
var gm runtime.MemStats
|
||||
runtime.ReadMemStats(&gm)
|
||||
res = &model.GoRunInfo{
|
||||
GoName: "Golang",
|
||||
GoOs: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
GoVersion: runtime.Version(),
|
||||
StartTime: SysStartTime.Format("YmdHis"),
|
||||
RunTime: gtime.Now().Timestamp() - SysStartTime.Timestamp(),
|
||||
RootPath: runtime.GOROOT(),
|
||||
Pwd: SysRunDir,
|
||||
Goroutine: strconv.Itoa(runtime.NumGoroutine()),
|
||||
GoMem: utility.FileSize(int64(gm.Sys)),
|
||||
GoSize: GoDiskSize,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetHostInfo 获取主机信息
|
||||
func (l *sMonitorServer) GetHostInfo(ctx context.Context) (data []byte) {
|
||||
hostInfo, _ := host.Info()
|
||||
timestamp, _ := host.BootTime()
|
||||
t := time.Unix(int64(timestamp), 0)
|
||||
tmpData := gconv.Map(hostInfo)
|
||||
tmpData["bootTime"] = t
|
||||
|
||||
tmpData["intranet_ip"] = LocalIP
|
||||
tmpData["public_ip"] = PublicIP
|
||||
data, _ = gjson.Encode(tmpData)
|
||||
return
|
||||
}
|
||||
|
||||
// GetSysLoad 获取系统负载信息
|
||||
func (l *sMonitorServer) GetSysLoad(ctx context.Context) (data []byte) {
|
||||
loadInfo, _ := load.Avg()
|
||||
data, _ = gjson.Encode(g.Map{
|
||||
"load1": loadInfo.Load1,
|
||||
"load5": loadInfo.Load5,
|
||||
"load15": loadInfo.Load15,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// GetCpuInfo 获取CPU信息
|
||||
func (l *sMonitorServer) GetCpuInfo(ctx context.Context) (data []byte) {
|
||||
var CpuInfoData model.CpuInfo
|
||||
cpus, _ := cpu.Info()
|
||||
for _, c := range cpus {
|
||||
CpuInfoData.Cores = CpuInfoData.Cores + c.Cores
|
||||
}
|
||||
CpuInfoData.Number = len(cpus)
|
||||
percent, _ := cpu.Percent(time.Second, false) //获取CPU使用率
|
||||
CpuInfoData.UsedPercent = percent
|
||||
if len(cpus) > 0 {
|
||||
CpuInfoData.ModelName = cpus[0].ModelName //CPU型号
|
||||
}
|
||||
|
||||
data, _ = json.Marshal(CpuInfoData)
|
||||
return
|
||||
}
|
||||
|
||||
// GetMemInfo 获取内存信息
|
||||
func (l *sMonitorServer) GetMemInfo(ctx context.Context) (data []byte) {
|
||||
hostInfo, _ := mem.VirtualMemory()
|
||||
tmpData := gconv.Map(hostInfo)
|
||||
var gomem runtime.MemStats
|
||||
runtime.ReadMemStats(&gomem)
|
||||
if tmpData == nil {
|
||||
tmpData = make(map[string]interface{})
|
||||
}
|
||||
tmpData["goUsed"] = gomem.Sys
|
||||
data, _ = gjson.Encode(tmpData)
|
||||
return
|
||||
}
|
||||
|
||||
// GetDiskInfo 获取磁盘信息
|
||||
func (l *sMonitorServer) GetDiskInfo(ctx context.Context) (data []byte) {
|
||||
diskUsed, _ := disk.Usage("/")
|
||||
data, _ = gjson.Encode(diskUsed)
|
||||
return
|
||||
}
|
||||
|
||||
// GetNetStatusInfo 获取网络信息
|
||||
func (l *sMonitorServer) GetNetStatusInfo(ctx context.Context) (data []byte) {
|
||||
IOCountersStat, _ := net.IOCounters(true)
|
||||
netWorkInfo := make([]model.NetWorkInfo, len(IOCountersStat))
|
||||
for i, n := range IOCountersStat {
|
||||
netWorkInfo[i].Name = n.Name
|
||||
netWorkInfo[i].Receive = n.BytesRecv
|
||||
netWorkInfo[i].Sent = n.BytesSent
|
||||
if netWorkInfo != nil && len(netWorkInfo) > i {
|
||||
netWorkInfo[i].ReceiveSpeed = n.BytesRecv - netWorkInfo[i].Receive
|
||||
netWorkInfo[i].SentSpeed = n.BytesSent - netWorkInfo[i].Sent
|
||||
}
|
||||
}
|
||||
data, _ = gjson.Encode(netWorkInfo)
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
// package system
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"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/bcache"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/library/event"
|
||||
"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/model/request"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/utility"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type sSysAuth struct {
|
||||
}
|
||||
|
||||
func NewSysAuth() *sSysAuth {
|
||||
return &sSysAuth{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
service.RegisterSysAuth(NewSysAuth())
|
||||
}
|
||||
|
||||
func (s *sSysAuth) Login(ctx context.Context, param *model.LoginParams) (res *model.LoginUserOut, token string, err error) {
|
||||
ip := g.RequestFromCtx(ctx).GetClientIp()
|
||||
logininfor := &model.SysLogininforAddModel{
|
||||
TenantId: param.TenantId,
|
||||
UserName: param.Username,
|
||||
ClientKey: "web",
|
||||
DeviceType: "web",
|
||||
Ipaddr: ip,
|
||||
LoginLocation: utility.GetCityByIp(ip),
|
||||
Browser: utility.GetBrowser(ctx),
|
||||
Os: utility.GetOs(ctx),
|
||||
Status: "0",
|
||||
Msg: "登录成功",
|
||||
LoginTime: gtime.Now(),
|
||||
}
|
||||
// 验证验证码
|
||||
err = service.SysCaptcha().VerifyCaptcha(ctx, param.CaptchaID, param.CaptchaValue)
|
||||
if err != nil {
|
||||
logininfor.Status = "1"
|
||||
logininfor.Msg = err.Error()
|
||||
return nil, "", err
|
||||
}
|
||||
// 获取用户信息
|
||||
user, err := service.SysUser().GetUserByUsernameAndPassword(ctx, param.TenantId, param.Username, param.Password)
|
||||
if err != nil {
|
||||
logininfor.Status = "1"
|
||||
logininfor.Msg = err.Error()
|
||||
return nil, "", err
|
||||
}
|
||||
userOut := &model.LoginUserOut{
|
||||
ID: user.UserId,
|
||||
Username: user.UserName,
|
||||
NickName: user.NickName,
|
||||
Avatar: user.Avatar,
|
||||
TenantId: user.TenantId,
|
||||
DeptId: user.DeptId,
|
||||
}
|
||||
// 构建临时的上下文
|
||||
identity := &model.Identity{
|
||||
BaseClaims: model.BaseClaims{
|
||||
ID: user.UserId,
|
||||
Username: user.UserName,
|
||||
NickName: user.NickName,
|
||||
TenantId: user.TenantId,
|
||||
DeptId: user.DeptId,
|
||||
Roles: []model.Role{},
|
||||
LoginAt: time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
contextModel := new(model.Context)
|
||||
contextModel.User = identity
|
||||
|
||||
ctx = contexts.Set(ctx, contextModel)
|
||||
|
||||
// 生成token
|
||||
claims, token, err := s.GenerateToken(ctx, userOut)
|
||||
if err != nil {
|
||||
logininfor.Status = "1"
|
||||
logininfor.Msg = err.Error()
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if err = service.SysUser().UpdateLoginInfo(ctx, user.UserId, ip); err != nil {
|
||||
g.Log().Warningf(ctx, "sSysAuth.Login UpdateLoginInfo failed, userId: %d, ip: %s, error: %v", user.UserId, ip, err)
|
||||
}
|
||||
|
||||
// 保存登录日志
|
||||
go func() {
|
||||
id, err := service.SysLogininfor().AddLogininfor(ctx, logininfor)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 保存在线列表
|
||||
service.SysUserOnline().Add(ctx, &model.SysUserOnlineAddModel{
|
||||
OnlineId: id,
|
||||
TenantId: user.TenantId,
|
||||
Uuid: claims.UUID,
|
||||
UserName: user.UserName,
|
||||
ClientKey: "web",
|
||||
DeviceType: "web",
|
||||
Ipaddr: ip,
|
||||
LoginLocation: utility.GetCityByIp(ip),
|
||||
Browser: utility.GetBrowser(ctx),
|
||||
Os: utility.GetOs(ctx),
|
||||
Token: token,
|
||||
LoginTime: gtime.Now(),
|
||||
ExpireTime: gtime.NewFromTime(claims.ExpiresAt.Time),
|
||||
})
|
||||
}()
|
||||
event.EventsInstance().Emit(ctx, consts.EventKeyUserLogin, user.UserId)
|
||||
return userOut, token, nil
|
||||
}
|
||||
|
||||
// 生成Token
|
||||
func (s *sSysAuth) GenerateToken(ctx context.Context, user *model.LoginUserOut) (claims *model.CustomClaims, token string, err error) {
|
||||
ets := g.Cfg().MustGet(ctx, "jwt.expiresTime", "7d").String()
|
||||
bts := g.Cfg().MustGet(ctx, "jwt.bufferTime", "1d").String()
|
||||
iss := g.Cfg().MustGet(ctx, "jwt.issuer", "XiuAdmin").String()
|
||||
sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "39c54195e73304e74a8429b178965865").String()
|
||||
et, _ := utility.ParseDuration(ets)
|
||||
bt, _ := utility.ParseDuration(bts)
|
||||
g.Log().Infof(ctx, "sSysAuth.GenerateToken 生成Token: user:%+v, et:%s, bt:%s, iss:%s, sk:%s", user, et, bt, iss, sk)
|
||||
roles := make([]model.Role, 0)
|
||||
if user.RoleIds != nil {
|
||||
for _, roleId := range user.RoleIds {
|
||||
roles = append(roles, model.Role{
|
||||
RoleId: roleId,
|
||||
DataScope: consts.SysRoleDataScopeAll,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
authorityIds, err := service.SysUser().GetUserRoleIds(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
rl, _, err := service.SysRole().List(ctx, &model.SysRoleListParam{
|
||||
PageInfo: request.PageInfo{
|
||||
Page: 1,
|
||||
PageSize: 5000,
|
||||
},
|
||||
RoleIds: authorityIds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, r := range rl {
|
||||
roles = append(roles, model.Role{
|
||||
RoleId: r.RoleId,
|
||||
DataScope: r.DataScope,
|
||||
})
|
||||
}
|
||||
}
|
||||
g.Log().Debugf(ctx, "sSysAuth.GenerateToken roles: %v", roles)
|
||||
uuidStr := user.UUID
|
||||
if uuidStr == "" {
|
||||
uuidStr = strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||
}
|
||||
claims = &model.CustomClaims{
|
||||
BaseClaims: model.BaseClaims{
|
||||
UUID: uuidStr,
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
NickName: user.NickName,
|
||||
TenantId: user.TenantId,
|
||||
DeptId: user.DeptId,
|
||||
Roles: roles,
|
||||
LoginAt: time.Now().Unix(),
|
||||
},
|
||||
BufferTime: int64(bt / time.Second),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Audience: jwt.ClaimStrings{"XiuAdmin"}, // 受众
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(et)), // 过期时间 7天 配置文件
|
||||
NotBefore: jwt.NewNumericDate(time.Now().Add(-1000)), // 签名生效时间
|
||||
Issuer: iss, // 发行人
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
token, err = t.SignedString([]byte(sk))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
g.Log().Infof(ctx, "sSysAuth.GenerateToken token: %s, claims: %+v", token, claims)
|
||||
bcache.SetSysAuthToken(ctx, claims.BaseClaims.ID, claims.BaseClaims.UUID, token, et)
|
||||
return claims, token, nil
|
||||
}
|
||||
|
||||
// 解析Token
|
||||
func (s *sSysAuth) ParseToken(ctx context.Context, token string) (claims *model.CustomClaims, err error) {
|
||||
sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "39c54195e73304e74a8429b178965865").String()
|
||||
t, err := jwt.ParseWithClaims(token, &model.CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(sk), nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
return nil, err
|
||||
}
|
||||
if t.Claims.(*model.CustomClaims).ExpiresAt.Unix() < time.Now().Unix() {
|
||||
return nil, errors.New("token已过期")
|
||||
}
|
||||
baseClaims := t.Claims.(*model.CustomClaims).BaseClaims
|
||||
_, err = bcache.GetSysAuthToken(ctx, baseClaims.ID, baseClaims.UUID)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "sSysAuth.ParseToken GetSysAuthToken failed, userId: %d, uuid: %s, error: %v", baseClaims.ID, baseClaims.UUID, err)
|
||||
return nil, err
|
||||
}
|
||||
return t.Claims.(*model.CustomClaims), nil
|
||||
}
|
||||
|
||||
// 删除Token
|
||||
func (s *sSysAuth) DeleteToken(ctx context.Context, token string) (err error) {
|
||||
sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "39c54195e73304e74a8429b178965865").String()
|
||||
t, err := jwt.ParseWithClaims(token, &model.CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(sk), nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timeout := t.Claims.(*model.CustomClaims).ExpiresAt.Unix() - time.Now().Unix()
|
||||
if timeout <= 0 {
|
||||
return nil
|
||||
}
|
||||
err = bcache.DelSysAuthToken(ctx, t.Claims.(*model.CustomClaims).BaseClaims.ID, t.Claims.(*model.CustomClaims).BaseClaims.UUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bcache.SetSysAuthTokenReject(ctx, t.Claims.(*model.CustomClaims).BaseClaims.ID, t.Claims.(*model.CustomClaims).BaseClaims.UUID, token, time.Duration(timeout)*time.Second)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取token
|
||||
func (s *sSysAuth) GetAccessToken(ctx context.Context) (token string, err error) {
|
||||
authorization := g.RequestFromCtx(ctx).Header.Get("Authorization")
|
||||
authParam := g.RequestFromCtx(ctx).Get("access_token").String()
|
||||
if authorization == "" && authParam == "" {
|
||||
return "", gerror.NewCode(gcode.CodeNotImplemented)
|
||||
}
|
||||
token = strings.TrimPrefix(authorization, "Bearer ")
|
||||
if token == "" {
|
||||
token = authParam
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// 根据Token获取当前登录用户信息
|
||||
func (s *sSysAuth) GetCurrentUser(ctx context.Context) (claims *model.CustomClaims, err error) {
|
||||
// 获取token
|
||||
token, err := s.GetAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解析token
|
||||
claims, err = s.ParseToken(ctx, token)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "sSysAuth.GetCurrentUser ParseToken failed, token: %s, error: %v", token, err)
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// 获取用户权限码
|
||||
func (s *sSysAuth) GetUserAccessCodeList(ctx context.Context, userId int64) (accessCodeList []string, menuRoleAccessCodeList []string, err error) {
|
||||
accessCodeList = make([]string, 0)
|
||||
menuRoleAccessCodeList = make([]string, 0)
|
||||
roleDataScopeMap := make(map[int64]string)
|
||||
|
||||
// 角色权限码
|
||||
roleIds, err := service.SysUser().GetUserRoleIds(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(roleIds) > 0 {
|
||||
rl, _, err := service.SysRole().List(ctx, &model.SysRoleListParam{
|
||||
PageInfo: request.PageInfo{
|
||||
Page: 1,
|
||||
PageSize: 5000,
|
||||
},
|
||||
RoleIds: roleIds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, r := range rl {
|
||||
accessCodeList = append(accessCodeList, consts.SysCheckPermissionRolePrefix+r.RoleKey)
|
||||
roleDataScopeMap[r.RoleId] = r.DataScope
|
||||
}
|
||||
}
|
||||
g.Log().Infof(ctx, "sSysAuth.GetUserAccessCodeList roleIds: %v", roleIds)
|
||||
// 菜单权限码
|
||||
// 获取角色菜单列表
|
||||
rmList, err := service.SysRole().GetRoleListMenu(ctx, roleIds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
type roleMenuDataScopeRow struct {
|
||||
RoleId int64 `orm:"role_id"`
|
||||
MenuId int64 `orm:"menu_id"`
|
||||
DataScope string `orm:"data_scope"`
|
||||
}
|
||||
roleMenuDataScopeMap := make(map[string]string)
|
||||
if len(roleIds) > 0 {
|
||||
var roleMenuDataScopes []*roleMenuDataScopeRow
|
||||
roleMenuScopeModel := dao.SysRoleMenu.Ctx(ctx).
|
||||
Fields(
|
||||
dao.SysRoleMenu.Columns().RoleId,
|
||||
dao.SysRoleMenu.Columns().MenuId,
|
||||
dao.SysRoleMenu.Columns().DataScope,
|
||||
).
|
||||
WhereIn(dao.SysRoleMenu.Columns().RoleId, roleIds)
|
||||
if tenantId := contexts.GetTenantId(ctx); tenantId != "" {
|
||||
roleMenuScopeModel = roleMenuScopeModel.Where(dao.SysRoleMenu.Columns().TenantId, tenantId)
|
||||
}
|
||||
if err = roleMenuScopeModel.Scan(&roleMenuDataScopes); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, row := range roleMenuDataScopes {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
roleMenuDataScopeMap[gconv.String(row.RoleId)+"_"+gconv.String(row.MenuId)] = row.DataScope
|
||||
}
|
||||
}
|
||||
menuIds := make([]int64, 0)
|
||||
for _, menu := range rmList {
|
||||
menuIds = append(menuIds, menu.MenuId)
|
||||
}
|
||||
g.Log().Infof(ctx, "sSysAuth.GetUserAccessCodeList menuIds: %v", menuIds)
|
||||
if len(menuIds) > 0 {
|
||||
// 权限码必须读取当前业务库,避免内存菜单库未刷新时拿不到新配置的按钮权限。
|
||||
var menuList []*entity.SysMenu
|
||||
err = dao.SysMenu.Ctx(ctx).
|
||||
WhereIn(dao.SysMenu.Columns().MenuId, menuIds).
|
||||
Where(dao.SysMenu.Columns().Status, consts.SysMenuStatusNormal).
|
||||
Order(dao.SysMenu.Columns().OrderNum).
|
||||
Scan(&menuList)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
g.Log().Infof(ctx, "sSysAuth.GetUserAccessCodeList menuList: %v", menuList)
|
||||
for _, m := range menuList {
|
||||
menuAccessCode := normalizeSysMenuAccessCode(m.Perms)
|
||||
if menuAccessCode == "" {
|
||||
continue
|
||||
}
|
||||
accessCodeList = append(accessCodeList, menuAccessCode)
|
||||
for _, r := range rmList {
|
||||
if r.MenuId == m.MenuId {
|
||||
roleDS := roleDataScopeMap[r.RoleId]
|
||||
if menuDataScope := roleMenuDataScopeMap[gconv.String(r.RoleId)+"_"+gconv.String(r.MenuId)]; menuDataScope != "" && menuDataScope != consts.SysRoleMenuDataScopeRole {
|
||||
roleDS = menuDataScope
|
||||
}
|
||||
if len(roleDS) < 1 {
|
||||
roleDS = consts.SysRoleDataScopeAll
|
||||
}
|
||||
menuRoleAccessCodeList = append(menuRoleAccessCodeList, menuAccessCode+"|"+gconv.String(r.RoleId)+"|"+roleDS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
g.Log().Infof(ctx, "sSysAuth.GetUserAccessCodeList accessCodeList: %v", accessCodeList)
|
||||
// 获取用户岗位信息
|
||||
postIds, err := service.SysUser().GetUserPostIds(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(postIds) > 0 {
|
||||
pl, _, err := service.SysPost().List(ctx, &model.SysPostListParam{
|
||||
PageInfo: request.PageInfo{
|
||||
Page: 1,
|
||||
PageSize: 5000,
|
||||
},
|
||||
PostIds: postIds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, p := range pl {
|
||||
accessCodeList = append(accessCodeList, consts.SysCheckPermissionPostPrefix+p.PostCode)
|
||||
}
|
||||
}
|
||||
// 获取用户信息
|
||||
user, err := service.SysUser().GetUserById(ctx, userId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
accessCodeList = append(accessCodeList, consts.SysCheckPermissionUserPrefix+user.UserName)
|
||||
accessCodeList = append(accessCodeList, consts.SysCheckPermissionCurrentUser)
|
||||
accessCodeList = slices.Compact(accessCodeList)
|
||||
return accessCodeList, menuRoleAccessCodeList, nil
|
||||
}
|
||||
|
||||
func normalizeSysMenuAccessCode(perms string) string {
|
||||
perms = strings.TrimSpace(perms)
|
||||
if perms == "" {
|
||||
return ""
|
||||
}
|
||||
for strings.HasPrefix(perms, consts.SysCheckPermissionMenuPrefix+consts.SysCheckPermissionMenuPrefix) {
|
||||
perms = strings.TrimPrefix(perms, consts.SysCheckPermissionMenuPrefix)
|
||||
}
|
||||
if strings.HasPrefix(perms, consts.SysCheckPermissionMenuPrefix) {
|
||||
return perms
|
||||
}
|
||||
return consts.SysCheckPermissionMenuPrefix + perms
|
||||
}
|
||||
|
||||
// 根据openId登录
|
||||
func (s *sSysAuth) LoginByOpenId(ctx context.Context, social *model.SysSocialListModel) (res *model.LoginUserOut, token string, err error) {
|
||||
ip := g.RequestFromCtx(ctx).GetClientIp()
|
||||
logininfor := &model.SysLogininforAddModel{
|
||||
TenantId: social.User.TenantId,
|
||||
UserName: social.User.UserName,
|
||||
ClientKey: "web",
|
||||
DeviceType: "web",
|
||||
Ipaddr: ip,
|
||||
LoginLocation: utility.GetCityByIp(ip),
|
||||
Browser: utility.GetBrowser(ctx),
|
||||
Os: utility.GetOs(ctx),
|
||||
Status: "0",
|
||||
Msg: "登录成功",
|
||||
LoginTime: gtime.Now(),
|
||||
}
|
||||
user, err := service.SysUser().GetUserByUsername(ctx, social.User.UserName, social.User.TenantId)
|
||||
if err != nil {
|
||||
return nil, "用户不存在", err
|
||||
}
|
||||
userOut := &model.LoginUserOut{
|
||||
ID: user.UserId,
|
||||
Username: user.UserName,
|
||||
NickName: user.NickName,
|
||||
Avatar: user.Avatar,
|
||||
TenantId: user.TenantId,
|
||||
DeptId: user.DeptId,
|
||||
}
|
||||
// 构建临时的上下文
|
||||
identity := &model.Identity{
|
||||
BaseClaims: model.BaseClaims{
|
||||
ID: user.UserId,
|
||||
Username: user.UserName,
|
||||
NickName: user.NickName,
|
||||
TenantId: user.TenantId,
|
||||
DeptId: user.DeptId,
|
||||
Roles: []model.Role{},
|
||||
LoginAt: time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
contextModel := new(model.Context)
|
||||
contextModel.User = identity
|
||||
|
||||
ctx = contexts.Set(ctx, contextModel)
|
||||
|
||||
// 生成token
|
||||
claims, token, err := s.GenerateToken(ctx, userOut)
|
||||
if err != nil {
|
||||
logininfor.Status = "1"
|
||||
logininfor.Msg = err.Error()
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
if err = service.SysUser().UpdateLoginInfo(ctx, user.UserId, ip); err != nil {
|
||||
g.Log().Warningf(ctx, "sSysAuth.LoginByOpenId UpdateLoginInfo failed, userId: %d, ip: %s, error: %v", user.UserId, ip, err)
|
||||
}
|
||||
|
||||
// 保存登录日志
|
||||
go func() {
|
||||
id, err := service.SysLogininfor().AddLogininfor(ctx, logininfor)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 保存在线列表
|
||||
service.SysUserOnline().Add(ctx, &model.SysUserOnlineAddModel{
|
||||
OnlineId: id,
|
||||
TenantId: user.TenantId,
|
||||
Uuid: claims.UUID,
|
||||
UserName: user.UserName,
|
||||
ClientKey: "web",
|
||||
DeviceType: "web",
|
||||
Ipaddr: ip,
|
||||
LoginLocation: utility.GetCityByIp(ip),
|
||||
Browser: utility.GetBrowser(ctx),
|
||||
Os: utility.GetOs(ctx),
|
||||
Token: token,
|
||||
LoginTime: gtime.Now(),
|
||||
ExpireTime: gtime.NewFromTime(claims.ExpiresAt.Time),
|
||||
})
|
||||
}()
|
||||
event.EventsInstance().Emit(ctx, consts.EventKeyUserLogin, user.UserId)
|
||||
return userOut, token, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user