Skip to content

Commit 5a77565

Browse files
committed
feat: 市场发现功能-一键从GitHub(pythonHuang/APIWorkflowCanvasDesignNet)market目录拉取index.json及各类型{id}.json到本地market目录,本地条目列表展示(图标/名称/描述/作者/版本/更新日期/是否已导入),按id导入市场(id相同更新否则新增),市场条目增加官方ID/图标/作者/版本字段,列表卡片展示元信息,条目JSON可下载,前端市场页发现弹窗+直接应用/下载按钮
1 parent ada3be3 commit 5a77565

5 files changed

Lines changed: 331 additions & 10 deletions

File tree

‎Juggle.Api/Controllers/Api/MarketController.cs‎

Lines changed: 177 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,176 @@ public class MarketController : ControllerBase
2020
{
2121
private readonly JuggleDbContext _db;
2222
private readonly ITenantAccessor _tenant;
23+
private readonly IHttpClientFactory _httpClientFactory;
2324

24-
public MarketController(JuggleDbContext db, ITenantAccessor tenant)
25+
public MarketController(JuggleDbContext db, ITenantAccessor tenant, IHttpClientFactory httpClientFactory)
2526
{
2627
_db = db;
2728
_tenant = tenant;
29+
_httpClientFactory = httpClientFactory;
30+
}
31+
32+
/// <summary>官方市场 GitHub 仓库(market 目录)</summary>
33+
private const string MarketRepo = "https://raw.githubusercontent.com/pythonHuang/APIWorkflowCanvasDesignNet/main/market";
34+
35+
/// <summary>本地 market 目录(发现下载的文件落盘位置)</summary>
36+
private static string MarketDir => Path.Combine(Directory.GetCurrentDirectory(), "market");
37+
38+
/// <summary>发现官方市场:拉取 index.json 并把各类型 {id}.json 下载到本地 market 目录。</summary>
39+
[HttpGet("discover")]
40+
public async Task<ApiResult> Discover()
41+
{
42+
var client = _httpClientFactory.CreateClient();
43+
client.Timeout = TimeSpan.FromSeconds(60);
44+
var downloaded = 0;
45+
var skipped = 0;
46+
var failed = new List<string>();
47+
48+
string? indexJson;
49+
try
50+
{
51+
var indexResp = await client.GetAsync($"{MarketRepo}/index.json");
52+
if (!indexResp.IsSuccessStatusCode)
53+
return ApiResult.Fail($"官方市场 index.json 获取失败({(int)indexResp.StatusCode}):{MarketRepo}/index.json(仓库尚未建立 market 目录时请忽略)");
54+
indexJson = await indexResp.Content.ReadAsStringAsync();
55+
}
56+
catch (Exception ex)
57+
{
58+
return ApiResult.Fail($"官方市场访问失败:{ex.Message}");
59+
}
60+
61+
Directory.CreateDirectory(MarketDir);
62+
await System.IO.File.WriteAllTextAsync(Path.Combine(MarketDir, "index.json"), indexJson);
63+
64+
using var doc = JsonDocument.Parse(indexJson);
65+
var root = doc.RootElement;
66+
JsonElement entries = root.ValueKind == JsonValueKind.Array ? root
67+
: root.TryGetProperty("items", out var it) ? it : default;
68+
69+
if (entries.ValueKind != JsonValueKind.Array)
70+
return ApiResult.Success(new { downloaded = 0, skipped = 0, failed = new List<string>(), message = "index.json 中没有 items 列表" });
71+
72+
foreach (var entry in entries.EnumerateArray())
73+
{
74+
var id = entry.TryGetProperty("id", out var idEl) ? idEl.GetInt64() : 0;
75+
var type = entry.TryGetProperty("type", out var typeEl) ? typeEl.GetString() ?? "" : "";
76+
var file = entry.TryGetProperty("file", out var fileEl) ? fileEl.GetString() ?? "" : "";
77+
if (id == 0 || string.IsNullOrWhiteSpace(type)) continue;
78+
if (string.IsNullOrWhiteSpace(file)) file = $"{type}/{id}.json";
79+
80+
var targetPath = Path.Combine(MarketDir, type, $"{id}.json");
81+
var url = $"{MarketRepo}/{file}";
82+
try
83+
{
84+
var resp = await client.GetAsync(url);
85+
if (!resp.IsSuccessStatusCode)
86+
{
87+
failed.Add($"{type}/{id}.json");
88+
continue;
89+
}
90+
var body = await resp.Content.ReadAsStringAsync();
91+
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
92+
await System.IO.File.WriteAllTextAsync(targetPath, body);
93+
downloaded++;
94+
}
95+
catch { failed.Add($"{type}/{id}.json"); }
96+
}
97+
98+
return ApiResult.Success(new { downloaded, skipped, failed, message = $"发现完成:成功下载 {downloaded} 个条目到本地 market 目录" });
99+
}
100+
101+
/// <summary>本地 market 目录条目列表(含元数据与是否已导入)。</summary>
102+
[HttpGet("local-files")]
103+
public async Task<ApiResult> ListLocalFiles()
104+
{
105+
var result = new List<object>();
106+
var imported = await _db.MarketItems.Where(m => m.Deleted == 0 && m.MarketItemId != null).ToListAsync();
107+
108+
if (Directory.Exists(MarketDir))
109+
{
110+
foreach (var typeDir in Directory.GetDirectories(MarketDir))
111+
{
112+
var type = Path.GetFileName(typeDir);
113+
foreach (var file in Directory.GetFiles(typeDir, "*.json"))
114+
{
115+
if (Path.GetFileName(file) == "index.json") continue;
116+
try
117+
{
118+
var json = await System.IO.File.ReadAllTextAsync(file);
119+
using var doc = JsonDocument.Parse(json);
120+
var root = doc.RootElement;
121+
string? Get(string p) => root.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
122+
var marketId = root.TryGetProperty("id", out var idEl) ? (int?)idEl.GetInt32() : null;
123+
var hit = imported.FirstOrDefault(m => m.MarketItemId == marketId && m.ItemType == type);
124+
result.Add(new
125+
{
126+
marketId,
127+
type,
128+
name = Get("name") ?? Path.GetFileNameWithoutExtension(file),
129+
description = Get("description"),
130+
icon = Get("icon"),
131+
author = Get("author"),
132+
version = Get("version"),
133+
updatedAt = Get("updatedAt"),
134+
imported = hit != null,
135+
localItemId = hit?.Id,
136+
localVersion = hit?.Version
137+
});
138+
}
139+
catch { /* 非法 JSON 跳过 */ }
140+
}
141+
}
142+
}
143+
144+
return ApiResult.Success(result);
145+
}
146+
147+
/// <summary>导入本地 market 目录条目到市场(market_item_id 相同则更新,否则新增)。</summary>
148+
[HttpPost("import-file")]
149+
public async Task<ApiResult> ImportFile([FromBody] MarketImportFileRequest req)
150+
{
151+
var filePath = Path.Combine(MarketDir, req.Type, $"{req.MarketId}.json");
152+
if (!System.IO.File.Exists(filePath)) return ApiResult.Fail($"本地文件不存在: {req.Type}/{req.MarketId}.json");
153+
154+
string json;
155+
try { json = await System.IO.File.ReadAllTextAsync(filePath); }
156+
catch (Exception ex) { return ApiResult.Fail($"读取文件失败: {ex.Message}"); }
157+
158+
try
159+
{
160+
using var doc = JsonDocument.Parse(json);
161+
var root = doc.RootElement;
162+
string? Get(string p) => root.TryGetProperty(p, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
163+
164+
var entity = await _db.MarketItems.FirstOrDefaultAsync(m =>
165+
m.MarketItemId == req.MarketId && m.ItemType == req.Type && m.Deleted == 0);
166+
var isUpdate = entity != null;
167+
entity ??= new MarketItemEntity { MarketItemId = req.MarketId, ItemType = req.Type, TenantId = null, Enabled = 1 };
168+
169+
entity.ItemName = Get("name") ?? entity.ItemName;
170+
entity.Description = Get("description") ?? entity.Description;
171+
entity.Icon = Get("icon") ?? entity.Icon;
172+
entity.Author = Get("author") ?? entity.Author;
173+
entity.Version = Get("version") ?? entity.Version;
174+
entity.GroupName = Get("group") ?? entity.GroupName;
175+
if (root.TryGetProperty("content", out var contentEl))
176+
entity.ContentJson = contentEl.ToString();
177+
else
178+
entity.ContentJson = json;
179+
entity.UpdatedAt = Get("updatedAt") ?? DateTime.Now.ToString("o");
180+
181+
if (entity.Id == 0) _db.MarketItems.Add(entity);
182+
await _db.SaveChangesAsync();
183+
return ApiResult.Success(new
184+
{
185+
id = entity.Id,
186+
message = isUpdate ? $"「{entity.ItemName}」已更新(市场条目 id={req.MarketId} 已存在)" : $"「{entity.ItemName}」已导入市场"
187+
});
188+
}
189+
catch (Exception ex)
190+
{
191+
return ApiResult.Fail($"解析/导入失败: {ex.Message}");
192+
}
28193
}
29194

30195
/// <summary>市场列表(平台级条目 + 我的条目),可按类型筛选</summary>
@@ -34,7 +199,11 @@ public async Task<ApiResult> List([FromQuery] string? type)
34199
var query = _db.MarketItems.Where(m => m.Deleted == 0 && m.Enabled == 1);
35200
if (!string.IsNullOrWhiteSpace(type)) query = query.Where(m => m.ItemType == type);
36201
var items = await query.OrderByDescending(m => m.DownloadCount).ThenByDescending(m => m.Id).ToListAsync();
37-
return ApiResult.Success(items.Select(m => new { m.Id, m.ItemType, m.ItemName, m.Description, m.GroupName, m.DownloadCount, m.CreatedAt }));
202+
return ApiResult.Success(items.Select(m => new
203+
{
204+
m.Id, m.ItemType, m.ItemName, m.Description, m.GroupName, m.DownloadCount,
205+
m.MarketItemId, m.Icon, m.Author, m.Version, m.UpdatedAt, m.ContentJson
206+
}));
38207
}
39208

40209
/// <summary>我发布的市场条目(管理/下架)</summary>
@@ -263,3 +432,9 @@ public class MarketPublishRequest
263432
public string GroupName { get; set; } = "";
264433
public string ContentJson { get; set; } = "{}";
265434
}
435+
436+
public class MarketImportFileRequest
437+
{
438+
public string Type { get; set; } = "";
439+
public int MarketId { get; set; }
440+
}

‎Juggle.Api/Program.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,11 @@ await ctx.Response.WriteAsync(
262262
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_skill(id INTEGER PRIMARY KEY AUTOINCREMENT, skill_name TEXT, group_name TEXT, description TEXT, content TEXT, enabled INTEGER DEFAULT 1, market_skill_id INTEGER, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
263263
// 市场条目表
264264
try { db.Database.ExecuteSqlRaw("CREATE TABLE IF NOT EXISTS t_market_item(id INTEGER PRIMARY KEY AUTOINCREMENT, item_type TEXT, item_name TEXT, description TEXT, group_name TEXT, content_json TEXT, download_count INTEGER DEFAULT 0, enabled INTEGER DEFAULT 1, created_at TEXT, created_by INTEGER, updated_at TEXT, updated_by INTEGER, tenant_id INTEGER, deleted INTEGER DEFAULT 0);"); } catch { }
265+
// 市场条目:官方市场 ID / 图标 / 作者 / 版本
266+
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_market_item ADD COLUMN market_item_id INTEGER DEFAULT NULL;"); } catch { }
267+
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_market_item ADD COLUMN icon TEXT DEFAULT NULL;"); } catch { }
268+
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_market_item ADD COLUMN author TEXT DEFAULT NULL;"); } catch { }
269+
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_market_item ADD COLUMN version TEXT DEFAULT NULL;"); } catch { }
265270
// AI 供应商能力字段
266271
try { db.Database.ExecuteSqlRaw("ALTER TABLE t_ai_provider ADD COLUMN capabilities TEXT DEFAULT NULL;"); } catch { }
267272
// 知识库 4 表

‎Juggle.Domain/Entities/MarketItemEntity.cs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,16 @@ public class MarketItemEntity : BaseEntity
2626

2727
/// <summary>是否启用:1=上架 0=下架</summary>
2828
public int Enabled { get; set; } = 1;
29+
30+
/// <summary>官方市场条目 ID(发现导入的条目,本地发布为 null)</summary>
31+
public int? MarketItemId { get; set; }
32+
33+
/// <summary>图标(emoji / 图片地址)</summary>
34+
public string? Icon { get; set; }
35+
36+
/// <summary>作者</summary>
37+
public string? Author { get; set; }
38+
39+
/// <summary>版本号</summary>
40+
public string? Version { get; set; }
2941
}

‎Juggle.Infrastructure/Persistence/JuggleDbContext.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
266266
e.Property(p => p.ContentJson).HasColumnName("content_json");
267267
e.Property(p => p.DownloadCount).HasColumnName("download_count");
268268
e.Property(p => p.Enabled).HasColumnName("enabled");
269+
e.Property(p => p.MarketItemId).HasColumnName("market_item_id");
270+
e.Property(p => p.Icon).HasColumnName("icon");
271+
e.Property(p => p.Author).HasColumnName("author");
272+
e.Property(p => p.Version).HasColumnName("version");
269273
});
270274
modelBuilder.Entity<AlertRuleEntity>().ToTable("t_alert_rule");
271275
modelBuilder.Entity<AlertRecordEntity>().ToTable("t_alert_record");

0 commit comments

Comments
 (0)