sync: 对齐小程序 v1.0.2 生产代码并脱敏

This commit is contained in:
MiFaZhan
2026-10-29 15:15:12 +08:00
parent a7abd476f0
commit 5fcf74deb0
423 changed files with 15001 additions and 6382 deletions
+4
View File
@@ -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)
}
}
}
+195 -31
View File
@@ -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)
+70 -9
View File
@@ -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)
}
}
+54 -187
View File
@@ -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,
+70 -124
View File
@@ -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,
}
}
+18 -1
View File
@@ -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))
+2 -1
View File
@@ -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,
+65 -8
View File
@@ -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 {
+147 -39
View File
@@ -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
}
+261 -13
View File
@@ -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)
}
})
}
}
+2 -4
View File
@@ -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)
}
}
+37
View File
@@ -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) {
+27 -5
View File
@@ -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)
}
}
+203 -56
View File
@@ -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(&currentDept); 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(&current); 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(&current); 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), &param); 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
+20
View File
@@ -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())
}