feat: GeoIP-based geo detection + configurable rename template

- Add MaxMind GeoLite2-City mmdb auto-download and IP-based geo lookup
  (covers all 249 countries globally, replacing limited 8-country regex)
- Add RenameOptions with 5 toggleable fields (flag/alias/country/city/index)
  stored as rename_options_json on collections
- Collection rename pipeline uses DetectGeoWithServer(name, server) which
  tries GeoIP lookup first, falls back to name-based regex matching
- Chinese country/city names + emoji flag prefix
- Country-city dedup: skip city when it equals country (e.g. 香港/香港)
- URI fragment encoding: url.QueryEscape -> url.PathEscape (%20 not +)
- Egress cache key excludes name field so renamed nodes still match cache
- Frontend: 5 toggle buttons in collection edit form, default all on
- Migrations: 0004 (source alias + collection rename_enabled),
  0005 (collection rename_options_json)
This commit is contained in:
2026-07-28 14:44:54 +08:00
parent 389c0d172a
commit de9ac92ede
29 changed files with 1256 additions and 225 deletions
+50 -11
View File
@@ -436,9 +436,11 @@ func mapToSourceRecord(input map[string]any) model.SourceRecord {
}
filters := parseFilters(input["filters"])
meta := getMapValue(input["meta"])
alias := getStringValue(input["alias"])
return model.SourceRecord{
ID: id,
Name: orDefault(getStringValue(input["name"]), id),
Alias: alias,
Type: sourceType,
URL: getStringValue(input["url"]),
Content: getStringValue(input["content"]),
@@ -455,7 +457,39 @@ func mapToCollectionRecord(input map[string]any) model.CollectionRecord {
}
id = util.ToId(id)
sourceIds := getStringArray(input["sourceIds"])
filters := parseFilters(input["filters"])
renameEnabled := false
if v, ok := input["renameEnabled"].(bool); ok {
renameEnabled = v
}
// Parse rename options (all default to true when renameEnabled is on)
var renameOptions *model.RenameOptions
if opts, ok := input["renameOptions"].(map[string]any); ok {
ro := model.RenameOptions{
Flag: true,
Alias: true,
Country: true,
City: true,
Index: true,
}
if v, ok := opts["flag"].(bool); ok {
ro.Flag = v
}
if v, ok := opts["alias"].(bool); ok {
ro.Alias = v
}
if v, ok := opts["country"].(bool); ok {
ro.Country = v
}
if v, ok := opts["city"].(bool); ok {
ro.City = v
}
if v, ok := opts["index"].(bool); ok {
ro.Index = v
}
renameOptions = &ro
}
templateId := getStringValue(input["templateId"])
if templateId == "" || templateId == "default" {
templateId = template.DefaultTemplateID
@@ -470,14 +504,15 @@ func mapToCollectionRecord(input map[string]any) model.CollectionRecord {
}
meta := getMapValue(input["meta"])
return model.CollectionRecord{
ID: id,
Name: orDefault(getStringValue(input["name"]), id),
SourceIds: sourceIds,
Filters: filters,
TemplateId: templateId,
IgnoreFailed: ignoreFailed,
Enabled: enabled,
Meta: meta,
ID: id,
Name: orDefault(getStringValue(input["name"]), id),
SourceIds: sourceIds,
RenameEnabled: renameEnabled,
RenameOptions: renameOptions,
TemplateId: templateId,
IgnoreFailed: ignoreFailed,
Enabled: enabled,
Meta: meta,
}
}
@@ -512,6 +547,9 @@ func mergeSource(existing model.SourceRecord, next model.SourceRecord) model.Sou
if next.Name != "" {
merged.Name = next.Name
}
if next.Alias != "" {
merged.Alias = next.Alias
}
if next.Type != "" {
merged.Type = next.Type
}
@@ -539,8 +577,9 @@ func mergeCollection(existing model.CollectionRecord, next model.CollectionRecor
if next.SourceIds != nil {
merged.SourceIds = next.SourceIds
}
if next.Filters != nil {
merged.Filters = next.Filters
merged.RenameEnabled = next.RenameEnabled
if next.RenameOptions != nil {
merged.RenameOptions = next.RenameOptions
}
if next.TemplateId != "" {
merged.TemplateId = next.TemplateId
+1 -1
View File
@@ -78,7 +78,7 @@ func (d *Deps) HandleEgressInfo(c fiber.Ctx) error {
func egressCacheKey(node model.ProxyNode) string {
clean := model.ProxyNode{}
skip := map[string]bool{
"id": true, "latencyMs": true, "latencyError": true,
"id": true, "name": true, "latencyMs": true, "latencyError": true,
"egressIp": true, "egressCountry": true, "egressRegion": true, "egressError": true,
"country": true, "region": true, "city": true, "isp": true, "cached": true,
}
+54 -38
View File
@@ -255,6 +255,7 @@ func TestValidateSource(t *testing.T) {
func TestMapToSourceRecord(t *testing.T) {
in := map[string]any{
"name": "My Source!",
"alias": "MyAlias",
"type": "local",
"url": "https://x.com",
"content": "ss://abc",
@@ -269,6 +270,9 @@ func TestMapToSourceRecord(t *testing.T) {
if rec.Name != "My Source!" {
t.Errorf("Name = %q, want %q", rec.Name, "My Source!")
}
if rec.Alias != "MyAlias" {
t.Errorf("Alias = %q, want MyAlias", rec.Alias)
}
if rec.Type != "local" {
t.Errorf("Type = %q, want local", rec.Type)
}
@@ -300,13 +304,13 @@ func TestMapToSourceRecord(t *testing.T) {
func TestMapToCollectionRecord(t *testing.T) {
in := map[string]any{
"name": "My Col",
"sourceIds": []any{"s1", "s2", "", 123},
"templateId": "default",
"ignoreFailed": false,
"enabled": false,
"meta": map[string]any{"k": "v"},
"filters": []any{map[string]any{"type": "rename"}},
"name": "My Col",
"sourceIds": []any{"s1", "s2", "", 123},
"templateId": "default",
"ignoreFailed": false,
"enabled": false,
"meta": map[string]any{"k": "v"},
"renameEnabled": true,
}
rec := mapToCollectionRecord(in)
if rec.ID == "" {
@@ -327,14 +331,14 @@ func TestMapToCollectionRecord(t *testing.T) {
if rec.Enabled {
t.Error("expected Enabled=false")
}
if !rec.RenameEnabled {
t.Error("expected RenameEnabled=true")
}
if rec.Meta["k"] != "v" {
t.Errorf("Meta = %v", rec.Meta)
}
if len(rec.Filters) != 1 {
t.Errorf("Filters len = %d", len(rec.Filters))
}
// default ignoreFailed = true
// default ignoreFailed = true, default renameEnabled = false
rec2 := mapToCollectionRecord(map[string]any{"id": "c1"})
if !rec2.IgnoreFailed {
t.Error("expected default IgnoreFailed=true")
@@ -342,6 +346,9 @@ func TestMapToCollectionRecord(t *testing.T) {
if !rec2.Enabled {
t.Error("expected default Enabled=true")
}
if rec2.RenameEnabled {
t.Error("expected default RenameEnabled=false")
}
}
func TestMapToTemplateRecord(t *testing.T) {
@@ -380,11 +387,11 @@ func TestMapToTemplateRecord(t *testing.T) {
func TestMergeSource(t *testing.T) {
existing := model.SourceRecord{
ID: "s1", Name: "Old", Type: "local", Content: "old", Enabled: true,
ID: "s1", Name: "Old", Alias: "OldAlias", Type: "local", Content: "old", Enabled: true,
Meta: map[string]any{"a": "1"},
}
next := model.SourceRecord{
Name: "New", URL: "https://x.com", Type: "remote", Enabled: false,
Name: "New", Alias: "NewAlias", URL: "https://x.com", Type: "remote", Enabled: false,
Filters: []model.FilterRule{{Type: "rename"}},
Meta: map[string]any{"b": "2"},
}
@@ -392,6 +399,9 @@ func TestMergeSource(t *testing.T) {
if merged.Name != "New" {
t.Errorf("Name = %q", merged.Name)
}
if merged.Alias != "NewAlias" {
t.Errorf("Alias = %q, want NewAlias", merged.Alias)
}
if merged.Type != "remote" {
t.Errorf("Type = %q", merged.Type)
}
@@ -413,6 +423,9 @@ func TestMergeSource(t *testing.T) {
if merged2.Name != "Old" {
t.Errorf("Name = %q, want Old", merged2.Name)
}
if merged2.Alias != "OldAlias" {
t.Errorf("Alias = %q, want OldAlias", merged2.Alias)
}
}
func TestMergeCollection(t *testing.T) {
@@ -422,7 +435,7 @@ func TestMergeCollection(t *testing.T) {
}
next := model.CollectionRecord{
Name: "New", SourceIds: []string{"s2"}, TemplateId: "t2",
Enabled: false, IgnoreFailed: false, Meta: map[string]any{"b": "2"},
Enabled: false, IgnoreFailed: false, RenameEnabled: true, Meta: map[string]any{"b": "2"},
}
merged := mergeCollection(existing, next)
if merged.Name != "New" {
@@ -440,6 +453,9 @@ func TestMergeCollection(t *testing.T) {
if merged.IgnoreFailed {
t.Error("expected IgnoreFailed=false")
}
if !merged.RenameEnabled {
t.Error("expected RenameEnabled=true")
}
}
func TestGetStringValue(t *testing.T) {
@@ -999,8 +1015,8 @@ func TestHandleUpdateSettings(t *testing.T) {
func TestHandleExportStorage(t *testing.T) {
deps := newTestDeps(t)
// seed some data
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", SourceIds: []string{}, Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", SourceIds: []string{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.TemplateRepo.Upsert(model.TemplateRecord{ID: "custom-t", Name: "Custom", Target: "mihomo", Config: map[string]any{"k": "v"}})
app := newApp(deps)
@@ -1132,12 +1148,12 @@ func TestSourceCRUDHandlers(t *testing.T) {
assertStatus(t, "SortSources bad json", code, 400)
// Delete - referenced by collection (create collection referencing source first)
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "ref-col", Name: "RefCol", SourceIds: []string{"my-src"}, Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "ref-col", Name: "RefCol", SourceIds: []string{"my-src"}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
code, _ = doRequest(t, app, "DELETE", "/api/sources/my-src", "", nil)
assertStatus(t, "DeleteSource referenced", code, 409)
// Delete - remove collection reference, then delete
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "ref-col", Name: "RefCol", SourceIds: []string{}, Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "ref-col", Name: "RefCol", SourceIds: []string{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
code, body = doRequest(t, app, "DELETE", "/api/sources/my-src", "", nil)
assertStatus(t, "DeleteSource", code, 200)
data2 := body["data"].(map[string]any)
@@ -1258,22 +1274,22 @@ func TestCollectionWithTemplateValidation(t *testing.T) {
func TestValidateCollectionMissingTemplate(t *testing.T) {
deps := newTestDeps(t)
// built-in template is ok
rec := model.CollectionRecord{ID: "c1", TemplateId: "mihomo-basic", Filters: []model.FilterRule{}, Meta: map[string]any{}}
rec := model.CollectionRecord{ID: "c1", TemplateId: "mihomo-basic", Meta: map[string]any{}}
if err := deps.validateCollection(rec); err != nil {
t.Errorf("builtin template: unexpected err: %v", err)
}
// missing template
rec2 := model.CollectionRecord{ID: "c2", TemplateId: "no-such", Filters: []model.FilterRule{}, Meta: map[string]any{}}
rec2 := model.CollectionRecord{ID: "c2", TemplateId: "no-such", Meta: map[string]any{}}
if err := deps.validateCollection(rec2); err == nil {
t.Error("missing template: expected err")
}
// missing source ref
rec3 := model.CollectionRecord{ID: "c3", SourceIds: []string{"missing"}, Filters: []model.FilterRule{}, Meta: map[string]any{}}
rec3 := model.CollectionRecord{ID: "c3", SourceIds: []string{"missing"}, Meta: map[string]any{}}
if err := deps.validateCollection(rec3); err == nil {
t.Error("missing source: expected err")
}
// no sourceIds, no templateId -> ok
rec4 := model.CollectionRecord{ID: "c4", Filters: []model.FilterRule{}, Meta: map[string]any{}}
rec4 := model.CollectionRecord{ID: "c4", Meta: map[string]any{}}
if err := deps.validateCollection(rec4); err != nil {
t.Errorf("empty collection: unexpected err: %v", err)
}
@@ -1385,7 +1401,7 @@ func TestRecycleBinHandlers(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// seed + delete a source to populate recycle bin
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, _ := doRequest(t, app, "DELETE", "/api/sources/s1", "", nil)
assertStatus(t, "seed delete", code, 200)
@@ -1401,7 +1417,7 @@ func TestRecycleBinHandlers(t *testing.T) {
// Get via Restore (409 conflict if source recreated)
// First recreate source manually to test conflict path
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, _ = doRequest(t, app, "POST", "/api/recycle-bin/"+entryID+"/restore", "", nil)
assertStatus(t, "RestoreRecycleBin conflict", code, 409)
// remove the conflicting source so restore can proceed
@@ -1425,7 +1441,7 @@ func TestRecycleBinHandlers(t *testing.T) {
assertStatus(t, "RestoreRecycleBin not found", code, 404)
// Delete entry - seed another
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s2", Name: "S2", Type: "local", Content: "ss://y", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s2", Name: "S2", Type: "local", Content: "ss://y", Enabled: true, Meta: map[string]any{}})
doRequest(t, app, "DELETE", "/api/sources/s2", "", nil)
code, body = doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
data = body["data"].([]any)
@@ -1448,7 +1464,7 @@ func TestRestoreCollectionAndTemplate(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// seed + delete collection
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
doRequest(t, app, "DELETE", "/api/collections/c1", "", nil)
// restore collection
code, body := doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
@@ -1482,10 +1498,10 @@ func TestRestoreCollectionAndTemplate(t *testing.T) {
func TestRestoreCollectionConflict(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "cc1", Name: "CC1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "cc1", Name: "CC1", TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
doRequest(t, app, "DELETE", "/api/collections/cc1", "", nil)
// recreate to force conflict
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "cc1", Name: "CC1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "cc1", Name: "CC1", TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
code, body := doRequest(t, app, "GET", "/api/recycle-bin", "", nil)
data := body["data"].([]any)
var entryID string
@@ -1531,7 +1547,7 @@ func TestRestoreUnknownType(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// Archive a source (which works) then manually update its resourceType to "unknown"
deps.SourceRepo.Upsert(model.SourceRecord{ID: "u1", Name: "U1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "u1", Name: "U1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
deps.RecycleRepo.ArchiveAndDelete("source", "u1", sourceToMap(model.SourceRecord{ID: "u1", Name: "U1"}), "DELETE FROM sources WHERE id = ?", "u1")
// Manually update the resource_type to "unknown" for testing
deps.DB.Exec("UPDATE recycle_bin SET resource_type = 'unknown' WHERE resource_id = 'u1'")
@@ -1567,8 +1583,8 @@ func TestRestoreUnknownType(t *testing.T) {
func TestLinkHandlers(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", Filters: []model.FilterRule{}, TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
deps.CollectionRepo.Upsert(model.CollectionRecord{ID: "c1", Name: "C1", TemplateId: template.DefaultTemplateID, IgnoreFailed: true, Enabled: true, Meta: map[string]any{}})
// Link source - found
code, body := doRequest(t, app, "GET", "/api/link/source/s1", "", nil)
@@ -1614,7 +1630,7 @@ func TestBuildDownloadLinkNoToken(t *testing.T) {
deps := newTestDeps(t)
deps.Cfg.Auth.DownloadToken = "" // no token
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, body := doRequest(t, app, "GET", "/api/link/source/s1", "", nil)
assertStatus(t, "LinkSource no token", code, 200)
d := body["data"].(map[string]any)
@@ -1783,7 +1799,7 @@ func TestHandleDownloadSourceNotFound(t *testing.T) {
code, _ = doRequest(t, app, "GET", "/sources/nope/dl-tok?target=mihomo", "", nil)
assertStatus(t, "DownloadSource not found", code, 404)
// bad target
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, _ = doRequest(t, app, "GET", "/sources/s1/dl-tok?target=bogus", "", nil)
assertStatus(t, "DownloadSource bad target", code, 400)
}
@@ -1801,7 +1817,7 @@ func TestHandleDownloadCollectionNotFound(t *testing.T) {
func TestHandleDownloadSourceLocalSuccess(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Meta: map[string]any{}})
code, body := doRequest(t, app, "GET", "/sources/s1/dl-tok?target=json", "", nil)
// Should succeed (200) — body contains rendered JSON
assertStatus(t, "DownloadSource local", code, 200)
@@ -1815,7 +1831,7 @@ func TestHandleDownloadSourceLocalSuccess(t *testing.T) {
func TestHandleDownloadSourceDisabled(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: false, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: false, Meta: map[string]any{}})
code, _ := doRequest(t, app, "GET", "/sources/s1/dl-tok?target=json", "", nil)
assertStatus(t, "DownloadSource disabled", code, 404)
}
@@ -1883,7 +1899,7 @@ func TestHandlePreviewCollectionBadJSON(t *testing.T) {
func TestHandlePreviewCollectionSuccess(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://pass@host:8080#name", Enabled: true, Meta: map[string]any{}})
code, body := doRequest(t, app, "POST", "/api/preview/collection", `{"name":"c1","sourceIds":["s1"]}`, nil)
assertStatus(t, "PreviewCollection", code, 200)
d := body["data"].(map[string]any)
@@ -1910,7 +1926,7 @@ func TestHandleFlowInfoNoFlow(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// local source has no flow URL -> parseFlowRequest returns nil
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{}})
code, _ := doRequest(t, app, "GET", "/api/source/flow/s1", "", nil)
assertStatus(t, "FlowInfo no flow", code, 400)
}
@@ -1919,7 +1935,7 @@ func TestHandleFlowInfoWithMetaOnly(t *testing.T) {
deps := newTestDeps(t)
app := newApp(deps)
// source with meta subUserinfo but no remote URL
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{"subUserinfo": "upload=100; download=200; total=1000"}})
deps.SourceRepo.Upsert(model.SourceRecord{ID: "s1", Name: "S1", Type: "local", Content: "ss://x", Enabled: true, Meta: map[string]any{"subUserinfo": "upload=100; download=200; total=1000"}})
code, body := doRequest(t, app, "GET", "/api/source/flow/s1", "", nil)
// Flow endpoint may return 200 or 400 depending on whether remote URL is required
if code != 200 && code != 400 {