sync: 对齐小程序 v1.0.2 生产代码并脱敏
This commit is contained in:
@@ -158,6 +158,10 @@ var appFeatureAccessRules = []appFeatureAccessRule{
|
||||
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"},
|
||||
|
||||
@@ -109,3 +109,15 @@ func TestDataServiceFeaturesUseConsolePermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -39,10 +39,20 @@ import (
|
||||
|
||||
const appSideCaptchaCachePrefix = "app:side:captcha:"
|
||||
const appSmsCachePrefix = "app:sms:"
|
||||
const appSmsCooldownPrefix = "app:sms:cooldown:"
|
||||
const appSmsDailyPhonePrefix = "app:sms:daily:phone:"
|
||||
const appSmsDailyIPPrefix = "app:sms:daily:ip:"
|
||||
const appWechatBindTicketCachePrefix = "app:wechat:bind:"
|
||||
const appWechatMiniAppSecretPlaceholder = "CHANGE_ME_WECHAT_MINI_APP_SECRET"
|
||||
const appWechatBindTicketExpire = 10 * time.Minute
|
||||
|
||||
const (
|
||||
appSmsCodeTTL = 5 * time.Minute
|
||||
appSmsCooldownTTL = 60 * time.Second
|
||||
appSmsDailyLimit = 10
|
||||
appSmsDailyExpire = 24 * time.Hour
|
||||
)
|
||||
|
||||
var appSlideCaptcha slide.Captcha
|
||||
|
||||
type appWechatCode2SessionRes struct {
|
||||
@@ -188,16 +198,32 @@ func (s *sAppAuth) SendSms(ctx context.Context, param *model.AppSendSmsParam) (r
|
||||
param.TenantId = strings.TrimSpace(param.TenantId)
|
||||
param.Phone = strings.TrimSpace(param.Phone)
|
||||
param.Scene = strings.TrimSpace(param.Scene)
|
||||
if err = s.validateAppSmsScene(ctx, param); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.reserveAppSmsCooldown(ctx, param.TenantId, param.Phone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.checkAppSmsRateLimit(ctx, param.TenantId, param.Phone); err != nil {
|
||||
_ = releaseAppSmsCooldown(ctx, param.TenantId, param.Phone)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
code := fmt.Sprintf("%04d", rand.Intn(10000))
|
||||
code, err := appRandomDigits(6)
|
||||
if err != nil {
|
||||
_ = releaseAppSmsCooldown(ctx, param.TenantId, param.Phone)
|
||||
return nil, gerror.Wrap(err, "生成短信验证码失败")
|
||||
}
|
||||
cacheKey := appSmsCacheKey(param.TenantId, param.Phone, param.Scene)
|
||||
if err = mcache.Set(ctx, cacheKey, code, 5*time.Minute); err != nil {
|
||||
if err = setAppSmsCode(ctx, cacheKey, code); err != nil {
|
||||
_ = releaseAppSmsCooldown(ctx, param.TenantId, param.Phone)
|
||||
return nil, gerror.New("短信验证码发送失败")
|
||||
}
|
||||
|
||||
sentByHTTP, err := sendAppPhoneCode(ctx, param.Phone, code)
|
||||
sentByHTTP, err := sendAppPhoneCode(ctx, param.Phone, code, appSmsServiceScene(param.Scene))
|
||||
if err != nil {
|
||||
_ = mcache.Remove(ctx, cacheKey)
|
||||
_ = deleteAppSmsCode(ctx, cacheKey)
|
||||
_ = releaseAppSmsCooldown(ctx, param.TenantId, param.Phone)
|
||||
g.Log().Errorf(ctx, "APP短信验证码发送失败 scene=%s phone=%s err=%+v", param.Scene, param.Phone, err)
|
||||
return nil, gerror.New("短信验证码发送失败,请稍后重试")
|
||||
}
|
||||
@@ -209,19 +235,110 @@ func (s *sAppAuth) SendSms(ctx context.Context, param *model.AppSendSmsParam) (r
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func sendAppPhoneCode(ctx context.Context, phone string, code string) (sentByHTTP bool, err error) {
|
||||
func (s *sAppAuth) validateAppSmsScene(ctx context.Context, param *model.AppSendSmsParam) error {
|
||||
if err := service.AppAuth().CheckSideCaptcha(ctx, &model.AppCheckSideCaptchaParam{
|
||||
CaptchaKey: strings.TrimSpace(param.CaptchaKey),
|
||||
Point: strings.TrimSpace(param.CaptchaPoint),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := s.findAppLoginUsersByAccount(ctx, param.TenantId, param.Phone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch param.Scene {
|
||||
case "register":
|
||||
if len(users) > 0 {
|
||||
return gerror.New("手机号已注册")
|
||||
}
|
||||
case "login", "forgotPassword", "wechatBindPhone":
|
||||
if len(users) == 0 {
|
||||
return gerror.New("手机号未注册")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppAuth) checkAppSmsRateLimit(ctx context.Context, tenantId string, phone string) error {
|
||||
tenantId = strings.TrimSpace(tenantId)
|
||||
if tenantId == "" {
|
||||
tenantId = consts.DefaultSystemTenantCode
|
||||
}
|
||||
clientIP := strings.TrimSpace(utility.GetClientIp(ctx))
|
||||
for _, item := range []struct {
|
||||
key string
|
||||
name string
|
||||
skip bool
|
||||
}{
|
||||
{appSmsDailyPhonePrefix + strings.TrimSpace(tenantId) + ":" + phone, "手机号", false},
|
||||
{appSmsDailyIPPrefix + clientIP, "当前网络", clientIP == ""},
|
||||
} {
|
||||
if item.skip {
|
||||
continue
|
||||
}
|
||||
value, err := g.Redis().Do(ctx, "INCR", item.key)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "短信服务暂不可用")
|
||||
}
|
||||
if value.Int() == 1 {
|
||||
_, _ = g.Redis().Do(ctx, "EXPIRE", item.key, int(appSmsDailyExpire.Seconds()))
|
||||
}
|
||||
if value.Int() > appSmsDailyLimit {
|
||||
return gerror.Newf("%s今日短信发送次数已达上限", item.name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppAuth) reserveAppSmsCooldown(ctx context.Context, tenantId string, phone string) error {
|
||||
key := appSmsCooldownCacheKey(tenantId, phone)
|
||||
value, err := g.Redis().Do(ctx, "SET", key, "1", "NX", "EX", int(appSmsCooldownTTL.Seconds()))
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "短信服务暂不可用")
|
||||
}
|
||||
if value.IsNil() || strings.EqualFold(value.String(), "") {
|
||||
return gerror.New("短信验证码发送过于频繁,请稍后再试")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func releaseAppSmsCooldown(ctx context.Context, tenantId string, phone string) error {
|
||||
_, err := g.Redis().Do(ctx, "DEL", appSmsCooldownCacheKey(tenantId, phone))
|
||||
return err
|
||||
}
|
||||
|
||||
func appSmsCooldownCacheKey(tenantId string, phone string) string {
|
||||
if tenantId == "" {
|
||||
tenantId = consts.DefaultSystemTenantCode
|
||||
}
|
||||
return fmt.Sprintf("%s%s:%s", appSmsCooldownPrefix, tenantId, phone)
|
||||
}
|
||||
|
||||
func appSmsServiceScene(scene string) string {
|
||||
switch scene {
|
||||
case "wechatBindPhone":
|
||||
return "bind_phone"
|
||||
default:
|
||||
return scene
|
||||
}
|
||||
}
|
||||
|
||||
func sendAppPhoneCode(ctx context.Context, phone string, code string, scene string) (sentByHTTP bool, err error) {
|
||||
var cfg appCodeHttpSenderConfig
|
||||
if v, cfgErr := g.Cfg().Get(ctx, "register.sms"); cfgErr == nil && !v.IsNil() {
|
||||
_ = v.Scan(&cfg)
|
||||
}
|
||||
if strings.TrimSpace(cfg.Url) != "" {
|
||||
return true, callAppCodeHTTP(ctx, cfg, phone, code)
|
||||
return true, callAppCodeHTTP(ctx, cfg, phone, code, scene)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(g.Cfg().MustGet(ctx, "system.gfmode", "prod").String()), "dev") {
|
||||
return false, gerror.New("短信服务未配置")
|
||||
}
|
||||
g.Log().Infof(ctx, "APP短信验证码已生成 phone=%s code=%s", phone, code)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func callAppCodeHTTP(ctx context.Context, cfg appCodeHttpSenderConfig, contact string, code string) error {
|
||||
func callAppCodeHTTP(ctx context.Context, cfg appCodeHttpSenderConfig, contact string, code string, scene string) error {
|
||||
method := strings.ToUpper(strings.TrimSpace(cfg.Method))
|
||||
if method == "" {
|
||||
method = http.MethodPost
|
||||
@@ -231,7 +348,7 @@ func callAppCodeHTTP(ctx context.Context, cfg appCodeHttpSenderConfig, contact s
|
||||
successStatus = http.StatusOK
|
||||
}
|
||||
|
||||
body := renderAppCodeTemplate(cfg.BodyTemplate, contact, code)
|
||||
body := renderAppCodeTemplate(cfg.BodyTemplate, contact, code, scene)
|
||||
if strings.TrimSpace(body) == "" {
|
||||
bodyBytes, _ := json.Marshal(g.Map{
|
||||
"contact": contact,
|
||||
@@ -280,7 +397,7 @@ func callAppCodeHTTP(ctx context.Context, cfg appCodeHttpSenderConfig, contact s
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderAppCodeTemplate(template string, contact string, code string) string {
|
||||
func renderAppCodeTemplate(template string, contact string, code string, scene string) string {
|
||||
template = strings.TrimSpace(template)
|
||||
if template == "" {
|
||||
return ""
|
||||
@@ -295,6 +412,9 @@ func renderAppCodeTemplate(template string, contact string, code string) string
|
||||
"{phone}", contact,
|
||||
"{{phone}}", contact,
|
||||
"${phone}", contact,
|
||||
"{scene}", scene,
|
||||
"{{scene}}", scene,
|
||||
"${scene}", scene,
|
||||
"{code}", code,
|
||||
"{{code}}", code,
|
||||
"${code}", code,
|
||||
@@ -309,23 +429,37 @@ func appSmsCacheKey(tenantId string, phone string, scene string) string {
|
||||
return fmt.Sprintf("%s%s:%s:%s", appSmsCachePrefix, tenantId, scene, phone)
|
||||
}
|
||||
|
||||
func setAppSmsCode(ctx context.Context, key string, code string) error {
|
||||
_, err := g.Redis().Do(ctx, "SETEX", key, int(appSmsCodeTTL.Seconds()), code)
|
||||
return err
|
||||
}
|
||||
|
||||
func deleteAppSmsCode(ctx context.Context, key string) error {
|
||||
_, err := g.Redis().Do(ctx, "DEL", key)
|
||||
return err
|
||||
}
|
||||
|
||||
func verifyAppSmsCode(ctx context.Context, tenantId string, phone string, scene string, code string) error {
|
||||
value, err := mcache.Get(ctx, appSmsCacheKey(tenantId, phone, scene))
|
||||
value, err := g.Redis().Get(ctx, appSmsCacheKey(tenantId, phone, scene))
|
||||
if err != nil || value == nil {
|
||||
return gerror.NewCode(consts.CodeCaptchaError, "短信验证码已失效")
|
||||
}
|
||||
if fmt.Sprintf("%v", value) != code {
|
||||
if strings.TrimSpace(value.String()) != strings.TrimSpace(code) {
|
||||
return gerror.NewCode(consts.CodeCaptchaError, "短信验证码错误")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyOptionalAppSmsCode(ctx context.Context, tenantId string, phone string, scene string, code string) error {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil
|
||||
func appRandomDigits(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = 6
|
||||
}
|
||||
return verifyAppSmsCode(ctx, tenantId, phone, scene, code)
|
||||
limit := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
|
||||
value, err := cryptorand.Int(cryptorand.Reader, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%0*d", length, value.Int64()), nil
|
||||
}
|
||||
|
||||
func (s *sAppAuth) PhoneRegister(ctx context.Context, param *model.AppPhoneRegisterParam) (res *model.AppAuthTokenModel, err error) {
|
||||
@@ -335,7 +469,7 @@ func (s *sAppAuth) PhoneRegister(ctx context.Context, param *model.AppPhoneRegis
|
||||
param.TenantId = strings.TrimSpace(param.TenantId)
|
||||
param.Phone = strings.TrimSpace(param.Phone)
|
||||
param.SmsCode = strings.TrimSpace(param.SmsCode)
|
||||
if err = verifyOptionalAppSmsCode(ctx, param.TenantId, param.Phone, "register", param.SmsCode); err != nil {
|
||||
if err = verifyAppSmsCode(ctx, param.TenantId, param.Phone, "register", param.SmsCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if param.TenantId == "" {
|
||||
@@ -406,7 +540,7 @@ func (s *sAppAuth) PhoneRegister(ctx context.Context, param *model.AppPhoneRegis
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_ = mcache.Remove(ctx, appSmsCacheKey(param.TenantId, param.Phone, "register"))
|
||||
_ = deleteAppSmsCode(ctx, appSmsCacheKey(param.TenantId, param.Phone, "register"))
|
||||
|
||||
return s.PhoneLogin(ctx, &model.AppPhoneLoginParam{
|
||||
TenantId: param.TenantId,
|
||||
@@ -522,6 +656,43 @@ func (s *sAppAuth) PhoneLogin(ctx context.Context, param *model.AppPhoneLoginPar
|
||||
return s.buildAppAuthToken(ctx, user)
|
||||
}
|
||||
|
||||
func (s *sAppAuth) PhoneSmsLogin(ctx context.Context, param *model.AppPhoneSmsLoginParam) (res *model.AppAuthTokenModel, err error) {
|
||||
if param == nil {
|
||||
return nil, gerror.New("请求参数不能为空")
|
||||
}
|
||||
param.TenantId = strings.TrimSpace(param.TenantId)
|
||||
param.Phone = strings.TrimSpace(param.Phone)
|
||||
param.SmsCode = strings.TrimSpace(param.SmsCode)
|
||||
if err = verifyAppSmsCode(ctx, param.TenantId, param.Phone, "login", param.SmsCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users, err := s.findAppWechatBindUsersByPhone(ctx, param.TenantId, param.Phone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return nil, gerror.New("手机号未注册")
|
||||
}
|
||||
if len(users) > 1 && param.SelectedUserId <= 0 {
|
||||
return &model.AppAuthTokenModel{NeedSelect: true, Candidates: appLoginCandidateModels(ctx, users)}, nil
|
||||
}
|
||||
user := users[0]
|
||||
if param.SelectedUserId > 0 {
|
||||
user = nil
|
||||
for _, item := range users {
|
||||
if item != nil && item.UserId == param.SelectedUserId {
|
||||
user = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if user == nil {
|
||||
return nil, gerror.New("选择的账号不属于当前手机号")
|
||||
}
|
||||
}
|
||||
_ = deleteAppSmsCode(ctx, appSmsCacheKey(param.TenantId, param.Phone, "login"))
|
||||
return s.buildAppAuthToken(ctx, user)
|
||||
}
|
||||
|
||||
func (s *sAppAuth) WechatLogin(ctx context.Context, param *model.AppWechatLoginParam) (res *model.AppWechatLoginModel, err error) {
|
||||
if param == nil {
|
||||
return nil, gerror.New("请求参数不能为空")
|
||||
@@ -619,6 +790,7 @@ func (s *sAppAuth) WechatBindPhone(ctx context.Context, param *model.AppWechatBi
|
||||
if err = s.saveWechatOpenId(ctx, user, ticketData.OpenId, ticketData.UnionId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = deleteAppSmsCode(ctx, appSmsCacheKey(tenantId, phone, "wechatBindPhone"))
|
||||
_ = mcache.Remove(ctx, appWechatBindTicketKey(param.BindTicket))
|
||||
|
||||
token, err := s.buildAppAuthToken(ctx, user)
|
||||
@@ -819,20 +991,12 @@ func appEnsureWechatBindPhoneVerified(ctx context.Context, param *model.AppWecha
|
||||
if ticketData != nil && strings.TrimSpace(ticketData.VerifiedPhone) == strings.TrimSpace(phone) {
|
||||
return nil
|
||||
}
|
||||
captchaKey := strings.TrimSpace(param.CaptchaKey)
|
||||
captchaPoint := strings.TrimSpace(param.CaptchaPoint)
|
||||
if captchaKey == "" || captchaPoint == "" {
|
||||
return gerror.New("请先完成图形验证")
|
||||
}
|
||||
if err := service.AppAuth().CheckSideCaptcha(ctx, &model.AppCheckSideCaptchaParam{
|
||||
CaptchaKey: captchaKey,
|
||||
Point: captchaPoint,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if ticketData == nil || strings.TrimSpace(ticketData.OpenId) == "" || strings.TrimSpace(phone) == "" {
|
||||
return gerror.New("微信绑定参数不完整")
|
||||
}
|
||||
if err := verifyAppSmsCode(ctx, ticketData.TenantId, phone, "wechatBindPhone", param.SmsCode); err != nil {
|
||||
return err
|
||||
}
|
||||
ticketData.VerifiedPhone = strings.TrimSpace(phone)
|
||||
if err := mcache.Set(ctx, appWechatBindTicketKey(param.BindTicket), ticketData, appWechatBindTicketExpire); err != nil {
|
||||
return gerror.Wrap(err, "保存手机号验证状态失败")
|
||||
@@ -1075,7 +1239,7 @@ func (s *sAppAuth) ForgotPassword(ctx context.Context, param *model.AppForgotPas
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = mcache.Remove(ctx, appSmsCacheKey(param.TenantId, param.Phone, "forgotPassword"))
|
||||
_ = deleteAppSmsCode(ctx, appSmsCacheKey(param.TenantId, param.Phone, "forgotPassword"))
|
||||
|
||||
salt := utility.RandomString(5)
|
||||
password := appPasswordEncryptForStore(param.Password, salt)
|
||||
|
||||
@@ -2,7 +2,11 @@ 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"
|
||||
@@ -12,30 +16,47 @@ import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestPhoneRegisterParamAllowsEmptySmsCode(t *testing.T) {
|
||||
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.Fatalf("AppPhoneRegisterParam should allow empty smsCode, got: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("AppPhoneRegisterParam should require smsCode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyOptionalAppSmsCodeSkipsEmptyCode(t *testing.T) {
|
||||
if err := verifyOptionalAppSmsCode(context.Background(), "000000", "13800008000", "register", ""); err != nil {
|
||||
t.Fatalf("empty smsCode should skip sms verification, got: %v", err)
|
||||
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 err := verifyOptionalAppSmsCode(context.Background(), "000000", "13800008000", "register", " "); err != nil {
|
||||
t.Fatalf("blank smsCode should skip sms verification, got: %v", err)
|
||||
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_004698" {
|
||||
if name != "del88_314698" {
|
||||
t.Fatalf("unexpected deleted register username: %s", name)
|
||||
}
|
||||
if len(name) > 30 {
|
||||
@@ -120,3 +141,43 @@ func appAuthTestUser(userId int64, username string, password string) *entity.Sys
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ 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/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
@@ -87,6 +87,7 @@ func (s *sAppCard) loadAllocateTargetDepts(ctx context.Context, user *entity.Sys
|
||||
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).
|
||||
@@ -145,12 +146,17 @@ func (s *sAppCard) AllocateBatchPrepare(ctx context.Context, param *model.AppCar
|
||||
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,
|
||||
@@ -177,8 +183,11 @@ func (s *sAppCard) AllocateBatchPrepare(ctx context.Context, param *model.AppCar
|
||||
return s.buildAllocateBatchModel(ctx, user, batch, batchNo, "", consts.BatchTypePrepare, status), nil
|
||||
}
|
||||
|
||||
// AllocateBatchSubmit 确认划拨预处理批次,并更新卡片归属;划拨后原企业的可见和办理权限仍保留。
|
||||
// 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{}
|
||||
}
|
||||
@@ -190,7 +199,15 @@ func (s *sAppCard) AllocateBatchSubmit(ctx context.Context, param *model.AppCard
|
||||
return nil, gerror.New("终端用户不能使用卡划拨功能")
|
||||
}
|
||||
|
||||
now := gtime.Now()
|
||||
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)
|
||||
@@ -199,6 +216,19 @@ func (s *sAppCard) AllocateBatchSubmit(ctx context.Context, param *model.AppCard
|
||||
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 {
|
||||
@@ -245,10 +275,7 @@ func (s *sAppCard) AllocateBatchSubmit(ctx context.Context, param *model.AppCard
|
||||
})
|
||||
}
|
||||
batch.refreshCounts()
|
||||
if err = s.updateAllocateCards(ctx, tx, user, batch, prepareBatch.BatchNo, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.markAppAllocatePrepareBatchConfirmed(ctx, tx, user, prepareBatch, batch, sourceBatchNo, strings.TrimSpace(param.Remark), now); err != nil {
|
||||
if err = s.queueAppAllocateBatch(ctx, tx, user, prepareBatch, batch, strings.TrimSpace(param.Remark)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -256,19 +283,17 @@ func (s *sAppCard) AllocateBatchSubmit(ctx context.Context, param *model.AppCard
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if batch == nil {
|
||||
return nil, gerror.New("划拨提交结果不完整")
|
||||
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 = "批次已创建,投递失败,请保留批次号并重试提交"
|
||||
}
|
||||
}
|
||||
res = s.buildAllocateBatchModel(ctx, user, batch, batchNo, sourceBatchNo, consts.BatchTypeAllocate, "success")
|
||||
_ = s.publishAppBusinessAsyncEvent(ctx, appBusinessAsyncEvent{
|
||||
EventType: appAsyncEventAllocateCompleted,
|
||||
BatchNo: batchNo,
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
TenantId: user.TenantId,
|
||||
UserId: user.UserId,
|
||||
DeptId: user.DeptId,
|
||||
Trigger: "allocate_submit",
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -368,75 +393,6 @@ func (item *appAllocateCardContext) allocateItemId() string {
|
||||
return strings.TrimSpace(item.ItemId)
|
||||
}
|
||||
|
||||
func (s *sAppCard) insertAppAllocatePrepareBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appAllocateBatchContext, batchNo string, now *gtime.Time) error {
|
||||
batch.refreshCounts()
|
||||
batchStatus := consts.BatchAsyncStatusSuccess
|
||||
runResult := consts.BatchRunResultSuccess
|
||||
successCount := batch.ValidCount
|
||||
if batch.FailCount > 0 {
|
||||
batchStatus = consts.BatchAsyncStatusFailed
|
||||
runResult = consts.BatchRunResultFailed
|
||||
successCount = 0
|
||||
}
|
||||
_, err := dao.BatchMain.Ctx(ctx).TX(tx).Data(do.BatchMain{
|
||||
BatchNo: batchNo,
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
BatchStatus: batchStatus,
|
||||
BatchAsyncStatus: batchStatus,
|
||||
SourceType: consts.BatchSourceTypeBatchSelect,
|
||||
SourceBatchNo: batchNo,
|
||||
IsCrossDept: 0,
|
||||
TargetDeptId: batch.Target.DeptId,
|
||||
DeptId: user.DeptId,
|
||||
TotalCount: batch.TotalCount,
|
||||
ValidCount: batch.ValidCount,
|
||||
SuccessCount: successCount,
|
||||
FailCount: batch.FailCount,
|
||||
RunResult: runResult,
|
||||
FailReason: batch.FailReason,
|
||||
BatchInfo: appAllocatePrepareBatchInfo(batch, batchNo),
|
||||
StartTime: now,
|
||||
EndTime: now,
|
||||
TenantId: user.TenantId,
|
||||
CreatedDept: user.DeptId,
|
||||
CreatedBy: user.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: user.UserId,
|
||||
UpdatedAt: now,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "写入划拨预处理主表失败")
|
||||
}
|
||||
|
||||
details := make([]do.BatchDetail, 0, len(batch.Cards))
|
||||
for _, item := range batch.Cards {
|
||||
detailStatus := consts.BatchDetailStatusSuccess
|
||||
if strings.TrimSpace(item.ErrorReason) != "" {
|
||||
detailStatus = consts.BatchDetailStatusFailed
|
||||
}
|
||||
details = append(details, do.BatchDetail{
|
||||
BatchNo: batchNo,
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
DetailStatus: detailStatus,
|
||||
BlankOutStatus: consts.BlankOutStatusNo,
|
||||
RowNo: item.RowNo,
|
||||
ItemId: item.allocateItemId(),
|
||||
ErrorReason: strings.TrimSpace(item.ErrorReason),
|
||||
Snapshot: appAllocatePrepareDetailSnapshot(batch, item),
|
||||
TenantId: user.TenantId,
|
||||
CreatedDept: user.DeptId,
|
||||
CreatedBy: user.UserId,
|
||||
CreatedAt: now,
|
||||
UpdatedBy: user.UserId,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
if _, err = dao.BatchDetail.Ctx(ctx).TX(tx).Data(details).Insert(); err != nil {
|
||||
return gerror.Wrap(err, "写入划拨预处理明细失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) lockAppAllocatePrepareBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, batchNo string) (*entity.BatchMain, error) {
|
||||
batchNo = strings.TrimSpace(batchNo)
|
||||
if batchNo == "" {
|
||||
@@ -446,7 +402,7 @@ func (s *sAppCard) lockAppAllocatePrepareBatch(ctx context.Context, tx gdb.TX, u
|
||||
columns := dao.BatchMain.Columns()
|
||||
err := dao.BatchMain.Ctx(ctx).TX(tx).
|
||||
Where(columns.BatchNo, batchNo).
|
||||
Where(columns.BatchType, consts.BatchTypePrepare).
|
||||
WhereIn(columns.BatchType, []int{consts.BatchTypePrepare, consts.BatchTypeAllocate}).
|
||||
Where(columns.TenantId, user.TenantId).
|
||||
Where(columns.CreatedBy, user.UserId).
|
||||
LockUpdate().
|
||||
@@ -461,6 +417,9 @@ func (s *sAppCard) lockAppAllocatePrepareBatch(ctx context.Context, tx gdb.TX, u
|
||||
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("划拨预处理批次存在校验失败数据,请重新校验后再提交")
|
||||
}
|
||||
@@ -533,6 +492,9 @@ func (s *sAppCard) validateAllocateCardBusinessRules(ctx context.Context, tx gdb
|
||||
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)
|
||||
@@ -540,104 +502,6 @@ func (s *sAppCard) validateAllocateCardBusinessRules(ctx context.Context, tx gdb
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) updateAllocateCards(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appAllocateBatchContext, batchNo string, now *gtime.Time) error {
|
||||
for _, item := range batch.Cards {
|
||||
result, err := dao.TSimCardInfo.Ctx(ctx).TX(tx).
|
||||
Where(dao.TSimCardInfo.Columns().Id, item.Card.Id).
|
||||
Where(dao.TSimCardInfo.Columns().DeptId, item.Card.DeptId).
|
||||
Data(do.TSimCardInfo{
|
||||
DeptId: batch.Target.DeptId,
|
||||
CreatedDept: user.DeptId,
|
||||
OutboundBatchNo: batchNo,
|
||||
AllocationBatchNo: batchNo,
|
||||
UpdatedBy: user.UserId,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "更新卡%s归属失败", item.Card.Iccid)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil {
|
||||
return gerror.Wrap(err, "读取划拨更新数量失败")
|
||||
} else if affected != 1 {
|
||||
return gerror.Newf("卡%s归属已变化,请重新校验", item.Card.Iccid)
|
||||
}
|
||||
if item.SourceDeptId <= 0 {
|
||||
item.SourceDeptId = item.Card.DeptId
|
||||
}
|
||||
item.Card.DeptId = batch.Target.DeptId
|
||||
item.Card.CreatedDept = user.DeptId
|
||||
item.Card.OutboundBatchNo = batchNo
|
||||
item.Card.AllocationBatchNo = batchNo
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) markAppAllocatePrepareBatchConfirmed(ctx context.Context, tx gdb.TX, user *entity.SysUser, prepareBatch *entity.BatchMain, batch *appAllocateBatchContext, sourceBatchNo string, remark string, now *gtime.Time) error {
|
||||
batchNo := strings.TrimSpace(prepareBatch.BatchNo)
|
||||
for _, item := range batch.Cards {
|
||||
_, err := dao.BatchDetail.Ctx(ctx).TX(tx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Where(dao.BatchDetail.Columns().DetailStatus, consts.BatchDetailStatusSuccess).
|
||||
Where(dao.BatchDetail.Columns().ItemId, item.Card.Iccid).
|
||||
Data(do.BatchDetail{
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
Snapshot: appAllocateSubmitDetailSnapshot(batch, item, batchNo, sourceBatchNo),
|
||||
UpdatedBy: user.UserId,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新划拨批次明细失败")
|
||||
}
|
||||
}
|
||||
if _, err := dao.BatchDetail.Ctx(ctx).TX(tx).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchDetail{
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
UpdatedBy: user.UserId,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
Update(); err != nil {
|
||||
return gerror.Wrap(err, "更新划拨批次明细类型失败")
|
||||
}
|
||||
|
||||
logFiles, err := s.ensureAppAllocateBatchLogFiles(ctx, tx, user, batch, prepareBatch.BatchInfo, batchNo, sourceBatchNo, remark, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
batchInfo := appAllocateBatchInfoWithLogFiles(
|
||||
appAllocateSubmitBatchInfo(batch, batchNo, sourceBatchNo, remark, now),
|
||||
logFiles,
|
||||
)
|
||||
|
||||
_, err = dao.BatchMain.Ctx(ctx).TX(tx).
|
||||
Where(dao.BatchMain.Columns().Id, prepareBatch.Id).
|
||||
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypePrepare).
|
||||
Data(do.BatchMain{
|
||||
BatchType: consts.BatchTypeAllocate,
|
||||
BatchStatus: consts.BatchStatusSuccess,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
TargetDeptId: batch.Target.DeptId,
|
||||
TotalCount: len(batch.Cards),
|
||||
ValidCount: len(batch.Cards),
|
||||
SuccessCount: len(batch.Cards),
|
||||
FailCount: 0,
|
||||
RunResult: consts.BatchRunResultSuccess,
|
||||
BatchInfo: batchInfo,
|
||||
EndTime: now,
|
||||
UpdatedBy: user.UserId,
|
||||
UpdatedAt: now,
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "更新划拨批次主表失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) loadAllocateTarget(ctx context.Context, user *entity.SysUser, targetDeptId int64) (*entity.SysDept, error) {
|
||||
if targetDeptId <= 0 {
|
||||
return nil, gerror.New("请选择划拨目标客户")
|
||||
@@ -649,6 +513,9 @@ func (s *sAppCard) loadAllocateTarget(ctx context.Context, user *entity.SysUser,
|
||||
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("划拨目标不能选择当前企业")
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"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"
|
||||
@@ -420,6 +421,8 @@ func (s *sAppCard) insertAppChangePrepareBatch(ctx context.Context, tx gdb.TX, u
|
||||
BatchStatus: consts.BatchStatusSuccess,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
SourceType: consts.BatchSourceTypeBatchSelect,
|
||||
SourceChannel: batchmetadata.MiniProgram,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
SourceBatchNo: batchNo,
|
||||
IsCrossDept: 0,
|
||||
TargetDeptId: batch.Party.CustomerAgentId,
|
||||
|
||||
@@ -2,7 +2,6 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-app/internal/dao"
|
||||
@@ -10,23 +9,53 @@ import (
|
||||
"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
|
||||
ChanKey string
|
||||
ChanName string
|
||||
ChanAlias string
|
||||
TempKey string
|
||||
ChanStatus int
|
||||
}
|
||||
|
||||
type appCardChannelAssocRecord struct {
|
||||
Id int64 `orm:"id"`
|
||||
ChannelID string `orm:"channel_id"`
|
||||
MatchType int `orm:"match_type"`
|
||||
Iccid string `orm:"iccid"`
|
||||
IccidStart string `orm:"iccid_start"`
|
||||
IccidEnd string `orm:"iccid_end"`
|
||||
Status int `orm:"status"`
|
||||
func (s *sAppCard) 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 {
|
||||
@@ -109,128 +138,44 @@ func (s *sAppCard) loadCardChannelInfo(ctx context.Context, tenantId string, icc
|
||||
}
|
||||
|
||||
func (s *sAppCard) loadDiagnosisCardChannelInfo(ctx context.Context, tenantId string, iccid string, legacyChanKey string) (*appCardChannelInfo, error) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
tenantId = strings.TrimSpace(tenantId)
|
||||
legacyChanKey = strings.TrimSpace(legacyChanKey)
|
||||
if iccid != "" {
|
||||
info, err := s.findAssocCardChannelInfo(ctx, tenantId, iccid)
|
||||
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 info != nil {
|
||||
if appCardChannelAvailable(info) {
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
if legacyChanKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return s.findLegacyCardChannelInfo(ctx, tenantId, legacyChanKey)
|
||||
}
|
||||
|
||||
func (s *sAppCard) findAssocCardChannelInfo(ctx context.Context, tenantId string, iccid string) (*appCardChannelInfo, error) {
|
||||
if strings.TrimSpace(iccid) == "" {
|
||||
return nil, nil
|
||||
card, err := queryLegacyDiagnosisCard(ctx, strings.TrimSpace(iccid))
|
||||
if err != nil || card == nil || strings.TrimSpace(card.ICCID) != strings.TrimSpace(iccid) {
|
||||
return nil, err
|
||||
}
|
||||
assoc, err := s.resolveCardChannelAssocByIccid(ctx, iccid)
|
||||
info, err := s.findLegacyCardChannelInfo(ctx, tenantId, card.ChannelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if assoc == nil || strings.TrimSpace(assoc.ChannelID) == "" {
|
||||
if !appCardChannelAvailable(info) {
|
||||
return nil, nil
|
||||
}
|
||||
info, err := s.findCardChannelInfoByRef(ctx, tenantId, assoc.ChannelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info == nil || strings.TrimSpace(info.ChanKey) == "" {
|
||||
return nil, gerror.New("卡通道关联已匹配,但未找到对应通道配置")
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) resolveCardChannelAssocByIccid(ctx context.Context, iccid string) (*appCardChannelAssocRecord, error) {
|
||||
iccid = strings.TrimSpace(iccid)
|
||||
if iccid == "" {
|
||||
return nil, nil
|
||||
}
|
||||
columns := dao.CardChannelAssoc.Columns()
|
||||
|
||||
var exact *appCardChannelAssocRecord
|
||||
err := dao.CardChannelAssoc.Ctx(ctx).
|
||||
Fields(columns.Id, columns.ChannelId, columns.MatchType, columns.Iccid, columns.IccidStart, columns.IccidEnd, columns.Status).
|
||||
Where(columns.Status, 1).
|
||||
Where(columns.MatchType, 1).
|
||||
Where(columns.Iccid+" COLLATE utf8mb4_general_ci = ?", iccid).
|
||||
OrderDesc(columns.Id).
|
||||
Limit(1).
|
||||
Scan(&exact)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡通道单卡关联失败")
|
||||
}
|
||||
if exact != nil && strings.TrimSpace(exact.ChannelID) != "" {
|
||||
return exact, nil
|
||||
}
|
||||
|
||||
var ranged *appCardChannelAssocRecord
|
||||
err = dao.CardChannelAssoc.Ctx(ctx).
|
||||
Fields(columns.Id, columns.ChannelId, columns.MatchType, columns.Iccid, columns.IccidStart, columns.IccidEnd, columns.Status).
|
||||
Where(columns.Status, 1).
|
||||
Where(columns.MatchType, 2).
|
||||
Where(columns.IccidStart+" COLLATE utf8mb4_general_ci <= ? AND "+columns.IccidEnd+" COLLATE utf8mb4_general_ci >= ?", iccid, iccid).
|
||||
OrderDesc(columns.Id).
|
||||
Limit(1).
|
||||
Scan(&ranged)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡通道号段关联失败")
|
||||
}
|
||||
if ranged != nil && strings.TrimSpace(ranged.ChannelID) != "" {
|
||||
return ranged, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) findCardChannelInfoByRef(ctx context.Context, tenantId string, channelRef string) (*appCardChannelInfo, error) {
|
||||
channelRef = strings.TrimSpace(channelRef)
|
||||
if channelRef == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if id, err := strconv.ParseInt(channelRef, 10, 64); err == nil && id > 0 {
|
||||
if info, err := s.findCardChannelInfoByID(ctx, tenantId, id); err != nil || info != nil {
|
||||
return info, err
|
||||
}
|
||||
}
|
||||
return s.findLegacyCardChannelInfo(ctx, tenantId, channelRef)
|
||||
}
|
||||
|
||||
func (s *sAppCard) findCardChannelInfoByID(ctx context.Context, tenantId string, channelID int64) (*appCardChannelInfo, error) {
|
||||
if channelID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if tenantId = strings.TrimSpace(tenantId); tenantId == "" {
|
||||
tenantId = contexts.GetTenantId(ctx)
|
||||
}
|
||||
if tenantId != "" {
|
||||
channel, err := s.findCardChannelEntityByID(ctx, tenantId, channelID)
|
||||
if err != nil || channel != nil {
|
||||
return appCardChannelInfoFromEntity(channel), err
|
||||
}
|
||||
}
|
||||
channel, err := s.findCardChannelEntityByID(ctx, "", channelID)
|
||||
return appCardChannelInfoFromEntity(channel), err
|
||||
}
|
||||
|
||||
func (s *sAppCard) findCardChannelEntityByID(ctx context.Context, tenantId string, channelID int64) (*entity.TSimChannels, error) {
|
||||
var channel *entity.TSimChannels
|
||||
mod := dao.TSimChannels.Ctx(ctx).
|
||||
Where(dao.TSimChannels.Columns().Id, channelID)
|
||||
if tenantId = strings.TrimSpace(tenantId); tenantId != "" {
|
||||
mod = mod.Where(dao.TSimChannels.Columns().TenantId, tenantId)
|
||||
}
|
||||
err := mod.Limit(1).Scan(&channel)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询通道信息失败")
|
||||
}
|
||||
return channel, nil
|
||||
func 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) {
|
||||
@@ -255,7 +200,7 @@ func (s *sAppCard) findCardChannelEntityByKey(ctx context.Context, tenantId stri
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -274,9 +219,10 @@ func appCardChannelInfoFromEntity(channel *entity.TSimChannels) *appCardChannelI
|
||||
return nil
|
||||
}
|
||||
return &appCardChannelInfo{
|
||||
ChanKey: strings.TrimSpace(channel.ChanKey),
|
||||
ChanName: strings.TrimSpace(channel.ChanName),
|
||||
ChanAlias: strings.TrimSpace(channel.ChanAlias),
|
||||
TempKey: strings.TrimSpace(channel.TempKey),
|
||||
ChanKey: strings.TrimSpace(channel.ChanKey),
|
||||
ChanName: strings.TrimSpace(channel.ChanName),
|
||||
ChanAlias: strings.TrimSpace(channel.ChanAlias),
|
||||
TempKey: strings.TrimSpace(channel.TempKey),
|
||||
ChanStatus: channel.ChanStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@ func (s *sAppCard) List(ctx context.Context, param *model.AppCardListParam) (ite
|
||||
}
|
||||
|
||||
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
|
||||
@@ -328,7 +337,15 @@ func (s *sAppCard) cardQueryBase(ctx context.Context, useAuth bool) *gdb.Model {
|
||||
|
||||
func (s *sAppCard) applyCardFilters(mod *gdb.Model, param *model.AppCardListParam) *gdb.Model {
|
||||
if strings.TrimSpace(param.CardValue) != "" {
|
||||
mod = mod.Where(appCardValueCondition(), appCardValueArgs(param.CardValue)...)
|
||||
if 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))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"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"
|
||||
@@ -227,7 +228,7 @@ func (s *sAppCard) RenewSubmit(ctx context.Context, param *model.AppCardRenewSub
|
||||
if err = s.insertAppRenewBatchOrderItem(ctx, tx, user, batch, orderId, orderSn, batchNo, "", duration.Months, totalAmount, totalAmount, serviceBeginAt, serviceEndAt, exec.CardPeriods, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.insertAppRenewSubmitBatch(ctx, tx, user, batch, batchNo, "", orderSn, duration.Months, totalAmount, totalAmount, serviceBeginAt, serviceEndAt, exec.CardPeriods, buyerNote, now)
|
||||
return s.insertAppRenewSubmitBatch(ctx, tx, user, batch, batchNo, "", orderSn, duration.Months, totalAmount, totalAmount, serviceBeginAt, serviceEndAt, exec.CardPeriods, buyerNote, batchmetadata.Single, now)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"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"
|
||||
@@ -79,6 +80,8 @@ func (s *sAppCard) RenewBatchPrepare(ctx context.Context, param *model.AppCardRe
|
||||
req := batchimport.SubmitRequest{
|
||||
BizParams: param,
|
||||
SourceType: consts.BatchSourceTypeBatchSelect,
|
||||
SourceChannel: batchmetadata.MiniProgram,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
BatchType: consts.BatchTypePrepare,
|
||||
FailureBatchType: consts.BatchTypeRenew,
|
||||
SkipExecute: true,
|
||||
@@ -174,7 +177,7 @@ func (s *sAppCard) RenewBatchSubmit(ctx context.Context, param *model.AppCardRen
|
||||
if err = s.insertAppRenewBatchOrderItem(ctx, tx, user, batch, orderId, orderSn, batchNo, "", duration.Months, singleCardAmount, totalAmount, minBeginAt, maxEndAt, cardPeriods, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.insertAppRenewSubmitBatch(ctx, tx, user, batch, batchNo, "", orderSn, duration.Months, singleCardAmount, totalAmount, minBeginAt, maxEndAt, cardPeriods, buyerNote, now)
|
||||
return s.insertAppRenewSubmitBatch(ctx, tx, user, batch, batchNo, "", orderSn, duration.Months, singleCardAmount, totalAmount, minBeginAt, maxEndAt, cardPeriods, buyerNote, batchmetadata.Bulk, now)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -462,6 +465,8 @@ func (s *sAppCard) insertAppRenewPrepareBatch(ctx context.Context, tx gdb.TX, us
|
||||
BatchStatus: consts.BatchStatusSuccess,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
SourceType: consts.BatchSourceTypeBatchSelect,
|
||||
SourceChannel: batchmetadata.MiniProgram,
|
||||
ExecutionMode: batchmetadata.Bulk,
|
||||
SourceBatchNo: batchNo,
|
||||
IsCrossDept: 0,
|
||||
TargetDeptId: batch.Party.CustomerAgentId,
|
||||
@@ -513,7 +518,7 @@ func (s *sAppCard) insertAppRenewPrepareBatch(ctx context.Context, tx gdb.TX, us
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) insertAppRenewSubmitBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appRenewBatchContext, 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) error {
|
||||
func (s *sAppCard) insertAppRenewSubmitBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, batch *appRenewBatchContext, batchNo string, sourceBatchNo string, orderSn string, durationMonths int, singleCardAmount float64, totalAmount float64, beginAt, endAt *gtime.Time, cardPeriods []map[string]any, buyerNote string, executionMode int, now *gtime.Time) error {
|
||||
sourceBatchNo = appRenewEffectiveSourceBatchNo(batchNo, sourceBatchNo)
|
||||
quantity := len(batch.Items)
|
||||
logFiles, err := s.ensureAppRenewBatchLogFiles(ctx, tx, user, batch, nil, batchNo, sourceBatchNo, orderSn, durationMonths, singleCardAmount, totalAmount, beginAt, endAt, cardPeriods, buyerNote, now)
|
||||
@@ -531,7 +536,9 @@ func (s *sAppCard) insertAppRenewSubmitBatch(ctx context.Context, tx gdb.TX, use
|
||||
BatchStatus: consts.BatchStatusSuccess,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusSuccess,
|
||||
SourceType: consts.BatchSourceTypeBatchSelect,
|
||||
SourceChannel: batchmetadata.MiniProgram,
|
||||
SourceBatchNo: strings.TrimSpace(sourceBatchNo),
|
||||
ExecutionMode: batchmetadata.NormalizeMode(executionMode),
|
||||
IsCrossDept: 0,
|
||||
TargetDeptId: batch.Party.CustomerAgentId,
|
||||
DeptId: batch.Party.CreatedDept,
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
@@ -126,7 +127,10 @@ func (s *sAppCard) LifecycleBatchSubmit(ctx context.Context, param *model.AppCar
|
||||
if err = appEnsureFeatureAccess(ctx, "card:"+operation.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardValues := appNormalizeLifecycleCardValues(param.CardValue, param.CardValues)
|
||||
cardValues, mode, err := appNormalizeLifecycleCardValues(param.CardValue, param.CardValues)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cardValues) == 0 {
|
||||
return nil, gerror.New("请选择需要操作的卡")
|
||||
}
|
||||
@@ -153,7 +157,7 @@ func (s *sAppCard) LifecycleBatchSubmit(ctx context.Context, param *model.AppCar
|
||||
seen[item.Card.Iccid] = struct{}{}
|
||||
items = append(items, item)
|
||||
}
|
||||
return s.insertPendingLifecycleBatch(ctx, tx, user, items, operation, batchNo, strings.TrimSpace(param.Remark), now)
|
||||
return s.insertPendingLifecycleBatch(ctx, tx, user, items, operation, batchNo, strings.TrimSpace(param.Remark), mode, now)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -246,19 +250,52 @@ func (s *sAppCard) LifecycleBatchStatus(ctx context.Context, param *model.AppCar
|
||||
if err = appEnsureFeatureAccess(ctx, "card:"+operation.Code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := appLifecycleBatchStatus(batch.BatchStatus, batch.BatchAsyncStatus)
|
||||
return &model.AppCardLifecycleModel{
|
||||
BatchNo: batch.BatchNo,
|
||||
BatchType: batch.BatchType,
|
||||
Operation: operation.Code,
|
||||
Status: appLifecycleBatchStatus(batch.BatchStatus, batch.BatchAsyncStatus),
|
||||
Status: status,
|
||||
Quantity: batch.TotalCount,
|
||||
SuccessCount: batch.SuccessCount,
|
||||
FailCount: batch.FailCount,
|
||||
FailReason: strings.TrimSpace(batch.FailReason),
|
||||
FailReason: appLifecycleFailReasonForResponse(status, batch.FailReason),
|
||||
Cards: []*model.AppCardModel{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func appLifecycleFailReasonForResponse(status string, reason string) string {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(reason)
|
||||
internalMarkers := []string{
|
||||
"save data service detail",
|
||||
"save lifecycle detail",
|
||||
"update `",
|
||||
"insert into",
|
||||
"delete from",
|
||||
"select ",
|
||||
"error 10",
|
||||
"sqlstate",
|
||||
"column '",
|
||||
"stack:",
|
||||
}
|
||||
for _, marker := range internalMarkers {
|
||||
if strings.Contains(lower, marker) {
|
||||
return "后台执行失败,请稍后重试;如持续失败,请联系管理员并提供批次号"
|
||||
}
|
||||
}
|
||||
if len([]rune(reason)) > 160 {
|
||||
return "后台执行失败,请稍后重试;如持续失败,请联系管理员并提供批次号"
|
||||
}
|
||||
if status == "failed" && (reason == "1 SIM cards failed" || reason == "1 SIM card failed") {
|
||||
return "卡片操作执行失败,请稍后重试"
|
||||
}
|
||||
return reason
|
||||
}
|
||||
|
||||
func (s *sAppCard) prepareLifecycleCard(ctx context.Context, user *entity.SysUser, cardValue string, rowNo int) (*appCardLifecycleItem, error) {
|
||||
card, err := s.findVisibleCard(ctx, user, cardValue)
|
||||
if err != nil {
|
||||
@@ -288,6 +325,13 @@ func (s *sAppCard) lockLifecycleCard(ctx context.Context, tx gdb.TX, user *entit
|
||||
if err = s.ensureOperateCard(ctx, user, card, operation.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channelInfo, err := s.resolveLifecycleCardChannelInfo(ctx, user.TenantId, card.Iccid, card.ChanKey)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "解析卡%s通道失败", appFirstNotEmpty(card.Iccid, cardValue))
|
||||
}
|
||||
if !appCardChannelAvailable(channelInfo) {
|
||||
return nil, gerror.Newf("此卡暂未收录,暂不能%s:%s", operation.Name, appFirstNotEmpty(card.Iccid, cardValue))
|
||||
}
|
||||
|
||||
var locked *entity.TSimCardInfo
|
||||
err = dao.TSimCardInfo.Ctx(ctx).TX(tx).
|
||||
@@ -306,6 +350,10 @@ func (s *sAppCard) lockLifecycleCard(ctx context.Context, tx gdb.TX, user *entit
|
||||
return nil, gerror.Newf("卡%s归属已变化,请刷新后重试", card.Iccid)
|
||||
}
|
||||
card.TSimCardInfo = *locked
|
||||
card.ChanKey = channelInfo.ChanKey
|
||||
card.ChanName = channelInfo.ChanName
|
||||
card.ChanAlias = channelInfo.ChanAlias
|
||||
card.TempKey = channelInfo.TempKey
|
||||
if err = s.ensureLifecycleCardStatus(card, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -382,7 +430,7 @@ func (s *sAppCard) applyMockLifecycleCards(ctx context.Context, tx gdb.TX, user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAppCard) insertPendingLifecycleBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, items []*appCardLifecycleItem, operation appCardLifecycleOperation, batchNo string, remark string, now *gtime.Time) error {
|
||||
func (s *sAppCard) insertPendingLifecycleBatch(ctx context.Context, tx gdb.TX, user *entity.SysUser, items []*appCardLifecycleItem, operation appCardLifecycleOperation, batchNo string, remark string, executionMode int, now *gtime.Time) error {
|
||||
quantity := len(items)
|
||||
_, err := dao.BatchMain.Ctx(ctx).TX(tx).Data(do.BatchMain{
|
||||
BatchNo: batchNo,
|
||||
@@ -390,7 +438,8 @@ func (s *sAppCard) insertPendingLifecycleBatch(ctx context.Context, tx gdb.TX, u
|
||||
BatchStatus: consts.BatchAsyncStatusPending,
|
||||
BatchAsyncStatus: consts.BatchAsyncStatusPending,
|
||||
SourceType: consts.BatchSourceTypeBatchSelect,
|
||||
SourceBatchNo: batchNo,
|
||||
SourceChannel: batchmetadata.MiniProgram,
|
||||
ExecutionMode: batchmetadata.NormalizeMode(executionMode),
|
||||
DeptId: user.DeptId,
|
||||
TotalCount: quantity,
|
||||
ValidCount: 0,
|
||||
@@ -962,13 +1011,21 @@ func appLifecycleOperType(operation appCardLifecycleOperation) string {
|
||||
}
|
||||
}
|
||||
|
||||
func appNormalizeLifecycleCardValues(cardValue string, cardValues []string) []string {
|
||||
func appNormalizeLifecycleCardValues(cardValue string, cardValues []string) ([]string, int, error) {
|
||||
values := make([]string, 0, len(cardValues)+1)
|
||||
if strings.TrimSpace(cardValue) != "" {
|
||||
values = append(values, cardValue)
|
||||
}
|
||||
values = append(values, cardValues...)
|
||||
return appNormalizeRenewCardValues(values)
|
||||
normalized := appNormalizeRenewCardValues(values)
|
||||
mode := batchmetadata.Bulk
|
||||
if strings.TrimSpace(cardValue) != "" && len(cardValues) == 0 {
|
||||
mode = batchmetadata.Single
|
||||
if len(normalized) != 1 {
|
||||
return nil, mode, gerror.New("单卡操作只能指定一张卡,请使用批量操作")
|
||||
}
|
||||
}
|
||||
return normalized, mode, nil
|
||||
}
|
||||
|
||||
func appLifecycleBatchInfo(operation appCardLifecycleOperation, items []*appCardLifecycleItem, batchNo string, remark string, now *gtime.Time) *gjson.Json {
|
||||
|
||||
@@ -25,6 +25,22 @@ func TestAppLifecycleShouldPublishWorkerIncludesRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -20,26 +21,30 @@ const (
|
||||
)
|
||||
|
||||
type appCustomerCardStat struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
TerminalId int64 `orm:"terminal_id"`
|
||||
CardCount int `orm:"card_count"`
|
||||
NormalCardCount int `orm:"normal_card_count"`
|
||||
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, err error) {
|
||||
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, err
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
if err = appEnsureEnterpriseCustomerScope(ctx, user); err != nil {
|
||||
return nil, 0, err
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
if param.Page <= 0 {
|
||||
param.Page = 1
|
||||
@@ -53,23 +58,65 @@ func (s *sAppUser) CustomerList(ctx context.Context, param *model.AppCustomerLis
|
||||
|
||||
customerType := appNormalizeCustomerType(param.CustomerType)
|
||||
if customerType == appCustomerTypeTerminal {
|
||||
return nil, 0, gerror.New("暂不支持查看终端客户")
|
||||
return nil, 0, nil, gerror.New("暂不支持查看终端客户")
|
||||
}
|
||||
|
||||
list, err := s.enterpriseCustomerList(ctx, user, param)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
total = len(list)
|
||||
start := (param.Page - 1) * param.PageSize
|
||||
if start >= total {
|
||||
return []*model.AppCustomerModel{}, total, nil
|
||||
summary = appCustomerSummary(list)
|
||||
items = appCustomerPage(list, param.Page, param.PageSize)
|
||||
if len(items) == 0 {
|
||||
return items, total, summary, nil
|
||||
}
|
||||
end := start + param.PageSize
|
||||
if end > total {
|
||||
end = total
|
||||
deptIds := make([]int64, 0, len(items))
|
||||
for _, item := range items {
|
||||
deptIds = append(deptIds, item.CustomerId)
|
||||
}
|
||||
return list[start:end], total, nil
|
||||
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) {
|
||||
@@ -95,16 +142,12 @@ func (s *sAppUser) CustomerDetail(ctx context.Context, param *model.AppCustomerD
|
||||
}
|
||||
|
||||
func (s *sAppUser) enterpriseCustomerList(ctx context.Context, user *entity.SysUser, param *model.AppCustomerListParam) ([]*model.AppCustomerModel, error) {
|
||||
depts, err := appEnterpriseDirectCustomerDepts(ctx, user)
|
||||
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)
|
||||
stats, err := appCardStatsByDept(ctx, user.TenantId, deptIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles, err := appEnterpriseProfileMap(ctx, user.TenantId, deptIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -113,8 +156,7 @@ func (s *sAppUser) enterpriseCustomerList(ctx context.Context, user *entity.SysU
|
||||
list := make([]*model.AppCustomerModel, 0, len(depts))
|
||||
for _, dept := range depts {
|
||||
profile := profiles[dept.DeptId]
|
||||
stat := stats[dept.DeptId]
|
||||
item := appEnterpriseCustomerModel(dept, profile, 0, stat)
|
||||
item := appEnterpriseCustomerModel(dept, profile, 0, nil)
|
||||
list = append(list, item)
|
||||
}
|
||||
return list, nil
|
||||
@@ -125,7 +167,7 @@ func (s *sAppUser) enterpriseCustomerDetail(ctx context.Context, user *entity.Sy
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dept == nil || dept.DeptId == 0 {
|
||||
if dept == nil || dept.DeptId == 0 || dept.DeptType != 1 {
|
||||
return nil, gerror.New("企业客户不存在")
|
||||
}
|
||||
visible, err := appEnterpriseCustomerDeptVisible(ctx, user, dept.DeptId)
|
||||
@@ -251,6 +293,7 @@ func appDirectChildDepts(ctx context.Context, user *entity.SysUser) ([]*entity.S
|
||||
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).
|
||||
@@ -324,28 +367,67 @@ func appEnterpriseSubCustomerDepts(ctx context.Context, user *entity.SysUser) ([
|
||||
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
|
||||
}
|
||||
if deptId == user.DeptId {
|
||||
return true, nil
|
||||
}
|
||||
excludeIds, err := appEnterpriseCustomerExcludedDeptIds(ctx, user.TenantId, user.DeptId)
|
||||
mod, err := appEnterpriseCustomerQuery(ctx, user)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, excluded := excludeIds[deptId]; excluded {
|
||||
return false, nil
|
||||
}
|
||||
dept, err := appLoadCustomerDept(ctx, user.TenantId, deptId)
|
||||
count, err := mod.Where(dao.SysDept.Columns().DeptId, deptId).Count()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, gerror.Wrap(err, "校验企业客户范围失败")
|
||||
}
|
||||
if dept == nil || dept.DeptId <= 0 || dept.ParentId != user.DeptId {
|
||||
return false, nil
|
||||
}
|
||||
return appEnterpriseDeptVisibleByDataScope(ctx, user, dept.DeptId)
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func appEnterpriseCustomerExcludedDeptIds(ctx context.Context, tenantId string, currentDeptId int64) (map[int64]struct{}, error) {
|
||||
@@ -461,6 +543,16 @@ func appDeptIds(depts []*entity.SysDept) []int64 {
|
||||
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 {
|
||||
@@ -474,16 +566,28 @@ func appCardStatsByDept(ctx context.Context, tenantId string, deptIds []int64) (
|
||||
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).
|
||||
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 {
|
||||
result[row.DeptId] = row
|
||||
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
|
||||
@@ -680,6 +784,8 @@ func appEnterpriseCustomerModel(dept *entity.SysDept, profile *entity.TAgentEnte
|
||||
if stat != nil {
|
||||
item.CardCount = stat.CardCount
|
||||
item.NormalCardCount = stat.NormalCardCount
|
||||
item.CardStatusCounts = stat.CardStatusCounts
|
||||
item.PackageStatusCounts = stat.PackageStatusCounts
|
||||
}
|
||||
return item
|
||||
}
|
||||
@@ -710,6 +816,8 @@ func appTerminalCustomerModel(terminal *entity.TTerminal, stat *appCustomerCardS
|
||||
if stat != nil {
|
||||
item.CardCount = stat.CardCount
|
||||
item.NormalCardCount = stat.NormalCardCount
|
||||
item.CardStatusCounts = stat.CardStatusCounts
|
||||
item.PackageStatusCounts = stat.PackageStatusCounts
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -8,6 +8,130 @@ import (
|
||||
"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") {
|
||||
@@ -15,6 +139,100 @@ func TestQueryDiagnosisFlowByCardRejectsUnsupportedPeriod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -103,7 +321,7 @@ func TestAppDiagnosisApplyRateLimitIncludesApnDetails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenDiagnosisInfoMasksIdentifiersAndRemovesNetworkData(t *testing.T) {
|
||||
func TestSanitizeOpenDiagnosisInfoKeepsImeiAndRemovesOtherNetworkData(t *testing.T) {
|
||||
res := &model.AppDiagnosisInfoModel{
|
||||
Info: &model.AppDiagnosisInfoItem{
|
||||
Iccid: appDiagnosisStringPtr("8986001234567890123"),
|
||||
@@ -116,7 +334,7 @@ func TestSanitizeOpenDiagnosisInfoMasksIdentifiersAndRemovesNetworkData(t *testi
|
||||
Session: &model.AppDiagnosisSessionItem{
|
||||
ApnId: appDiagnosisStringPtr("internet"),
|
||||
Imei: appDiagnosisStringPtr("860123456789012"),
|
||||
Ipv4: appDiagnosisStringPtr("10.0.0.1"),
|
||||
Ipv4: appDiagnosisStringPtr("192.0.2.10"),
|
||||
Ipv6: appDiagnosisStringPtr("2001:db8::1"),
|
||||
},
|
||||
Card: &model.AppCardModel{
|
||||
@@ -124,7 +342,7 @@ func TestSanitizeOpenDiagnosisInfoMasksIdentifiersAndRemovesNetworkData(t *testi
|
||||
Msisdn: "13800008000",
|
||||
Imsi: "460001234567890",
|
||||
Imei: "860123456789012",
|
||||
Ipv4: "10.0.0.1",
|
||||
Ipv4: "192.0.2.10",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -135,24 +353,30 @@ func TestSanitizeOpenDiagnosisInfoMasksIdentifiersAndRemovesNetworkData(t *testi
|
||||
if got := *res.Info.Msisdn; got != "1380****8000" {
|
||||
t.Fatalf("masked MSISDN = %q", got)
|
||||
}
|
||||
if res.Info.Imsi != nil || len(res.Info.RateLimitApnDetails) != 0 || res.Session.ApnId != nil || res.Session.Imei != nil || res.Session.Ipv4 != nil {
|
||||
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.Card.Imsi != "" || res.Card.Imei != "" || res.Card.Ipv4 != "" {
|
||||
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 TestBuildDiagnosisInfoKeepsSessionImeiBeforeOpenSanitization(t *testing.T) {
|
||||
res := &model.AppDiagnosisInfoModel{
|
||||
Session: &model.AppDiagnosisSessionItem{Imei: appDiagnosisStringPtr("860123456789012")},
|
||||
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)
|
||||
}
|
||||
if res.Session.Imei == nil || *res.Session.Imei != "860123456789012" {
|
||||
t.Fatal("authenticated diagnosis should keep session IMEI")
|
||||
items := []appUpstreamSimQueryItem{
|
||||
{Code: 5000, ResData: json.RawMessage(`{"imei":"ignored"}`)},
|
||||
{Code: 0, ResData: raw},
|
||||
}
|
||||
sanitizeOpenDiagnosisInfo(res)
|
||||
if res.Session.Imei != nil {
|
||||
t.Fatal("open diagnosis should remove session IMEI")
|
||||
if got := appDiagnosisLatestImei(items); got != "861789024851199" {
|
||||
t.Fatalf("latest IMEI = %q, want 861789024851199", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +430,30 @@ func TestAppDiagnosisBindStatusSupportsTelecomCardBindingType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -36,18 +36,18 @@ type appDiagnosisTrafficConfig struct {
|
||||
MaxIdleConns int
|
||||
}
|
||||
|
||||
// dbNameFor 返回指定超级表所在 database。
|
||||
//
|
||||
// 日流量与月流量分库存储(两库 duration 不同,而 duration 只能建库时确定);
|
||||
// 未单独配置 dailyDbName/monthlyDbName 时回退到 DbName,与改造前行为一致。
|
||||
func (c appDiagnosisTrafficConfig) dbNameFor(stable string) string {
|
||||
if stable == appDiagnosisDailyStable && c.DailyDbName != "" {
|
||||
return c.DailyDbName
|
||||
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
|
||||
}
|
||||
}
|
||||
if stable == appDiagnosisMonthlyStable && c.MonthlyDbName != "" {
|
||||
return c.MonthlyDbName
|
||||
}
|
||||
return c.DbName
|
||||
return cfg.DbName
|
||||
}
|
||||
|
||||
type appDiagnosisTrafficRecord struct {
|
||||
@@ -67,24 +67,28 @@ func (s *sAppDiagnosis) queryDiagnosisDailyFlow(ctx context.Context, iccid strin
|
||||
|
||||
records, err := appDiagnosisQueryTrafficRows(ctx, iccid, appDiagnosisDailyStable, startTime, endTime)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询SIM卡使用流量失败,请稍后重试")
|
||||
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
|
||||
usageMap[item.Ts.Format("2006-01-02")] += item.UsageMb
|
||||
}
|
||||
|
||||
res := &model.AppDiagnosisFlowModel{
|
||||
Times: make([]string, 0, days),
|
||||
Flow: make([]float64, 0, days),
|
||||
Unit: "MB",
|
||||
Granularity: "day",
|
||||
HasData: len(records) > 0,
|
||||
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"))
|
||||
res.Flow = append(res.Flow, usageMap[current.Format("2006-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
|
||||
}
|
||||
@@ -100,25 +104,29 @@ func (s *sAppDiagnosis) queryDiagnosisMonthlyFlow(ctx context.Context, iccid str
|
||||
|
||||
records, err := appDiagnosisQueryTrafficRows(ctx, iccid, appDiagnosisMonthlyStable, startMonth, endTime)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询SIM卡使用流量失败,请稍后重试")
|
||||
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
|
||||
usageMap[item.Ts.Format("2006-01")] += item.UsageMb
|
||||
}
|
||||
|
||||
res := &model.AppDiagnosisFlowModel{
|
||||
Times: make([]string, 0, months),
|
||||
Flow: make([]float64, 0, months),
|
||||
Unit: "MB",
|
||||
Granularity: "month",
|
||||
HasData: len(records) > 0,
|
||||
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)
|
||||
res.Flow = append(res.Flow, usageMap[key])
|
||||
usage, ok := usageMap[key]
|
||||
res.Flow = append(res.Flow, usage)
|
||||
res.FlowAvailable = append(res.FlowAvailable, ok)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -133,8 +141,8 @@ func appDiagnosisQueryTrafficRows(ctx context.Context, iccid string, stable stri
|
||||
g.Log().Debug(ctx, "app diagnosis traffic query skipped, tdengine disabled")
|
||||
return []appDiagnosisTrafficRecord{}, nil
|
||||
}
|
||||
dbName := cfg.dbNameFor(stable)
|
||||
if err := appDiagnosisValidateTableConfig(dbName, stable); err != nil {
|
||||
database := cfg.databaseFor(stable)
|
||||
if err := appDiagnosisValidateTableConfig(database, stable); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -150,7 +158,7 @@ func appDiagnosisQueryTrafficRows(ctx context.Context, iccid string, stable stri
|
||||
db.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||
db.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||
|
||||
table := appDiagnosisQualify(dbName, appDiagnosisChildTrafficTableName(stable, iccid))
|
||||
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,
|
||||
@@ -179,18 +187,23 @@ func appDiagnosisQueryTrafficRows(ctx context.Context, iccid string, stable stri
|
||||
}
|
||||
|
||||
func appDiagnosisGetTrafficConfig(ctx context.Context) appDiagnosisTrafficConfig {
|
||||
// dbName 只是兑底键名,不决定实际访问哪个库;默认取日库,避免兑底到不存在的库名。
|
||||
dbName := strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dbName", "iotlcm_daily").String())
|
||||
return 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: dbName,
|
||||
DailyDbName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.dailyDbName", dbName).String()),
|
||||
MonthlyDbName: strings.TrimSpace(g.Cfg().MustGet(ctx, "tsdb.tdengine.monthlyDbName", dbName).String()),
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
type sAppMessage struct{}
|
||||
@@ -118,7 +117,6 @@ func (s *sAppMessage) Read(ctx context.Context, param *model.AppMessageReadParam
|
||||
Data(do.TUserMessage{
|
||||
IsRead: 1,
|
||||
UpdatedBy: user.UserId,
|
||||
UpdatedAt: gtime.Now(),
|
||||
}).
|
||||
Update()
|
||||
if err != nil {
|
||||
@@ -129,10 +127,10 @@ func (s *sAppMessage) Read(ctx context.Context, param *model.AppMessageReadParam
|
||||
|
||||
func (s *sAppMessage) ownerMessageModel(ctx context.Context, user *entity.SysUser) *gdb.Model {
|
||||
return dao.TUserMessage.Ctx(ctx).As("um").
|
||||
LeftJoin(dao.TMessageLog.Table()+" ml", "ml."+dao.TMessageLog.Columns().MessageLogId+" = um."+dao.TUserMessage.Columns().MessageLogId).
|
||||
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().MessageLogId+" IS NULL OR ml."+dao.TMessageLog.Columns().IsRecall+" = ?)", 0)
|
||||
Where("ml."+dao.TMessageLog.Columns().IsRecall, 0)
|
||||
}
|
||||
|
||||
func (s *sAppMessage) applyFilters(mod *gdb.Model, param *model.AppMessageListParam) *gdb.Model {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func newAppOneLinkMockClient(ctx context.Context) (*appOneLinkMockClient, error)
|
||||
|
||||
func appLoadOneLinkMockConfig(ctx context.Context) (*appOneLinkMockConfig, error) {
|
||||
cfg := &appOneLinkMockConfig{
|
||||
BaseURL: "https://iotlcm.example.com/onelink-mock/v5",
|
||||
BaseURL: "https://example.com/onelink-mock/v5",
|
||||
Timeout: "10s",
|
||||
}
|
||||
if err := g.Cfg().MustGet(ctx, "upstream.onelinkMock").Scan(cfg); err != nil {
|
||||
|
||||
@@ -84,7 +84,7 @@ type appWechatPayRequest struct {
|
||||
|
||||
func appWechatPayUrl(payRequest *appWechatPayRequest) string {
|
||||
if payRequest == nil {
|
||||
return "物联网卡管理平台"
|
||||
return ""
|
||||
}
|
||||
return payRequest.PayUrl
|
||||
}
|
||||
@@ -875,7 +875,7 @@ func appWechatH5AppName(cfg *appWechatConfig) string {
|
||||
if cfg.H5.AppName != "" {
|
||||
return cfg.H5.AppName
|
||||
}
|
||||
return "物联网卡管理平台"
|
||||
return "示例企业"
|
||||
}
|
||||
|
||||
func appParseWechatPayTime(value string) *gtime.Time {
|
||||
|
||||
@@ -19,6 +19,8 @@ type appUpstreamDefaultConfig struct {
|
||||
Timeout string `json:"timeout"`
|
||||
}
|
||||
|
||||
type appDiagnosisProxyContextKey struct{}
|
||||
|
||||
type appUpstreamSimKey struct {
|
||||
ChanKey string `json:"chanKey,omitempty"`
|
||||
ICCID string `json:"iccid,omitempty"`
|
||||
@@ -39,7 +41,7 @@ type appUpstreamSimQueryItem struct {
|
||||
}
|
||||
|
||||
type appUpstreamSimQueryResponse struct {
|
||||
Code int `json:"code"`
|
||||
Code *int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []appUpstreamSimQueryItem `json:"data"`
|
||||
}
|
||||
@@ -57,7 +59,7 @@ func queryAppCardRealtimeSessionWithKey(ctx context.Context, row *appCardQueryRo
|
||||
if err == nil {
|
||||
return session, "upstream", nil
|
||||
}
|
||||
if !appUpstreamMockFallbackEnabled(ctx) {
|
||||
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)
|
||||
@@ -90,6 +92,7 @@ func queryAppCardRealtimeSessionByUpstreamWithKey(ctx context.Context, simKey *a
|
||||
if err = json.Unmarshal(item.ResData, &session); err != nil {
|
||||
return nil, gerror.Wrap(err, "解析上游会话响应失败")
|
||||
}
|
||||
appNormalizeUpstreamSession(&session, item.ResData)
|
||||
if !session.isZero() {
|
||||
return &session, nil
|
||||
}
|
||||
@@ -97,6 +100,42 @@ func queryAppCardRealtimeSessionByUpstreamWithKey(ctx context.Context, simKey *a
|
||||
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 {
|
||||
@@ -171,18 +210,29 @@ func queryAppUpstreamSimWithKey(ctx context.Context, simKey *appUpstreamSimKey,
|
||||
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 output.Code != 0 {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || output.Code == nil || *output.Code != 0 {
|
||||
message := strings.TrimSpace(output.Message)
|
||||
if message == "" {
|
||||
message = "上游查询失败"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,16 @@ func (s *sAppUser) UpdateInfo(ctx context.Context, param *model.AppUpdateUserInf
|
||||
}
|
||||
|
||||
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
|
||||
@@ -122,6 +132,9 @@ func (s *sAppUser) BindWechat(ctx context.Context, param *model.AppWechatBindPar
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !userInfo.WechatBound {
|
||||
return nil, gerror.New("微信绑定未完成,请检查小程序配置后重试")
|
||||
}
|
||||
return &model.AppWechatBindModel{
|
||||
Success: true,
|
||||
UserInfo: userInfo,
|
||||
@@ -291,6 +304,10 @@ func getAppUserInfoById(ctx context.Context, userId int64) (res *model.AppUserIn
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -298,6 +315,7 @@ func getAppUserInfoById(ctx context.Context, userId int64) (res *model.AppUserIn
|
||||
}
|
||||
|
||||
return &model.AppUserInfoModel{
|
||||
IsTopLevelEnterprise: isTopLevelEnterprise,
|
||||
UserId: user.UserId,
|
||||
TenantId: user.TenantId,
|
||||
DeptId: user.DeptId,
|
||||
@@ -321,6 +339,25 @@ func getAppUserInfoById(ctx context.Context, userId int64) (res *model.AppUserIn
|
||||
}, 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) {
|
||||
|
||||
@@ -87,15 +87,37 @@ func (s *sAppWarning) ConfigMeta(ctx context.Context) (res *model.AppWarningConf
|
||||
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),
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@ 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"
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -42,9 +46,38 @@ func init() {
|
||||
|
||||
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
|
||||
}
|
||||
return service.SysOss().Upload(ctx, &model.SysOssUploadParam{FileType: "file"})
|
||||
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) {
|
||||
@@ -70,11 +103,30 @@ func (s *sAppWorkOrder) List(ctx context.Context, param *model.AppWorkOrderListP
|
||||
}
|
||||
|
||||
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 > 0 {
|
||||
mod = mod.Where(dao.TWoMain.Columns().WoType, param.WoType)
|
||||
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)
|
||||
@@ -142,17 +194,59 @@ func (s *sAppWorkOrder) Detail(ctx context.Context, param *model.AppWorkOrderDet
|
||||
|
||||
res = appWorkOrderToModel(row)
|
||||
var communications []*entity.TWoCommunication
|
||||
if err = dao.TWoCommunication.Ctx(ctx).
|
||||
communicationModel := dao.TWoCommunication.Ctx(ctx).
|
||||
Where(dao.TWoCommunication.Columns().TenantId, user.TenantId).
|
||||
Where(dao.TWoCommunication.Columns().WoNo, row.WoNo).
|
||||
OrderAsc(dao.TWoCommunication.Columns().Id).
|
||||
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
|
||||
@@ -178,6 +272,22 @@ func (s *sAppWorkOrder) Create(ctx context.Context, param *model.AppWorkOrderCre
|
||||
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
|
||||
@@ -194,6 +304,7 @@ func (s *sAppWorkOrder) Create(ctx context.Context, param *model.AppWorkOrderCre
|
||||
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,
|
||||
@@ -201,12 +312,12 @@ func (s *sAppWorkOrder) Create(ctx context.Context, param *model.AppWorkOrderCre
|
||||
RelatedId: param.RelatedId,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Attachment: firstAttachment(param.AttachmentUrls),
|
||||
Attachment: appWorkOrderEncodeAttachments(param.AttachmentUrls),
|
||||
CreatorType: creatorType,
|
||||
CreatorDeptId: createdDept,
|
||||
CreatorUserId: user.UserId,
|
||||
CreatorName: creatorName,
|
||||
CreatorPhone: strings.TrimSpace(user.Phonenumber),
|
||||
CreatorPhone: creatorPhone,
|
||||
AssignDeptId: handlerDeptId,
|
||||
AssignUserId: handlerUserId,
|
||||
CurrentStatus: appWorkOrderStatusProcessing,
|
||||
@@ -260,73 +371,110 @@ func (s *sAppWorkOrder) Create(ctx context.Context, param *model.AppWorkOrderCre
|
||||
}).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 s.Detail(ctx, &model.AppWorkOrderDetailParam{WoNo: woNo})
|
||||
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)
|
||||
if appIsTerminalUser(ctx, user) {
|
||||
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)
|
||||
}
|
||||
return appApplyEnterpriseDataScope(
|
||||
ctx,
|
||||
user,
|
||||
mod,
|
||||
columns.CreatorDeptId,
|
||||
columns.CreatorUserId,
|
||||
func(legacyMod *gdb.Model) *gdb.Model {
|
||||
return legacyMod.Where(
|
||||
"("+columns.CreatorDeptId+" = ? OR "+columns.CreatorUserId+" = ?)",
|
||||
user.DeptId,
|
||||
user.UserId,
|
||||
)
|
||||
},
|
||||
)
|
||||
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 {
|
||||
return createdDept, 0, nil
|
||||
if user == nil || strings.TrimSpace(user.TenantId) == "" || createdDept <= 0 {
|
||||
return 0, 0, gerror.New("当前账号未配置有效的工单所属部门")
|
||||
}
|
||||
|
||||
var topDept *entity.SysDept
|
||||
columns := dao.SysDept.Columns()
|
||||
var currentDept *entity.SysDept
|
||||
if err = dao.SysDept.Ctx(ctx).
|
||||
Where(dao.SysDept.Columns().TenantId, user.TenantId).
|
||||
Where(dao.SysDept.Columns().ParentId, 0).
|
||||
OrderAsc(dao.SysDept.Columns().OrderNum).
|
||||
OrderAsc(dao.SysDept.Columns().DeptId).
|
||||
Fields(columns.DeptId, columns.ParentId, columns.Leader, columns.Status).
|
||||
Where(columns.TenantId, user.TenantId).
|
||||
Where(columns.DeptId, createdDept).
|
||||
WhereNull(columns.DeletedAt).
|
||||
Limit(1).
|
||||
Scan(&topDept); err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "查询顶级工单处理部门失败")
|
||||
Scan(¤tDept); err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "查询工单所属部门失败")
|
||||
}
|
||||
if topDept != nil && topDept.DeptId > 0 {
|
||||
return topDept.DeptId, topDept.Leader, nil
|
||||
if currentDept == nil || currentDept.DeptId <= 0 || currentDept.Status != "0" {
|
||||
return 0, 0, gerror.New("工单所属部门不存在或已停用")
|
||||
}
|
||||
|
||||
deptId = createdDept
|
||||
if deptId <= 0 {
|
||||
deptId = appUserDeptOrDefault(user)
|
||||
if currentDept.ParentId == 0 {
|
||||
return currentDept.DeptId, currentDept.Leader, nil
|
||||
}
|
||||
var dept *entity.SysDept
|
||||
if currentDept.ParentId == currentDept.DeptId {
|
||||
return 0, 0, gerror.New("工单所属部门的上级配置异常")
|
||||
}
|
||||
var parentDept *entity.SysDept
|
||||
if err = dao.SysDept.Ctx(ctx).
|
||||
Where(dao.SysDept.Columns().TenantId, user.TenantId).
|
||||
Where(dao.SysDept.Columns().DeptId, deptId).
|
||||
Fields(columns.DeptId, columns.Leader, columns.Status).
|
||||
Where(columns.TenantId, user.TenantId).
|
||||
Where(columns.DeptId, currentDept.ParentId).
|
||||
WhereNull(columns.DeletedAt).
|
||||
Limit(1).
|
||||
Scan(&dept); err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "查询工单处理部门失败")
|
||||
Scan(&parentDept); err != nil {
|
||||
return 0, 0, gerror.Wrap(err, "查询上级工单处理部门失败")
|
||||
}
|
||||
if dept != nil && dept.Leader > 0 {
|
||||
userId = dept.Leader
|
||||
if parentDept == nil || parentDept.DeptId <= 0 || parentDept.Status != "0" {
|
||||
return 0, 0, gerror.New("上级工单处理部门不存在或已停用,请联系管理员")
|
||||
}
|
||||
return deptId, userId, nil
|
||||
return parentDept.DeptId, parentDept.Leader, nil
|
||||
}
|
||||
|
||||
func appWorkOrderToModel(row *entity.TWoMain) *model.AppWorkOrderModel {
|
||||
@@ -335,6 +483,12 @@ func appWorkOrderToModel(row *entity.TWoMain) *model.AppWorkOrderModel {
|
||||
}
|
||||
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),
|
||||
@@ -373,20 +527,13 @@ func appWorkOrderCommunicationsToModels(rows []*entity.TWoCommunication) []*mode
|
||||
return items
|
||||
}
|
||||
|
||||
func firstAttachment(urls []string) string {
|
||||
if len(urls) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(urls[0])
|
||||
}
|
||||
|
||||
func decodeAttachmentUrls(value string) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
var urls []string
|
||||
if err := gjson.DecodeTo(value, &urls); err == nil && len(urls) > 0 {
|
||||
if err := gjson.DecodeTo(value, &urls); err == nil && (strings.HasPrefix(value, "[") || value == "null") {
|
||||
return urls
|
||||
}
|
||||
return []string{value}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ func appWorkerBatchAsyncEnabled(ctx context.Context) bool {
|
||||
return g.Cfg().MustGet(ctx, "workerBatchAsync.enabled", false).Bool()
|
||||
}
|
||||
|
||||
func (s *sAppCard) publishWorkerBatchExecute(ctx context.Context, batchNo string, batchType int) error {
|
||||
func (s *sAppCard) publishWorkerBatchExecute(ctx context.Context, batchNo string, batchType int, requirePublication ...bool) error {
|
||||
if strings.TrimSpace(batchNo) == "" {
|
||||
return gerror.New("批次号不能为空")
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (s *sAppCard) publishWorkerBatchExecute(ctx context.Context, batchNo string
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "投递 worker 批次失败, queue:%s, batchNo:%s, batchType:%d, err:%+v", queueName, batchNo, batchType, err)
|
||||
if g.Cfg().MustGet(ctx, "workerBatchAsync.failOnPublish", true).Bool() {
|
||||
if (len(requirePublication) > 0 && requirePublication[0]) || g.Cfg().MustGet(ctx, "workerBatchAsync.failOnPublish", true).Bool() {
|
||||
return gerror.Wrap(err, "批次已创建但投递 worker 失败")
|
||||
}
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user