campus-errand/server/Endpoints/EarningEndpoints.cs
2026-04-18 02:32:02 +08:00

489 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using CampusErrand.Data;
using CampusErrand.Models;
using CampusErrand.Models.Dtos;
using CampusErrand.Helpers;
using CampusErrand.Services;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
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(),
RejectReason = w.RejectReason,
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,
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),
UserUid = w.User!.Uid ?? w.UserId.ToString(),
w.Amount,
PaymentMethod = w.PaymentMethod.ToString(),
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 outBillNo = $"W{withdrawal.Id}T{DateTime.UtcNow:yyyyMMddHHmmss}";
// 构造回调地址
var baseUrl = app.Configuration["BaseUrl"] ?? "https://your-domain.com";
var notifyUrl = $"{baseUrl}/api/notify/transfer";
var transferResult = await wxPay.TransferToWallet(outBillNo, user.OpenId, amountFen, notifyUrl, "跑腿提现到账");
if (!transferResult.Success)
{
Console.WriteLine($"[提现] 转账失败: {transferResult.ErrorMessage}");
// 解析微信错误信息,返回友好提示
var friendlyMsg = "转账失败,请稍后重试";
try
{
var errJson = JsonSerializer.Deserialize<JsonElement>(transferResult.ErrorMessage ?? "{}");
var errCode = errJson.TryGetProperty("code", out var c) ? c.GetString() : "";
var errMessage = errJson.TryGetProperty("message", out var m) ? m.GetString() : "";
friendlyMsg = errCode switch
{
"NOT_ENOUGH" => "商户账户余额不足,请充值后重试",
"NO_AUTH" => "没有转账权限,请检查商户平台配置",
"PARAM_ERROR" => $"参数错误:{errMessage}",
"INVALID_REQUEST" => $"请求无效:{errMessage}",
"FREQUENCY_LIMIT_EXCEED" => "请求频率超限,请稍后重试",
_ => $"转账失败:{errMessage}"
};
}
catch { }
return Results.BadRequest(new { code = 400, message = friendlyMsg });
}
// 保存转账信息,等待用户确认收款
withdrawal.Status = WithdrawalStatus.WaitConfirm;
withdrawal.TransferBillNo = transferResult.TransferBillNo;
withdrawal.PackageInfo = transferResult.PackageInfo;
withdrawal.ProcessedAt = DateTime.UtcNow;
}
else if (request.Action == "reject")
{
if (string.IsNullOrWhiteSpace(request.Reason))
return Results.BadRequest(new { code = 400, message = "请填写拒绝理由" });
withdrawal.Status = WithdrawalStatus.Rejected;
withdrawal.ProcessedAt = DateTime.UtcNow;
withdrawal.RejectReason = request.Reason.Trim();
// 将对应收益退回待提现状态
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;
}
}
}
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");
// 小程序端:获取待确认收款的提现记录(含 package_info
app.MapGet("/api/earnings/withdrawals/pending-confirm", 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 list = await db.Withdrawals
.Where(w => w.UserId == userId && w.Status == WithdrawalStatus.WaitConfirm)
.OrderByDescending(w => w.ProcessedAt)
.Select(w => new
{
w.Id,
w.Amount,
w.TransferBillNo,
w.PackageInfo,
w.ProcessedAt
})
.ToListAsync();
return Results.Ok(list);
}).RequireAuthorization();
// 微信转账结果回调
app.MapPost("/api/notify/transfer", async (HttpContext httpContext, AppDbContext db, WxPayService wxPay) =>
{
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();
try
{
// 用转账专用方法解密回调数据
var notifyResult = wxPay.DecryptTransferNotify(body);
if (notifyResult == null)
{
Console.WriteLine("[转账回调] 解密失败");
return Results.Json(new { code = "FAIL", message = "解密失败" }, statusCode: 500);
}
var outBillNo = notifyResult.OutBillNo;
var transferBillNo = notifyResult.TransferBillNo;
var state = notifyResult.State;
Console.WriteLine($"[转账回调] outBillNo={outBillNo}, transferBillNo={transferBillNo}, state={state}");
// 通过微信转账单号精确匹配提现记录
var withdrawal = await db.Withdrawals
.FirstOrDefaultAsync(w => w.TransferBillNo == transferBillNo && w.Status == WithdrawalStatus.WaitConfirm);
if (withdrawal == null)
{
Console.WriteLine($"[转账回调] 未找到匹配的提现记录: {transferBillNo}");
return Results.Json(new { code = "SUCCESS", message = "OK" });
}
if (state == "SUCCESS" || state == "ACCEPTED")
{
// 转账成功,标记提现完成
if (withdrawal != null && withdrawal.Status == WithdrawalStatus.WaitConfirm)
{
withdrawal.Status = WithdrawalStatus.Completed;
// 将对应收益标记为已提现
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;
}
}
await db.SaveChangesAsync();
Console.WriteLine($"[转账回调] 提现完成: withdrawalId={withdrawal.Id}");
}
}
else if (state == "FAIL" || state == "CLOSED")
{
// 转账失败/关闭,退回收益
if (withdrawal != null && withdrawal.Status == WithdrawalStatus.WaitConfirm)
{
withdrawal.Status = WithdrawalStatus.Pending;
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;
}
}
db.Withdrawals.Remove(withdrawal);
await db.SaveChangesAsync();
Console.WriteLine($"[转账回调] 转账失败/关闭,已退回: withdrawalId={withdrawal.Id}");
}
}
return Results.Json(new { code = "SUCCESS", message = "OK" });
}
catch (Exception ex)
{
Console.WriteLine($"[转账回调] 处理异常: {ex.Message}");
return Results.Json(new { code = "FAIL", message = ex.Message }, statusCode: 500);
}
});
}
}