using DouyinLogistics.API.Models; using DouyinLogistics.API.Services; using SqlSugar; var builder = WebApplication.CreateBuilder(args); // 添加服务 builder.Services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // 配置 CORS builder.Services.AddCors(options => { options.AddPolicy("AllowAll", policy => { policy.WithOrigins("http://localhost:5173", "http://localhost:5174","https://douyin.ponggame.cn", "http://localhost:5174") .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials(); }); }); // 配置 HttpClient builder.Services.AddHttpClient(); // 内存缓存:用于订单列表分页的短期缓存,大幅降低翻页时的重复计算 builder.Services.AddMemoryCache(); // 抖音店铺配置:首选来源 = ERP(WtDyDpsz/internal/all),兜底 = appsettings.json/DouyinShops // 这样后续新开店铺时,只需要在 ERP 页面「抖音店铺设置」里维护,无需改配置文件和重启。 var douyinShopFallback = builder.Configuration.GetSection("DouyinShops").Get>() ?? new List(); if (douyinShopFallback.Count == 0) { // 兼容旧版单店配置 var douyinSection = builder.Configuration.GetSection("Douyin"); if (douyinSection.Exists()) { long envShopId = 0; long.TryParse(Environment.GetEnvironmentVariable("DY_SHOP_ID"), out envShopId); douyinShopFallback.Add(new DouyinConfig { SyncDays = douyinSection.GetValue("SyncDays") ?? 30, AppKey = Environment.GetEnvironmentVariable("DY_APP_KEY") ?? douyinSection["AppKey"] ?? string.Empty, AppSecret = Environment.GetEnvironmentVariable("DY_APP_SECRET") ?? douyinSection["AppSecret"] ?? string.Empty, CallbackUrl = Environment.GetEnvironmentVariable("DY_CALLBACK_URL") ?? douyinSection["CallbackUrl"] ?? string.Empty, ApiBaseUrl = Environment.GetEnvironmentVariable("DY_API_BASE_URL") ?? douyinSection["ApiBaseUrl"] ?? "https://openapi-fxg.jinritemai.com", ShopId = envShopId != 0 ? envShopId : (douyinSection.GetValue("ShopId") ?? 0), ShopName = douyinSection["ShopName"] ?? "默认店铺", SenderName = Environment.GetEnvironmentVariable("DY_SENDER_NAME") ?? douyinSection["SenderName"], SenderPhone = Environment.GetEnvironmentVariable("DY_SENDER_PHONE") ?? douyinSection["SenderPhone"], SenderAddress = Environment.GetEnvironmentVariable("DY_SENDER_ADDRESS") ?? douyinSection["SenderAddress"], SenderProvince = Environment.GetEnvironmentVariable("DY_SENDER_PROVINCE") ?? douyinSection["SenderProvince"], SenderCity = Environment.GetEnvironmentVariable("DY_SENDER_CITY") ?? douyinSection["SenderCity"], SenderDistrict = Environment.GetEnvironmentVariable("DY_SENDER_DISTRICT") ?? douyinSection["SenderDistrict"], SenderStreet = Environment.GetEnvironmentVariable("DY_SENDER_STREET") ?? douyinSection["SenderStreet"] }); } } // ShopConfigProvider:封装从 ERP 拉取店铺配置的逻辑(ERP 不可用时自动回退到 DouyinShops) builder.Services.AddSingleton(sp => { var httpFactory = sp.GetRequiredService(); var loggerFactory = sp.GetRequiredService(); var erpCfg = sp.GetRequiredService(); return new ShopConfigProvider(httpFactory, loggerFactory.CreateLogger(), erpCfg, douyinShopFallback); }); // 注册多店 Factory(Singleton,每个店一个 DouyinService 实例) // 构造函数里不立即加载,由启动钩子和 reload 接口驱动 builder.Services.AddSingleton(sp => { var httpFactory = sp.GetRequiredService(); var loggerFactory = sp.GetRequiredService(); var provider = sp.GetRequiredService(); var factory = new DouyinServiceFactory(httpFactory, loggerFactory, provider); // 先用兜底配置填充一份,避免极早请求到达时 factory 为空 factory.ReplaceAll(douyinShopFallback); return factory; }); // 配置顺丰服务 var sfConfig = builder.Configuration.GetSection("Sf").Get() ?? new SfConfig(); builder.Services.AddSingleton(sfConfig); builder.Services.AddScoped(); // 配置发货人信息 var senderConfig = builder.Configuration.GetSection("Sender").Get() ?? new SenderConfig(); builder.Services.AddSingleton(senderConfig); // 配置ERP API var erpApiConfig = builder.Configuration.GetSection("ErpApi").Get() ?? new ErpApiConfig(); builder.Services.AddSingleton(erpApiConfig); // 配置 SQLSugar(使用 Scoped 确保每个请求有独立连接) // 支持通过环境变量 DB_CONNECTION 覆盖连接字符串,方便上云部署 var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION") ?? builder.Configuration.GetConnectionString("DefaultConnection"); builder.Services.AddScoped(provider => { return new SqlSugarClient(new ConnectionConfig { ConnectionString = connectionString ?? "Server=localhost;Port=55000;Database=DouyinLogistics;Uid=root;Pwd=1234.com;CharSet=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=true;", DbType = DbType.MySql, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute }); }); // 配置HttpClient(用于调用ERP API) builder.Services.AddHttpClient(); // 配置业务服务 builder.Services.AddScoped(); // 配置后台服务(订单实时同步) // ⚠️ 订单同步已改为手动触发,不再自动执行 // 可以通过以下方式手动同步订单: // 1. API 接口:POST /api/orders/sync // 2. Swagger UI:访问 /swagger,找到 Orders/SyncOrders 接口 // 如需恢复自动同步,取消下面的注释即可(每 30 秒执行一次) // builder.Services.AddHostedService(); var app = builder.Build(); // 配置 HTTP 请求管道 // 在生产环境也启用 Swagger,方便调试和测试(正式上线后可考虑关闭) app.UseSwagger(); app.UseSwaggerUI(); // 如果应用在反向代理(Nginx)后面,不需要 HTTPS 重定向,由 Nginx 处理 // 只在开发环境启用 HTTPS 重定向 // if (app.Environment.IsDevelopment()) // { app.UseHttpsRedirection(); // } // CORS 必须在 UseAuthorization 之前 // 在生产环境允许所有来源(因为通过 Nginx 反向代理访问) app.UseCors("AllowAll"); app.UseAuthorization(); // 先映射 API 路由(必须在静态文件之前,确保 API 路由优先) app.MapControllers(); // 启用静态文件服务(用于提供前端文件) // 如果 wwwroot 目录存在,则提供静态文件 app.UseStaticFiles(); // 配置默认文件(index.html) app.UseDefaultFiles(); // 配置 SPA 回退路由(必须在 MapControllers 之后) // 所有非 API 和非静态文件的请求都返回 index.html,让 Vue Router 处理 // 注意:只有 wwwroot 目录存在时才启用回退路由 var wwwrootPath = Path.Combine(app.Environment.ContentRootPath, "wwwroot"); if (Directory.Exists(wwwrootPath)) { app.MapFallbackToFile("index.html"); } // 初始化数据库表(失败时不阻止应用启动) _ = Task.Run(async () => { await Task.Delay(2000); // 等待服务完全启动 try { using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); // 检查并初始化orders表 if (!db.DbMaintenance.IsAnyTable("orders")) { db.CodeFirst.InitTables(typeof(Order)); Console.WriteLine("✅ orders表初始化成功"); } else { Console.WriteLine("✅ orders表已存在,检查并添加缺失的字段..."); // 检查并添加缺失的字段 var columns = db.DbMaintenance.GetColumnInfosByTableName("orders"); var columnNames = columns.Select(c => c.DbColumnName.ToLower()).ToHashSet(); // 需要添加的字段列表 var fieldsToAdd = new Dictionary { { "ShopId", "bigint NULL COMMENT '所属店铺ID(抖音ShopId)'" }, { "ShopName", "varchar(100) NULL COMMENT '所属店铺名称'" }, { "OpenId", "varchar(100) NULL COMMENT '下单人ID(抖音open_id)'" }, { "PayTime", "datetime NULL COMMENT '付款时间'" }, { "DouyinOrderTime", "datetime NULL COMMENT '抖音下单时间(create_time)'" }, { "OrderAmount", "bigint NULL COMMENT '订单金额(分)'" }, { "PayAmount", "bigint NULL COMMENT '实付金额(分)'" }, { "ItemCount", "int NULL COMMENT '商品数量'" }, { "ProductName", "varchar(500) NULL COMMENT '商品名称(主商品)'" }, { "ProductPic", "varchar(500) NULL COMMENT '商品图片(主商品)'" }, { "ProductSpec", "varchar(1000) NULL COMMENT '商品规格(主商品,JSON格式)'" }, { "ProductItems", "text NULL COMMENT '商品明细(JSON格式,包含所有商品信息)'" }, { "BuyerWords", "varchar(500) NULL COMMENT '买家留言'" }, { "SellerWords", "varchar(500) NULL COMMENT '卖家备注'" }, { "LogisticsCode", "varchar(50) NULL COMMENT '物流公司代码(抖音company_code)'" }, { "NoMerge", "tinyint NOT NULL DEFAULT 0 COMMENT '是否手动拆分出合并组:1=不参与自动合并'" } }; foreach (var field in fieldsToAdd) { if (!columnNames.Contains(field.Key.ToLower())) { try { // 使用原生 SQL 添加列 db.Ado.ExecuteCommand($"ALTER TABLE `orders` ADD COLUMN `{field.Key}` {field.Value}"); Console.WriteLine($" ✅ 已添加字段: {field.Key}"); } catch (Exception ex) { Console.WriteLine($" ⚠️ 添加字段 {field.Key} 失败: {ex.Message}"); } } } Console.WriteLine("✅ orders表字段检查完成"); } // 检查并初始化waybills表(发货单表) try { if (!db.DbMaintenance.IsAnyTable("waybills")) { Console.WriteLine("📋 开始创建waybills表..."); db.CodeFirst.InitTables(typeof(Waybill)); Console.WriteLine("✅ waybills表初始化成功"); } else { Console.WriteLine("✅ waybills表已存在,检查并添加缺失的字段..."); // 检查并添加缺失的字段 var columns = db.DbMaintenance.GetColumnInfosByTableName("waybills"); var columnNames = columns.Select(c => c.DbColumnName.ToLower()).ToHashSet(); // 需要添加的字段列表 var fieldsToAdd = new Dictionary { { "ShopId", "bigint NULL COMMENT '所属店铺ID(抖音ShopId)'" }, { "Cjck", "varchar(100) NULL COMMENT '出库仓库(门店ID)'" }, { "SalesOrderId", "varchar(100) NULL COMMENT '销售出库单ID(ERP系统中的出库单ID)'" }, { "MerchantIncome", "decimal(12,2) NULL COMMENT '商家实际收入(元)。空值表示按用户支付金额默认'" }, { "Fhr", "varchar(64) NULL COMMENT '发货人(ERP 用户 Id),提交销售出库单时写入 ERP fhr 字段'" } }; foreach (var field in fieldsToAdd) { if (!columnNames.Contains(field.Key.ToLower())) { try { // 使用原生 SQL 添加列 db.Ado.ExecuteCommand($"ALTER TABLE `waybills` ADD COLUMN `{field.Key}` {field.Value}"); Console.WriteLine($" ✅ 已添加字段: {field.Key}"); } catch (Exception ex) { Console.WriteLine($" ⚠️ 添加字段 {field.Key} 失败: {ex.Message}"); } } } Console.WriteLine("✅ waybills表字段检查完成"); } } catch (Exception ex) { Console.WriteLine($"❌ waybills表创建失败: {ex.Message}"); Console.WriteLine($" 异常详情: {ex}"); // 尝试使用原生SQL创建 try { Console.WriteLine("📋 尝试使用原生SQL创建waybills表..."); var createTableSql = @" CREATE TABLE IF NOT EXISTS `waybills` ( `Id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', `OrderId` int NOT NULL COMMENT '关联的订单ID(orders表的Id)', `OrderId_Douyin` varchar(100) NOT NULL COMMENT '抖音订单号', `TrackingNumber` varchar(100) NOT NULL COMMENT '运单号', `LogisticsCompany` varchar(50) NOT NULL DEFAULT '顺丰' COMMENT '物流公司', `Status` int NOT NULL DEFAULT '0' COMMENT '发货单状态:0-待打印,1-已打印,2-已发货', `ReceiverName` varchar(100) NOT NULL COMMENT '收货人姓名', `ReceiverPhone` varchar(50) NOT NULL COMMENT '收货人电话', `ReceiverAddress` varchar(500) NOT NULL COMMENT '收货地址(完整地址)', `Province` varchar(50) NOT NULL COMMENT '省份', `City` varchar(50) NOT NULL COMMENT '城市', `District` varchar(50) NOT NULL COMMENT '区县', `Street` varchar(100) DEFAULT NULL COMMENT '街道', `SenderName` varchar(100) NOT NULL COMMENT '寄件人姓名', `SenderPhone` varchar(50) NOT NULL COMMENT '寄件人电话', `SenderAddress` varchar(500) NOT NULL COMMENT '寄件地址(完整地址)', `ProductInfo` text COMMENT '商品信息(JSON格式)', `CreateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `UpdateTime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `PrintTime` datetime DEFAULT NULL COMMENT '打印时间', `ShipTime` datetime DEFAULT NULL COMMENT '发货时间(同步到抖音的时间)', `Remark` varchar(500) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`Id`), KEY `idx_orderid` (`OrderId`), KEY `idx_orderid_douyin` (`OrderId_Douyin`), KEY `idx_trackingnumber` (`TrackingNumber`), KEY `idx_status` (`Status`), KEY `idx_createtime` (`CreateTime`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发货单表';"; db.Ado.ExecuteCommand(createTableSql); Console.WriteLine("✅ waybills表通过原生SQL创建成功"); } catch (Exception sqlEx) { Console.WriteLine($"❌ waybills表原生SQL创建也失败: {sqlEx.Message}"); Console.WriteLine($" 异常详情: {sqlEx}"); } } // 历史数据刷 ShopId:把 ShopId 为空的订单/运单归属到第一个配置的店铺 var factoryForHistory = app.Services.GetRequiredService(); var historyCfgs = factoryForHistory.GetAllConfigs(); if (historyCfgs.Count > 0) { var defaultCfg = historyCfgs[0]; var updOrders = db.Ado.ExecuteCommand( "UPDATE `orders` SET ShopId = @sid, ShopName = @sname WHERE ShopId IS NULL", new { sid = defaultCfg.ShopId, sname = defaultCfg.ShopName }); var updWaybills = db.Ado.ExecuteCommand( "UPDATE `waybills` SET ShopId = @sid WHERE ShopId IS NULL", new { sid = defaultCfg.ShopId }); if (updOrders > 0 || updWaybills > 0) Console.WriteLine($"✅ 历史数据已归属到 [{defaultCfg.ShopName}]: orders={updOrders}, waybills={updWaybills}"); } } } catch (Exception ex) { Console.WriteLine($"❌ 数据库初始化失败: {ex.Message}"); Console.WriteLine("💡 请手动执行 create_table.sql 脚本创建表,或通过 API /api/orders/init-database 初始化"); } }); // 启动时异步从 ERP 拉取抖音店铺配置并热替换 Factory(ERP 不可用时已经有 appsettings 兜底) _ = Task.Run(async () => { await Task.Delay(1500); try { var factory = app.Services.GetRequiredService(); var count = await factory.ReloadAsync(); Console.WriteLine($"✅ 启动时已从 ERP 加载 {count} 个抖音店铺配置"); } catch (Exception ex) { Console.WriteLine($"⚠️ 启动时从 ERP 加载抖音店铺配置失败(已使用 appsettings 兜底): {ex.Message}"); } }); app.Run();