All checks were successful
continuous-integration/drone/push Build is passing
1009 lines
44 KiB
C#
1009 lines
44 KiB
C#
using CampusErrand.Data;
|
||
using CampusErrand.Models;
|
||
using CampusErrand.Models.Dtos;
|
||
using CampusErrand.Services;
|
||
using CampusErrand.Helpers;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace CampusErrand.Endpoints;
|
||
|
||
public static class OrderEndpoints
|
||
{
|
||
public static void MapOrderEndpoints(this WebApplication app)
|
||
{
|
||
// 创建订单
|
||
app.MapPost("/api/orders", async (CreateOrderRequest request, HttpContext httpContext, AppDbContext db, WxPayService wxPay) =>
|
||
{
|
||
// 获取当前用户 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 = "订单类型不合法" });
|
||
}
|
||
|
||
// 佣金校验:读取最低佣金配置
|
||
var minCommissionConfig = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "min_commission");
|
||
var minCommission = 1.0m;
|
||
if (minCommissionConfig != null && decimal.TryParse(minCommissionConfig.Value, out var configMin))
|
||
minCommission = configMin;
|
||
|
||
if (request.Commission < minCommission)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = $"跑腿佣金不可低于{minCommission}元" });
|
||
}
|
||
|
||
// 佣金校验:小数点后最多 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 += BusinessHelpers.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();
|
||
}
|
||
|
||
// 调用微信支付 JSAPI 下单
|
||
var user = await db.Users.FindAsync(userId);
|
||
if (user == null || string.IsNullOrEmpty(user.OpenId))
|
||
return Results.BadRequest(new { code = 400, message = "用户信息异常,无法发起支付" });
|
||
|
||
var notifyUrl = $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/pay/notify";
|
||
var typeLabel = orderType switch
|
||
{
|
||
OrderType.Pickup => "代取",
|
||
OrderType.Delivery => "代送",
|
||
OrderType.Help => "万能帮",
|
||
OrderType.Purchase => "代购",
|
||
OrderType.Food => "美食街",
|
||
_ => "跑腿"
|
||
};
|
||
var payResult = await wxPay.CreateJsapiOrder(order.OrderNo, order.TotalAmount, $"校园跑腿-{typeLabel}", user.OpenId, notifyUrl);
|
||
|
||
if (!payResult.Success)
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "支付下单失败", detail = payResult.ErrorMessage });
|
||
}
|
||
|
||
return Results.Created($"/api/orders/{order.Id}", new
|
||
{
|
||
Id = order.Id,
|
||
OrderNo = order.OrderNo,
|
||
TotalAmount = order.TotalAmount,
|
||
PaymentParams = new
|
||
{
|
||
timeStamp = payResult.PaymentParams!.TimeStamp,
|
||
nonceStr = payResult.PaymentParams.NonceStr,
|
||
package_ = payResult.PaymentParams.Package,
|
||
signType = payResult.PaymentParams.SignType,
|
||
paySign = payResult.PaymentParams.PaySign
|
||
}
|
||
});
|
||
}).RequireAuthorization();
|
||
|
||
// 获取当前用户的聊天订单列表(已接单的订单,按时间排序)
|
||
app.MapGet("/api/orders/chat-list", async (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 rawOrders = await db.Orders
|
||
.Where(o => o.RunnerId != null &&
|
||
(o.OwnerId == currentUserId || o.RunnerId == currentUserId) &&
|
||
o.Status != OrderStatus.Cancelled)
|
||
.OrderByDescending(o => o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt)
|
||
.Select(o => new
|
||
{
|
||
OrderId = o.Id,
|
||
o.OrderNo,
|
||
OrderType = o.OrderType.ToString(),
|
||
o.ItemName,
|
||
Status = o.Status.ToString(),
|
||
o.Commission,
|
||
o.OwnerId,
|
||
o.RunnerId,
|
||
OwnerNickname = o.Owner!.Nickname,
|
||
OwnerAvatar = o.Owner!.AvatarUrl,
|
||
RunnerNickname = o.Runner!.Nickname,
|
||
RunnerAvatar = o.Runner!.AvatarUrl,
|
||
LastTime = o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
var orders = rawOrders.Select(o =>
|
||
{
|
||
var isOwner = o.OwnerId == currentUserId;
|
||
var targetId = isOwner ? o.RunnerId : (int?)o.OwnerId;
|
||
var nickname = isOwner ? o.RunnerNickname : o.OwnerNickname;
|
||
var avatar = isOwner ? o.RunnerAvatar : o.OwnerAvatar;
|
||
if (string.IsNullOrWhiteSpace(nickname))
|
||
nickname = $"用户{targetId}";
|
||
return new
|
||
{
|
||
o.OrderId,
|
||
o.OrderNo,
|
||
o.OrderType,
|
||
o.ItemName,
|
||
o.Status,
|
||
o.Commission,
|
||
TargetUserId = targetId,
|
||
TargetNickname = nickname,
|
||
TargetAvatar = avatar ?? "",
|
||
o.LastTime
|
||
};
|
||
});
|
||
|
||
return Results.Ok(orders);
|
||
}).RequireAuthorization();
|
||
|
||
// 根据对方用户ID查找最近的关联订单(用于聊天页显示订单卡片)
|
||
app.MapGet("/api/orders/by-chat-user/{targetUserId}", async (int targetUserId, 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
|
||
.Where(o =>
|
||
(o.OwnerId == currentUserId && o.RunnerId == targetUserId) ||
|
||
(o.OwnerId == targetUserId && o.RunnerId == currentUserId))
|
||
.OrderByDescending(o => o.CreatedAt)
|
||
.FirstOrDefaultAsync();
|
||
|
||
if (order == null)
|
||
return Results.Ok(new { found = false });
|
||
|
||
return Results.Ok(new
|
||
{
|
||
found = true,
|
||
orderId = order.Id
|
||
});
|
||
}).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).ThenInclude(fi => fi.Shop)
|
||
.Include(o => o.FoodOrderItems).ThenInclude(fi => fi.Dish)
|
||
.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;
|
||
}
|
||
|
||
// 跑腿手机号:从认证记录中获取,仅单主可见
|
||
string? runnerPhone = null;
|
||
if (currentUserId == order.OwnerId && order.RunnerId != null)
|
||
{
|
||
var cert = await db.RunnerCertifications
|
||
.Where(c => c.UserId == order.RunnerId && c.Status == CertificationStatus.Approved)
|
||
.OrderByDescending(c => c.CreatedAt)
|
||
.FirstOrDefaultAsync();
|
||
runnerPhone = cert?.Phone;
|
||
}
|
||
|
||
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,
|
||
RunnerPhone = runnerPhone
|
||
};
|
||
|
||
// 查询佣金抽成信息(有 Earning 记录用实际值,否则实时计算预估值)
|
||
if (order.RunnerId.HasValue)
|
||
{
|
||
var earning = await db.Earnings
|
||
.Where(e => e.OrderId == order.Id)
|
||
.FirstOrDefaultAsync();
|
||
if (earning != null)
|
||
{
|
||
response.PlatformFee = earning.PlatformFee;
|
||
response.NetEarning = earning.NetEarning;
|
||
}
|
||
else
|
||
{
|
||
// 未完成订单:实时计算预估抽成
|
||
var rules = await db.CommissionRules.OrderBy(r => r.MinAmount).ToListAsync();
|
||
var fee = BusinessHelpers.CalculatePlatformFee(order.Commission, rules);
|
||
response.PlatformFee = fee;
|
||
response.NetEarning = order.Commission - fee;
|
||
}
|
||
}
|
||
|
||
// 美食街订单附带菜品详情
|
||
if (order.OrderType == OrderType.Food && order.FoodOrderItems.Count > 0)
|
||
{
|
||
response.FoodItems = order.FoodOrderItems.Select(fi => new FoodOrderItemResponse
|
||
{
|
||
Id = fi.Id,
|
||
ShopId = fi.ShopId,
|
||
ShopName = fi.Shop?.Name ?? "未知门店",
|
||
DishId = fi.DishId,
|
||
DishName = fi.Dish?.Name ?? "未知菜品",
|
||
DishPhoto = fi.Dish?.Photo,
|
||
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() switch
|
||
{
|
||
"commission" => query.OrderByDescending(o => o.Commission),
|
||
"distance" => query.OrderByDescending(o => o.CreatedAt), // 距离排序需地址坐标,暂按时间排序
|
||
_ => query.OrderByDescending(o => o.CreatedAt) // 默认按时间排序
|
||
};
|
||
|
||
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);
|
||
}).AllowAnonymous();
|
||
|
||
// 接单
|
||
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/pay/notify", async (HttpContext httpContext, AppDbContext db) =>
|
||
{
|
||
// 读取请求体
|
||
using var reader = new StreamReader(httpContext.Request.Body);
|
||
var body = await reader.ReadToEndAsync();
|
||
|
||
var serialNo = httpContext.Request.Headers["Wechatpay-Serial"].ToString();
|
||
var timestamp = httpContext.Request.Headers["Wechatpay-Timestamp"].ToString();
|
||
var nonce = httpContext.Request.Headers["Wechatpay-Nonce"].ToString();
|
||
var signature = httpContext.Request.Headers["Wechatpay-Signature"].ToString();
|
||
|
||
var wxPay = httpContext.RequestServices.GetRequiredService<WxPayService>();
|
||
var result = wxPay.VerifyAndDecryptNotify(serialNo, timestamp, nonce, signature, body);
|
||
|
||
if (result == null)
|
||
{
|
||
return Results.Json(new { code = "FAIL", message = "解密失败" }, statusCode: 500);
|
||
}
|
||
|
||
if (result.TradeState == "SUCCESS")
|
||
{
|
||
// 支付成功,更新订单状态(如果还是 Pending 说明支付完成)
|
||
var order = await db.Orders.FirstOrDefaultAsync(o => o.OrderNo == result.OrderNo);
|
||
if (order != null)
|
||
{
|
||
Console.WriteLine($"[微信支付] 支付成功: {result.OrderNo}, 金额: {result.TotalAmount}分");
|
||
}
|
||
}
|
||
|
||
return Results.Json(new { code = "SUCCESS", message = "OK" });
|
||
}).AllowAnonymous();
|
||
|
||
// 取消订单(仅单主可取消待接单订单)
|
||
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 = BusinessHelpers.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.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.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/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();
|
||
}
|
||
}
|