campus-errand/server/Endpoints/EarningEndpoints.cs
18631081161 681d2b5fe8
All checks were successful
continuous-integration/drone/push Build is passing
提现
2026-04-02 16:55:18 +08:00

349 lines
15 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;
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,
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 batchNo = $"W{withdrawal.Id}T{DateTime.UtcNow:yyyyMMddHHmmss}";
var detailNo = $"D{withdrawal.Id}T{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");
}
}