2656 lines
90 KiB
C#
2656 lines
90 KiB
C#
using System.Text;
|
||
using CampusErrand.Data;
|
||
using CampusErrand.Models;
|
||
using CampusErrand.Models.Dtos;
|
||
using CampusErrand.Services;
|
||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
// 数据库配置
|
||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")!;
|
||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||
options.UseMySQL(connectionString));
|
||
|
||
// JWT 认证配置
|
||
var jwtConfig = builder.Configuration.GetSection("Jwt");
|
||
var secretKey = Encoding.UTF8.GetBytes(jwtConfig["Secret"]!);
|
||
|
||
builder.Services.AddAuthentication(options =>
|
||
{
|
||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
})
|
||
.AddJwtBearer(options =>
|
||
{
|
||
options.TokenValidationParameters = new TokenValidationParameters
|
||
{
|
||
ValidateIssuer = true,
|
||
ValidateAudience = true,
|
||
ValidateLifetime = true,
|
||
ValidateIssuerSigningKey = true,
|
||
ValidIssuer = jwtConfig["Issuer"],
|
||
ValidAudience = jwtConfig["Audience"],
|
||
IssuerSigningKey = new SymmetricSecurityKey(secretKey),
|
||
ClockSkew = TimeSpan.Zero
|
||
};
|
||
});
|
||
|
||
// 角色授权策略
|
||
builder.Services.AddAuthorizationBuilder()
|
||
.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"))
|
||
.AddPolicy("UserOrAdmin", policy => policy.RequireRole("User", "Runner", "Admin"));
|
||
|
||
// CORS 配置
|
||
builder.Services.AddCors(options =>
|
||
{
|
||
options.AddDefaultPolicy(policy =>
|
||
{
|
||
policy.AllowAnyOrigin()
|
||
.AllowAnyMethod()
|
||
.AllowAnyHeader();
|
||
});
|
||
});
|
||
|
||
// 注册 JWT 服务
|
||
builder.Services.AddSingleton<JwtService>();
|
||
|
||
// 注册微信服务
|
||
builder.Services.AddHttpClient<IWeChatService, WeChatService>();
|
||
|
||
// OpenAPI 文档(.NET 10 内置)
|
||
builder.Services.AddOpenApi();
|
||
|
||
var app = builder.Build();
|
||
|
||
// 中间件管道
|
||
if (app.Environment.IsDevelopment())
|
||
{
|
||
app.MapOpenApi();
|
||
// Swagger UI 用于可视化调试
|
||
app.UseSwaggerUI(options =>
|
||
{
|
||
options.SwaggerEndpoint("/openapi/v1.json", "校园跑腿 API v1");
|
||
});
|
||
}
|
||
|
||
app.UseCors();
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
|
||
// 微信手机号登录接口
|
||
app.MapPost("/api/auth/login", async (
|
||
WeChatLoginRequest request,
|
||
IWeChatService weChatService,
|
||
JwtService jwtService,
|
||
AppDbContext db) =>
|
||
{
|
||
// 1. 调用微信 code2Session 获取 session_key 和 openid
|
||
var sessionResult = await weChatService.Code2SessionAsync(request.Code);
|
||
if (!sessionResult.Success)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = sessionResult.ErrorMessage });
|
||
}
|
||
|
||
// 2. 使用 session_key 解密手机号
|
||
string phoneNumber;
|
||
try
|
||
{
|
||
phoneNumber = weChatService.DecryptPhoneNumber(
|
||
sessionResult.SessionKey!, request.EncryptedData, request.Iv);
|
||
}
|
||
catch (Exception)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "手机号解密失败" });
|
||
}
|
||
|
||
// 3. 根据手机号查找或创建用户
|
||
var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == phoneNumber);
|
||
if (user == null)
|
||
{
|
||
user = new User
|
||
{
|
||
OpenId = sessionResult.OpenId!,
|
||
Phone = phoneNumber,
|
||
Nickname = $"用户{phoneNumber[^4..]}",
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
db.Users.Add(user);
|
||
await db.SaveChangesAsync();
|
||
}
|
||
else if (string.IsNullOrEmpty(user.OpenId) && !string.IsNullOrEmpty(sessionResult.OpenId))
|
||
{
|
||
// 更新 OpenId(如果之前为空)
|
||
user.OpenId = sessionResult.OpenId;
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
// 4. 生成 JWT 返回
|
||
var token = jwtService.GenerateToken(user.Id, user.Role.ToString());
|
||
|
||
return Results.Ok(new LoginResponse
|
||
{
|
||
Token = token,
|
||
UserInfo = new UserInfo
|
||
{
|
||
Id = user.Id,
|
||
Phone = user.Phone,
|
||
Nickname = user.Nickname,
|
||
AvatarUrl = user.AvatarUrl,
|
||
Role = user.Role.ToString()
|
||
}
|
||
});
|
||
});
|
||
|
||
// 受保护的测试端点(用于验证认证和授权)
|
||
app.MapGet("/api/protected", () => Results.Ok("ok"))
|
||
.RequireAuthorization();
|
||
|
||
app.MapGet("/api/admin/protected", () => Results.Ok("admin ok"))
|
||
.RequireAuthorization("AdminOnly");
|
||
|
||
// ========== Banner 接口 ==========
|
||
|
||
// 前端获取 Banner 列表(仅启用+按排序权重排列)
|
||
app.MapGet("/api/banners", async (AppDbContext db) =>
|
||
{
|
||
var banners = await db.Banners
|
||
.Where(b => b.IsEnabled)
|
||
.OrderBy(b => b.SortOrder)
|
||
.Select(b => new BannerResponse
|
||
{
|
||
Id = b.Id,
|
||
ImageUrl = b.ImageUrl,
|
||
LinkType = b.LinkType.ToString(),
|
||
LinkUrl = b.LinkUrl,
|
||
SortOrder = b.SortOrder,
|
||
IsEnabled = b.IsEnabled,
|
||
CreatedAt = b.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
return Results.Ok(banners);
|
||
});
|
||
|
||
// 管理端获取全部 Banner 列表
|
||
app.MapGet("/api/admin/banners", async (AppDbContext db) =>
|
||
{
|
||
var banners = await db.Banners
|
||
.OrderBy(b => b.SortOrder)
|
||
.Select(b => new BannerResponse
|
||
{
|
||
Id = b.Id,
|
||
ImageUrl = b.ImageUrl,
|
||
LinkType = b.LinkType.ToString(),
|
||
LinkUrl = b.LinkUrl,
|
||
SortOrder = b.SortOrder,
|
||
IsEnabled = b.IsEnabled,
|
||
CreatedAt = b.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
return Results.Ok(banners);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 创建 Banner
|
||
app.MapPost("/api/admin/banners", async (BannerRequest request, AppDbContext db) =>
|
||
{
|
||
// 校验
|
||
var errors = ValidateBannerRequest(request);
|
||
if (errors.Count > 0)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors });
|
||
}
|
||
|
||
if (!Enum.TryParse<LinkType>(request.LinkType, true, out var linkType) || !Enum.IsDefined(linkType))
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "linkType", message = "链接类型不合法" } } });
|
||
}
|
||
|
||
var banner = new Banner
|
||
{
|
||
ImageUrl = request.ImageUrl,
|
||
LinkType = linkType,
|
||
LinkUrl = request.LinkUrl,
|
||
SortOrder = request.SortOrder,
|
||
IsEnabled = request.IsEnabled,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
db.Banners.Add(banner);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Created($"/api/admin/banners/{banner.Id}", new BannerResponse
|
||
{
|
||
Id = banner.Id,
|
||
ImageUrl = banner.ImageUrl,
|
||
LinkType = banner.LinkType.ToString(),
|
||
LinkUrl = banner.LinkUrl,
|
||
SortOrder = banner.SortOrder,
|
||
IsEnabled = banner.IsEnabled,
|
||
CreatedAt = banner.CreatedAt
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 更新 Banner
|
||
app.MapPut("/api/admin/banners/{id}", async (int id, BannerRequest request, AppDbContext db) =>
|
||
{
|
||
var banner = await db.Banners.FindAsync(id);
|
||
if (banner == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "Banner 不存在" });
|
||
}
|
||
|
||
var errors = ValidateBannerRequest(request);
|
||
if (errors.Count > 0)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors });
|
||
}
|
||
|
||
if (!Enum.TryParse<LinkType>(request.LinkType, true, out var linkType) || !Enum.IsDefined(linkType))
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "linkType", message = "链接类型不合法" } } });
|
||
}
|
||
|
||
banner.ImageUrl = request.ImageUrl;
|
||
banner.LinkType = linkType;
|
||
banner.LinkUrl = request.LinkUrl;
|
||
banner.SortOrder = request.SortOrder;
|
||
banner.IsEnabled = request.IsEnabled;
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new BannerResponse
|
||
{
|
||
Id = banner.Id,
|
||
ImageUrl = banner.ImageUrl,
|
||
LinkType = banner.LinkType.ToString(),
|
||
LinkUrl = banner.LinkUrl,
|
||
SortOrder = banner.SortOrder,
|
||
IsEnabled = banner.IsEnabled,
|
||
CreatedAt = banner.CreatedAt
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 删除 Banner
|
||
app.MapDelete("/api/admin/banners/{id}", async (int id, AppDbContext db) =>
|
||
{
|
||
var banner = await db.Banners.FindAsync(id);
|
||
if (banner == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "Banner 不存在" });
|
||
}
|
||
|
||
db.Banners.Remove(banner);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.NoContent();
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 服务入口接口 ==========
|
||
|
||
// 前端获取服务入口列表(仅启用+按排序权重排列)
|
||
app.MapGet("/api/service-entries", async (AppDbContext db) =>
|
||
{
|
||
var entries = await db.ServiceEntries
|
||
.Where(e => e.IsEnabled)
|
||
.OrderBy(e => e.SortOrder)
|
||
.Select(e => new ServiceEntryResponse
|
||
{
|
||
Id = e.Id,
|
||
Name = e.Name,
|
||
IconUrl = e.IconUrl,
|
||
PagePath = e.PagePath,
|
||
SortOrder = e.SortOrder,
|
||
IsEnabled = e.IsEnabled
|
||
})
|
||
.ToListAsync();
|
||
return Results.Ok(entries);
|
||
});
|
||
|
||
// 管理端获取全部服务入口列表
|
||
app.MapGet("/api/admin/service-entries", async (AppDbContext db) =>
|
||
{
|
||
var entries = await db.ServiceEntries
|
||
.OrderBy(e => e.SortOrder)
|
||
.Select(e => new ServiceEntryResponse
|
||
{
|
||
Id = e.Id,
|
||
Name = e.Name,
|
||
IconUrl = e.IconUrl,
|
||
PagePath = e.PagePath,
|
||
SortOrder = e.SortOrder,
|
||
IsEnabled = e.IsEnabled
|
||
})
|
||
.ToListAsync();
|
||
return Results.Ok(entries);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 更新服务入口
|
||
app.MapPut("/api/admin/service-entries/{id}", async (int id, ServiceEntryUpdateRequest request, AppDbContext db) =>
|
||
{
|
||
var entry = await db.ServiceEntries.FindAsync(id);
|
||
if (entry == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "服务入口不存在" });
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(request.IconUrl))
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "iconUrl", message = "图标图片地址不能为空" } } });
|
||
}
|
||
|
||
entry.IconUrl = request.IconUrl;
|
||
entry.SortOrder = request.SortOrder;
|
||
entry.IsEnabled = request.IsEnabled;
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new ServiceEntryResponse
|
||
{
|
||
Id = entry.Id,
|
||
Name = entry.Name,
|
||
IconUrl = entry.IconUrl,
|
||
PagePath = entry.PagePath,
|
||
SortOrder = entry.SortOrder,
|
||
IsEnabled = entry.IsEnabled
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 订单接口 ==========
|
||
|
||
// 创建订单
|
||
app.MapPost("/api/orders", async (CreateOrderRequest request, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
// 获取当前用户 ID
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
// 校验订单类型
|
||
if (!Enum.TryParse<OrderType>(request.OrderType, true, out var orderType) || !Enum.IsDefined(orderType))
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "订单类型不合法" });
|
||
}
|
||
|
||
// 佣金校验:最低 1.0 元
|
||
if (request.Commission < 1.0m)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "跑腿佣金不可低于1.0元" });
|
||
}
|
||
|
||
// 佣金校验:小数点后最多 1 位
|
||
if (request.Commission != Math.Round(request.Commission, 1))
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "跑腿佣金最多支持小数点后1位" });
|
||
}
|
||
|
||
// 计算支付总金额
|
||
decimal totalAmount;
|
||
decimal packingFee = 0;
|
||
if (orderType == OrderType.Help || orderType == OrderType.Purchase || orderType == OrderType.Food)
|
||
{
|
||
// 美食街订单:自动计算打包费并加入商品总金额
|
||
if (orderType == OrderType.Food && request.FoodItems != null && request.FoodItems.Count > 0)
|
||
{
|
||
// 获取涉及的门店 ID 列表
|
||
var shopIds = request.FoodItems.Select(fi => fi.ShopId).Distinct().ToList();
|
||
var shops = await db.Shops.Where(s => shopIds.Contains(s.Id)).ToListAsync();
|
||
|
||
// 按门店计算打包费
|
||
foreach (var shop in shops)
|
||
{
|
||
var shopItems = request.FoodItems.Where(fi => fi.ShopId == shop.Id).ToList();
|
||
var totalQuantity = shopItems.Sum(fi => fi.Quantity);
|
||
packingFee += CalculatePackingFee(shop.PackingFeeType, shop.PackingFeeAmount, totalQuantity);
|
||
}
|
||
|
||
// 计算菜品总金额
|
||
var dishTotal = request.FoodItems.Sum(fi => fi.Quantity * fi.UnitPrice);
|
||
// 商品总金额 = 菜品总金额 + 打包费
|
||
request.GoodsAmount = dishTotal + packingFee;
|
||
}
|
||
|
||
// 商品+佣金类:支付总金额 = 商品总金额 + 跑腿佣金
|
||
if (request.GoodsAmount == null || request.GoodsAmount < 0)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "商品总金额不能为空或负数" });
|
||
}
|
||
totalAmount = request.GoodsAmount.Value + request.Commission;
|
||
}
|
||
else
|
||
{
|
||
// 佣金类(代取、代送):支付总金额 = 跑腿佣金
|
||
totalAmount = request.Commission;
|
||
}
|
||
|
||
// 生成订单编号
|
||
var orderNo = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + Random.Shared.Next(100000, 999999).ToString();
|
||
|
||
var order = new Order
|
||
{
|
||
OrderNo = orderNo,
|
||
OwnerId = userId,
|
||
OrderType = orderType,
|
||
Status = OrderStatus.Pending,
|
||
ItemName = request.ItemName,
|
||
PickupLocation = request.PickupLocation,
|
||
DeliveryLocation = request.DeliveryLocation,
|
||
Remark = request.Remark,
|
||
Phone = request.Phone,
|
||
Commission = request.Commission,
|
||
GoodsAmount = request.GoodsAmount,
|
||
TotalAmount = totalAmount,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
db.Orders.Add(order);
|
||
await db.SaveChangesAsync();
|
||
|
||
// 美食街订单:保存菜品详情
|
||
if (orderType == OrderType.Food && request.FoodItems != null)
|
||
{
|
||
foreach (var item in request.FoodItems)
|
||
{
|
||
db.FoodOrderItems.Add(new FoodOrderItem
|
||
{
|
||
OrderId = order.Id,
|
||
ShopId = item.ShopId,
|
||
DishId = item.DishId,
|
||
Quantity = item.Quantity,
|
||
UnitPrice = item.UnitPrice
|
||
});
|
||
}
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
return Results.Created($"/api/orders/{order.Id}", new OrderResponse
|
||
{
|
||
Id = order.Id,
|
||
OrderNo = order.OrderNo,
|
||
OwnerId = order.OwnerId,
|
||
RunnerId = order.RunnerId,
|
||
OrderType = order.OrderType.ToString(),
|
||
Status = order.Status.ToString(),
|
||
ItemName = order.ItemName,
|
||
PickupLocation = order.PickupLocation,
|
||
DeliveryLocation = order.DeliveryLocation,
|
||
Remark = order.Remark,
|
||
Phone = order.Phone, // 创建者自己可以看到手机号
|
||
Commission = order.Commission,
|
||
GoodsAmount = order.GoodsAmount,
|
||
TotalAmount = order.TotalAmount,
|
||
CreatedAt = order.CreatedAt
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// 获取订单详情(含手机号隐藏逻辑和按状态显示字段)
|
||
app.MapGet("/api/orders/{id}", async (int id, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var currentUserId = int.Parse(userIdClaim.Value);
|
||
|
||
var order = await db.Orders
|
||
.Include(o => o.FoodOrderItems)
|
||
.Include(o => o.Runner)
|
||
.FirstOrDefaultAsync(o => o.Id == id);
|
||
|
||
if (order == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
}
|
||
|
||
// 手机号隐藏逻辑:
|
||
// - 单主自己始终可见
|
||
// - 已接单且查询者为接单跑腿时可见
|
||
// - 其他情况隐藏
|
||
string? visiblePhone = null;
|
||
if (currentUserId == order.OwnerId)
|
||
{
|
||
visiblePhone = order.Phone;
|
||
}
|
||
else if (order.RunnerId != null && currentUserId == order.RunnerId)
|
||
{
|
||
visiblePhone = order.Phone;
|
||
}
|
||
|
||
// 按状态显示字段:
|
||
// - 待接单:接单时间、跑腿信息、完成信息为空
|
||
// - 进行中/待确认:显示接单时间和跑腿信息
|
||
// - 已完成:显示所有信息
|
||
DateTime? visibleAcceptedAt = null;
|
||
string? runnerNickname = null;
|
||
int? runnerUid = null;
|
||
DateTime? visibleCompletedAt = null;
|
||
string? visibleCompletionProof = null;
|
||
|
||
if (order.Status != OrderStatus.Pending && order.Status != OrderStatus.Cancelled)
|
||
{
|
||
// 进行中及之后的状态显示接单时间和跑腿信息
|
||
visibleAcceptedAt = order.AcceptedAt;
|
||
runnerNickname = order.Runner?.Nickname;
|
||
runnerUid = order.RunnerId;
|
||
}
|
||
|
||
if (order.Status == OrderStatus.Completed || order.Status == OrderStatus.WaitConfirm)
|
||
{
|
||
// 已完成和待确认状态显示完成时间和凭证
|
||
visibleCompletedAt = order.CompletedAt;
|
||
visibleCompletionProof = order.CompletionProof;
|
||
}
|
||
|
||
var response = new OrderResponse
|
||
{
|
||
Id = order.Id,
|
||
OrderNo = order.OrderNo,
|
||
OwnerId = order.OwnerId,
|
||
RunnerId = order.RunnerId,
|
||
OrderType = order.OrderType.ToString(),
|
||
Status = order.Status.ToString(),
|
||
ItemName = order.ItemName,
|
||
PickupLocation = order.PickupLocation,
|
||
DeliveryLocation = order.DeliveryLocation,
|
||
Remark = order.Remark,
|
||
Phone = visiblePhone,
|
||
Commission = order.Commission,
|
||
GoodsAmount = order.GoodsAmount,
|
||
TotalAmount = order.TotalAmount,
|
||
CompletionProof = visibleCompletionProof,
|
||
IsReviewed = order.IsReviewed,
|
||
CreatedAt = order.CreatedAt,
|
||
AcceptedAt = visibleAcceptedAt,
|
||
CompletedAt = visibleCompletedAt,
|
||
RunnerNickname = runnerNickname,
|
||
RunnerUid = runnerUid
|
||
};
|
||
|
||
// 美食街订单附带菜品详情
|
||
if (order.OrderType == OrderType.Food && order.FoodOrderItems.Count > 0)
|
||
{
|
||
response.FoodItems = order.FoodOrderItems.Select(fi => new FoodOrderItemResponse
|
||
{
|
||
Id = fi.Id,
|
||
ShopId = fi.ShopId,
|
||
DishId = fi.DishId,
|
||
Quantity = fi.Quantity,
|
||
UnitPrice = fi.UnitPrice
|
||
}).ToList();
|
||
}
|
||
|
||
return Results.Ok(response);
|
||
}).RequireAuthorization();
|
||
|
||
// ========== 订单状态管理接口=========
|
||
|
||
// 获取订单大厅列表(仅返回 Pending 状态订单)
|
||
app.MapGet("/api/orders/hall", async (
|
||
string? type,
|
||
string? sort,
|
||
AppDbContext db) =>
|
||
{
|
||
// 基础查询:仅返回待接单订单
|
||
var query = db.Orders.Where(o => o.Status == OrderStatus.Pending);
|
||
|
||
// 按订单类型筛选
|
||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<OrderType>(type, true, out var orderType))
|
||
{
|
||
query = query.Where(o => o.OrderType == orderType);
|
||
}
|
||
|
||
// 排序:佣金优先(默认)或距离优先(暂不支持距离,回退到佣金)
|
||
query = sort?.ToLower() == "distance"
|
||
? query.OrderByDescending(o => o.Commission) // 距离排序需地图 API,暂用佣金排序
|
||
: query.OrderByDescending(o => o.Commission);
|
||
|
||
var orders = await query.ToListAsync();
|
||
|
||
// 美食街订单需要额外查询门店数和菜品数
|
||
var foodOrderIds = orders
|
||
.Where(o => o.OrderType == OrderType.Food)
|
||
.Select(o => o.Id)
|
||
.ToList();
|
||
|
||
var foodOrderStats = new Dictionary<int, (int ShopCount, int DishCount)>();
|
||
if (foodOrderIds.Count > 0)
|
||
{
|
||
var stats = await db.FoodOrderItems
|
||
.Where(fi => foodOrderIds.Contains(fi.OrderId))
|
||
.GroupBy(fi => fi.OrderId)
|
||
.Select(g => new
|
||
{
|
||
OrderId = g.Key,
|
||
ShopCount = g.Select(fi => fi.ShopId).Distinct().Count(),
|
||
DishCount = g.Sum(fi => fi.Quantity)
|
||
})
|
||
.ToListAsync();
|
||
foreach (var s in stats)
|
||
{
|
||
foodOrderStats[s.OrderId] = (s.ShopCount, s.DishCount);
|
||
}
|
||
}
|
||
|
||
var result = orders.Select(o =>
|
||
{
|
||
var item = new OrderHallItemResponse
|
||
{
|
||
Id = o.Id,
|
||
OrderNo = o.OrderNo,
|
||
OrderType = o.OrderType.ToString(),
|
||
Status = o.Status.ToString(),
|
||
ItemName = o.ItemName,
|
||
PickupLocation = o.PickupLocation,
|
||
DeliveryLocation = o.DeliveryLocation,
|
||
Remark = o.Remark,
|
||
Commission = o.Commission,
|
||
GoodsAmount = o.GoodsAmount,
|
||
CreatedAt = o.CreatedAt
|
||
};
|
||
|
||
// 美食街订单附加门店数和菜品数
|
||
if (o.OrderType == OrderType.Food && foodOrderStats.TryGetValue(o.Id, out var stat))
|
||
{
|
||
item.ShopCount = stat.ShopCount;
|
||
item.DishItemCount = stat.DishCount;
|
||
}
|
||
|
||
return item;
|
||
}).ToList();
|
||
|
||
return Results.Ok(result);
|
||
}).RequireAuthorization();
|
||
|
||
// 接取订单
|
||
app.MapPost("/api/orders/{id}/accept", async (int id, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
// 获取当前用户
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
// 检查跑腿认证
|
||
var certification = await db.RunnerCertifications
|
||
.Where(c => c.UserId == userId && c.Status == CertificationStatus.Approved)
|
||
.FirstOrDefaultAsync();
|
||
if (certification == null)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "您尚未通过跑腿认证,无法接单" });
|
||
}
|
||
|
||
// 检查用户是否被封禁
|
||
var user = await db.Users.FindAsync(userId);
|
||
if (user != null && user.IsBanned)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "您的跑腿身份已被封禁,无法接单" });
|
||
}
|
||
|
||
// 使用乐观锁查询并更新订单(仅 Pending 状态可接单)
|
||
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id);
|
||
if (order == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
}
|
||
|
||
if (order.Status == OrderStatus.Cancelled)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "该订单已被取消" });
|
||
}
|
||
|
||
if (order.Status != OrderStatus.Pending)
|
||
{
|
||
return Results.Conflict(new { code = 409, message = "该订单已被接取" });
|
||
}
|
||
|
||
// 不能接自己的订单
|
||
if (order.OwnerId == userId)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "不能接取自己的订单" });
|
||
}
|
||
|
||
// 状态转换:Pending → InProgress
|
||
order.Status = OrderStatus.InProgress;
|
||
order.RunnerId = userId;
|
||
order.AcceptedAt = DateTime.UtcNow;
|
||
|
||
try
|
||
{
|
||
await db.SaveChangesAsync();
|
||
}
|
||
catch (DbUpdateConcurrencyException)
|
||
{
|
||
return Results.Conflict(new { code = 409, message = "该订单已被接取" });
|
||
}
|
||
|
||
return Results.Ok(new AcceptOrderResponse
|
||
{
|
||
Id = order.Id,
|
||
OrderNo = order.OrderNo,
|
||
Status = order.Status.ToString(),
|
||
RunnerId = userId,
|
||
AcceptedAt = order.AcceptedAt.Value
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// ========== 订单状态管理接口 ==========
|
||
|
||
// 取消订单(仅单主可取消待接单订单)
|
||
app.MapPost("/api/orders/{id}/cancel", async (int id, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id);
|
||
if (order == null)
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
|
||
if (order.OwnerId != userId)
|
||
return Results.BadRequest(new { code = 400, message = "仅单主可取消订单" });
|
||
|
||
if (order.Status != OrderStatus.Pending)
|
||
return Results.BadRequest(new { code = 400, message = "仅待接单状态的订单可取消" });
|
||
|
||
order.Status = OrderStatus.Cancelled;
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() });
|
||
}).RequireAuthorization();
|
||
|
||
// 跑腿提交完成
|
||
app.MapPost("/api/orders/{id}/complete", async (int id, CompleteOrderRequest? request, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id);
|
||
if (order == null)
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
|
||
if (order.RunnerId != userId)
|
||
return Results.BadRequest(new { code = 400, message = "仅接单跑腿可提交完成" });
|
||
|
||
if (order.Status != OrderStatus.InProgress)
|
||
return Results.BadRequest(new { code = 400, message = "仅进行中的订单可提交完成" });
|
||
|
||
order.Status = OrderStatus.WaitConfirm;
|
||
order.CompletionProof = request?.CompletionProof;
|
||
order.CompletedAt = DateTime.UtcNow;
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString(), completedAt = order.CompletedAt });
|
||
}).RequireAuthorization();
|
||
|
||
// 单主确认完成
|
||
app.MapPost("/api/orders/{id}/confirm", async (int id, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id);
|
||
if (order == null)
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
|
||
if (order.OwnerId != userId)
|
||
return Results.BadRequest(new { code = 400, message = "仅单主可确认订单完成" });
|
||
|
||
if (order.Status != OrderStatus.WaitConfirm)
|
||
return Results.BadRequest(new { code = 400, message = "仅待确认状态的订单可确认完成" });
|
||
|
||
order.Status = OrderStatus.Completed;
|
||
|
||
// 创建收益记录(佣金分销计算)
|
||
if (order.RunnerId.HasValue)
|
||
{
|
||
var commissionRules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync();
|
||
var platformFee = CalculatePlatformFee(order.Commission, commissionRules);
|
||
var netEarning = order.Commission - platformFee;
|
||
|
||
// 获取冻结时间配置(默认 1 天)
|
||
var freezeDaysConfig = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "freeze_days");
|
||
var freezeDays = 1;
|
||
if (freezeDaysConfig != null && int.TryParse(freezeDaysConfig.Value, out var configDays))
|
||
{
|
||
freezeDays = configDays;
|
||
}
|
||
|
||
var earning = new Earning
|
||
{
|
||
UserId = order.RunnerId.Value,
|
||
OrderId = order.Id,
|
||
GoodsAmount = order.GoodsAmount,
|
||
Commission = order.Commission,
|
||
PlatformFee = platformFee,
|
||
NetEarning = netEarning,
|
||
Status = EarningStatus.Frozen,
|
||
FrozenUntil = DateTime.UtcNow.AddDays(freezeDays),
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
db.Earnings.Add(earning);
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() });
|
||
}).RequireAuthorization();
|
||
|
||
// 单主拒绝完成(订单回到进行中)
|
||
app.MapPost("/api/orders/{id}/reject", async (int id, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id);
|
||
if (order == null)
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
|
||
if (order.OwnerId != userId)
|
||
return Results.BadRequest(new { code = 400, message = "仅单主可拒绝订单完成" });
|
||
|
||
if (order.Status != OrderStatus.WaitConfirm)
|
||
return Results.BadRequest(new { code = 400, message = "仅待确认状态的订单可拒绝" });
|
||
|
||
order.Status = OrderStatus.InProgress;
|
||
order.CompletedAt = null;
|
||
order.CompletionProof = null;
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() });
|
||
}).RequireAuthorization();
|
||
|
||
// 我的订单(单主查看自己发布的订单)
|
||
app.MapGet("/api/orders/mine", async (string? status, string? type, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var query = db.Orders.Where(o => o.OwnerId == userId);
|
||
|
||
// 按状态筛选
|
||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<OrderStatus>(status, true, out var orderStatus))
|
||
query = query.Where(o => o.Status == orderStatus);
|
||
|
||
// 按类型筛选
|
||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<OrderType>(type, true, out var orderType))
|
||
query = query.Where(o => o.OrderType == orderType);
|
||
|
||
var orders = await query
|
||
.OrderByDescending(o => o.CreatedAt)
|
||
.Select(o => new OrderListItemResponse
|
||
{
|
||
Id = o.Id,
|
||
OrderNo = o.OrderNo,
|
||
OwnerId = o.OwnerId,
|
||
RunnerId = o.RunnerId,
|
||
OrderType = o.OrderType.ToString(),
|
||
Status = o.Status.ToString(),
|
||
ItemName = o.ItemName,
|
||
PickupLocation = o.PickupLocation,
|
||
DeliveryLocation = o.DeliveryLocation,
|
||
Remark = o.Remark,
|
||
Commission = o.Commission,
|
||
GoodsAmount = o.GoodsAmount,
|
||
TotalAmount = o.TotalAmount,
|
||
IsReviewed = o.IsReviewed,
|
||
CreatedAt = o.CreatedAt,
|
||
AcceptedAt = o.AcceptedAt,
|
||
CompletedAt = o.CompletedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(orders);
|
||
}).RequireAuthorization();
|
||
|
||
// 我的接单(跑腿查看自己接取的订单)
|
||
app.MapGet("/api/orders/taken", async (string? status, string? type, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var query = db.Orders.Where(o => o.RunnerId == userId);
|
||
|
||
// 按状态筛选
|
||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<OrderStatus>(status, true, out var orderStatus))
|
||
query = query.Where(o => o.Status == orderStatus);
|
||
|
||
// 按类型筛选
|
||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<OrderType>(type, true, out var orderType))
|
||
query = query.Where(o => o.OrderType == orderType);
|
||
|
||
var orders = await query
|
||
.OrderByDescending(o => o.CreatedAt)
|
||
.Select(o => new OrderListItemResponse
|
||
{
|
||
Id = o.Id,
|
||
OrderNo = o.OrderNo,
|
||
OwnerId = o.OwnerId,
|
||
RunnerId = o.RunnerId,
|
||
OrderType = o.OrderType.ToString(),
|
||
Status = o.Status.ToString(),
|
||
ItemName = o.ItemName,
|
||
PickupLocation = o.PickupLocation,
|
||
DeliveryLocation = o.DeliveryLocation,
|
||
Remark = o.Remark,
|
||
Commission = o.Commission,
|
||
GoodsAmount = o.GoodsAmount,
|
||
TotalAmount = o.TotalAmount,
|
||
IsReviewed = o.IsReviewed,
|
||
CreatedAt = o.CreatedAt,
|
||
AcceptedAt = o.AcceptedAt,
|
||
CompletedAt = o.CompletedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(orders);
|
||
}).RequireAuthorization();
|
||
|
||
// ========== 美食街门店接口 ==========
|
||
|
||
// 前端获取门店列表(仅启用门店,含菜品种类数量)
|
||
app.MapGet("/api/shops", async (AppDbContext db) =>
|
||
{
|
||
var shops = await db.Shops
|
||
.Where(s => s.IsEnabled)
|
||
.Select(s => new ShopListResponse
|
||
{
|
||
Id = s.Id,
|
||
Name = s.Name,
|
||
Photo = s.Photo,
|
||
Location = s.Location,
|
||
DishCount = s.Dishes.Count(d => d.IsEnabled)
|
||
})
|
||
.ToListAsync();
|
||
return Results.Ok(shops);
|
||
});
|
||
|
||
// 前端获取门店详情(含 Banner 和菜品)
|
||
app.MapGet("/api/shops/{id}", async (int id, AppDbContext db) =>
|
||
{
|
||
var shop = await db.Shops
|
||
.Include(s => s.ShopBanners)
|
||
.Include(s => s.Dishes)
|
||
.FirstOrDefaultAsync(s => s.Id == id);
|
||
|
||
if (shop == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "门店不存在" });
|
||
}
|
||
|
||
var response = new ShopDetailResponse
|
||
{
|
||
Id = shop.Id,
|
||
Name = shop.Name,
|
||
Photo = shop.Photo,
|
||
Location = shop.Location,
|
||
Notice = shop.Notice,
|
||
PackingFeeType = shop.PackingFeeType.ToString(),
|
||
PackingFeeAmount = shop.PackingFeeAmount,
|
||
IsEnabled = shop.IsEnabled,
|
||
Banners = shop.ShopBanners
|
||
.OrderBy(b => b.SortOrder)
|
||
.Select(b => new ShopBannerResponse
|
||
{
|
||
Id = b.Id,
|
||
ImageUrl = b.ImageUrl,
|
||
SortOrder = b.SortOrder
|
||
}).ToList(),
|
||
Dishes = shop.Dishes
|
||
.Where(d => d.IsEnabled)
|
||
.Select(d => new DishResponse
|
||
{
|
||
Id = d.Id,
|
||
ShopId = d.ShopId,
|
||
Name = d.Name,
|
||
Photo = d.Photo,
|
||
Price = d.Price,
|
||
IsEnabled = d.IsEnabled
|
||
}).ToList()
|
||
};
|
||
|
||
return Results.Ok(response);
|
||
});
|
||
|
||
// ========== 管理端门店接口 ==========
|
||
|
||
// 管理端获取门店列表(全部)
|
||
app.MapGet("/api/admin/shops", async (AppDbContext db) =>
|
||
{
|
||
var shops = await db.Shops
|
||
.Select(s => new AdminShopResponse
|
||
{
|
||
Id = s.Id,
|
||
Name = s.Name,
|
||
Photo = s.Photo,
|
||
Location = s.Location,
|
||
Notice = s.Notice,
|
||
PackingFeeType = s.PackingFeeType.ToString(),
|
||
PackingFeeAmount = s.PackingFeeAmount,
|
||
IsEnabled = s.IsEnabled,
|
||
DishCount = s.Dishes.Count
|
||
})
|
||
.ToListAsync();
|
||
return Results.Ok(shops);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端创建门店
|
||
app.MapPost("/api/admin/shops", async (ShopRequest request, AppDbContext db) =>
|
||
{
|
||
var errors = ValidateShopRequest(request);
|
||
if (errors.Count > 0)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors });
|
||
}
|
||
|
||
if (!Enum.TryParse<PackingFeeType>(request.PackingFeeType, true, out var feeType) || !Enum.IsDefined(feeType))
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "packingFeeType", message = "打包费类型不合法" } } });
|
||
}
|
||
|
||
var shop = new Shop
|
||
{
|
||
Name = request.Name,
|
||
Photo = request.Photo,
|
||
Location = request.Location,
|
||
Notice = request.Notice,
|
||
PackingFeeType = feeType,
|
||
PackingFeeAmount = request.PackingFeeAmount,
|
||
IsEnabled = request.IsEnabled
|
||
};
|
||
|
||
db.Shops.Add(shop);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Created($"/api/admin/shops/{shop.Id}", new AdminShopResponse
|
||
{
|
||
Id = shop.Id,
|
||
Name = shop.Name,
|
||
Photo = shop.Photo,
|
||
Location = shop.Location,
|
||
Notice = shop.Notice,
|
||
PackingFeeType = shop.PackingFeeType.ToString(),
|
||
PackingFeeAmount = shop.PackingFeeAmount,
|
||
IsEnabled = shop.IsEnabled,
|
||
DishCount = 0
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端更新门店
|
||
app.MapPut("/api/admin/shops/{id}", async (int id, ShopRequest request, AppDbContext db) =>
|
||
{
|
||
var shop = await db.Shops.FindAsync(id);
|
||
if (shop == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "门店不存在" });
|
||
}
|
||
|
||
var errors = ValidateShopRequest(request);
|
||
if (errors.Count > 0)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors });
|
||
}
|
||
|
||
if (!Enum.TryParse<PackingFeeType>(request.PackingFeeType, true, out var feeType) || !Enum.IsDefined(feeType))
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "packingFeeType", message = "打包费类型不合法" } } });
|
||
}
|
||
|
||
shop.Name = request.Name;
|
||
shop.Photo = request.Photo;
|
||
shop.Location = request.Location;
|
||
shop.Notice = request.Notice;
|
||
shop.PackingFeeType = feeType;
|
||
shop.PackingFeeAmount = request.PackingFeeAmount;
|
||
shop.IsEnabled = request.IsEnabled;
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new AdminShopResponse
|
||
{
|
||
Id = shop.Id,
|
||
Name = shop.Name,
|
||
Photo = shop.Photo,
|
||
Location = shop.Location,
|
||
Notice = shop.Notice,
|
||
PackingFeeType = shop.PackingFeeType.ToString(),
|
||
PackingFeeAmount = shop.PackingFeeAmount,
|
||
IsEnabled = shop.IsEnabled,
|
||
DishCount = await db.Dishes.CountAsync(d => d.ShopId == shop.Id)
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端删除门店
|
||
app.MapDelete("/api/admin/shops/{id}", async (int id, AppDbContext db) =>
|
||
{
|
||
var shop = await db.Shops
|
||
.Include(s => s.Dishes)
|
||
.Include(s => s.ShopBanners)
|
||
.FirstOrDefaultAsync(s => s.Id == id);
|
||
|
||
if (shop == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "门店不存在" });
|
||
}
|
||
|
||
// 级联删除菜品和门店 Banner
|
||
db.Dishes.RemoveRange(shop.Dishes);
|
||
db.ShopBanners.RemoveRange(shop.ShopBanners);
|
||
db.Shops.Remove(shop);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.NoContent();
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 管理端门店 Banner 接口 ==========
|
||
|
||
// 获取门店 Banner 列表
|
||
app.MapGet("/api/admin/shops/{shopId}/banners", async (int shopId, AppDbContext db) =>
|
||
{
|
||
var shop = await db.Shops.FindAsync(shopId);
|
||
if (shop == null)
|
||
return Results.NotFound(new { code = 404, message = "门店不存在" });
|
||
|
||
var banners = await db.ShopBanners
|
||
.Where(b => b.ShopId == shopId)
|
||
.OrderBy(b => b.SortOrder)
|
||
.Select(b => new ShopBannerResponse { Id = b.Id, ImageUrl = b.ImageUrl, SortOrder = b.SortOrder })
|
||
.ToListAsync();
|
||
return Results.Ok(banners);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 创建门店 Banner
|
||
app.MapPost("/api/admin/shops/{shopId}/banners", async (int shopId, ShopBannerRequest request, AppDbContext db) =>
|
||
{
|
||
var shop = await db.Shops.FindAsync(shopId);
|
||
if (shop == null)
|
||
return Results.NotFound(new { code = 404, message = "门店不存在" });
|
||
|
||
if (string.IsNullOrWhiteSpace(request.ImageUrl))
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors = new[] { new { field = "imageUrl", message = "图片地址不能为空" } } });
|
||
|
||
var banner = new ShopBanner { ShopId = shopId, ImageUrl = request.ImageUrl, SortOrder = request.SortOrder };
|
||
db.ShopBanners.Add(banner);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Created($"/api/admin/shops/{shopId}/banners/{banner.Id}",
|
||
new ShopBannerResponse { Id = banner.Id, ImageUrl = banner.ImageUrl, SortOrder = banner.SortOrder });
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 删除门店 Banner
|
||
app.MapDelete("/api/admin/shops/{shopId}/banners/{bannerId}", async (int shopId, int bannerId, AppDbContext db) =>
|
||
{
|
||
var banner = await db.ShopBanners.FirstOrDefaultAsync(b => b.Id == bannerId && b.ShopId == shopId);
|
||
if (banner == null)
|
||
return Results.NotFound(new { code = 404, message = "门店 Banner 不存在" });
|
||
|
||
db.ShopBanners.Remove(banner);
|
||
await db.SaveChangesAsync();
|
||
return Results.NoContent();
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 管理端菜品接口 ==========
|
||
|
||
// 获取门店菜品列表
|
||
app.MapGet("/api/admin/shops/{shopId}/dishes", async (int shopId, AppDbContext db) =>
|
||
{
|
||
var shop = await db.Shops.FindAsync(shopId);
|
||
if (shop == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "门店不存在" });
|
||
}
|
||
|
||
var dishes = await db.Dishes
|
||
.Where(d => d.ShopId == shopId)
|
||
.Select(d => new DishResponse
|
||
{
|
||
Id = d.Id,
|
||
ShopId = d.ShopId,
|
||
Name = d.Name,
|
||
Photo = d.Photo,
|
||
Price = d.Price,
|
||
IsEnabled = d.IsEnabled
|
||
})
|
||
.ToListAsync();
|
||
return Results.Ok(dishes);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 创建菜品
|
||
app.MapPost("/api/admin/shops/{shopId}/dishes", async (int shopId, DishRequest request, AppDbContext db) =>
|
||
{
|
||
var shop = await db.Shops.FindAsync(shopId);
|
||
if (shop == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "门店不存在" });
|
||
}
|
||
|
||
var errors = ValidateDishRequest(request);
|
||
if (errors.Count > 0)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors });
|
||
}
|
||
|
||
var dish = new Dish
|
||
{
|
||
ShopId = shopId,
|
||
Name = request.Name,
|
||
Photo = request.Photo,
|
||
Price = request.Price,
|
||
IsEnabled = request.IsEnabled
|
||
};
|
||
|
||
db.Dishes.Add(dish);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Created($"/api/admin/shops/{shopId}/dishes/{dish.Id}", new DishResponse
|
||
{
|
||
Id = dish.Id,
|
||
ShopId = dish.ShopId,
|
||
Name = dish.Name,
|
||
Photo = dish.Photo,
|
||
Price = dish.Price,
|
||
IsEnabled = dish.IsEnabled
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 更新菜品
|
||
app.MapPut("/api/admin/shops/{shopId}/dishes/{dishId}", async (int shopId, int dishId, DishRequest request, AppDbContext db) =>
|
||
{
|
||
var dish = await db.Dishes.FirstOrDefaultAsync(d => d.Id == dishId && d.ShopId == shopId);
|
||
if (dish == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "菜品不存在" });
|
||
}
|
||
|
||
var errors = ValidateDishRequest(request);
|
||
if (errors.Count > 0)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors });
|
||
}
|
||
|
||
dish.Name = request.Name;
|
||
dish.Photo = request.Photo;
|
||
dish.Price = request.Price;
|
||
dish.IsEnabled = request.IsEnabled;
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new DishResponse
|
||
{
|
||
Id = dish.Id,
|
||
ShopId = dish.ShopId,
|
||
Name = dish.Name,
|
||
Photo = dish.Photo,
|
||
Price = dish.Price,
|
||
IsEnabled = dish.IsEnabled
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 删除菜品
|
||
app.MapDelete("/api/admin/shops/{shopId}/dishes/{dishId}", async (int shopId, int dishId, AppDbContext db) =>
|
||
{
|
||
var dish = await db.Dishes.FirstOrDefaultAsync(d => d.Id == dishId && d.ShopId == shopId);
|
||
if (dish == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "菜品不存在" });
|
||
}
|
||
|
||
db.Dishes.Remove(dish);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.NoContent();
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 跑腿认证接口 ==========
|
||
|
||
// 提交跑腿认证申请
|
||
app.MapPost("/api/runner/certification", async (CertificationRequest request, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
// 校验
|
||
var errors = new List<object>();
|
||
if (string.IsNullOrWhiteSpace(request.RealName))
|
||
errors.Add(new { field = "realName", message = "姓名不能为空" });
|
||
if (string.IsNullOrWhiteSpace(request.Phone))
|
||
errors.Add(new { field = "phone", message = "手机号不能为空" });
|
||
if (errors.Count > 0)
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors });
|
||
|
||
// 检查是否已有待审核或已通过的认证
|
||
var existing = await db.RunnerCertifications
|
||
.Where(c => c.UserId == userId && (c.Status == CertificationStatus.Pending || c.Status == CertificationStatus.Approved))
|
||
.FirstOrDefaultAsync();
|
||
|
||
if (existing != null)
|
||
{
|
||
if (existing.Status == CertificationStatus.Approved)
|
||
return Results.BadRequest(new { code = 400, message = "您已通过跑腿认证" });
|
||
return Results.BadRequest(new { code = 400, message = "平台审核中" });
|
||
}
|
||
|
||
var certification = new RunnerCertification
|
||
{
|
||
UserId = userId,
|
||
RealName = request.RealName,
|
||
Phone = request.Phone,
|
||
Status = CertificationStatus.Pending,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
db.RunnerCertifications.Add(certification);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Created($"/api/runner/certification", new CertificationResponse
|
||
{
|
||
Id = certification.Id,
|
||
UserId = certification.UserId,
|
||
RealName = certification.RealName,
|
||
Phone = certification.Phone,
|
||
Status = certification.Status.ToString(),
|
||
CreatedAt = certification.CreatedAt,
|
||
ReviewedAt = certification.ReviewedAt
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// 查询跑腿认证状态
|
||
app.MapGet("/api/runner/certification", async (HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
// 返回最新的认证记录
|
||
var certification = await db.RunnerCertifications
|
||
.Where(c => c.UserId == userId)
|
||
.OrderByDescending(c => c.CreatedAt)
|
||
.FirstOrDefaultAsync();
|
||
|
||
if (certification == null)
|
||
{
|
||
return Results.Ok(new { status = "None", message = "未提交认证" });
|
||
}
|
||
|
||
return Results.Ok(new CertificationResponse
|
||
{
|
||
Id = certification.Id,
|
||
UserId = certification.UserId,
|
||
RealName = certification.RealName,
|
||
Phone = certification.Phone,
|
||
Status = certification.Status.ToString(),
|
||
CreatedAt = certification.CreatedAt,
|
||
ReviewedAt = certification.ReviewedAt
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// 管理端获取认证列表
|
||
app.MapGet("/api/admin/certifications", async (string? status, AppDbContext db) =>
|
||
{
|
||
var query = db.RunnerCertifications
|
||
.Include(c => c.User)
|
||
.AsQueryable();
|
||
|
||
// 按状态筛选
|
||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<CertificationStatus>(status, true, out var certStatus))
|
||
{
|
||
query = query.Where(c => c.Status == certStatus);
|
||
}
|
||
|
||
var certifications = await query
|
||
.OrderByDescending(c => c.CreatedAt)
|
||
.Select(c => new AdminCertificationResponse
|
||
{
|
||
Id = c.Id,
|
||
UserId = c.UserId,
|
||
RealName = c.RealName,
|
||
Phone = c.Phone,
|
||
Status = c.Status.ToString(),
|
||
CreatedAt = c.CreatedAt,
|
||
ReviewedAt = c.ReviewedAt,
|
||
UserNickname = c.User != null ? c.User.Nickname : null,
|
||
UserPhone = c.User != null ? c.User.Phone : null
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(certifications);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端审核认证
|
||
app.MapPut("/api/admin/certifications/{id}", async (int id, ReviewCertificationRequest request, AppDbContext db) =>
|
||
{
|
||
var certification = await db.RunnerCertifications
|
||
.Include(c => c.User)
|
||
.FirstOrDefaultAsync(c => c.Id == id);
|
||
|
||
if (certification == null)
|
||
{
|
||
return Results.NotFound(new { code = 404, message = "认证记录不存在" });
|
||
}
|
||
|
||
if (certification.Status != CertificationStatus.Pending)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "该认证已审核" });
|
||
}
|
||
|
||
if (!Enum.TryParse<CertificationStatus>(request.Status, true, out var newStatus)
|
||
|| (newStatus != CertificationStatus.Approved && newStatus != CertificationStatus.Rejected))
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "审核结果不合法,仅支持 Approved 或 Rejected" });
|
||
}
|
||
|
||
certification.Status = newStatus;
|
||
certification.ReviewedAt = DateTime.UtcNow;
|
||
|
||
// 审核通过时,更新用户角色为 Runner
|
||
if (newStatus == CertificationStatus.Approved && certification.User != null)
|
||
{
|
||
certification.User.Role = UserRole.Runner;
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new CertificationResponse
|
||
{
|
||
Id = certification.Id,
|
||
UserId = certification.UserId,
|
||
RealName = certification.RealName,
|
||
Phone = certification.Phone,
|
||
Status = certification.Status.ToString(),
|
||
CreatedAt = certification.CreatedAt,
|
||
ReviewedAt = certification.ReviewedAt
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 评价接口 ==========
|
||
|
||
// 提交评价(单主评价跑腿)
|
||
app.MapPost("/api/orders/{id}/review", async (int id, SubmitReviewRequest request, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id);
|
||
if (order == null)
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
|
||
// 仅单主可评价
|
||
if (order.OwnerId != userId)
|
||
return Results.BadRequest(new { code = 400, message = "仅单主可评价跑腿" });
|
||
|
||
// 仅已完成订单可评价
|
||
if (order.Status != OrderStatus.Completed)
|
||
return Results.BadRequest(new { code = 400, message = "仅已完成的订单可评价" });
|
||
|
||
// 不可重复评价
|
||
if (order.IsReviewed)
|
||
return Results.BadRequest(new { code = 400, message = "该订单已评价" });
|
||
|
||
// 校验评分范围
|
||
if (request.Rating < 1 || request.Rating > 5)
|
||
return Results.BadRequest(new { code = 400, message = "评分必须在 1-5 之间" });
|
||
|
||
// 评分计算:1星=-2, 2星=-1, 3星=0, 4星=+1, 5星=+2
|
||
var scoreChange = request.Rating - 3;
|
||
|
||
var review = new Review
|
||
{
|
||
OrderId = id,
|
||
RunnerId = order.RunnerId!.Value,
|
||
Rating = request.Rating,
|
||
Content = request.Content,
|
||
ScoreChange = scoreChange,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
db.Reviews.Add(review);
|
||
|
||
// 更新跑腿评分(限制在 0-100 之间)
|
||
var runner = await db.Users.FindAsync(order.RunnerId!.Value);
|
||
if (runner != null)
|
||
{
|
||
runner.RunnerScore = Math.Clamp(runner.RunnerScore + scoreChange, 0, 100);
|
||
}
|
||
|
||
// 标记订单已评价
|
||
order.IsReviewed = true;
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new ReviewResponse
|
||
{
|
||
Id = review.Id,
|
||
OrderId = review.OrderId,
|
||
RunnerId = review.RunnerId,
|
||
Rating = review.Rating,
|
||
ScoreChange = review.ScoreChange,
|
||
IsDisabled = review.IsDisabled,
|
||
CreatedAt = review.CreatedAt
|
||
// 注意:不返回 Content,评价内容仅管理员可见
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// 管理端获取评价列表
|
||
app.MapGet("/api/admin/reviews", async (int? runnerId, AppDbContext db) =>
|
||
{
|
||
var query = db.Reviews
|
||
.Include(r => r.Order)
|
||
.Include(r => r.Runner)
|
||
.AsQueryable();
|
||
|
||
if (runnerId.HasValue)
|
||
query = query.Where(r => r.RunnerId == runnerId.Value);
|
||
|
||
var reviews = await query
|
||
.OrderByDescending(r => r.CreatedAt)
|
||
.Select(r => new AdminReviewResponse
|
||
{
|
||
Id = r.Id,
|
||
OrderId = r.OrderId,
|
||
OrderNo = r.Order != null ? r.Order.OrderNo : "",
|
||
RunnerId = r.RunnerId,
|
||
RunnerNickname = r.Runner != null ? r.Runner.Nickname : null,
|
||
Rating = r.Rating,
|
||
Content = r.Content,
|
||
ScoreChange = r.ScoreChange,
|
||
IsDisabled = r.IsDisabled,
|
||
CreatedAt = r.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(reviews);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端禁用评价
|
||
app.MapPut("/api/admin/reviews/{id}/disable", async (int id, AppDbContext db) =>
|
||
{
|
||
var review = await db.Reviews.FindAsync(id);
|
||
if (review == null)
|
||
return Results.NotFound(new { code = 404, message = "评价不存在" });
|
||
|
||
if (review.IsDisabled)
|
||
return Results.BadRequest(new { code = 400, message = "该评价已被禁用" });
|
||
|
||
review.IsDisabled = true;
|
||
|
||
// 回退该评价对跑腿分数的影响
|
||
var runner = await db.Users.FindAsync(review.RunnerId);
|
||
if (runner != null)
|
||
{
|
||
runner.RunnerScore = Math.Clamp(runner.RunnerScore - review.ScoreChange, 0, 100);
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new ReviewResponse
|
||
{
|
||
Id = review.Id,
|
||
OrderId = review.OrderId,
|
||
RunnerId = review.RunnerId,
|
||
Rating = review.Rating,
|
||
Content = review.Content,
|
||
ScoreChange = review.ScoreChange,
|
||
IsDisabled = review.IsDisabled,
|
||
CreatedAt = review.CreatedAt
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 改价接口 ==========
|
||
|
||
// 发起改价
|
||
app.MapPost("/api/orders/{id}/price-change", async (int id, PriceChangeRequest request, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var order = await db.Orders.FirstOrDefaultAsync(o => o.Id == id);
|
||
if (order == null)
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
|
||
// 仅进行中或待确认的订单可改价
|
||
if (order.Status != OrderStatus.InProgress && order.Status != OrderStatus.WaitConfirm)
|
||
return Results.BadRequest(new { code = 400, message = "当前订单状态不支持改价" });
|
||
|
||
// 仅单主或跑腿可发起改价
|
||
if (userId != order.OwnerId && userId != order.RunnerId)
|
||
return Results.BadRequest(new { code = 400, message = "仅订单相关方可发起改价" });
|
||
|
||
// 校验改价类型
|
||
if (!Enum.TryParse<PriceChangeType>(request.ChangeType, true, out var changeType) || !Enum.IsDefined(changeType))
|
||
return Results.BadRequest(new { code = 400, message = "改价类型不合法" });
|
||
|
||
// 代购和美食街订单才支持修改商品总额
|
||
if (changeType == PriceChangeType.GoodsAmount &&
|
||
order.OrderType != OrderType.Purchase && order.OrderType != OrderType.Food && order.OrderType != OrderType.Help)
|
||
return Results.BadRequest(new { code = 400, message = "该订单类型不支持修改商品总额" });
|
||
|
||
// 检查是否有待处理的改价申请
|
||
var pendingChange = await db.PriceChanges
|
||
.AnyAsync(pc => pc.OrderId == id && pc.Status == PriceChangeStatus.Pending);
|
||
if (pendingChange)
|
||
return Results.BadRequest(new { code = 400, message = "已有待处理的改价申请,请等待对方确认" });
|
||
|
||
// 获取原价
|
||
var originalPrice = changeType == PriceChangeType.Commission
|
||
? order.Commission
|
||
: order.GoodsAmount ?? 0;
|
||
|
||
if (request.NewPrice < 0)
|
||
return Results.BadRequest(new { code = 400, message = "新价格不能为负数" });
|
||
|
||
var priceChange = new PriceChange
|
||
{
|
||
OrderId = id,
|
||
InitiatorId = userId,
|
||
ChangeType = changeType,
|
||
OriginalPrice = originalPrice,
|
||
NewPrice = request.NewPrice,
|
||
Status = PriceChangeStatus.Pending,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
db.PriceChanges.Add(priceChange);
|
||
await db.SaveChangesAsync();
|
||
|
||
// 计算差额
|
||
var difference = request.NewPrice - originalPrice;
|
||
|
||
return Results.Created($"/api/orders/{id}/price-change/{priceChange.Id}", new PriceChangeResponse
|
||
{
|
||
Id = priceChange.Id,
|
||
OrderId = priceChange.OrderId,
|
||
InitiatorId = priceChange.InitiatorId,
|
||
ChangeType = priceChange.ChangeType.ToString(),
|
||
OriginalPrice = priceChange.OriginalPrice,
|
||
NewPrice = priceChange.NewPrice,
|
||
Difference = difference,
|
||
Status = priceChange.Status.ToString(),
|
||
CreatedAt = priceChange.CreatedAt
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// 响应改价(同意或拒绝)
|
||
app.MapPut("/api/orders/{id}/price-change/{changeId}", async (int id, int changeId, RespondPriceChangeRequest request, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var priceChange = await db.PriceChanges
|
||
.Include(pc => pc.Order)
|
||
.FirstOrDefaultAsync(pc => pc.Id == changeId && pc.OrderId == id);
|
||
|
||
if (priceChange == null)
|
||
return Results.NotFound(new { code = 404, message = "改价记录不存在" });
|
||
|
||
if (priceChange.Status != PriceChangeStatus.Pending)
|
||
return Results.BadRequest(new { code = 400, message = "该改价申请已处理" });
|
||
|
||
// 不能自己响应自己的改价申请
|
||
if (priceChange.InitiatorId == userId)
|
||
return Results.BadRequest(new { code = 400, message = "不能响应自己的改价申请" });
|
||
|
||
// 仅订单相关方可响应
|
||
var order = priceChange.Order!;
|
||
if (userId != order.OwnerId && userId != order.RunnerId)
|
||
return Results.BadRequest(new { code = 400, message = "仅订单相关方可响应改价" });
|
||
|
||
if (!Enum.TryParse<PriceChangeStatus>(request.Action, true, out var action)
|
||
|| (action != PriceChangeStatus.Accepted && action != PriceChangeStatus.Rejected))
|
||
return Results.BadRequest(new { code = 400, message = "操作不合法,仅支持 Accepted 或 Rejected" });
|
||
|
||
priceChange.Status = action;
|
||
|
||
// 同意改价时更新订单金额
|
||
if (action == PriceChangeStatus.Accepted)
|
||
{
|
||
if (priceChange.ChangeType == PriceChangeType.Commission)
|
||
{
|
||
order.Commission = priceChange.NewPrice;
|
||
}
|
||
else
|
||
{
|
||
order.GoodsAmount = priceChange.NewPrice;
|
||
}
|
||
|
||
// 重新计算支付总金额
|
||
if (order.OrderType == OrderType.Help || order.OrderType == OrderType.Purchase || order.OrderType == OrderType.Food)
|
||
{
|
||
order.TotalAmount = (order.GoodsAmount ?? 0) + order.Commission;
|
||
}
|
||
else
|
||
{
|
||
order.TotalAmount = order.Commission;
|
||
}
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
var difference = priceChange.NewPrice - priceChange.OriginalPrice;
|
||
|
||
return Results.Ok(new PriceChangeResponse
|
||
{
|
||
Id = priceChange.Id,
|
||
OrderId = priceChange.OrderId,
|
||
InitiatorId = priceChange.InitiatorId,
|
||
ChangeType = priceChange.ChangeType.ToString(),
|
||
OriginalPrice = priceChange.OriginalPrice,
|
||
NewPrice = priceChange.NewPrice,
|
||
Difference = difference,
|
||
Status = priceChange.Status.ToString(),
|
||
CreatedAt = priceChange.CreatedAt
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// ========== 收益与提现接口 ==========
|
||
|
||
// 获取收益概览(四种金额状态)
|
||
app.MapGet("/api/earnings", async (HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
// 先执行冻结解冻逻辑
|
||
await UnfreezeEarnings(db);
|
||
|
||
var earnings = await db.Earnings.Where(e => e.UserId == userId).ToListAsync();
|
||
|
||
var overview = new EarningsOverviewResponse
|
||
{
|
||
FrozenAmount = earnings.Where(e => e.Status == EarningStatus.Frozen).Sum(e => e.NetEarning),
|
||
AvailableAmount = earnings.Where(e => e.Status == EarningStatus.Available).Sum(e => e.NetEarning),
|
||
WithdrawingAmount = earnings.Where(e => e.Status == EarningStatus.Withdrawing).Sum(e => e.NetEarning),
|
||
WithdrawnAmount = earnings.Where(e => e.Status == EarningStatus.Withdrawn).Sum(e => e.NetEarning)
|
||
};
|
||
|
||
return Results.Ok(overview);
|
||
}).RequireAuthorization();
|
||
|
||
// 获取收益记录
|
||
app.MapGet("/api/earnings/records", async (HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var records = await db.Earnings
|
||
.Where(e => e.UserId == userId)
|
||
.Include(e => e.Order)
|
||
.OrderByDescending(e => e.CreatedAt)
|
||
.Select(e => new EarningRecordResponse
|
||
{
|
||
Id = e.Id,
|
||
OrderId = e.OrderId,
|
||
OrderNo = e.Order != null ? e.Order.OrderNo : "",
|
||
OrderType = e.Order != null ? e.Order.OrderType.ToString() : "",
|
||
GoodsAmount = e.GoodsAmount,
|
||
Commission = e.Commission,
|
||
PlatformFee = e.PlatformFee,
|
||
NetEarning = e.NetEarning,
|
||
Status = e.Status.ToString(),
|
||
CompletedAt = e.Order != null ? e.Order.CompletedAt : null,
|
||
CreatedAt = e.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(records);
|
||
}).RequireAuthorization();
|
||
|
||
// 获取提现记录
|
||
app.MapGet("/api/earnings/withdrawals", async (HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var withdrawals = await db.Withdrawals
|
||
.Where(w => w.UserId == userId)
|
||
.OrderByDescending(w => w.CreatedAt)
|
||
.Select(w => new WithdrawalRecordResponse
|
||
{
|
||
Id = w.Id,
|
||
Amount = w.Amount,
|
||
PaymentMethod = w.PaymentMethod.ToString(),
|
||
Status = w.Status.ToString(),
|
||
CreatedAt = w.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(withdrawals);
|
||
}).RequireAuthorization();
|
||
|
||
// 申请提现
|
||
app.MapPost("/api/earnings/withdraw", async (WithdrawRequest request, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
// 金额校验:最低 1 元
|
||
if (request.Amount < 1.0m)
|
||
return Results.BadRequest(new { code = 400, message = "提现金额不能低于1元" });
|
||
|
||
// 金额校验:小数点后最多 2 位
|
||
if (request.Amount != Math.Round(request.Amount, 2))
|
||
return Results.BadRequest(new { code = 400, message = "请输入正确的提现金额" });
|
||
|
||
// 收款方式校验
|
||
if (!Enum.TryParse<PaymentMethod>(request.PaymentMethod, true, out var paymentMethod) || !Enum.IsDefined(paymentMethod))
|
||
return Results.BadRequest(new { code = 400, message = "收款方式不合法" });
|
||
|
||
// 收款二维码校验
|
||
if (string.IsNullOrWhiteSpace(request.QrCodeImage))
|
||
return Results.BadRequest(new { code = 400, message = "收款二维码不能为空" });
|
||
|
||
// 先执行冻结解冻逻辑
|
||
await UnfreezeEarnings(db);
|
||
|
||
// 计算可提现余额
|
||
var availableAmount = await db.Earnings
|
||
.Where(e => e.UserId == userId && e.Status == EarningStatus.Available)
|
||
.SumAsync(e => e.NetEarning);
|
||
|
||
if (request.Amount > availableAmount)
|
||
return Results.BadRequest(new { code = 400, message = "超出可提现范围" });
|
||
|
||
// 创建提现记录
|
||
var withdrawal = new Withdrawal
|
||
{
|
||
UserId = userId,
|
||
Amount = request.Amount,
|
||
PaymentMethod = paymentMethod,
|
||
QrCodeImage = request.QrCodeImage,
|
||
Status = WithdrawalStatus.Pending,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
db.Withdrawals.Add(withdrawal);
|
||
|
||
// 将对应金额的收益标记为提现中(按创建时间先进先出)
|
||
var remainingAmount = request.Amount;
|
||
var availableEarnings = await db.Earnings
|
||
.Where(e => e.UserId == userId && e.Status == EarningStatus.Available)
|
||
.OrderBy(e => e.CreatedAt)
|
||
.ToListAsync();
|
||
|
||
foreach (var earning in availableEarnings)
|
||
{
|
||
if (remainingAmount <= 0) break;
|
||
|
||
if (earning.NetEarning <= remainingAmount)
|
||
{
|
||
earning.Status = EarningStatus.Withdrawing;
|
||
remainingAmount -= earning.NetEarning;
|
||
}
|
||
else
|
||
{
|
||
// 需要拆分收益记录:部分提现
|
||
earning.Status = EarningStatus.Withdrawing;
|
||
var originalNet = earning.NetEarning;
|
||
earning.NetEarning = remainingAmount;
|
||
|
||
// 创建剩余部分的新收益记录
|
||
var remainingEarning = new Earning
|
||
{
|
||
UserId = earning.UserId,
|
||
OrderId = earning.OrderId,
|
||
GoodsAmount = earning.GoodsAmount,
|
||
Commission = earning.Commission,
|
||
PlatformFee = 0,
|
||
NetEarning = originalNet - remainingAmount,
|
||
Status = EarningStatus.Available,
|
||
FrozenUntil = earning.FrozenUntil,
|
||
CreatedAt = earning.CreatedAt
|
||
};
|
||
db.Earnings.Add(remainingEarning);
|
||
remainingAmount = 0;
|
||
}
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Created($"/api/earnings/withdrawals/{withdrawal.Id}", new WithdrawalRecordResponse
|
||
{
|
||
Id = withdrawal.Id,
|
||
Amount = withdrawal.Amount,
|
||
PaymentMethod = withdrawal.PaymentMethod.ToString(),
|
||
Status = withdrawal.Status.ToString(),
|
||
CreatedAt = withdrawal.CreatedAt
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// ========== 佣金规则接口 ==========
|
||
|
||
// 获取佣金规则
|
||
app.MapGet("/api/admin/commission-rules", async (AppDbContext db) =>
|
||
{
|
||
var rules = await db.CommissionRules
|
||
.OrderBy(r => r.MinAmount)
|
||
.ToListAsync();
|
||
return Results.Ok(rules);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 更新佣金规则
|
||
app.MapPut("/api/admin/commission-rules", async (List<CommissionRule> rules, AppDbContext db) =>
|
||
{
|
||
// 清除旧规则
|
||
var existing = await db.CommissionRules.ToListAsync();
|
||
db.CommissionRules.RemoveRange(existing);
|
||
|
||
// 添加新规则
|
||
foreach (var rule in rules)
|
||
{
|
||
db.CommissionRules.Add(new CommissionRule
|
||
{
|
||
MinAmount = rule.MinAmount,
|
||
MaxAmount = rule.MaxAmount,
|
||
RateType = rule.RateType,
|
||
Rate = rule.Rate
|
||
});
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
return Results.Ok(await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync());
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 消息通知接口 ==========
|
||
|
||
// 获取未读消息数
|
||
app.MapGet("/api/messages/unread-count", async (HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
// 获取该用户可见的系统消息(按目标类型过滤)
|
||
var user = await db.Users.FindAsync(userId);
|
||
var visibleSystemMessages = await GetVisibleSystemMessages(db, userId, user);
|
||
var visibleSystemMessageIds = visibleSystemMessages.Select(m => m.Id).ToHashSet();
|
||
|
||
// 已读的系统消息 ID
|
||
var readSystemIds = await db.MessageReads
|
||
.Where(r => r.UserId == userId && r.MessageType == MessageType.System)
|
||
.Select(r => r.MessageId)
|
||
.ToListAsync();
|
||
|
||
var systemUnread = visibleSystemMessageIds.Count(id => !readSystemIds.Contains(id));
|
||
|
||
// 订单通知:用户相关的订单中状态变更过的订单数(简化实现:统计用户相关的已接单/已完成/已取消订单)
|
||
var orderNotifications = await db.Orders
|
||
.Where(o => (o.OwnerId == userId || o.RunnerId == userId) && o.Status != OrderStatus.Pending)
|
||
.Select(o => o.Id)
|
||
.ToListAsync();
|
||
|
||
var readOrderNotificationIds = await db.MessageReads
|
||
.Where(r => r.UserId == userId && r.MessageType == MessageType.OrderNotification)
|
||
.Select(r => r.MessageId)
|
||
.ToListAsync();
|
||
|
||
var orderNotificationUnread = orderNotifications.Count(id => !readOrderNotificationIds.Contains(id));
|
||
|
||
return Results.Ok(new UnreadCountResponse
|
||
{
|
||
SystemUnread = systemUnread,
|
||
OrderNotificationUnread = orderNotificationUnread,
|
||
TotalUnread = systemUnread + orderNotificationUnread
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// 获取系统消息列表
|
||
app.MapGet("/api/messages/system", async (HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
var user = await db.Users.FindAsync(userId);
|
||
var messages = await GetVisibleSystemMessages(db, userId, user);
|
||
|
||
// 获取已读记录
|
||
var readIds = await db.MessageReads
|
||
.Where(r => r.UserId == userId && r.MessageType == MessageType.System)
|
||
.Select(r => r.MessageId)
|
||
.ToListAsync();
|
||
|
||
var result = messages
|
||
.OrderByDescending(m => m.CreatedAt)
|
||
.Select(m => new SystemMessageResponse
|
||
{
|
||
Id = m.Id,
|
||
Title = m.Title,
|
||
ContentPreview = m.Content.Length > 100 ? m.Content[..100] + "…" : m.Content,
|
||
ThumbnailUrl = m.ThumbnailUrl,
|
||
CreatedAt = m.CreatedAt,
|
||
IsRead = readIds.Contains(m.Id)
|
||
})
|
||
.ToList();
|
||
|
||
// 标记所有为已读
|
||
foreach (var msg in messages.Where(m => !readIds.Contains(m.Id)))
|
||
{
|
||
db.MessageReads.Add(new MessageRead
|
||
{
|
||
UserId = userId,
|
||
MessageType = MessageType.System,
|
||
MessageId = msg.Id,
|
||
ReadAt = DateTime.UtcNow
|
||
});
|
||
}
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(result);
|
||
}).RequireAuthorization();
|
||
|
||
// 获取系统消息详情
|
||
app.MapGet("/api/messages/system/{id}", async (int id, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
|
||
var message = await db.SystemMessages.FindAsync(id);
|
||
if (message == null) return Results.NotFound(new { code = 404, message = "消息不存在" });
|
||
|
||
return Results.Ok(new SystemMessageDetailResponse
|
||
{
|
||
Id = message.Id,
|
||
Title = message.Title,
|
||
Content = message.Content,
|
||
ThumbnailUrl = message.ThumbnailUrl,
|
||
CreatedAt = message.CreatedAt
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// 获取订单通知列表
|
||
app.MapGet("/api/messages/order-notifications", async (string? category, HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
var userIdClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier);
|
||
if (userIdClaim == null) return Results.Unauthorized();
|
||
var userId = int.Parse(userIdClaim.Value);
|
||
|
||
// 查询用户相关的订单(状态已变更的)
|
||
var query = db.Orders
|
||
.Where(o => (o.OwnerId == userId || o.RunnerId == userId) && o.Status != OrderStatus.Pending);
|
||
|
||
// 按分类筛选
|
||
if (!string.IsNullOrEmpty(category))
|
||
{
|
||
query = category.ToLower() switch
|
||
{
|
||
"accepted" => query.Where(o => o.Status == OrderStatus.InProgress),
|
||
"completed" => query.Where(o => o.Status == OrderStatus.Completed),
|
||
"cancelled" => query.Where(o => o.Status == OrderStatus.Cancelled),
|
||
_ => query
|
||
};
|
||
}
|
||
|
||
var orders = await query
|
||
.OrderByDescending(o => o.AcceptedAt ?? o.CreatedAt)
|
||
.Select(o => new OrderNotificationResponse
|
||
{
|
||
Id = o.Id,
|
||
OrderNo = o.OrderNo,
|
||
OrderType = o.OrderType.ToString(),
|
||
Title = o.Status == OrderStatus.InProgress ? "订单已被接取"
|
||
: o.Status == OrderStatus.Completed ? "订单已完成"
|
||
: o.Status == OrderStatus.Cancelled ? "订单已取消"
|
||
: o.Status == OrderStatus.WaitConfirm ? "订单待确认"
|
||
: o.Status == OrderStatus.Appealing ? "订单申诉中"
|
||
: "订单状态变更",
|
||
ItemName = o.ItemName,
|
||
Status = o.Status.ToString(),
|
||
CreatedAt = o.AcceptedAt ?? o.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
// 标记订单通知为已读
|
||
var orderIds = orders.Select(o => o.Id).ToList();
|
||
var readIds = await db.MessageReads
|
||
.Where(r => r.UserId == userId && r.MessageType == MessageType.OrderNotification && orderIds.Contains(r.MessageId))
|
||
.Select(r => r.MessageId)
|
||
.ToListAsync();
|
||
|
||
foreach (var orderId in orderIds.Where(id => !readIds.Contains(id)))
|
||
{
|
||
db.MessageReads.Add(new MessageRead
|
||
{
|
||
UserId = userId,
|
||
MessageType = MessageType.OrderNotification,
|
||
MessageId = orderId,
|
||
ReadAt = DateTime.UtcNow
|
||
});
|
||
}
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(orders);
|
||
}).RequireAuthorization();
|
||
|
||
// 管理端发布系统通知
|
||
app.MapPost("/api/admin/notifications", async (CreateNotificationRequest request, AppDbContext db) =>
|
||
{
|
||
// 校验
|
||
var errors = new List<object>();
|
||
if (string.IsNullOrWhiteSpace(request.Title))
|
||
errors.Add(new { field = "title", message = "标题不能为空" });
|
||
if (string.IsNullOrWhiteSpace(request.Content))
|
||
errors.Add(new { field = "content", message = "正文不能为空" });
|
||
if (errors.Count > 0)
|
||
return Results.BadRequest(new { code = 400, message = "校验失败", errors });
|
||
|
||
if (!Enum.TryParse<MessageTargetType>(request.TargetType, true, out var targetType) || !Enum.IsDefined(targetType))
|
||
return Results.BadRequest(new { code = 400, message = "目标类型不合法" });
|
||
|
||
var message = new SystemMessage
|
||
{
|
||
Title = request.Title,
|
||
Content = request.Content,
|
||
ThumbnailUrl = request.ThumbnailUrl,
|
||
TargetType = targetType,
|
||
TargetUserIds = request.TargetUserIds != null
|
||
? System.Text.Json.JsonSerializer.Serialize(request.TargetUserIds)
|
||
: null,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
|
||
db.SystemMessages.Add(message);
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Created($"/api/admin/notifications/{message.Id}", new SystemMessageDetailResponse
|
||
{
|
||
Id = message.Id,
|
||
Title = message.Title,
|
||
Content = message.Content,
|
||
ThumbnailUrl = message.ThumbnailUrl,
|
||
CreatedAt = message.CreatedAt
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 跑腿管理接口 ==========
|
||
|
||
// 管理端获取跑腿列表
|
||
app.MapGet("/api/admin/runners", async (AppDbContext db) =>
|
||
{
|
||
var runners = await db.Users
|
||
.Where(u => db.RunnerCertifications.Any(c => c.UserId == u.Id && c.Status == CertificationStatus.Approved))
|
||
.Select(u => new
|
||
{
|
||
u.Id,
|
||
u.Nickname,
|
||
u.Phone,
|
||
u.RunnerScore,
|
||
u.IsBanned,
|
||
u.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(runners);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端封禁/解封跑腿
|
||
app.MapPut("/api/admin/runners/{id}/ban", async (int id, BanRunnerRequest request, AppDbContext db) =>
|
||
{
|
||
var user = await db.Users.FindAsync(id);
|
||
if (user == null)
|
||
return Results.NotFound(new { code = 404, message = "用户不存在" });
|
||
|
||
// 确认该用户是已认证的跑腿
|
||
var hasCert = await db.RunnerCertifications
|
||
.AnyAsync(c => c.UserId == id && c.Status == CertificationStatus.Approved);
|
||
if (!hasCert)
|
||
return Results.BadRequest(new { code = 400, message = "该用户不是已认证的跑腿" });
|
||
|
||
user.IsBanned = request.IsBanned;
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new
|
||
{
|
||
user.Id,
|
||
user.Nickname,
|
||
user.Phone,
|
||
user.RunnerScore,
|
||
user.IsBanned
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 管理端订单列表接口 ==========
|
||
|
||
// 管理端获取订单列表
|
||
app.MapGet("/api/admin/orders", async (string? status, string? orderType, AppDbContext db) =>
|
||
{
|
||
var query = db.Orders.AsQueryable();
|
||
|
||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<OrderStatus>(status, true, out var s))
|
||
query = query.Where(o => o.Status == s);
|
||
|
||
if (!string.IsNullOrEmpty(orderType) && Enum.TryParse<OrderType>(orderType, true, out var t))
|
||
query = query.Where(o => o.OrderType == t);
|
||
|
||
var orders = await query
|
||
.OrderByDescending(o => o.CreatedAt)
|
||
.Select(o => new
|
||
{
|
||
o.Id,
|
||
o.OrderNo,
|
||
o.OwnerId,
|
||
o.RunnerId,
|
||
OrderType = o.OrderType.ToString(),
|
||
Status = o.Status.ToString(),
|
||
o.ItemName,
|
||
o.DeliveryLocation,
|
||
o.Commission,
|
||
o.GoodsAmount,
|
||
o.TotalAmount,
|
||
o.CreatedAt,
|
||
o.AcceptedAt,
|
||
o.CompletedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(orders);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// ========== 订单申诉管理接口 ==========
|
||
|
||
// 管理端将订单状态改为申诉中
|
||
app.MapPost("/api/admin/orders/{id}/appeal", async (int id, AppDbContext db) =>
|
||
{
|
||
var order = await db.Orders.FindAsync(id);
|
||
if (order == null)
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
|
||
if (order.Status != OrderStatus.InProgress && order.Status != OrderStatus.WaitConfirm)
|
||
return Results.BadRequest(new { code = 400, message = "当前订单状态不支持申诉" });
|
||
|
||
order.Status = OrderStatus.Appealing;
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new { id = order.Id, orderNo = order.OrderNo, status = order.Status.ToString() });
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端处理申诉结果
|
||
app.MapPost("/api/admin/orders/{id}/appeal/resolve", async (int id, ResolveAppealRequest request, AppDbContext db) =>
|
||
{
|
||
var order = await db.Orders.FindAsync(id);
|
||
if (order == null)
|
||
return Results.NotFound(new { code = 404, message = "订单不存在" });
|
||
|
||
if (order.Status != OrderStatus.Appealing)
|
||
return Results.BadRequest(new { code = 400, message = "仅申诉中的订单可处理" });
|
||
|
||
if (string.IsNullOrWhiteSpace(request.Result))
|
||
return Results.BadRequest(new { code = 400, message = "处理结果不能为空" });
|
||
|
||
if (!Enum.TryParse<OrderStatus>(request.NewStatus, true, out var newStatus)
|
||
|| (newStatus != OrderStatus.Completed && newStatus != OrderStatus.Cancelled))
|
||
return Results.BadRequest(new { code = 400, message = "目标状态不合法,仅支持 Completed 或 Cancelled" });
|
||
|
||
// 创建申诉记录
|
||
var appeal = new Appeal
|
||
{
|
||
OrderId = id,
|
||
Result = request.Result,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
db.Appeals.Add(appeal);
|
||
|
||
// 更新订单状态
|
||
order.Status = newStatus;
|
||
if (newStatus == OrderStatus.Completed && order.CompletedAt == null)
|
||
{
|
||
order.CompletedAt = DateTime.UtcNow;
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new
|
||
{
|
||
id = order.Id,
|
||
orderNo = order.OrderNo,
|
||
status = order.Status.ToString(),
|
||
appealId = appeal.Id,
|
||
appealResult = appeal.Result,
|
||
appealCreatedAt = appeal.CreatedAt
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 获取订单申诉记录
|
||
app.MapGet("/api/orders/{id}/appeals", async (int id, AppDbContext db) =>
|
||
{
|
||
var appeals = await db.Appeals
|
||
.Where(a => a.OrderId == id)
|
||
.OrderByDescending(a => a.CreatedAt)
|
||
.Select(a => new
|
||
{
|
||
a.Id,
|
||
a.OrderId,
|
||
a.Result,
|
||
a.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(appeals);
|
||
}).RequireAuthorization();
|
||
|
||
// ========== 图片上传接口 ==========
|
||
|
||
app.MapPost("/api/upload/image", async (IFormFile file, IConfiguration config) =>
|
||
{
|
||
if (file == null || file.Length == 0)
|
||
return Results.BadRequest(new { code = 400, message = "请选择要上传的图片" });
|
||
|
||
// 文件大小校验(默认 5MB)
|
||
var maxSize = config.GetValue<long>("Upload:MaxFileSizeBytes", 5242880);
|
||
if (file.Length > maxSize)
|
||
return Results.BadRequest(new { code = 400, message = $"图片大小不能超过 {maxSize / 1024 / 1024}MB" });
|
||
|
||
// 文件扩展名校验
|
||
var allowedExtensions = config.GetSection("Upload:AllowedExtensions").Get<string[]>()
|
||
?? new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
|
||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||
if (!allowedExtensions.Contains(ext))
|
||
return Results.BadRequest(new { code = 400, message = $"不支持的图片格式,仅支持 {string.Join(", ", allowedExtensions)}" });
|
||
|
||
// 保存文件
|
||
var uploadDir = config.GetValue<string>("Upload:Directory") ?? "uploads";
|
||
var fullDir = Path.Combine(Directory.GetCurrentDirectory(), uploadDir);
|
||
Directory.CreateDirectory(fullDir);
|
||
|
||
var fileName = $"{Guid.NewGuid()}{ext}";
|
||
var filePath = Path.Combine(fullDir, fileName);
|
||
|
||
using (var stream = new FileStream(filePath, FileMode.Create))
|
||
{
|
||
await file.CopyToAsync(stream);
|
||
}
|
||
|
||
var url = $"/{uploadDir}/{fileName}";
|
||
return Results.Ok(new UploadImageResponse { Url = url });
|
||
}).RequireAuthorization()
|
||
.DisableAntiforgery();
|
||
|
||
// 静态文件服务(上传的图片)
|
||
var uploadPath = Path.Combine(Directory.GetCurrentDirectory(),
|
||
builder.Configuration.GetValue<string>("Upload:Directory") ?? "uploads");
|
||
Directory.CreateDirectory(uploadPath);
|
||
app.UseStaticFiles(new StaticFileOptions
|
||
{
|
||
FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadPath),
|
||
RequestPath = "/" + (builder.Configuration.GetValue<string>("Upload:Directory") ?? "uploads")
|
||
});
|
||
|
||
// ========== 系统配置接口 ==========
|
||
|
||
// 获取客服二维码
|
||
app.MapGet("/api/config/qrcode", async (AppDbContext db) =>
|
||
{
|
||
var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "qrcode");
|
||
return Results.Ok(new ConfigResponse
|
||
{
|
||
Key = "qrcode",
|
||
Value = config?.Value ?? "",
|
||
UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue
|
||
});
|
||
});
|
||
|
||
// 获取用户协议
|
||
app.MapGet("/api/config/agreement", async (AppDbContext db) =>
|
||
{
|
||
var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "agreement");
|
||
return Results.Ok(new ConfigResponse
|
||
{
|
||
Key = "agreement",
|
||
Value = config?.Value ?? "",
|
||
UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue
|
||
});
|
||
});
|
||
|
||
// 获取隐私政策
|
||
app.MapGet("/api/config/privacy", async (AppDbContext db) =>
|
||
{
|
||
var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "privacy");
|
||
return Results.Ok(new ConfigResponse
|
||
{
|
||
Key = "privacy",
|
||
Value = config?.Value ?? "",
|
||
UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue
|
||
});
|
||
});
|
||
|
||
// 获取提现说明
|
||
app.MapGet("/api/config/withdrawal-guide", async (AppDbContext db) =>
|
||
{
|
||
var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "withdrawal_guide");
|
||
return Results.Ok(new ConfigResponse
|
||
{
|
||
Key = "withdrawal_guide",
|
||
Value = config?.Value ?? "",
|
||
UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue
|
||
});
|
||
});
|
||
|
||
// 管理端获取指定配置
|
||
app.MapGet("/api/admin/config/{key}", async (string key, AppDbContext db) =>
|
||
{
|
||
var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == key);
|
||
return Results.Ok(new ConfigResponse
|
||
{
|
||
Key = key,
|
||
Value = config?.Value ?? "",
|
||
UpdatedAt = config?.UpdatedAt ?? DateTime.MinValue
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端更新系统配置
|
||
app.MapPut("/api/admin/config/{key}", async (string key, UpdateConfigRequest request, AppDbContext db) =>
|
||
{
|
||
// 允许的配置键白名单
|
||
var allowedKeys = new HashSet<string>
|
||
{
|
||
"qrcode", "agreement", "privacy", "withdrawal_guide", "freeze_days"
|
||
};
|
||
|
||
if (!allowedKeys.Contains(key))
|
||
return Results.BadRequest(new { code = 400, message = $"不支持的配置键: {key}" });
|
||
|
||
var config = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == key);
|
||
if (config == null)
|
||
{
|
||
config = new SystemConfig
|
||
{
|
||
Key = key,
|
||
Value = request.Value,
|
||
UpdatedAt = DateTime.UtcNow
|
||
};
|
||
db.SystemConfigs.Add(config);
|
||
}
|
||
else
|
||
{
|
||
config.Value = request.Value;
|
||
config.UpdatedAt = DateTime.UtcNow;
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
|
||
return Results.Ok(new ConfigResponse
|
||
{
|
||
Key = config.Key,
|
||
Value = config.Value,
|
||
UpdatedAt = config.UpdatedAt
|
||
});
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理员账号密码登录接口
|
||
app.MapPost("/api/admin/auth/login", async (
|
||
AdminLoginRequest request,
|
||
JwtService jwtService,
|
||
AppDbContext db,
|
||
IConfiguration configuration) =>
|
||
{
|
||
// 从配置读取管理员凭据
|
||
var adminUsername = configuration["Admin:Username"] ?? "admin";
|
||
var adminPassword = configuration["Admin:Password"] ?? "admin123";
|
||
|
||
if (request.Username != adminUsername || request.Password != adminPassword)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "账号或密码错误" });
|
||
}
|
||
|
||
// 查找或创建管理员用户
|
||
var admin = await db.Users.FirstOrDefaultAsync(u => u.Role == UserRole.Admin);
|
||
if (admin == null)
|
||
{
|
||
admin = new User
|
||
{
|
||
OpenId = "admin",
|
||
Phone = "admin",
|
||
Nickname = "管理员",
|
||
Role = UserRole.Admin,
|
||
CreatedAt = DateTime.UtcNow
|
||
};
|
||
db.Users.Add(admin);
|
||
await db.SaveChangesAsync();
|
||
}
|
||
|
||
var token = jwtService.GenerateToken(admin.Id, admin.Role.ToString());
|
||
|
||
return Results.Ok(new { token });
|
||
});
|
||
|
||
app.Run();
|
||
|
||
// Banner 请求校验辅助方法
|
||
static List<object> ValidateBannerRequest(BannerRequest request)
|
||
{
|
||
var errors = new List<object>();
|
||
if (string.IsNullOrWhiteSpace(request.ImageUrl))
|
||
errors.Add(new { field = "imageUrl", message = "图片地址不能为空" });
|
||
if (string.IsNullOrWhiteSpace(request.LinkUrl))
|
||
errors.Add(new { field = "linkUrl", message = "链接地址不能为空" });
|
||
if (string.IsNullOrWhiteSpace(request.LinkType))
|
||
errors.Add(new { field = "linkType", message = "链接类型不能为空" });
|
||
else if (!Enum.TryParse<LinkType>(request.LinkType, true, out var parsed) || !Enum.IsDefined(parsed))
|
||
errors.Add(new { field = "linkType", message = "链接类型不合法" });
|
||
return errors;
|
||
}
|
||
|
||
// 门店请求校验辅助方法
|
||
static List<object> ValidateShopRequest(ShopRequest request)
|
||
{
|
||
var errors = new List<object>();
|
||
if (string.IsNullOrWhiteSpace(request.Name))
|
||
errors.Add(new { field = "name", message = "门店名称不能为空" });
|
||
if (string.IsNullOrWhiteSpace(request.Photo))
|
||
errors.Add(new { field = "photo", message = "门店照片不能为空" });
|
||
if (string.IsNullOrWhiteSpace(request.Location))
|
||
errors.Add(new { field = "location", message = "门店位置不能为空" });
|
||
if (string.IsNullOrWhiteSpace(request.PackingFeeType))
|
||
errors.Add(new { field = "packingFeeType", message = "打包费类型不能为空" });
|
||
if (request.PackingFeeAmount < 0)
|
||
errors.Add(new { field = "packingFeeAmount", message = "打包费金额不能为负数" });
|
||
return errors;
|
||
}
|
||
|
||
// 菜品请求校验辅助方法
|
||
static List<object> ValidateDishRequest(DishRequest request)
|
||
{
|
||
var errors = new List<object>();
|
||
if (string.IsNullOrWhiteSpace(request.Name))
|
||
errors.Add(new { field = "name", message = "菜品名称不能为空" });
|
||
if (string.IsNullOrWhiteSpace(request.Photo))
|
||
errors.Add(new { field = "photo", message = "菜品照片不能为空" });
|
||
if (request.Price < 0)
|
||
errors.Add(new { field = "price", message = "菜品价格不能为负数" });
|
||
return errors;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算单个门店的打包费
|
||
/// Fixed 模式:固定金额,不论菜品数量
|
||
/// PerItem 模式:菜品总份数 × 单份打包费
|
||
/// </summary>
|
||
static decimal CalculatePackingFee(PackingFeeType feeType, decimal feeAmount, int totalQuantity)
|
||
{
|
||
return feeType switch
|
||
{
|
||
PackingFeeType.Fixed => feeAmount,
|
||
PackingFeeType.PerItem => feeAmount * totalQuantity,
|
||
_ => 0m
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 计算平台佣金分成
|
||
/// 根据佣金区间规则计算:百分比类型按比例,固定类型扣除固定金额
|
||
/// </summary>
|
||
static decimal CalculatePlatformFee(decimal commission, List<CommissionRule> rules)
|
||
{
|
||
// 查找匹配的佣金区间
|
||
var matchedRule = rules
|
||
.Where(r => commission >= r.MinAmount && (r.MaxAmount == null || commission <= r.MaxAmount))
|
||
.FirstOrDefault();
|
||
|
||
if (matchedRule == null) return 0m;
|
||
|
||
return matchedRule.RateType switch
|
||
{
|
||
CommissionRateType.Percentage => Math.Round(commission * matchedRule.Rate / 100m, 2),
|
||
CommissionRateType.Fixed => Math.Min(matchedRule.Rate, commission),
|
||
_ => 0m
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取用户可见的系统消息(按目标类型过滤)
|
||
/// </summary>
|
||
static async Task<List<SystemMessage>> GetVisibleSystemMessages(AppDbContext db, int userId, User? user)
|
||
{
|
||
var allMessages = await db.SystemMessages.ToListAsync();
|
||
return allMessages.Where(m =>
|
||
{
|
||
if (m.TargetType == MessageTargetType.All) return true;
|
||
if (m.TargetType == MessageTargetType.OrderUser)
|
||
return user != null && user.Role != UserRole.Runner;
|
||
if (m.TargetType == MessageTargetType.RunnerUser)
|
||
return user != null && (user.Role == UserRole.Runner || user.Role == UserRole.Admin);
|
||
if (m.TargetType == MessageTargetType.Specific && m.TargetUserIds != null)
|
||
{
|
||
try
|
||
{
|
||
var ids = System.Text.Json.JsonSerializer.Deserialize<List<int>>(m.TargetUserIds);
|
||
return ids != null && ids.Contains(userId);
|
||
}
|
||
catch { return false; }
|
||
}
|
||
return false;
|
||
}).ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 解冻已到期的收益记录:将冻结期满的收益从 Frozen 变为 Available
|
||
/// </summary>
|
||
static async Task UnfreezeEarnings(AppDbContext db)
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
var frozenEarnings = await db.Earnings
|
||
.Where(e => e.Status == EarningStatus.Frozen && e.FrozenUntil <= now)
|
||
.ToListAsync();
|
||
|
||
foreach (var earning in frozenEarnings)
|
||
{
|
||
earning.Status = EarningStatus.Available;
|
||
}
|
||
|
||
if (frozenEarnings.Count > 0)
|
||
{
|
||
await db.SaveChangesAsync();
|
||
}
|
||
}
|
||
|
||
// 用于集成测试访问
|
||
public partial class Program { }
|