feat: 新增渠道代理注册归因与返佣明细
All checks were successful
Build and Deploy / deploy (push) Successful in 1m13s
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:
parent
221a2c8a66
commit
1d987cde6e
78
sql/migrate_agent_module_20261029.sql
Normal file
78
sql/migrate_agent_module_20261029.sql
Normal 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;
|
||||
234
src/Oms.Application/Dtos/Agents/AgentDtos.cs
Normal file
234
src/Oms.Application/Dtos/Agents/AgentDtos.cs
Normal 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; }
|
||||
}
|
||||
@ -124,4 +124,10 @@ public class CreateCustomerInput
|
||||
[StringLength(64)]
|
||||
public string? AdvisorCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? AgentCode { get; set; }
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
414
src/Oms.Application/Services/Agents/AgentAdminService.cs
Normal file
414
src/Oms.Application/Services/Agents/AgentAdminService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
258
src/Oms.Application/Services/Agents/AgentPortalService.cs
Normal file
258
src/Oms.Application/Services/Agents/AgentPortalService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
40
src/Oms.Application/Shared/AgentBinder.cs
Normal file
40
src/Oms.Application/Shared/AgentBinder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
203
src/Oms.Application/Shared/AgentCommissionManager.cs
Normal file
203
src/Oms.Application/Shared/AgentCommissionManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
45
src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
Normal file
45
src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
Normal 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; }
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
@ -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; }
|
||||
|
||||
37
src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
Normal file
37
src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
Normal 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);
|
||||
}
|
||||
32
src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
Normal file
32
src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
Normal 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"
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,8 @@ namespace SimpleAdmin.Api.Auth;
|
||||
public enum OmsLoginType
|
||||
{
|
||||
User = 0,
|
||||
Customer = 1
|
||||
Customer = 1,
|
||||
Agent = 2
|
||||
}
|
||||
|
||||
public class OmsLoginInput
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user