// ============================================================================ // 牛牛GEO内容推送协议 - 通用文章接收器(.NET / ASP.NET Core 版) // ============================================================================ // // 协议版本:牛牛GEO内容推送协议 v1.0 // 适用场景:.NET 技术栈的自建站官网(.NET 6+,Minimal API 单文件示例) // // 【部署步骤】 // 1. 创建项目:dotnet new web -n NgeoReceiver && cd NgeoReceiver // 2. 安装依赖:dotnet add package MySqlConnector // 3. 用本文件内容替换 Program.cs,修改下方【配置区域】(密钥、数据库、表名、字段映射) // 4. 启动服务:dotnet run(默认监听 3900 端口,建议用 Nginx 反代对外提供 HTTPS) // 5. 在牛牛GEO平台「官网管理」添加站点(密钥由平台自动生成), // 接口地址填 https://你的域名/ngeo/push ,用站点列表「复制密钥」获取密钥 // 填入下方 SecretKey,两者必须完全一致 // 6. 回到平台站点列表,依次点「测试密钥」「测试传输」,两步都通过才算连接成功 // // 【协议行为】 // - POST {_action:"verify"} 连通性测试(不触碰数据库) // - GET 或 {_action:"channels"} 获取栏目列表(无栏目功能返回空数组) // - POST 完整文章载荷 写入文章(支持 source_id 幂等去重) // // 【响应格式】统一为 {"code":0,"msg":"...","data":...},code=0 表示成功 // // ============================================================================ using System.Text.Json; using MySqlConnector; // ============================================================================ // 配置区域(请根据实际情况修改) // ============================================================================ var config = new ReceiverConfig { // 监听端口(建议配合 Nginx 反向代理对外提供 HTTPS) Port = 3900, // 认证密钥:必须修改!从牛牛GEO平台站点列表点「复制密钥」获取,两者完全一致 SecretKey = "CHANGE_THIS_TO_YOUR_SECRET_KEY", // MySQL 数据库连接字符串 ConnectionString = "Server=127.0.0.1;Port=3306;Database=your_database;User=root;Password=;Charset=utf8mb4", // 文章表名 Table = "articles", // 字段映射:Key = 协议推送的字段名(不要改),Value = 你的数据库字段名 // 你的表里没有对应字段时,把 Value 改成 null 即可跳过该字段 FieldMapping = new Dictionary { ["title"] = "title", // 文章标题 ["content"] = "content", // 正文(HTML 格式) ["summary"] = "summary", // 摘要 ["category_id"] = "category_id", // 栏目/分类 ID ["seo_keywords"] = "keywords", // SEO 关键词 ["seo_description"] = "description", // SEO 描述 ["thumbnail"] = "thumbnail", // 封面图 URL ["images"] = "images", // 配图 URL 数组(写入时自动转 JSON 字符串) ["tags"] = "tags", // 标签数组(写入时自动转 JSON 字符串) ["source_id"] = "source_id", // 牛牛GEO平台文章唯一 ID(用于去重) ["status"] = "status", // 发布状态:1=发布(默认),0=草稿 }, // 额外默认值:每篇文章都会带上的固定字段值(按你的表结构增删) Defaults = new Dictionary { ["author"] = "牛牛GEO", }, // 幂等去重:开启后同一 source_id 重复推送不产生重复记录 DedupEnable = true, DedupField = "source_id", }; // ============================================================================ // 应用入口 // ============================================================================ var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseUrls($"http://0.0.0.0:{config.Port}"); var app = builder.Build(); object Ok(object? data = null, string msg = "success") => new { code = 0, msg, data }; object Err(int code, string msg) => new { code, msg, data = (object?)null }; /** 认证校验:支持 Bearer Token(推荐)/ X-API-Key 请求头 / ?api_key= 查询参数 */ bool Authenticated(HttpContext ctx) { var auth = ctx.Request.Headers.Authorization.ToString(); if (auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return auth["Bearer ".Length..] == config.SecretKey; var apiKey = ctx.Request.Headers["X-API-Key"].ToString(); if (!string.IsNullOrEmpty(apiKey)) return apiKey == config.SecretKey; var queryKey = ctx.Request.Query["api_key"].ToString(); if (!string.IsNullOrEmpty(queryKey)) return queryKey == config.SecretKey; return false; } /** 获取栏目列表:没有栏目功能时返回空数组即可 */ async Task GetChannels() { // 示例:如果你的网站有栏目表,在这里查询后返回 // return Ok(new[] { new { id = 1, name = "公司新闻", parent_id = 0 } }); await Task.CompletedTask; return Ok(Array.Empty()); } /** 推送接口:POST,按 _action 分发三种行为 */ app.MapPost("/ngeo/push", async (HttpContext ctx) => { if (!Authenticated(ctx)) return Results.Json(Err(1001, "认证失败:密钥不正确"), statusCode: 200); JsonElement body; try { using var doc = await JsonDocument.ParseAsync(ctx.Request.Body); body = doc.RootElement.Clone(); } catch { return Results.Json(Err(2001, "参数错误:请求体不是有效的 JSON")); } var action = body.TryGetProperty("_action", out var act) ? act.GetString() : null; // 行为一:连通性测试(不触碰数据库) if (action == "verify") return Results.Json(Ok(null, "连接成功")); // 行为二:获取栏目列表 if (action == "channels") return Results.Json(await GetChannels()); // 行为三:写入文章 var title = body.TryGetProperty("title", out var t) ? t.GetString() : null; if (string.IsNullOrWhiteSpace(title)) return Results.Json(Err(2001, "参数错误:缺少文章标题")); try { await using var conn = new MySqlConnection(config.ConnectionString); await conn.OpenAsync(); // 幂等去重:source_id 已存在则直接返回已有文章 string? sourceId = body.TryGetProperty("source_id", out var sid) ? sid.GetString() : null; if (config.DedupEnable && !string.IsNullOrEmpty(sourceId)) { await using var dupCmd = new MySqlCommand( $"SELECT id FROM `{config.Table}` WHERE `{config.DedupField}` = @sid LIMIT 1", conn); dupCmd.Parameters.AddWithValue("@sid", sourceId); var existId = await dupCmd.ExecuteScalarAsync(); if (existId != null) return Results.Json(Ok(new { id = Convert.ToInt64(existId), exists = true }, "文章已存在,跳过重复推送")); } // 组装字段:协议字段 → 数据库字段(跳过映射为 null 的字段) var cols = new List(); var names = new List(); var values = new List(); foreach (var (protoField, dbField) in config.FieldMapping) { if (dbField == null) continue; if (!body.TryGetProperty(protoField, out var prop)) continue; object val = prop.ValueKind switch { JsonValueKind.Array => JsonSerializer.Serialize(prop), // 数组转 JSON 字符串 JsonValueKind.Number => prop.GetDecimal(), JsonValueKind.Null => DBNull.Value, JsonValueKind.True => 1, JsonValueKind.False => 0, _ => prop.GetString() ?? (object)DBNull.Value, }; cols.Add($"`{dbField}`"); names.Add($"@f{cols.Count}"); values.Add(val); } foreach (var (col, val) in config.Defaults) { cols.Add($"`{col}`"); names.Add($"@f{cols.Count}"); values.Add(val); } await using var insCmd = new MySqlCommand( $"INSERT INTO `{config.Table}` ({string.Join(",", cols)}) VALUES ({string.Join(",", names)}); SELECT LAST_INSERT_ID();", conn); for (int i = 0; i < names.Count; i++) insCmd.Parameters.AddWithValue(names[i], values[i]); var newId = Convert.ToInt64(await insCmd.ExecuteScalarAsync()); return Results.Json(Ok(new { id = newId }, "文章接收成功")); } catch (MySqlException ex) { return Results.Json(Err(3001, $"数据库写入失败:{ex.Message}")); } catch (Exception ex) { return Results.Json(Err(5000, $"服务器内部错误:{ex.Message}")); } }); /** GET 请求也返回栏目列表(协议兼容) */ app.MapGet("/ngeo/push", (HttpContext ctx) => !Authenticated(ctx) ? Results.Json(Err(1001, "认证失败:密钥不正确")) : Results.Json(GetChannels().Result)); app.Run(); // ============================================================================ // 配置模型 // ============================================================================ class ReceiverConfig { public int Port { get; set; } public string SecretKey { get; set; } = ""; public string ConnectionString { get; set; } = ""; public string Table { get; set; } = "articles"; public Dictionary FieldMapping { get; set; } = new(); public Dictionary Defaults { get; set; } = new(); public bool DedupEnable { get; set; } = true; public string DedupField { get; set; } = "source_id"; }