Program.cs 17.6 KB
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<List<DouyinConfig>>() ?? new List<DouyinConfig>();
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<int?>("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<long?>("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<ShopConfigProvider>(sp =>
{
    var httpFactory = sp.GetRequiredService<IHttpClientFactory>();
    var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
    var erpCfg = sp.GetRequiredService<ErpApiConfig>();
    return new ShopConfigProvider(httpFactory, loggerFactory.CreateLogger<ShopConfigProvider>(), erpCfg, douyinShopFallback);
});

// 注册多店 Factory(Singleton,每个店一个 DouyinService 实例)
// 构造函数里不立即加载,由启动钩子和 reload 接口驱动
builder.Services.AddSingleton<DouyinServiceFactory>(sp =>
{
    var httpFactory = sp.GetRequiredService<IHttpClientFactory>();
    var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
    var provider = sp.GetRequiredService<ShopConfigProvider>();
    var factory = new DouyinServiceFactory(httpFactory, loggerFactory, provider);
    // 先用兜底配置填充一份,避免极早请求到达时 factory 为空
    factory.ReplaceAll(douyinShopFallback);
    return factory;
});

// 配置顺丰服务
var sfConfig = builder.Configuration.GetSection("Sf").Get<SfConfig>() ?? new SfConfig();
builder.Services.AddSingleton(sfConfig);
builder.Services.AddScoped<SfService>();

// 配置发货人信息
var senderConfig = builder.Configuration.GetSection("Sender").Get<SenderConfig>() ?? new SenderConfig();
builder.Services.AddSingleton(senderConfig);

// 配置ERP API
var erpApiConfig = builder.Configuration.GetSection("ErpApi").Get<ErpApiConfig>() ?? new ErpApiConfig();
builder.Services.AddSingleton(erpApiConfig);

// 配置 SQLSugar(使用 Scoped 确保每个请求有独立连接)
// 支持通过环境变量 DB_CONNECTION 覆盖连接字符串,方便上云部署
var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION")
                     ?? builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddScoped<ISqlSugarClient>(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<OrderService>();

// 配置后台服务(订单实时同步)
// ⚠️ 订单同步已改为手动触发,不再自动执行
// 可以通过以下方式手动同步订单:
//   1. API 接口:POST /api/orders/sync
//   2. Swagger UI:访问 /swagger,找到 Orders/SyncOrders 接口
// 如需恢复自动同步,取消下面的注释即可(每 30 秒执行一次)
// builder.Services.AddHostedService<OrderSyncService>();

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<ISqlSugarClient>();
            // 检查并初始化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<string, string>
                {
                    { "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<string, string>
                    {
                        { "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 '关联的订单IDorders表的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<DouyinServiceFactory>();
            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<DouyinServiceFactory>();
        var count = await factory.ReloadAsync();
        Console.WriteLine($"✅ 启动时已从 ERP 加载 {count} 个抖音店铺配置");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"⚠️ 启动时从 ERP 加载抖音店铺配置失败(已使用 appsettings 兜底): {ex.Message}");
    }
});

app.Run();