diff --git a/sql/migrate_agent_module_20261029.sql b/sql/migrate_agent_module_20261029.sql
new file mode 100644
index 0000000..78a5ba3
--- /dev/null
+++ b/sql/migrate_agent_module_20261029.sql
@@ -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;
diff --git a/src/Oms.Application/Dtos/Agents/AgentDtos.cs b/src/Oms.Application/Dtos/Agents/AgentDtos.cs
new file mode 100644
index 0000000..8e849e3
--- /dev/null
+++ b/src/Oms.Application/Dtos/Agents/AgentDtos.cs
@@ -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;
+
+ /// freight / receivable。
+ [Required]
+ [StringLength(32)]
+ public string BaseType { get; set; } = "freight";
+
+ [Range(0, 1)]
+ public decimal CommissionRate { get; set; }
+
+ /// 可选销售产品 Id,JSON 或逗号分隔;空=不限。
+ [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; }
+
+ /// 不传则不改密码。
+ [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; }
+
+ /// 空则解绑。
+ 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 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; }
+}
diff --git a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
index 1c1ac13..a1c3d94 100644
--- a/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
+++ b/src/Oms.Application/Dtos/Customers/CreateCustomerInput.cs
@@ -124,4 +124,10 @@ public class CreateCustomerInput
[StringLength(64)]
public string? AdvisorCode { get; set; }
+ ///
+ /// 渠道代理编号(注册绑定;有值时不走 C2C 邀请码)。
+ ///
+ [StringLength(32)]
+ public string? AgentCode { get; set; }
+
}
diff --git a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs
index e82a28c..4a31055 100644
--- a/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs
+++ b/src/Oms.Application/Messaging/TrackChange/TrackChangeOrderStatusApplier.cs
@@ -16,6 +16,7 @@ public sealed class TrackChangeOrderStatusApplier(
IOrderRepository orderRepository,
OrderOperationLogWriter operationLogWriter,
ReferralCommissionManager referralCommissionManager,
+ AgentCommissionManager agentCommissionManager,
MarketingCouponManager marketingCouponManager,
IUnitOfWorkManager unitOfWorkManager,
ILogger 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;
diff --git a/src/Oms.Application/Services/Agents/AgentAdminService.cs b/src/Oms.Application/Services/Agents/AgentAdminService.cs
new file mode 100644
index 0000000..a92323f
--- /dev/null
+++ b/src/Oms.Application/Services/Agents/AgentAdminService.cs
@@ -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;
+
+///
+/// 渠道代理管理端 API(开户、规则、改绑、返佣明细)。
+///
+[ApiExplorerSettings(GroupName = "OMS-Agent")]
+public class AgentAdminService : ApplicationService
+{
+ public const string AgentRoleCode = "Agent";
+
+ private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService();
+
+ private IAgentCommissionDetailRepository DetailRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
+
+ private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService();
+
+ private UserService UserService => LazyServiceProvider.GetRequiredService();
+
+ private RoleService RoleService => LazyServiceProvider.GetRequiredService();
+
+ private FakeRbacDbContext RbacDbContext => LazyServiceProvider.GetRequiredService();
+
+ /// 分页查询代理。
+ [HttpPost("/agent/admin/list")]
+ public async Task> 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 { [nameof(Agent.CreateTime)] = false, [nameof(Agent.Id)] = false });
+ var total = await AgentRepository.CountAsync(predicate);
+ return new OmsPageResult
+ {
+ Items = entities.Select(MapAgent).ToList(),
+ Total = total
+ };
+ }
+
+ /// 创建代理(同步 RBAC 用户 + Agent 角色)。
+ [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;
+ }
+ }
+
+ /// 更新代理资料与返佣规则。
+ [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);
+ }
+
+ /// 改绑主客户所属代理(仅影响之后新单)。
+ [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);
+ }
+
+ /// 分页查询返佣明细。
+ [HttpPost("/agent/admin/commission/list")]
+ public async Task> 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
+ {
+ [nameof(AgentCommissionDetail.CreateTime)] = false,
+ [nameof(AgentCommissionDetail.Id)] = false
+ });
+ var total = await DetailRepository.CountAsync(predicate);
+ return new OmsPageResult
+ {
+ Items = await MapCommissionOutputsAsync(entities),
+ Total = total
+ };
+ }
+
+ /// 标记返佣明细为已打款。
+ [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> 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> 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> MapCommissionOutputsAsync(
+ List 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 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 GetRbacUserMapIdOrThrowAsync(Guid userId)
+ {
+ var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}";
+ var mapId = await RbacDbContext.Database.SqlQueryRaw(sql, userId).FirstOrDefaultAsync();
+ if (!mapId.HasValue || mapId.Value <= 0)
+ {
+ throw new BusinessException("RBAC 用户映射失败");
+ }
+
+ return mapId.Value;
+ }
+}
diff --git a/src/Oms.Application/Services/Agents/AgentPortalService.cs b/src/Oms.Application/Services/Agents/AgentPortalService.cs
new file mode 100644
index 0000000..ba05bf9
--- /dev/null
+++ b/src/Oms.Application/Services/Agents/AgentPortalService.cs
@@ -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;
+
+///
+/// 渠道代理门户只读 API(名下客户 / 订单 / 返佣)。
+///
+[ApiExplorerSettings(GroupName = "OMS-Agent")]
+public class AgentPortalService : ApplicationService
+{
+ private IAgentRepository AgentRepository => LazyServiceProvider.GetRequiredService();
+
+ private IAgentCommissionDetailRepository DetailRepository =>
+ LazyServiceProvider.GetRequiredService();
+
+ private ICustomerRepository CustomerRepository => LazyServiceProvider.GetRequiredService();
+
+ private IOrderRepository OrderRepository => LazyServiceProvider.GetRequiredService();
+
+ /// 当前代理资料。
+ [HttpGet("/agent/portal/profile")]
+ public async Task 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
+ };
+ }
+
+ /// 名下客户分页(含主客户 AgentId=我)。
+ [HttpPost("/agent/portal/customers/list")]
+ public async Task> GetCustomersAsync(AgentPortalPageInput input)
+ {
+ var agent = await GetCurrentAgentOrThrowAsync();
+ var keyword = input.Keyword?.Trim();
+ Expression> 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
+ {
+ [nameof(Customer.CreateTime)] = false,
+ [nameof(Customer.Id)] = false
+ });
+ var total = await CustomerRepository.CountAsync(predicate);
+ return new OmsPageResult
+ {
+ 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
+ };
+ }
+
+ /// 名下订单分页(强制 Order.AgentId;不含应付)。
+ [HttpPost("/agent/portal/orders/list")]
+ public async Task> GetOrdersAsync(AgentPortalPageInput input)
+ {
+ var agent = await GetCurrentAgentOrThrowAsync();
+ var keyword = input.Keyword?.Trim();
+ Expression> 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
+ {
+ [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
+ {
+ 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
+ };
+ }
+
+ /// 名下返佣明细。
+ [HttpPost("/agent/portal/commission/list")]
+ public async Task> GetCommissionListAsync(AgentPortalPageInput input)
+ {
+ var agent = await GetCurrentAgentOrThrowAsync();
+ Expression> 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
+ {
+ [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
+ {
+ 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 GetCurrentAgentOrThrowAsync()
+ {
+ var mapId = CurrentUser.GetId();
+ 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));
+ }
+}
diff --git a/src/Oms.Application/Services/Customers/CustomerService.cs b/src/Oms.Application/Services/Customers/CustomerService.cs
index 02d8ab0..fe27eed 100644
--- a/src/Oms.Application/Services/Customers/CustomerService.cs
+++ b/src/Oms.Application/Services/Customers/CustomerService.cs
@@ -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();
+ private IAgentRepository AgentRepository =>
+ LazyServiceProvider.GetRequiredService();
+
private MarketingCouponManager CouponManager =>
LazyServiceProvider.GetRequiredService();
@@ -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());
+ await AgentBinder.BindAgentOnCreateAsync(entity, input.AgentCode, AgentRepository);
+ // 有代理编号时不走 C2C 邀请,避免双发佣金
+ if (!entity.AgentId.HasValue)
+ {
+ await MarketingReferralBinder.BindReferrerOnCreateAsync(
+ entity,
+ input.InviteCode,
+ ReferralCodeRepository,
+ CustomerRepository,
+ MemberLevelRepository,
+ LazyServiceProvider.GetRequiredService());
+ }
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,
input.AdvisorCode,
diff --git a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
index 3237847..a2237c3 100644
--- a/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.ParcelBilling.cs
@@ -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);
diff --git a/src/Oms.Application/Services/Orders/OrderService.cs b/src/Oms.Application/Services/Orders/OrderService.cs
index b8d113a..1938010 100644
--- a/src/Oms.Application/Services/Orders/OrderService.cs
+++ b/src/Oms.Application/Services/Orders/OrderService.cs
@@ -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();
await couponManager.OnFirstDeliverySuccessAsync(order);
+
+ await ApplyAgentCommissionOnDeliveryTerminalAsync(order, OrderStatusCodes.DeliverySuccess);
+ }
+
+ private async Task ApplyAgentCommissionOnDeliveryTerminalAsync(Order order, string triggerStatus)
+ {
+ var agentCommission = LazyServiceProvider.GetRequiredService();
+ await agentCommission.TryAccrueOnDeliveryTerminalAsync(order.Id, triggerStatus);
}
///
diff --git a/src/Oms.Application/Shared/AgentBinder.cs b/src/Oms.Application/Shared/AgentBinder.cs
new file mode 100644
index 0000000..d4abe01
--- /dev/null
+++ b/src/Oms.Application/Shared/AgentBinder.cs
@@ -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;
+
+///
+/// 注册时绑定渠道代理。与 C2C 邀请互斥:有 agent 时由调用方跳过邀请绑定。
+///
+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;
+ }
+}
diff --git a/src/Oms.Application/Shared/AgentCommissionManager.cs b/src/Oms.Application/Shared/AgentCommissionManager.cs
new file mode 100644
index 0000000..6684b59
--- /dev/null
+++ b/src/Oms.Application/Shared/AgentCommissionManager.cs
@@ -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;
+
+///
+/// 渠道代理返佣:派送成功/失败按代理规则写明细(幂等,不入余额)。
+///
+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 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 ParseProductIds(string? raw)
+ {
+ var set = new HashSet();
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return set;
+ }
+
+ var text = raw.Trim();
+ try
+ {
+ if (text.StartsWith('['))
+ {
+ var list = JsonSerializer.Deserialize>(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;
+ }
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs
new file mode 100644
index 0000000..36b1a26
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionBaseTypes.cs
@@ -0,0 +1,12 @@
+namespace Oms.Domain.AgentsAggregate.Consts;
+
+/// 代理返佣基数类型(首版仅运费/应收)。
+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);
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs
new file mode 100644
index 0000000..0639032
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Consts/AgentCommissionStatusCodes.cs
@@ -0,0 +1,8 @@
+namespace Oms.Domain.AgentsAggregate.Consts;
+
+/// 代理返佣明细状态(首版)。
+public static class AgentCommissionStatusCodes
+{
+ public const string Pending = "pending";
+ public const string Paid = "paid";
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs b/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
new file mode 100644
index 0000000..fb3ade8
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Entities/Agent.cs
@@ -0,0 +1,45 @@
+using Oms.Domain.Shared;
+using Oms.Domain.Shared.Enums;
+using SqlSugar;
+
+namespace Oms.Domain.AgentsAggregate.Entities;
+
+/// 渠道代理主数据。
+[SugarTable("t_agent")]
+public class Agent : OmsAuditedAggregateRoot
+{
+ /// RBAC user.userid / JWT mapId。
+ public int UserId { get; set; }
+
+ /// 登录账号 + 注册链接编码。
+ [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; }
+
+ /// MD5 密码(对齐客户)。
+ [SugarColumn(Length = 64)]
+ public string Password { get; set; } = string.Empty;
+
+ public EnableStatus Status { get; set; } = EnableStatus.Enabled;
+
+ /// freight / receivable。
+ [SugarColumn(Length = 32)]
+ public string BaseType { get; set; } = "freight";
+
+ public decimal CommissionRate { get; set; }
+
+ /// 可选销售产品 Id 列表(JSON 或逗号);空=不限产品。
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? SalesProductIds { get; set; }
+
+ [SugarColumn(Length = 512, IsNullable = true)]
+ public string? Remark { get; set; }
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs
new file mode 100644
index 0000000..dba8aec
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Entities/AgentCommissionDetail.cs
@@ -0,0 +1,38 @@
+using Oms.Domain.Shared;
+using SqlSugar;
+
+namespace Oms.Domain.AgentsAggregate.Entities;
+
+/// 代理返佣明细(首版无余额入账)。
+[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; }
+
+ /// pending / paid。
+ [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; }
+}
diff --git a/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs b/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs
new file mode 100644
index 0000000..66e61f4
--- /dev/null
+++ b/src/Oms.Domain/AgentsAggregate/Repositories/IAgentRepositories.cs
@@ -0,0 +1,18 @@
+using Fake.Domain.Repositories;
+using Oms.Domain.AgentsAggregate.Entities;
+
+namespace Oms.Domain.AgentsAggregate.Repositories;
+
+public interface IAgentRepository : IRepository
+{
+ Task GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default);
+
+ Task GetByUserIdAsync(int userId, CancellationToken cancellationToken = default);
+
+ Task ExistsAgentCodeAsync(string agentCode, int? excludeId = null, CancellationToken cancellationToken = default);
+}
+
+public interface IAgentCommissionDetailRepository : IRepository
+{
+ Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
index 0f73967..22f87fa 100644
--- a/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
+++ b/src/Oms.Domain/CustomersAggregate/Entities/Customer.cs
@@ -55,6 +55,11 @@ public class Customer : OmsAuditedAggregateRoot
public decimal CommissionBalance { get; set; }
+ /// 所属渠道代理(建议写在主客户上)。
+ public int? AgentId { get; set; }
+
+ public DateTime? AgentBindTime { get; set; }
+
public string? LastLoginIp { get; set; }
public DateTime? LastLoginTime { get; set; }
diff --git a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
index 2f2b77e..d0334bf 100644
--- a/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
+++ b/src/Oms.Domain/OrdersAggregate/Entities/Order.cs
@@ -129,6 +129,9 @@ public class Order : OmsAuditedAggregateRoot
public int? CustomerId { get; set; }
+ /// 下单时代理快照(跟主客户 AgentId)。
+ public int? AgentId { get; set; }
+
public int? MemberLevelId { get; set; }
public string? PriceTierKey { get; set; }
diff --git a/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs b/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
new file mode 100644
index 0000000..fce388b
--- /dev/null
+++ b/src/Oms.SqlSugarCore/Agents/AgentRepositories.cs
@@ -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, IAgentRepository, IScopedDependency
+{
+ public Task GetByAgentCodeAsync(string agentCode, CancellationToken cancellationToken = default)
+ => FirstOrDefaultAsync(x => x.AgentCode == agentCode, cancellationToken);
+
+ public Task GetByUserIdAsync(int userId, CancellationToken cancellationToken = default)
+ => FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
+
+ public async Task 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,
+ IAgentCommissionDetailRepository,
+ IScopedDependency
+{
+ public Task GetByOrderIdAsync(int orderId, CancellationToken cancellationToken = default)
+ => FirstOrDefaultAsync(x => x.OrderId == orderId, cancellationToken);
+}
diff --git a/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs b/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
new file mode 100644
index 0000000..d40a217
--- /dev/null
+++ b/src/SimpleAdmin.Api/Auth/AgentRoleSeedContributor.cs
@@ -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"
+ ]
+ });
+ }
+}
diff --git a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs
index c1c40fb..54ed3b0 100644
--- a/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs
+++ b/src/SimpleAdmin.Api/Auth/AuthRequestDtos.cs
@@ -5,7 +5,8 @@ namespace SimpleAdmin.Api.Auth;
public enum OmsLoginType
{
User = 0,
- Customer = 1
+ Customer = 1,
+ Agent = 2
}
public class OmsLoginInput
diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs
index 5bef285..6809f4a 100644
--- a/src/SimpleAdmin.Api/Auth/AuthService.cs
+++ b/src/SimpleAdmin.Api/Auth/AuthService.cs
@@ -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 LoginAgentAsync(string account, string password, CancellationToken cancellationToken)
+ {
+ var agentRepository = LazyServiceProvider.GetRequiredService();
+ 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 RefreshAgentTokenAsync(IEnumerable 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();
+ 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 LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
{
var normalizedAccount = account.Trim();
diff --git a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs
index d254466..246efac 100644
--- a/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs
+++ b/src/SimpleAdmin.Api/WeChat/Dtos/WeChatDtos.cs
@@ -114,6 +114,11 @@ public class WeChatCustomerRegisterInput
///
public string? AdvisorCode { get; set; }
+ ///
+ /// 渠道代理编号(可选)。来自 ?agent=;有值时不走 C2C 邀请。
+ ///
+ public string? AgentCode { get; set; }
+
[Required]
public WeChatProfileInput Wechat { get; set; } = new();
}
diff --git a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
index e5c6ec2..5771985 100644
--- a/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
+++ b/src/SimpleAdmin.Api/WeChat/WeChatCustomerService.cs
@@ -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(),
- 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(),
+ cancellationToken);
+ if (!string.IsNullOrWhiteSpace(input.InviteCode) && entity.ReferrerCustomerId.HasValue)
+ {
+ entity.ReferralChannel = "wechat";
+ }
}
await StaffAdvisorBinder.BindAdvisorOnCreateAsync(
entity,