feat: 新增渠道代理注册归因与返佣明细
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s

独立 Agent 主体与 loginType=2,注册绑定/下单快照,派送成功失败计提明细,不含提现入账。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-10-29 17:18:07 +08:00
parent 221a2c8a66
commit 1d987cde6e
24 changed files with 1562 additions and 18 deletions

View File

@ -0,0 +1,78 @@
-- 渠道代理与返佣(首版轻量)。可重复执行。
-- 含:t_agent、t_agent_commission_detail;t_customer / t_order 扩展 AgentId。
SET @db := DATABASE();
-- ---------- t_agent ----------
CREATE TABLE IF NOT EXISTS `t_agent` (
`Id` INT NOT NULL AUTO_INCREMENT,
`UserId` INT NOT NULL COMMENT 'RBAC user.userid / JWT mapId',
`AgentCode` VARCHAR(32) NOT NULL COMMENT '登录账号+注册链接编码',
`AgentName` VARCHAR(64) NOT NULL COMMENT '显示名',
`Phone` VARCHAR(32) NULL,
`Email` VARCHAR(128) NULL,
`Password` VARCHAR(64) NOT NULL COMMENT 'MD5 密码',
`Status` TINYINT NOT NULL DEFAULT 2 COMMENT '1停用 2启用',
`BaseType` VARCHAR(32) NOT NULL DEFAULT 'freight' COMMENT 'freight/receivable',
`CommissionRate` DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '返佣比例',
`SalesProductIds` VARCHAR(512) NULL COMMENT '可选销售产品Id列表 JSON/[1,2] 或逗号;空=不限',
`Remark` VARCHAR(512) NULL,
`IsDeleted` TINYINT(1) NOT NULL DEFAULT 0,
`CreateUserId` INT NOT NULL DEFAULT 0,
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`UpdateUserId` INT NOT NULL DEFAULT 0,
`UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`Id`),
KEY `ix_agent_code` (`AgentCode`),
KEY `ix_agent_userid` (`UserId`)
) COMMENT='渠道代理主数据';
-- ---------- t_agent_commission_detail ----------
CREATE TABLE IF NOT EXISTS `t_agent_commission_detail` (
`Id` INT NOT NULL AUTO_INCREMENT,
`AgentId` INT NOT NULL,
`OrderId` INT NOT NULL,
`CustomerId` INT NOT NULL,
`SalesProductId` INT NULL,
`BaseType` VARCHAR(32) NOT NULL,
`BaseAmount` DECIMAL(18,4) NOT NULL DEFAULT 0,
`CommissionRate` DECIMAL(18,6) NOT NULL DEFAULT 0,
`CommissionAmount` DECIMAL(18,4) NOT NULL DEFAULT 0,
`Status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid',
`TriggerOrderStatus` VARCHAR(32) NOT NULL,
`PaidTime` DATETIME NULL,
`Remark` VARCHAR(512) NULL,
`IsDeleted` TINYINT(1) NOT NULL DEFAULT 0,
`CreateUserId` INT NOT NULL DEFAULT 0,
`CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`UpdateUserId` INT NOT NULL DEFAULT 0,
`UpdateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`Id`),
KEY `ix_agent_comm_agent` (`AgentId`),
KEY `ix_agent_comm_order` (`OrderId`),
KEY `ix_agent_comm_status` (`Status`)
) COMMENT='代理返佣明细(首版无余额入账)';
-- ---------- t_customer.AgentId ----------
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_customer' AND COLUMN_NAME = 'AgentId'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_customer` ADD COLUMN `AgentId` INT NULL COMMENT ''所属渠道代理'' AFTER `CommissionBalance`, ADD COLUMN `AgentBindTime` DATETIME NULL COMMENT ''代理绑定时间'' AFTER `AgentId`, ADD KEY `ix_customer_agent` (`AgentId`)',
'SELECT ''t_customer.AgentId already exists'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ---------- t_order.AgentId ----------
SET @exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'AgentId'
);
SET @sql := IF(
@exists = 0,
'ALTER TABLE `t_order` ADD COLUMN `AgentId` INT NULL COMMENT ''下单时代理快照'' AFTER `CustomerId`, ADD KEY `ix_order_agent` (`AgentId`)',
'SELECT ''t_order.AgentId already exists'' AS info'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,234 @@
using System.ComponentModel.DataAnnotations;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Dtos.Agents;
public class AgentListInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
public string? Keyword { get; set; }
public EnableStatus? Status { get; set; }
}
public class AgentOutput
{
public int Id { get; set; }
public string AgentCode { get; set; } = string.Empty;
public string AgentName { get; set; } = string.Empty;
public string? Phone { get; set; }
public string? Email { get; set; }
public EnableStatus Status { get; set; }
public string BaseType { get; set; } = string.Empty;
public decimal CommissionRate { get; set; }
public string? SalesProductIds { get; set; }
public string? Remark { get; set; }
public DateTime CreateTime { get; set; }
}
public class AgentCreateInput
{
[Required]
[StringLength(32)]
public string AgentCode { get; set; } = string.Empty;
[Required]
[StringLength(64)]
public string AgentName { get; set; } = string.Empty;
[StringLength(32)]
public string? Phone { get; set; }
[StringLength(128)]
public string? Email { get; set; }
[Required]
[MinLength(6)]
public string Password { get; set; } = string.Empty;
/// <summary>freight / receivable。</summary>
[Required]
[StringLength(32)]
public string BaseType { get; set; } = "freight";
[Range(0, 1)]
public decimal CommissionRate { get; set; }
/// <summary>可选销售产品 Id,JSON 或逗号分隔;空=不限。</summary>
[StringLength(512)]
public string? SalesProductIds { get; set; }
[StringLength(512)]
public string? Remark { get; set; }
}
public class AgentUpdateInput
{
[Required]
public int Id { get; set; }
[Required]
[StringLength(64)]
public string AgentName { get; set; } = string.Empty;
[StringLength(32)]
public string? Phone { get; set; }
[StringLength(128)]
public string? Email { get; set; }
/// <summary>不传则不改密码。</summary>
[MinLength(6)]
public string? Password { get; set; }
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
[Required]
[StringLength(32)]
public string BaseType { get; set; } = "freight";
[Range(0, 1)]
public decimal CommissionRate { get; set; }
[StringLength(512)]
public string? SalesProductIds { get; set; }
[StringLength(512)]
public string? Remark { get; set; }
}
public class AgentRebindCustomerInput
{
[Required]
public int CustomerId { get; set; }
/// <summary>空则解绑。</summary>
public int? AgentId { get; set; }
}
public class AgentCommissionListInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
public int? AgentId { get; set; }
public int? OrderId { get; set; }
public string? Status { get; set; }
}
public class AgentCommissionDetailOutput
{
public int Id { get; set; }
public int AgentId { get; set; }
public string? AgentCode { get; set; }
public string? AgentName { get; set; }
public int OrderId { get; set; }
public string? ServiceNo { get; set; }
public int CustomerId { get; set; }
public string? CustomerCode { get; set; }
public string? CustomerName { get; set; }
public int? SalesProductId { get; set; }
public string BaseType { get; set; } = string.Empty;
public decimal BaseAmount { get; set; }
public decimal CommissionRate { get; set; }
public decimal CommissionAmount { get; set; }
public string Status { get; set; } = string.Empty;
public string TriggerOrderStatus { get; set; } = string.Empty;
public DateTime? PaidTime { get; set; }
public string? Remark { get; set; }
public DateTime CreateTime { get; set; }
}
public class AgentCommissionMarkPaidInput
{
[Required]
public List<int> Ids { get; set; } = [];
[StringLength(512)]
public string? Remark { get; set; }
}
public class AgentPortalCustomerOutput
{
public int Id { get; set; }
public string CustomerCode { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string? Phone { get; set; }
public DateTime? AgentBindTime { get; set; }
public DateTime CreateTime { get; set; }
}
public class AgentPortalOrderOutput
{
public int Id { get; set; }
public string ServiceNo { get; set; } = string.Empty;
public string? TrackNo { get; set; }
public int? CustomerId { get; set; }
public string? CustomerCode { get; set; }
public string? CustomerName { get; set; }
public string? ProductCode { get; set; }
public string? OrderStatus { get; set; }
public string? ConsigneeCountryName { get; set; }
public decimal? ReceivableSubtotal { get; set; }
public decimal? PayableAmount { get; set; }
public DateTime CreateTime { get; set; }
}
public class AgentPortalPageInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
public string? Keyword { get; set; }
}

View File

@ -124,4 +124,10 @@ public class CreateCustomerInput
[StringLength(64)]
public string? AdvisorCode { get; set; }
/// <summary>
/// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。
/// </summary>
[StringLength(32)]
public string? AgentCode { get; set; }
}

View File

@ -16,6 +16,7 @@ public sealed class TrackChangeOrderStatusApplier(
IOrderRepository orderRepository,
OrderOperationLogWriter operationLogWriter,
ReferralCommissionManager referralCommissionManager,
AgentCommissionManager agentCommissionManager,
MarketingCouponManager marketingCouponManager,
IUnitOfWorkManager unitOfWorkManager,
ILogger<TrackChangeOrderStatusApplier> logger) : ITrackChangeOrderStatusApplier, IScopedDependency
@ -132,6 +133,17 @@ public sealed class TrackChangeOrderStatusApplier(
// 重新加载,避免营销侧依赖最新字段
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;
await marketingCouponManager.OnFirstDeliverySuccessAsync(order, cancellationToken);
await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync(
order.Id,
OrderStatusCodes.DeliverySuccess,
cancellationToken);
}
else if (string.Equals(targetStatus, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase))
{
await agentCommissionManager.TryAccrueOnDeliveryTerminalAsync(
order.Id,
OrderStatusCodes.DeliveryFailed,
cancellationToken);
}
order = await orderRepository.GetByServiceNoAsync(orderNo, cancellationToken) ?? order;

View File

@ -0,0 +1,414 @@
using System.Linq.Expressions;
using Fake.Application;
using Fake.Domain.Exceptions;
using Fake.Rbac.Application.Dtos.Role;
using Fake.Rbac.Application.Dtos.User;
using Fake.Rbac.Application.Services;
using Fake.Rbac.Infrastructure;
using Fake.EntityFrameworkCore;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Oms.Application.Dtos.Agents;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.AgentsAggregate.Consts;
using Oms.Domain.AgentsAggregate.Entities;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Services.Agents;
/// <summary>
/// 渠道代理管理端 API(开户、规则、改绑、返佣明细)。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Agent")]
public class AgentAdminService : ApplicationService
{
public const string AgentRoleCode = "Agent";
private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService<IAgentRepository>();
private IAgentCommissionDetailRepository DetailRepository =>
LazyServiceProvider.GetRequiredService<IAgentCommissionDetailRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
private UserService UserService => LazyServiceProvider.GetRequiredService<UserService>();
private RoleService RoleService => LazyServiceProvider.GetRequiredService<RoleService>();
private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService<FakeRbacDbContext>();
/// <summary>分页查询代理。</summary>
[HttpPost("/agent/admin/list")]
public async Task<OmsPageResult<AgentOutput>> GetListAsync(AgentListInput input)
{
var predicate = BuildAgentListPredicate(input);
var page = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await AgentRepository.GetPagedListAsync(
predicate,
page,
pageSize,
new Dictionary<string, bool> { [nameof(Agent.CreateTime)] = false, [nameof(Agent.Id)] = false });
var total = await AgentRepository.CountAsync(predicate);
return new OmsPageResult<AgentOutput>
{
Items = entities.Select(MapAgent).ToList(),
Total = total
};
}
/// <summary>创建代理(同步 RBAC 用户 + Agent 角色)。</summary>
[HttpPost("/agent/admin/create")]
public async Task CreateAsync(AgentCreateInput input)
{
var code = input.AgentCode.Trim();
if (await AgentRepository.ExistsAgentCodeAsync(code))
{
throw new BusinessException("代理编号已存在");
}
EnsureBaseType(input.BaseType);
var roleId = await GetAgentRoleIdOrThrowAsync();
var user = await UserService.CreateAsync(new UserCreateDto
{
Name = input.AgentName.Trim(),
Account = code,
Password = input.Password,
Email = string.IsNullOrWhiteSpace(input.Email)
? $"{code}@agent.local"
: input.Email.Trim(),
RoleIds = []
});
try
{
await UserService.AssignRolesAsync(user.Id, [roleId]);
var userId = await GetRbacUserMapIdOrThrowAsync(user.Id);
await AgentRepository.InsertAsync(new Agent
{
UserId = userId,
AgentCode = code,
AgentName = input.AgentName.Trim(),
Phone = NormalizeOptional(input.Phone),
Email = NormalizeOptional(input.Email),
Password = OmsPasswordHelper.ToMd5(input.Password),
Status = EnableStatus.Enabled,
BaseType = input.BaseType.Trim().ToLowerInvariant(),
CommissionRate = input.CommissionRate,
SalesProductIds = NormalizeOptional(input.SalesProductIds),
Remark = NormalizeOptional(input.Remark)
});
}
catch
{
try
{
await UserService.DeleteAsync(user.Id);
}
catch
{
// ignore rollback failure
}
throw;
}
}
/// <summary>更新代理资料与返佣规则。</summary>
[HttpPost("/agent/admin/update")]
public async Task UpdateAsync(AgentUpdateInput input)
{
var entity = await AgentRepository.FirstOrDefaultAsync(x => x.Id == input.Id)
?? throw new BusinessException("代理不存在");
EnsureBaseType(input.BaseType);
entity.AgentName = input.AgentName.Trim();
entity.Phone = NormalizeOptional(input.Phone);
entity.Email = NormalizeOptional(input.Email);
entity.Status = input.Status == EnableStatus.None ? EnableStatus.Enabled : input.Status;
entity.BaseType = input.BaseType.Trim().ToLowerInvariant();
entity.CommissionRate = input.CommissionRate;
entity.SalesProductIds = NormalizeOptional(input.SalesProductIds);
entity.Remark = NormalizeOptional(input.Remark);
if (!string.IsNullOrWhiteSpace(input.Password))
{
entity.Password = OmsPasswordHelper.ToMd5(input.Password);
}
await AgentRepository.UpdateAsync(entity);
}
/// <summary>改绑主客户所属代理(仅影响之后新单)。</summary>
[HttpPost("/agent/admin/rebind")]
public async Task RebindCustomerAsync(AgentRebindCustomerInput input)
{
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId)
?? throw new BusinessException("客户不存在");
if (customer.ParentCustomerId.HasValue)
{
throw new BusinessException("请改绑主客户");
}
if (input.AgentId.HasValue && input.AgentId.Value > 0)
{
var agent = await AgentRepository.FirstOrDefaultAsync(x => x.Id == input.AgentId.Value)
?? throw new BusinessException("代理不存在");
if (agent.Status != EnableStatus.Enabled)
{
throw new BusinessException("代理已停用");
}
customer.AgentId = agent.Id;
customer.AgentBindTime = DateTime.Now;
}
else
{
customer.AgentId = null;
customer.AgentBindTime = null;
}
await CustomerRepository.UpdateAsync(customer);
}
/// <summary>分页查询返佣明细。</summary>
[HttpPost("/agent/admin/commission/list")]
public async Task<OmsPageResult<AgentCommissionDetailOutput>> GetCommissionListAsync(AgentCommissionListInput input)
{
var predicate = BuildCommissionPredicate(input);
var page = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await DetailRepository.GetPagedListAsync(
predicate,
page,
pageSize,
new Dictionary<string, bool>
{
[nameof(AgentCommissionDetail.CreateTime)] = false,
[nameof(AgentCommissionDetail.Id)] = false
});
var total = await DetailRepository.CountAsync(predicate);
return new OmsPageResult<AgentCommissionDetailOutput>
{
Items = await MapCommissionOutputsAsync(entities),
Total = total
};
}
/// <summary>标记返佣明细为已打款。</summary>
[HttpPost("/agent/admin/commission/mark-paid")]
public async Task MarkCommissionPaidAsync(AgentCommissionMarkPaidInput input)
{
if (input.Ids == null || input.Ids.Count == 0)
{
throw new BusinessException("请选择明细");
}
var ids = input.Ids.Distinct().ToList();
var list = await DetailRepository.GetListAsync(x => ids.Contains(x.Id));
var now = DateTime.Now;
var remark = NormalizeOptional(input.Remark);
foreach (var item in list)
{
if (string.Equals(item.Status, AgentCommissionStatusCodes.Paid, StringComparison.OrdinalIgnoreCase))
{
continue;
}
item.Status = AgentCommissionStatusCodes.Paid;
item.PaidTime = now;
if (!string.IsNullOrWhiteSpace(remark))
{
item.Remark = remark;
}
await DetailRepository.UpdateAsync(item);
}
}
private static Expression<Func<Agent, bool>> BuildAgentListPredicate(AgentListInput input)
{
var keyword = input.Keyword?.Trim();
var hasStatus = input.Status.HasValue && input.Status.Value != EnableStatus.None;
if (hasStatus && !string.IsNullOrWhiteSpace(keyword))
{
var status = input.Status!.Value;
return x => x.Status == status
&& (x.AgentCode.Contains(keyword!)
|| x.AgentName.Contains(keyword!)
|| (x.Phone != null && x.Phone.Contains(keyword!)));
}
if (hasStatus)
{
var status = input.Status!.Value;
return x => x.Status == status;
}
if (!string.IsNullOrWhiteSpace(keyword))
{
return x => x.AgentCode.Contains(keyword!)
|| x.AgentName.Contains(keyword!)
|| (x.Phone != null && x.Phone.Contains(keyword!));
}
return x => true;
}
private static Expression<Func<AgentCommissionDetail, bool>> BuildCommissionPredicate(AgentCommissionListInput input)
{
var status = input.Status?.Trim();
if (input.AgentId is > 0 && input.OrderId is > 0 && !string.IsNullOrWhiteSpace(status))
{
var agentId = input.AgentId.Value;
var orderId = input.OrderId.Value;
return x => x.AgentId == agentId && x.OrderId == orderId && x.Status == status;
}
if (input.AgentId is > 0 && input.OrderId is > 0)
{
var agentId = input.AgentId.Value;
var orderId = input.OrderId.Value;
return x => x.AgentId == agentId && x.OrderId == orderId;
}
if (input.AgentId is > 0 && !string.IsNullOrWhiteSpace(status))
{
var agentId = input.AgentId.Value;
return x => x.AgentId == agentId && x.Status == status;
}
if (input.OrderId is > 0 && !string.IsNullOrWhiteSpace(status))
{
var orderId = input.OrderId.Value;
return x => x.OrderId == orderId && x.Status == status;
}
if (input.AgentId is > 0)
{
var agentId = input.AgentId.Value;
return x => x.AgentId == agentId;
}
if (input.OrderId is > 0)
{
var orderId = input.OrderId.Value;
return x => x.OrderId == orderId;
}
if (!string.IsNullOrWhiteSpace(status))
{
return x => x.Status == status;
}
return x => true;
}
private async Task<List<AgentCommissionDetailOutput>> MapCommissionOutputsAsync(
List<AgentCommissionDetail> entities)
{
if (entities.Count == 0)
{
return [];
}
var agentIds = entities.Select(x => x.AgentId).Distinct().ToList();
var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList();
var orderIds = entities.Select(x => x.OrderId).Distinct().ToList();
var agents = await AgentRepository.GetListAsync(x => agentIds.Contains(x.Id));
var customers = await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
var orders = await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
var agentMap = agents.ToDictionary(x => x.Id);
var customerMap = customers.ToDictionary(x => x.Id);
var orderMap = orders.ToDictionary(x => x.Id);
return entities.Select(x =>
{
agentMap.TryGetValue(x.AgentId, out var agent);
customerMap.TryGetValue(x.CustomerId, out var customer);
orderMap.TryGetValue(x.OrderId, out var order);
return new AgentCommissionDetailOutput
{
Id = x.Id,
AgentId = x.AgentId,
AgentCode = agent?.AgentCode,
AgentName = agent?.AgentName,
OrderId = x.OrderId,
ServiceNo = order?.ServiceNo,
CustomerId = x.CustomerId,
CustomerCode = customer?.CustomerCode,
CustomerName = customer?.CustomerName,
SalesProductId = x.SalesProductId,
BaseType = x.BaseType,
BaseAmount = x.BaseAmount,
CommissionRate = x.CommissionRate,
CommissionAmount = x.CommissionAmount,
Status = x.Status,
TriggerOrderStatus = x.TriggerOrderStatus,
PaidTime = x.PaidTime,
Remark = x.Remark,
CreateTime = x.CreateTime
};
}).ToList();
}
private static AgentOutput MapAgent(Agent x) => new()
{
Id = x.Id,
AgentCode = x.AgentCode,
AgentName = x.AgentName,
Phone = x.Phone,
Email = x.Email,
Status = x.Status,
BaseType = x.BaseType,
CommissionRate = x.CommissionRate,
SalesProductIds = x.SalesProductIds,
Remark = x.Remark,
CreateTime = x.CreateTime
};
private static void EnsureBaseType(string? baseType)
{
if (!AgentCommissionBaseTypes.IsSupported(baseType))
{
throw new BusinessException("基数类型仅支持 freight(运费)或 receivable(应收)");
}
}
private static string? NormalizeOptional(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private async Task<Guid> GetAgentRoleIdOrThrowAsync()
{
var roles = await RoleService.GetAllRolesAsync();
var role = roles.FirstOrDefault(x =>
string.Equals(x.Code, AgentRoleCode, StringComparison.OrdinalIgnoreCase));
if (role == null)
{
throw new BusinessException("未找到 Agent 角色,请先执行种子或手动创建");
}
return role.Id;
}
private async Task<int> GetRbacUserMapIdOrThrowAsync(Guid userId)
{
var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}";
var mapId = await RbacDbContext.Database.SqlQueryRaw<int?>(sql, userId).FirstOrDefaultAsync();
if (!mapId.HasValue || mapId.Value <= 0)
{
throw new BusinessException("RBAC 用户映射失败");
}
return mapId.Value;
}
}

View File

@ -0,0 +1,258 @@
using System.Linq.Expressions;
using Fake.Application;
using Fake.Domain.Exceptions;
using Fake.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Agents;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.AgentsAggregate.Entities;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Entities;
using Oms.Domain.OrdersAggregate.Repositories;
namespace Oms.Application.Services.Agents;
/// <summary>
/// 渠道代理门户只读 API(名下客户 / 订单 / 返佣)。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Agent")]
public class AgentPortalService : ApplicationService
{
private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService<IAgentRepository>();
private IAgentCommissionDetailRepository DetailRepository =>
LazyServiceProvider.GetRequiredService<IAgentCommissionDetailRepository>();
private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService<ICustomerRepository>();
private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService<IOrderRepository>();
/// <summary>当前代理资料。</summary>
[HttpGet("/agent/portal/profile")]
public async Task<AgentOutput> GetProfileAsync()
{
var agent = await GetCurrentAgentOrThrowAsync();
return new AgentOutput
{
Id = agent.Id,
AgentCode = agent.AgentCode,
AgentName = agent.AgentName,
Phone = agent.Phone,
Email = agent.Email,
Status = agent.Status,
BaseType = agent.BaseType,
CommissionRate = agent.CommissionRate,
SalesProductIds = agent.SalesProductIds,
Remark = agent.Remark,
CreateTime = agent.CreateTime
};
}
/// <summary>名下客户分页(含主客户 AgentId=我)。</summary>
[HttpPost("/agent/portal/customers/list")]
public async Task<OmsPageResult<AgentPortalCustomerOutput>> GetCustomersAsync(AgentPortalPageInput input)
{
var agent = await GetCurrentAgentOrThrowAsync();
var keyword = input.Keyword?.Trim();
Expression<Func<Customer, bool>> predicate = string.IsNullOrWhiteSpace(keyword)
? x => x.AgentId == agent.Id && x.ParentCustomerId == null
: x => x.AgentId == agent.Id
&& x.ParentCustomerId == null
&& (x.CustomerCode.Contains(keyword!)
|| x.CustomerName.Contains(keyword!)
|| (x.Phone != null && x.Phone.Contains(keyword!)));
var page = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await CustomerRepository.GetPagedListAsync(
predicate,
page,
pageSize,
new Dictionary<string, bool>
{
[nameof(Customer.CreateTime)] = false,
[nameof(Customer.Id)] = false
});
var total = await CustomerRepository.CountAsync(predicate);
return new OmsPageResult<AgentPortalCustomerOutput>
{
Items = entities.Select(x => new AgentPortalCustomerOutput
{
Id = x.Id,
CustomerCode = x.CustomerCode,
CustomerName = MaskName(x.CustomerName) ?? string.Empty,
Phone = MaskPhone(x.Phone),
AgentBindTime = x.AgentBindTime,
CreateTime = x.CreateTime
}).ToList(),
Total = total
};
}
/// <summary>名下订单分页(强制 Order.AgentId;不含应付)。</summary>
[HttpPost("/agent/portal/orders/list")]
public async Task<OmsPageResult<AgentPortalOrderOutput>> GetOrdersAsync(AgentPortalPageInput input)
{
var agent = await GetCurrentAgentOrThrowAsync();
var keyword = input.Keyword?.Trim();
Expression<Func<Order, bool>> predicate = string.IsNullOrWhiteSpace(keyword)
? x => x.AgentId == agent.Id
: x => x.AgentId == agent.Id
&& (x.ServiceNo.Contains(keyword!)
|| (x.TrackNo != null && x.TrackNo.Contains(keyword!))
|| (x.ProductCode != null && x.ProductCode.Contains(keyword!)));
var page = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await OrderRepository.GetPagedListAsync(
predicate,
page,
pageSize,
new Dictionary<string, bool>
{
[nameof(Order.CreateTime)] = false,
[nameof(Order.Id)] = false
});
var total = await OrderRepository.CountAsync(predicate);
var customerIds = entities.Where(x => x.CustomerId.HasValue).Select(x => x.CustomerId!.Value).Distinct().ToList();
var customers = customerIds.Count == 0
? []
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
var customerMap = customers.ToDictionary(x => x.Id);
return new OmsPageResult<AgentPortalOrderOutput>
{
Items = entities.Select(x =>
{
customerMap.TryGetValue(x.CustomerId ?? 0, out var customer);
return new AgentPortalOrderOutput
{
Id = x.Id,
ServiceNo = x.ServiceNo,
TrackNo = x.TrackNo,
CustomerId = x.CustomerId,
CustomerCode = customer?.CustomerCode,
CustomerName = MaskName(customer?.CustomerName),
ProductCode = x.ProductCode,
OrderStatus = x.OrderStatus,
ConsigneeCountryName = x.ConsigneeCountryName,
ReceivableSubtotal = x.ReceivableSubtotal,
// 门户不返回应付:固定 null
PayableAmount = null,
CreateTime = x.CreateTime
};
}).ToList(),
Total = total
};
}
/// <summary>名下返佣明细。</summary>
[HttpPost("/agent/portal/commission/list")]
public async Task<OmsPageResult<AgentCommissionDetailOutput>> GetCommissionListAsync(AgentPortalPageInput input)
{
var agent = await GetCurrentAgentOrThrowAsync();
Expression<Func<AgentCommissionDetail, bool>> predicate = x => x.AgentId == agent.Id;
var page = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await DetailRepository.GetPagedListAsync(
predicate,
page,
pageSize,
new Dictionary<string, bool>
{
[nameof(AgentCommissionDetail.CreateTime)] = false,
[nameof(AgentCommissionDetail.Id)] = false
});
var total = await DetailRepository.CountAsync(predicate);
var customerIds = entities.Select(x => x.CustomerId).Distinct().ToList();
var orderIds = entities.Select(x => x.OrderId).Distinct().ToList();
var customers = customerIds.Count == 0
? []
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
var orders = orderIds.Count == 0
? []
: await OrderRepository.GetListAsync(x => orderIds.Contains(x.Id));
var customerMap = customers.ToDictionary(x => x.Id);
var orderMap = orders.ToDictionary(x => x.Id);
return new OmsPageResult<AgentCommissionDetailOutput>
{
Items = entities.Select(x =>
{
customerMap.TryGetValue(x.CustomerId, out var customer);
orderMap.TryGetValue(x.OrderId, out var order);
return new AgentCommissionDetailOutput
{
Id = x.Id,
AgentId = x.AgentId,
AgentCode = agent.AgentCode,
AgentName = agent.AgentName,
OrderId = x.OrderId,
ServiceNo = order?.ServiceNo,
CustomerId = x.CustomerId,
CustomerCode = customer?.CustomerCode,
CustomerName = MaskName(customer?.CustomerName),
SalesProductId = x.SalesProductId,
BaseType = x.BaseType,
BaseAmount = x.BaseAmount,
CommissionRate = x.CommissionRate,
CommissionAmount = x.CommissionAmount,
Status = x.Status,
TriggerOrderStatus = x.TriggerOrderStatus,
PaidTime = x.PaidTime,
Remark = x.Remark,
CreateTime = x.CreateTime
};
}).ToList(),
Total = total
};
}
private async Task<Agent> GetCurrentAgentOrThrowAsync()
{
var mapId = CurrentUser.GetId<int>();
if (mapId <= 0)
{
throw new BusinessException("代理未登录");
}
var agent = await AgentRepository.GetByUserIdAsync(mapId);
if (agent == null)
{
throw new BusinessException("代理不存在");
}
if (agent.Status != Domain.Shared.Enums.EnableStatus.Enabled)
{
throw new BusinessException("代理已停用");
}
return agent;
}
private static string? MaskPhone(string? phone)
{
if (string.IsNullOrWhiteSpace(phone) || phone.Length < 7)
{
return phone;
}
return string.Concat(phone.AsSpan(0, 3), "****", phone.AsSpan(phone.Length - 4));
}
private static string? MaskName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return name;
}
var text = name.Trim();
return text.Length <= 1 ? "*" : text[0] + new string('*', Math.Min(2, text.Length - 1));
}
}

View File

@ -15,6 +15,7 @@ using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Repositories;
using Oms.Domain.Shared.Enums;
@ -36,6 +37,9 @@ public class CustomerService : ApplicationService
private ICustomerReferralCodeRepository ReferralCodeRepository =>
LazyServiceProvider.GetRequiredService<ICustomerReferralCodeRepository>();
private IAgentRepository AgentRepository =>
LazyServiceProvider.GetRequiredService<IAgentRepository>();
private MarketingCouponManager CouponManager =>
LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
@ -240,13 +244,18 @@ public class CustomerService : ApplicationService
var entity = BuildCustomerEntity(input, normalized, null, userId);
entity.MemberLevelId = await ResolveMemberLevelIdForCreateAsync(input.MemberLevelId);
entity.PriceTierKey = MarketingCustomerHelper.ResolveNewCustomerPriceTierKey(input.PriceTierKey);
await MarketingReferralBinder.BindReferrerOnCreateAsync(
entity,
input.InviteCode,
ReferralCodeRepository,
CustomerRepository,
MemberLevelRepository,
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, AgentRepository);
// 有代理编号时不走 C2C 邀请,避免双发佣金
if (!entity.AgentId.HasValue)
{
await MarketingReferralBinder.BindReferrerOnCreateAsync(
entity,
input.InviteCode,
ReferralCodeRepository,
CustomerRepository,
MemberLevelRepository,
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>());
}
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,
input.AdvisorCode,

View File

@ -138,6 +138,10 @@ public partial class OrderService
// 2. 硬校验。拦截类自动触发在落库前执行,命中即失败,订单不会留下。
var sessionCustomer = await ResolveEffectiveCustomerAsync(input.CustomerId);
input.CustomerId = sessionCustomer.Id;
var mainCustomerForAgent = await MarketingCustomerHelper.ResolveMarketingMainCustomerAsync(
CustomerRepository,
sessionCustomer);
var orderAgentId = mainCustomerForAgent.AgentId;
await ValidateParcelCreateInputAsync(input);
@ -180,6 +184,7 @@ public partial class OrderService
snapshotBinding,
serviceNo,
createContext);
order.AgentId = orderAgentId;
order = await OrderRepository.InsertAsync(order);
var weights = BuildInitialWeightRows(order.Id, input);

View File

@ -297,6 +297,11 @@ public partial class OrderService : ApplicationService
order = await GetOrderOrThrowAsync(order.Id);
await ApplyDeliverySuccessMarketingAsync(order);
}
else if (string.Equals(target, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase))
{
order = await GetOrderOrThrowAsync(order.Id);
await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliveryFailed);
}
order = await GetOrderOrThrowAsync(order.Id);
var afterSnap = await CaptureOrderAuditSnapshotAsync(order);
@ -340,6 +345,14 @@ public partial class OrderService : ApplicationService
var couponManager = LazyServiceProvider.GetRequiredService<MarketingCouponManager>();
await couponManager.OnFirstDeliverySuccessAsync(order);
await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess);
}
private async Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus)
{
var agentCommission = LazyServiceProvider.GetRequiredService<AgentCommissionManager>();
await agentCommission.TryAccrueOnDeliveryTerminalAsync(order.Id, triggerStatus);
}
/// <summary>

View File

@ -0,0 +1,40 @@
using Fake.Application;
using Fake.Domain.Exceptions;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Shared;
/// <summary>
/// 注册时绑定渠道代理。与 C2C 邀请互斥:有 agent 时由调用方跳过邀请绑定。
/// </summary>
public static class AgentBinder
{
public static async Task BindAgentOnCreateAsync(
Customer entity,
string? agentCode,
IAgentRepository agentRepository,
CancellationToken cancellationToken = default)
{
if (entity.AgentId.HasValue && entity.AgentId.Value > 0)
{
return;
}
var code = agentCode?.Trim();
if (string.IsNullOrWhiteSpace(code))
{
return;
}
var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
if (agent == null || agent.Status != EnableStatus.Enabled)
{
throw new BusinessException("代理编号无效或已停用");
}
entity.AgentId = agent.Id;
entity.AgentBindTime = DateTime.Now;
}
}

View File

@ -0,0 +1,203 @@
using Fake.DependencyInjection;
using Oms.Domain.AgentsAggregate.Consts;
using Oms.Domain.AgentsAggregate.Entities;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.OrdersAggregate.Repositories;
using System.Globalization;
using System.Text.Json;
namespace Oms.Application.Shared;
/// <summary>
/// 渠道代理返佣:派送成功/失败按代理规则写明细(幂等,不入余额)。
/// </summary>
public class AgentCommissionManager : IScopedDependency
{
private readonly IAgentRepository _agentRepository;
private readonly IAgentCommissionDetailRepository _detailRepository;
private readonly IOrderRepository _orderRepository;
private readonly ISalesProductRepository _salesProductRepository;
public AgentCommissionManager(
IAgentRepository agentRepository,
IAgentCommissionDetailRepository detailRepository,
IOrderRepository orderRepository,
ISalesProductRepository salesProductRepository)
{
_agentRepository = agentRepository;
_detailRepository = detailRepository;
_orderRepository = orderRepository;
_salesProductRepository = salesProductRepository;
}
public async Task TryAccrueOnDeliveryTerminalAsync(
int orderId,
string triggerOrderStatus,
CancellationToken cancellationToken = default)
{
if (!IsTerminalTrigger(triggerOrderStatus))
{
return;
}
var existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
if (existing != null)
{
return;
}
var order = await _orderRepository.FirstOrDefaultAsync(x => x.Id == orderId, cancellationToken);
if (order == null || !order.AgentId.HasValue || order.AgentId.Value <= 0 || !order.CustomerId.HasValue)
{
return;
}
if (order.IsRefunded > 0)
{
return;
}
var agent = await _agentRepository.FirstOrDefaultAsync(x => x.Id == order.AgentId.Value, cancellationToken);
if (agent == null || agent.Status != Domain.Shared.Enums.EnableStatus.Enabled)
{
return;
}
if (!AgentCommissionBaseTypes.IsSupported(agent.BaseType))
{
return;
}
if (agent.CommissionRate <= 0)
{
return;
}
int? salesProductId = null;
if (!string.IsNullOrWhiteSpace(order.ProductCode))
{
var product = await _salesProductRepository.GetByProductCodeAsync(order.ProductCode.Trim());
salesProductId = product?.Id;
}
if (!IsProductAllowed(agent.SalesProductIds, salesProductId))
{
return;
}
var fees = await _orderRepository.GetFeesByOrderIdsAsync([order.Id]);
var baseAmount = ResolveBaseAmount(agent.BaseType, fees, order);
if (baseAmount <= 0)
{
return;
}
var amount = decimal.Round(baseAmount * agent.CommissionRate, 4, MidpointRounding.AwayFromZero);
if (amount <= 0)
{
return;
}
// 并发双写时靠二次查重;无库级唯一索引
existing = await _detailRepository.GetByOrderIdAsync(orderId, cancellationToken);
if (existing != null)
{
return;
}
await _detailRepository.InsertAsync(new AgentCommissionDetail
{
AgentId = agent.Id,
OrderId = order.Id,
CustomerId = order.CustomerId.Value,
SalesProductId = salesProductId,
BaseType = agent.BaseType.Trim().ToLowerInvariant(),
BaseAmount = baseAmount,
CommissionRate = agent.CommissionRate,
CommissionAmount = amount,
Status = AgentCommissionStatusCodes.Pending,
TriggerOrderStatus = triggerOrderStatus.Trim()
}, cancellationToken);
}
private static bool IsTerminalTrigger(string? status) =>
string.Equals(status, OrderStatusCodes.DeliverySuccess, StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, OrderStatusCodes.DeliveryFailed, StringComparison.OrdinalIgnoreCase);
private static decimal ResolveBaseAmount(
string baseType,
List<Domain.OrdersAggregate.Entities.OrderFee> fees,
Domain.OrdersAggregate.Entities.Order order)
{
if (string.Equals(baseType, AgentCommissionBaseTypes.Freight, StringComparison.OrdinalIgnoreCase))
{
return MarketingFeeHelper.ResolvePaidBaseFreight(
MarketingFeeHelper.SumReceivableBaseFreight(fees),
order.LevelDiscountAmount,
order.CouponDiscountAmount);
}
// receivable:折前应收小计(排除营销抵扣行)
var sum = MarketingFeeHelper.SumReceivable(fees);
if (sum > 0)
{
return sum;
}
return Math.Max(0M, order.ReceivableSubtotal ?? 0M);
}
internal static bool IsProductAllowed(string? salesProductIdsRaw, int? salesProductId)
{
var allowed = ParseProductIds(salesProductIdsRaw);
if (allowed.Count == 0)
{
return true;
}
return salesProductId.HasValue && allowed.Contains(salesProductId.Value);
}
internal static HashSet<int> ParseProductIds(string? raw)
{
var set = new HashSet<int>();
if (string.IsNullOrWhiteSpace(raw))
{
return set;
}
var text = raw.Trim();
try
{
if (text.StartsWith('['))
{
var list = JsonSerializer.Deserialize<List<int>>(text);
if (list != null)
{
foreach (var id in list.Where(x => x > 0))
{
set.Add(id);
}
}
return set;
}
}
catch (JsonException)
{
// fall through to comma split
}
foreach (var part in text.Split([',', ';', '|', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id) && id > 0)
{
set.Add(id);
}
}
return set;
}
}

View File

@ -0,0 +1,12 @@
namespace Oms.Domain.AgentsAggregate.Consts;
/// <summary>代理返佣基数类型(首版仅运费/应收)。</summary>
public static class AgentCommissionBaseTypes
{
public const string Freight = "freight";
public const string Receivable = "receivable";
public static bool IsSupported(string? baseType) =>
string.Equals(baseType, Freight, StringComparison.OrdinalIgnoreCase)
|| string.Equals(baseType, Receivable, StringComparison.OrdinalIgnoreCase);
}

View File

@ -0,0 +1,8 @@
namespace Oms.Domain.AgentsAggregate.Consts;
/// <summary>代理返佣明细状态(首版)。</summary>
public static class AgentCommissionStatusCodes
{
public const string Pending = "pending";
public const string Paid = "paid";
}

View File

@ -0,0 +1,45 @@
using Oms.Domain.Shared;
using Oms.Domain.Shared.Enums;
using SqlSugar;
namespace Oms.Domain.AgentsAggregate.Entities;
/// <summary>渠道代理主数据。</summary>
[SugarTable("t_agent")]
public class Agent : OmsAuditedAggregateRoot
{
/// <summary>RBAC user.userid / JWT mapId。</summary>
public int UserId { get; set; }
/// <summary>登录账号 + 注册链接编码。</summary>
[SugarColumn(Length = 32)]
public string AgentCode { get; set; } = string.Empty;
[SugarColumn(Length = 64)]
public string AgentName { get; set; } = string.Empty;
[SugarColumn(Length = 32, IsNullable = true)]
public string? Phone { get; set; }
[SugarColumn(Length = 128, IsNullable = true)]
public string? Email { get; set; }
/// <summary>MD5 密码(对齐客户)。</summary>
[SugarColumn(Length = 64)]
public string Password { get; set; } = string.Empty;
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
/// <summary>freight / receivable。</summary>
[SugarColumn(Length = 32)]
public string BaseType { get; set; } = "freight";
public decimal CommissionRate { get; set; }
/// <summary>可选销售产品 Id 列表(JSON 或逗号);空=不限产品。</summary>
[SugarColumn(Length = 512, IsNullable = true)]
public string? SalesProductIds { get; set; }
[SugarColumn(Length = 512, IsNullable = true)]
public string? Remark { get; set; }
}

View File

@ -0,0 +1,38 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.AgentsAggregate.Entities;
/// <summary>代理返佣明细(首版无余额入账)。</summary>
[SugarTable("t_agent_commission_detail")]
public class AgentCommissionDetail : OmsAuditedAggregateRoot
{
public int AgentId { get; set; }
public int OrderId { get; set; }
public int CustomerId { get; set; }
public int? SalesProductId { get; set; }
[SugarColumn(Length = 32)]
public string BaseType { get; set; } = string.Empty;
public decimal BaseAmount { get; set; }
public decimal CommissionRate { get; set; }
public decimal CommissionAmount { get; set; }
/// <summary>pending / paid。</summary>
[SugarColumn(Length = 16)]
public string Status { get; set; } = "pending";
[SugarColumn(Length = 32)]
public string TriggerOrderStatus { get; set; } = string.Empty;
public DateTime? PaidTime { get; set; }
[SugarColumn(Length = 512, IsNullable = true)]
public string? Remark { get; set; }
}

View File

@ -0,0 +1,18 @@
using Fake.Domain.Repositories;
using Oms.Domain.AgentsAggregate.Entities;
namespace Oms.Domain.AgentsAggregate.Repositories;
public interface IAgentRepository : IRepository<Agent>
{
Task<Agent?> GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default);
Task<Agent?> GetByUserIdAsync(int userId, CancellationToken cancellationToken = default);
Task<bool> ExistsAgentCodeAsync(string agentCode, int? excludeId = null, CancellationToken cancellationToken = default);
}
public interface IAgentCommissionDetailRepository : IRepository<AgentCommissionDetail>
{
Task<AgentCommissionDetail?> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
}

View File

@ -55,6 +55,11 @@ public class Customer : OmsAuditedAggregateRoot
public decimal CommissionBalance { get; set; }
/// <summary>所属渠道代理(建议写在主客户上)。</summary>
public int? AgentId { get; set; }
public DateTime? AgentBindTime { get; set; }
public string? LastLoginIp { get; set; }
public DateTime? LastLoginTime { get; set; }

View File

@ -129,6 +129,9 @@ public class Order : OmsAuditedAggregateRoot
public int? CustomerId { get; set; }
/// <summary>下单时代理快照(跟主客户 AgentId)。</summary>
public int? AgentId { get; set; }
public int? MemberLevelId { get; set; }
public string? PriceTierKey { get; set; }

View File

@ -0,0 +1,37 @@
using Fake.DependencyInjection;
using Oms.Domain.AgentsAggregate.Entities;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.Agents;
public class AgentRepository : OmsSqlSugarRepository<Agent>, IAgentRepository, IScopedDependency
{
public Task<Agent?> GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default)
=> FirstOrDefaultAsync(x => x.AgentCode == agentCode, cancellationToken);
public Task<Agent?> GetByUserIdAsync(int userId, CancellationToken cancellationToken = default)
=> FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
public async Task<bool> ExistsAgentCodeAsync(
string agentCode,
int? excludeId = null,
CancellationToken cancellationToken = default)
{
if (excludeId.HasValue)
{
return await AnyAsync(x => x.AgentCode == agentCode && x.Id != excludeId.Value, cancellationToken);
}
return await AnyAsync(x => x.AgentCode == agentCode, cancellationToken);
}
}
public class AgentCommissionDetailRepository
: OmsSqlSugarRepository<AgentCommissionDetail>,
IAgentCommissionDetailRepository,
IScopedDependency
{
public Task<AgentCommissionDetail?> GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default)
=> FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken);
}

View File

@ -0,0 +1,32 @@
using Fake.Data.Seeding;
using Fake.Rbac.Application.Dtos.Role;
using Fake.Rbac.Application.Services;
namespace SimpleAdmin.Api.Auth;
public class AgentRoleSeedContributor(RoleService roleService) : IDataSeedContributor
{
public const string AgentRoleCode = "Agent";
public const string AgentRoleName = "渠道代理";
public async Task SeedAsync()
{
var roles = await roleService.GetAllRolesAsync();
if (roles.Any(x => string.Equals(x.Code, AgentRoleCode, StringComparison.OrdinalIgnoreCase)))
{
return;
}
await roleService.CreateAsync(new RoleCreateDto
{
Code = AgentRoleCode,
Name = AgentRoleName,
Permissions =
[
"Agent.Portal",
"Agent.Customers.Query",
"Agent.Orders.Query"
]
});
}
}

View File

@ -5,7 +5,8 @@ namespace SimpleAdmin.Api.Auth;
public enum OmsLoginType
{
User = 0,
Customer = 1
Customer = 1,
Agent = 2
}
public class OmsLoginInput

View File

@ -21,9 +21,11 @@ using Fake.ObjectStorage;
using Oms.Application;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.Shared.Enums;
using SimpleAdmin.Api.WeChat.Dtos;
using SimpleAdmin.Api.WeChat.Services;
using SixLabors.ImageSharp;
@ -68,6 +70,7 @@ public partial class AuthService(
{
OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken),
OmsLoginType.Customer => await LoginCustomerAsync(input.Account, input.Password, cancellationToken),
OmsLoginType.Agent => await LoginAgentAsync(input.Account, input.Password, cancellationToken),
_ => throw new BusinessException("不支持的登录类型")
};
}
@ -284,6 +287,11 @@ public partial class AuthService(
return await RefreshCustomerTokenAsync(token.Claims, cancellationToken);
}
if (loginType == OmsLoginType.Agent)
{
return await RefreshAgentTokenAsync(token.Claims, cancellationToken);
}
if (!Guid.TryParse(userIdText, out var userId))
{
throw new BusinessException("无效的刷新令牌");
@ -438,6 +446,60 @@ public partial class AuthService(
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
}
private async Task<OmsLoginResultDto> LoginAgentAsync(string account, string password, CancellationToken cancellationToken)
{
var agentRepository = LazyServiceProvider.GetRequiredService<IAgentRepository>();
var code = account.Trim();
var agent = await agentRepository.GetByAgentCodeAsync(code, cancellationToken);
if (agent == null)
{
throw new BusinessException("代理账号或密码错误");
}
if (agent.Status != EnableStatus.Enabled)
{
throw new BusinessException("代理已停用");
}
if (!string.Equals(agent.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
{
throw new BusinessException("代理账号或密码错误");
}
var userGuid = await GetRbacUserGuidByMapIdAsync(agent.UserId, cancellationToken);
if (!userGuid.HasValue)
{
throw new BusinessException("代理未关联用户");
}
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Agent, cancellationToken);
}
private async Task<OmsLoginResultDto> RefreshAgentTokenAsync(IEnumerable<Claim> claims, CancellationToken cancellationToken)
{
var mapId = ParseIntClaim(claims, SimpleOmsClaimTypes.MapId);
if (mapId <= 0)
{
throw new BusinessException("无效的刷新令牌");
}
var userGuid = await GetRbacUserGuidByMapIdAsync(mapId, cancellationToken);
if (!userGuid.HasValue)
{
throw new BusinessException("用户不存在");
}
var agentRepository = LazyServiceProvider.GetRequiredService<IAgentRepository>();
var agent = await agentRepository.GetByUserIdAsync(mapId, cancellationToken);
if (agent == null || agent.Status != EnableStatus.Enabled)
{
throw new BusinessException("代理不存在或已停用");
}
var refreshedClaims = ReuseClaimsFromRefreshToken(claims, OmsLoginType.Agent);
return BuildTokenOnlyResult(userGuid.Value, refreshedClaims);
}
private async Task<OmsLoginResultDto> LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
{
var normalizedAccount = account.Trim();

View File

@ -114,6 +114,11 @@ public class WeChatCustomerRegisterInput
/// </summary>
public string? AdvisorCode { get; set; }
/// <summary>
/// 渠道代理编号(可选)。来自 ?agent=;有值时不走 C2C 邀请。
/// </summary>
public string? AgentCode { get; set; }
[Required]
public WeChatProfileInput Wechat { get; set; } = new();
}

View File

@ -14,6 +14,7 @@ using Oms.Application.Shared;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Enums;
using Oms.Domain.CustomersAggregate.Repositories;
using Oms.Domain.AgentsAggregate.Repositories;
using Oms.Domain.MarketingAggregate.Repositories;
using SimpleAdmin.Api.WeChat.Dtos;
using SimpleAdmin.Api.WeChat.Services;
@ -30,6 +31,7 @@ public class WeChatCustomerService(
ICustomerRepository customerRepository,
ICustomerReferralCodeRepository referralCodeRepository,
IMemberLevelRepository memberLevelRepository,
IAgentRepository agentRepository,
MarketingCouponManager couponManager,
UserService userService,
RoleService roleService,
@ -156,17 +158,21 @@ public class WeChatCustomerService(
cancellationToken),
PriceTierKey = MarketingCustomerHelper.DefaultNewCustomerPriceTierKey
};
await MarketingReferralBinder.BindReferrerOnCreateAsync(
entity,
input.InviteCode,
referralCodeRepository,
customerRepository,
memberLevelRepository,
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>(),
cancellationToken);
if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, agentRepository, cancellationToken);
if (!entity.AgentId.HasValue)
{
entity.ReferralChannel = "wechat";
await MarketingReferralBinder.BindReferrerOnCreateAsync(
entity,
input.InviteCode,
referralCodeRepository,
customerRepository,
memberLevelRepository,
LazyServiceProvider.GetRequiredService<IReferralCommissionRuleRepository>(),
cancellationToken);
if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
{
entity.ReferralChannel = "wechat";
}
}
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,