Program.cs
17.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
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 '关联的订单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<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();