refactor: 合并第三方服务类型,去重字段

1. token_service 合并到 alarm_service
   - 去掉重复的 username/password/tenant_code
   - alarm_service 保留 get_token_url、put_message_url、tenant_code 三个有效字段
   - token_service_main 槽位绑定类型改为接受 alarm_service

2. 第三方服务页支持按类型动态显示字段(JS控制)
   - 添加自定义(custom)类型,使用 JSON 文本域
   - custom 类型支持槽位解析(resolvedServiceBinding)

3. 后端代码清理
   - 移除 token_service 类型相关逻辑
   - integration_type_label 去掉认证服务
This commit is contained in:
tian 2026-05-09 12:31:09 +08:00
parent ce18c85eba
commit 62d73f36e2
6 changed files with 115 additions and 95 deletions

View File

@ -101,9 +101,8 @@ type TokenServiceConfig struct {
}
type AlarmServiceConfig struct {
GetTokenURL string `json:"get_token_url"`
PutMessageURL string `json:"put_message_url"`
Username string `json:"username"`
Password string `json:"password"`
TenantCode string `json:"tenant_code"`
}
@ -1375,32 +1374,24 @@ func (s *ConfigPreviewService) SaveIntegrationServiceAsset(asset ConfigIntegrati
return fmt.Errorf("object storage %s is required", key)
}
}
case "token_service":
if asset.TokenService == nil {
return fmt.Errorf("token service config is required")
}
setAnyString(configMap, "get_token_url", asset.TokenService.GetTokenURL)
setAnyString(configMap, "username", asset.TokenService.Username)
setAnyString(configMap, "password", asset.TokenService.Password)
setAnyString(configMap, "tenant_code", asset.TokenService.TenantCode)
if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" {
return fmt.Errorf("token service get_token_url is required")
}
case "alarm_service":
if asset.AlarmService == nil {
return fmt.Errorf("alarm service config is required")
}
setAnyString(configMap, "get_token_url", asset.AlarmService.GetTokenURL)
setAnyString(configMap, "put_message_url", asset.AlarmService.PutMessageURL)
setAnyString(configMap, "username", asset.AlarmService.Username)
setAnyString(configMap, "password", asset.AlarmService.Password)
setAnyString(configMap, "tenant_code", asset.AlarmService.TenantCode)
if strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" {
return fmt.Errorf("alarm service put_message_url is required")
if strings.TrimSpace(stringValue(configMap["get_token_url"])) == "" && strings.TrimSpace(stringValue(configMap["put_message_url"])) == "" {
return fmt.Errorf("alarm service requires at least one of get_token_url or put_message_url")
}
case "custom":
raw["config"] = asset.Raw
default:
return fmt.Errorf("unsupported third-party service type: %s", serviceType)
}
raw["config"] = configMap
if serviceType != "custom" {
raw["config"] = configMap
}
body, err := marshalConfigJSON(raw)
if err != nil {
return err
@ -1659,22 +1650,19 @@ func integrationServiceAssetFromRecord(record storage.IntegrationServiceRecord)
SecretKey: firstString(sourceMap["secret_key"], stringValue(sourceMap["minio_secret_key"])),
}
item.AddressSummary = strings.TrimSpace(strings.Trim(strings.Join([]string{item.ObjectStorage.Endpoint, item.ObjectStorage.Bucket}, " / "), " /"))
case "token_service":
item.TokenService = &TokenServiceConfig{
GetTokenURL: stringValue(sourceMap["get_token_url"]),
Username: stringValue(sourceMap["username"]),
Password: stringValue(sourceMap["password"]),
TenantCode: stringValue(sourceMap["tenant_code"]),
}
item.AddressSummary = item.TokenService.GetTokenURL
case "alarm_service":
item.AlarmService = &AlarmServiceConfig{
GetTokenURL: stringValue(sourceMap["get_token_url"]),
PutMessageURL: stringValue(sourceMap["put_message_url"]),
Username: stringValue(sourceMap["username"]),
Password: stringValue(sourceMap["password"]),
TenantCode: stringValue(sourceMap["tenant_code"]),
}
item.AddressSummary = item.AlarmService.PutMessageURL
summary := strings.TrimSpace(item.AlarmService.PutMessageURL)
if summary == "" {
summary = item.AlarmService.GetTokenURL
}
item.AddressSummary = summary
case "custom":
item.AddressSummary = "自定义"
}
return item, nil
}
@ -1719,10 +1707,10 @@ func integrationTypeLabel(v string) string {
switch strings.TrimSpace(v) {
case "object_storage":
return "对象存储"
case "token_service":
return "认证服务"
case "alarm_service":
return "告警服务"
case "custom":
return "自定义"
default:
return strings.TrimSpace(v)
}

View File

@ -359,7 +359,7 @@ func (s *ConfigPreviewService) resolveSceneBindings(raw map[string]any) (map[str
expected string
}{
{slot: "object_storage_main", expected: "object_storage"},
{slot: "token_service_main", expected: "token_service"},
{slot: "token_service_main", expected: "alarm_service"},
{slot: "alarm_service_main", expected: "alarm_service"},
} {
serviceRef := bindingField(serviceBindings, binding.slot, "service_ref")
@ -405,26 +405,22 @@ func resolvedServiceBinding(asset *ConfigIntegrationServiceAsset) map[string]any
"access_key": asset.ObjectStorage.AccessKey,
"secret_key": asset.ObjectStorage.SecretKey,
}
case "token_service":
if asset.TokenService == nil {
return nil
}
return map[string]any{
"get_token_url": asset.TokenService.GetTokenURL,
"username": asset.TokenService.Username,
"password": asset.TokenService.Password,
"tenant_code": asset.TokenService.TenantCode,
}
case "alarm_service":
if asset.AlarmService == nil {
return nil
}
return map[string]any{
"get_token_url": asset.AlarmService.GetTokenURL,
"put_message_url": asset.AlarmService.PutMessageURL,
"username": asset.AlarmService.Username,
"password": asset.AlarmService.Password,
"tenant_code": asset.AlarmService.TenantCode,
}
case "custom":
if cfg, ok := asset.Raw["config"]; ok {
if m, ok := cfg.(map[string]any); ok {
return m
}
}
return nil
default:
return nil
}

View File

@ -186,7 +186,7 @@ func TestConfigPreviewServiceRenderProfileEditorWritesResolvedBindings(t *testin
t.Fatalf("SaveVideoSource: %v", err)
}
saveIntegrationServiceForPreviewTest(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
saveIntegrationServiceForPreviewTest(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
saveIntegrationServiceForPreviewTest(t, repo, "token_main", "alarm_service", `{"name":"token_main","type":"alarm_service","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
saveIntegrationServiceForPreviewTest(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","config":{"put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
svc := NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
@ -248,7 +248,7 @@ func TestConfigPreviewServiceRenderProfileEditorAllowsUnboundOptionalServiceSlot
"source":"standard",
"slots":{
"inputs":[{"name":"video_input_main","type":"video_source","required":true,"description":"主视频输入"}],
"services":[{"name":"token_service_main","type":"token_service","required":false,"description":"认证服务"}],
"services":[{"name":"token_service_main","type":"alarm_service","required":false,"description":"告警服务"}],
"outputs":[{"name":"stream_output_main","type":"stream_publish","required":true,"description":"主视频输出"}]
},
"template":{

View File

@ -131,6 +131,7 @@ type PageData struct {
TemplateCloneDesc string
TemplateCreateMode string
OverlayDraftJSON string
IntegrationConfigDraft string
AuditEntries []storage.AuditLogRecord
PersistedConfig *storage.DeviceConfigStateRecord
DBPath string
@ -419,8 +420,6 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
return "视频源"
case "object_storage":
return "对象存储"
case "token_service":
return "认证服务"
case "alarm_service":
return "告警服务"
case "stream_publish":
@ -2468,6 +2467,16 @@ func (u *UI) pageAssetIntegrations(w http.ResponseWriter, r *http.Request) {
} else {
data.AssetIntegrationEditing = newMode || editMode
}
if data.AssetIntegration != nil && data.AssetIntegration.Type == "custom" {
if configRaw, ok := data.AssetIntegration.Raw["config"]; ok {
if s, err := compactJSON(configRaw); err == nil {
data.IntegrationConfigDraft = s
}
}
if data.IntegrationConfigDraft == "" {
data.IntegrationConfigDraft = "{}"
}
}
u.render(w, r, "assets", data)
}
@ -2478,29 +2487,34 @@ func (u *UI) actionAssetIntegrationSave(w http.ResponseWriter, r *http.Request)
}
_ = r.ParseForm()
enabled := strings.TrimSpace(r.FormValue("enabled")) == "1" || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "true") || strings.EqualFold(strings.TrimSpace(r.FormValue("enabled")), "on")
serviceType := strings.TrimSpace(r.FormValue("type"))
asset := service.ConfigIntegrationServiceAsset{
Name: strings.TrimSpace(r.FormValue("name")),
Type: strings.TrimSpace(r.FormValue("type")),
Type: serviceType,
Description: strings.TrimSpace(r.FormValue("description")),
Enabled: enabled,
ObjectStorage: &service.ObjectStorageConfig{
Endpoint: strings.TrimSpace(r.FormValue("endpoint")),
Bucket: strings.TrimSpace(r.FormValue("bucket")),
AccessKey: strings.TrimSpace(r.FormValue("access_key")),
SecretKey: strings.TrimSpace(r.FormValue("secret_key")),
},
TokenService: &service.TokenServiceConfig{
GetTokenURL: strings.TrimSpace(r.FormValue("get_token_url")),
Username: strings.TrimSpace(r.FormValue("username")),
Password: strings.TrimSpace(r.FormValue("password")),
TenantCode: strings.TrimSpace(r.FormValue("tenant_code")),
},
AlarmService: &service.AlarmServiceConfig{
PutMessageURL: strings.TrimSpace(r.FormValue("put_message_url")),
Username: strings.TrimSpace(r.FormValue("alarm_username")),
Password: strings.TrimSpace(r.FormValue("alarm_password")),
TenantCode: strings.TrimSpace(r.FormValue("alarm_tenant_code")),
},
ObjectStorage: &service.ObjectStorageConfig{},
AlarmService: &service.AlarmServiceConfig{},
}
if serviceType == "custom" {
configRaw := strings.TrimSpace(r.FormValue("config_json"))
if configRaw == "" {
configRaw = "{}"
}
var raw map[string]any
if err := json.Unmarshal([]byte(configRaw), &raw); err != nil {
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape("自定义配置 JSON 格式不正确:"+err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)
return
}
asset.Raw = raw
} else {
asset.ObjectStorage.Endpoint = strings.TrimSpace(r.FormValue("endpoint"))
asset.ObjectStorage.Bucket = strings.TrimSpace(r.FormValue("bucket"))
asset.ObjectStorage.AccessKey = strings.TrimSpace(r.FormValue("access_key"))
asset.ObjectStorage.SecretKey = strings.TrimSpace(r.FormValue("secret_key"))
asset.AlarmService.GetTokenURL = strings.TrimSpace(r.FormValue("get_token_url"))
asset.AlarmService.PutMessageURL = strings.TrimSpace(r.FormValue("put_message_url"))
asset.AlarmService.TenantCode = strings.TrimSpace(r.FormValue("tenant_code"))
}
if err := u.preview.SaveIntegrationServiceAsset(asset); err != nil {
http.Redirect(w, r, "/ui/assets/integrations?error="+urlQueryEscape(err.Error())+"&name="+url.PathEscape(asset.Name), http.StatusFound)

View File

@ -98,35 +98,40 @@
<label><span>服务名称<span class="required-mark">*</span></span><input name="name" value="{{.AssetIntegration.Name}}" autofocus /></label>
<label>
<span>服务类型<span class="required-mark">*</span></span>
<select name="type">
<select name="type" onchange="toggleIntegrationFields(this.value)">
<option value="object_storage" {{if eq .AssetIntegration.Type "object_storage"}}selected{{end}}>对象存储</option>
<option value="token_service" {{if eq .AssetIntegration.Type "token_service"}}selected{{end}}>认证服务</option>
<option value="alarm_service" {{if eq .AssetIntegration.Type "alarm_service"}}selected{{end}}>告警服务</option>
<option value="custom" {{if eq .AssetIntegration.Type "custom"}}selected{{end}}>自定义</option>
</select>
</label>
<label><span>描述</span><input name="description" value="{{.AssetIntegration.Description}}" /></label>
<label><span>启用</span><select name="enabled"><option value="1" {{if .AssetIntegration.Enabled}}selected{{end}}>启用</option><option value="0" {{if not .AssetIntegration.Enabled}}selected{{end}}>停用</option></select></label>
</div>
<div class="field-grid">
<label><span>对象存储地址</span><input class="mono" name="endpoint" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{end}}" /></label>
<label><span>Bucket</span><input class="mono" name="bucket" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Bucket}}{{end}}" /></label>
<label><span>Access Key</span><input class="mono" name="access_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{end}}" /></label>
<label><span>Secret Key</span><input class="mono" name="secret_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{end}}" /></label>
<div class="field-group" data-type="object_storage">
<h3>对象存储配置</h3>
<div class="field-grid">
<label><span>对象存储地址</span><input class="mono" name="endpoint" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{end}}" /></label>
<label><span>Bucket</span><input class="mono" name="bucket" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.Bucket}}{{end}}" /></label>
<label><span>Access Key</span><input class="mono" name="access_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{end}}" /></label>
<label><span>Secret Key</span><input class="mono" name="secret_key" value="{{if .AssetIntegration.ObjectStorage}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{end}}" /></label>
</div>
</div>
<div class="field-grid">
<label><span>Token 获取地址</span><input class="mono" name="get_token_url" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.GetTokenURL}}{{end}}" /></label>
<label><span>认证用户名</span><input name="username" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Username}}{{end}}" /></label>
<label><span>认证密码</span><input name="password" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.Password}}{{end}}" /></label>
<label><span>认证租户编码</span><input class="mono" name="tenant_code" value="{{if .AssetIntegration.TokenService}}{{.AssetIntegration.TokenService.TenantCode}}{{end}}" /></label>
<div class="field-group" data-type="alarm_service">
<h3>告警服务配置</h3>
<div class="field-grid">
<label><span>Token 获取地址</span><input class="mono" name="get_token_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.GetTokenURL}}{{end}}" /></label>
<label><span>消息上报地址</span><input class="mono" name="put_message_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{end}}" /></label>
<label><span>租户编码</span><input class="mono" name="tenant_code" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.TenantCode}}{{end}}" /></label>
</div>
</div>
<div class="field-grid">
<label><span>消息上报地址</span><input class="mono" name="put_message_url" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{end}}" /></label>
<label><span>告警用户名</span><input name="alarm_username" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Username}}{{end}}" /></label>
<label><span>告警密码</span><input name="alarm_password" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.Password}}{{end}}" /></label>
<label><span>告警租户编码</span><input class="mono" name="alarm_tenant_code" value="{{if .AssetIntegration.AlarmService}}{{.AssetIntegration.AlarmService.TenantCode}}{{end}}" /></label>
<div class="field-group" data-type="custom">
<h3>自定义配置</h3>
<div class="field-grid">
<label class="full"><span>配置 JSON<span class="required-mark">*</span></span><textarea class="code-input" name="config_json" rows="10">{{.IntegrationConfigDraft}}</textarea></label>
</div>
</div>
<div class="actions">
@ -139,21 +144,38 @@
<div class="detail-item"><span>服务类型</span><strong>{{.AssetIntegration.TypeLabel}}</strong></div>
<div class="detail-item"><span>描述</span><strong>{{if .AssetIntegration.Description}}{{.AssetIntegration.Description}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>启用状态</span><strong>{{if .AssetIntegration.Enabled}}启用{{else}}停用{{end}}</strong></div>
{{if eq .AssetIntegration.Type "object_storage"}}
<div class="detail-item"><span>对象存储地址</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Endpoint}}{{.AssetIntegration.ObjectStorage.Endpoint}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Bucket</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.Bucket}}{{.AssetIntegration.ObjectStorage.Bucket}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Access Key</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.AccessKey}}{{.AssetIntegration.ObjectStorage.AccessKey}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Secret Key</span><strong class="mono">{{if and .AssetIntegration.ObjectStorage .AssetIntegration.ObjectStorage.SecretKey}}{{.AssetIntegration.ObjectStorage.SecretKey}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>Token 获取地址</span><strong class="mono">{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.GetTokenURL}}{{.AssetIntegration.TokenService.GetTokenURL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>认证用户名</span><strong>{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.Username}}{{.AssetIntegration.TokenService.Username}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>认证租户编码</span><strong class="mono">{{if and .AssetIntegration.TokenService .AssetIntegration.TokenService.TenantCode}}{{.AssetIntegration.TokenService.TenantCode}}{{else}}-{{end}}</strong></div>
{{end}}
{{if eq .AssetIntegration.Type "alarm_service"}}
<div class="detail-item"><span>Token 获取地址</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.GetTokenURL}}{{.AssetIntegration.AlarmService.GetTokenURL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>消息上报地址</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.PutMessageURL}}{{.AssetIntegration.AlarmService.PutMessageURL}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>告警用户名</span><strong>{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.Username}}{{.AssetIntegration.AlarmService.Username}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>告警租户编码</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.TenantCode}}{{.AssetIntegration.AlarmService.TenantCode}}{{else}}-{{end}}</strong></div>
<div class="detail-item"><span>租户编码</span><strong class="mono">{{if and .AssetIntegration.AlarmService .AssetIntegration.AlarmService.TenantCode}}{{.AssetIntegration.AlarmService.TenantCode}}{{else}}-{{end}}</strong></div>
{{end}}
</div>
{{end}}
</div>
</form>
{{if and (not .AssetIntegrationEditing) (eq .AssetIntegration.Type "custom")}}
<details class="card collapsible">
<summary class="title-with-icon">{{icon "tech"}}<span>原始 JSON</span></summary>
<pre>{{json .AssetIntegration.Raw}}</pre>
</details>
{{end}}
{{end}}
<script>
function toggleIntegrationFields(type) {
document.querySelectorAll('[data-type]').forEach(function(el) {
el.style.display = el.getAttribute('data-type') === type ? '' : 'none';
});
}
toggleIntegrationFields('{{.AssetIntegration.Type}}');
</script>
{{else if eq .AssetTab "video-sources"}}
<div class="card">
<div class="section-title">

View File

@ -2327,11 +2327,11 @@ func TestUI_AssetIntegrationsPageShowsStructuredServices(t *testing.T) {
defer store.Close()
repo := storage.NewAssetsRepo(store.DB())
mustSaveIntegrationService(t, repo, "minio_main", "object_storage", `{"name":"minio_main","type":"object_storage","description":"主对象存储","config":{"endpoint":"http://10.0.0.49:9000","bucket":"myminio","access_key":"admin","secret_key":"password"}}`)
mustSaveIntegrationService(t, repo, "token_main", "token_service", `{"name":"token_main","type":"token_service","description":"认证","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","tenant_code":"32"}}`)
mustSaveIntegrationService(t, repo, "alarm_main", "alarm_service", `{"name":"alarm_main","type":"alarm_service","description":"告警","config":{"get_token_url":"http://10.0.0.49:8080/api/getToken","put_message_url":"http://10.0.0.49:8080/api/putMessage","tenant_code":"32"}}`)
ui.preview = service.NewConfigPreviewService(&config.Config{MediaRepoPath: root}, repo)
mustImportAssetsForUI(t, ui.preview)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations", nil)
req := httptest.NewRequest(http.MethodGet, "/ui/assets/integrations?name=alarm_main", nil)
rr := httptest.NewRecorder()
ui.pageAssetIntegrations(rr, req)
@ -2343,15 +2343,15 @@ func TestUI_AssetIntegrationsPageShowsStructuredServices(t *testing.T) {
"编辑",
"删除",
"minio_main",
"token_main",
"alarm_main",
"对象存储",
"认证服务",
"告警服务",
"http://10.0.0.49:9000 / myminio",
"1",
"查看模式",
`<strong class="mono">minio_main</strong>`,
`<strong class="mono">alarm_main</strong>`,
`data-nav-row`,
`data-nav-href="/ui/assets/integrations?name=minio_main"`,
`data-nav-href="/ui/assets/integrations?name=alarm_main"`,
`class="selected"`,
} {
if !strings.Contains(body, want) {