All checks were successful
continuous-integration/drone/push Build is passing
350 lines
15 KiB
C#
350 lines
15 KiB
C#
using CampusErrand.Data;
|
||
using CampusErrand.Models;
|
||
using CampusErrand.Models.Dtos;
|
||
using CampusErrand.Helpers;
|
||
using CampusErrand.Services;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace CampusErrand.Endpoints;
|
||
|
||
public static class EarningEndpoints
|
||
{
|
||
public static void MapEarningEndpoints(this WebApplication app)
|
||
{
|
||
// 获取收益概览(四种金额状态)
|
||
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 BusinessHelpers.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);
|
||
|
||
// 金额校验:读取最低提现金额配置
|
||
var minWithdrawalConfig = await db.SystemConfigs.FirstOrDefaultAsync(c => c.Key == "min_withdrawal");
|
||
var minWithdrawal = 1.0m;
|
||
if (minWithdrawalConfig != null && decimal.TryParse(minWithdrawalConfig.Value, out var configMinW))
|
||
minWithdrawal = configMinW;
|
||
|
||
if (request.Amount < minWithdrawal)
|
||
return Results.BadRequest(new { code = 400, message = $"提现金额不能低于{minWithdrawal}元" });
|
||
|
||
// 金额校验:小数点后最多 2 位
|
||
if (request.Amount != Math.Round(request.Amount, 2))
|
||
return Results.BadRequest(new { code = 400, message = "请输入正确的提现金额" });
|
||
|
||
// 收款方式默认微信零钱
|
||
var paymentMethod = PaymentMethod.WeChat;
|
||
if (!string.IsNullOrEmpty(request.PaymentMethod))
|
||
Enum.TryParse<PaymentMethod>(request.PaymentMethod, true, out paymentMethod);
|
||
|
||
// 先执行冻结解冻逻辑
|
||
await BusinessHelpers.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/withdrawals", async (string? status, AppDbContext db) =>
|
||
{
|
||
var query = db.Withdrawals
|
||
.Include(w => w.User)
|
||
.AsQueryable();
|
||
|
||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<WithdrawalStatus>(status, out var s))
|
||
query = query.Where(w => w.Status == s);
|
||
|
||
var list = await query
|
||
.OrderByDescending(w => w.CreatedAt)
|
||
.Select(w => new
|
||
{
|
||
w.Id,
|
||
w.UserId,
|
||
UserNickname = w.User!.Nickname ?? ("用户" + w.UserId),
|
||
w.Amount,
|
||
PaymentMethod = w.PaymentMethod.ToString(),
|
||
w.QrCodeImage,
|
||
Status = w.Status.ToString(),
|
||
w.CreatedAt,
|
||
w.ProcessedAt
|
||
})
|
||
.ToListAsync();
|
||
|
||
return Results.Ok(list);
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 管理端审核提现(通过/拒绝)
|
||
app.MapPut("/api/admin/withdrawals/{id}", async (int id, AdminWithdrawalRequest request, AppDbContext db, WxPayService wxPay) =>
|
||
{
|
||
var withdrawal = await db.Withdrawals.FindAsync(id);
|
||
if (withdrawal == null)
|
||
return Results.NotFound(new { code = 404, message = "提现记录不存在" });
|
||
|
||
if (withdrawal.Status != WithdrawalStatus.Pending && withdrawal.Status != WithdrawalStatus.Processing)
|
||
return Results.BadRequest(new { code = 400, message = "该提现记录已处理完毕" });
|
||
|
||
if (request.Action == "approve")
|
||
{
|
||
// 先调用微信商家转账到零钱
|
||
var user = await db.Users.FindAsync(withdrawal.UserId);
|
||
if (user == null || string.IsNullOrEmpty(user.OpenId))
|
||
return Results.BadRequest(new { code = 400, message = "用户信息异常,无法转账" });
|
||
|
||
var amountFen = (int)(withdrawal.Amount * 100);
|
||
var batchNo = $"W{withdrawal.Id}_{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||
var detailNo = $"D{withdrawal.Id}_{DateTime.UtcNow:yyyyMMddHHmmss}";
|
||
var (transferSuccess, transferError) = await wxPay.TransferToWallet(batchNo, detailNo, user.OpenId, amountFen, "跑腿提现到账");
|
||
|
||
if (!transferSuccess)
|
||
{
|
||
Console.WriteLine($"[提现] 转账失败: {transferError}");
|
||
return Results.BadRequest(new { code = 400, message = $"转账失败,请稍后重试", detail = transferError });
|
||
}
|
||
|
||
withdrawal.Status = WithdrawalStatus.Completed;
|
||
withdrawal.ProcessedAt = DateTime.UtcNow;
|
||
|
||
// 将对应收益标记为已提现
|
||
var earnings = await db.Earnings
|
||
.Where(e => e.UserId == withdrawal.UserId && e.Status == EarningStatus.Withdrawing)
|
||
.OrderBy(e => e.CreatedAt)
|
||
.ToListAsync();
|
||
|
||
var remaining = withdrawal.Amount;
|
||
foreach (var earning in earnings)
|
||
{
|
||
if (remaining <= 0) break;
|
||
if (earning.NetEarning <= remaining)
|
||
{
|
||
earning.Status = EarningStatus.Withdrawn;
|
||
remaining -= earning.NetEarning;
|
||
}
|
||
else
|
||
{
|
||
earning.Status = EarningStatus.Withdrawn;
|
||
remaining = 0;
|
||
}
|
||
}
|
||
}
|
||
else if (request.Action == "reject")
|
||
{
|
||
withdrawal.Status = WithdrawalStatus.Pending;
|
||
withdrawal.ProcessedAt = DateTime.UtcNow;
|
||
|
||
// 将对应收益退回待提现状态
|
||
var earnings = await db.Earnings
|
||
.Where(e => e.UserId == withdrawal.UserId && e.Status == EarningStatus.Withdrawing)
|
||
.OrderBy(e => e.CreatedAt)
|
||
.ToListAsync();
|
||
|
||
var remaining = withdrawal.Amount;
|
||
foreach (var earning in earnings)
|
||
{
|
||
if (remaining <= 0) break;
|
||
if (earning.NetEarning <= remaining)
|
||
{
|
||
earning.Status = EarningStatus.Available;
|
||
remaining -= earning.NetEarning;
|
||
}
|
||
else
|
||
{
|
||
earning.Status = EarningStatus.Available;
|
||
remaining = 0;
|
||
}
|
||
}
|
||
|
||
// 拒绝后将提现记录状态设为特殊标记(复用 Pending 但已有 ProcessedAt)
|
||
// 实际上拒绝后应该删除或标记,这里直接删除提现记录
|
||
db.Withdrawals.Remove(withdrawal);
|
||
}
|
||
else if (request.Action == "processing")
|
||
{
|
||
withdrawal.Status = WithdrawalStatus.Processing;
|
||
}
|
||
else
|
||
{
|
||
return Results.BadRequest(new { code = 400, message = "无效操作,可选: approve, reject, processing" });
|
||
}
|
||
|
||
await db.SaveChangesAsync();
|
||
return Results.Ok(new { message = "操作成功" });
|
||
}).RequireAuthorization("AdminOnly");
|
||
|
||
// 获取佣金规则
|
||
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");
|
||
}
|
||
}
|