feat: RBAC用户扩展、专属顾问接口及仓库字段清理
接管用户 API 支持电话/工号/微信号/微信二维码,新增客户专属顾问查询,并移除仓库客服电话与微信相关字段。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d8752959e7
commit
3818ce7876
3
db.sql
3
db.sql
@ -1125,9 +1125,6 @@ CREATE TABLE `t_warehouse_address` (
|
||||
`Recipient` VARCHAR(64) NULL COMMENT '收件人',
|
||||
`ContactPhone` VARCHAR(32) NULL COMMENT '联系电话',
|
||||
`ShippingAddress` TEXT COMMENT '收货地址',
|
||||
`ServicePhone` VARCHAR(32) NULL COMMENT '客服电话',
|
||||
`ServiceWechat` VARCHAR(64) NULL COMMENT '客服微信',
|
||||
`ServiceWechatFiles` JSON NULL COMMENT '微信二维码图片',
|
||||
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
|
||||
`ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳',
|
||||
`CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人',
|
||||
|
||||
10
sql/migrate_rbac_user_profile.sql
Normal file
10
sql/migrate_rbac_user_profile.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- RBAC 用户扩展:电话、工号、微信号、微信二维码
|
||||
-- 执行前请确认当前库为 RBAC 库(含 `user` 表)
|
||||
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `Phone` VARCHAR(32) NULL COMMENT '电话' AFTER `Avatar`,
|
||||
ADD COLUMN `EmployeeNo` VARCHAR(16) NULL COMMENT '工号(NE+4位)' AFTER `Phone`,
|
||||
ADD COLUMN `WechatId` VARCHAR(64) NULL COMMENT '微信号' AFTER `EmployeeNo`,
|
||||
ADD COLUMN `WechatQrCode` LONGTEXT NULL COMMENT '微信二维码' AFTER `WechatId`;
|
||||
|
||||
CREATE UNIQUE INDEX `IX_user_EmployeeNo` ON `user` (`EmployeeNo`);
|
||||
6
sql/migrate_t_warehouse_address_drop_service_fields.sql
Normal file
6
sql/migrate_t_warehouse_address_drop_service_fields.sql
Normal file
@ -0,0 +1,6 @@
|
||||
-- 仓库地址表移除客服电话、客服微信及二维码字段
|
||||
|
||||
ALTER TABLE `t_warehouse_address`
|
||||
DROP COLUMN `ServicePhone`,
|
||||
DROP COLUMN `ServiceWechat`,
|
||||
DROP COLUMN `ServiceWechatFiles`;
|
||||
@ -0,0 +1,22 @@
|
||||
namespace Oms.Application.Dtos.Customers;
|
||||
|
||||
/// <summary>
|
||||
/// 当前客户所属客服(专属顾问)展示信息。
|
||||
/// </summary>
|
||||
public class CustomerServiceAdvisorOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// 顾问姓名。
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信号。
|
||||
/// </summary>
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 微信二维码(URL 或 Base64)。
|
||||
/// </summary>
|
||||
public string? WechatQrCode { get; set; }
|
||||
}
|
||||
@ -14,6 +14,12 @@ public class InternalUserListItemDto
|
||||
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? EmployeeNo { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public string? OrganizationId { get; set; }
|
||||
|
||||
public string? OrganizationName { get; set; }
|
||||
|
||||
45
src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs
Normal file
45
src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs
Normal file
@ -0,0 +1,45 @@
|
||||
using Fake.Rbac.Application.Dtos.User;
|
||||
|
||||
namespace Oms.Application.Dtos.Rbac;
|
||||
|
||||
public class UserProfileData
|
||||
{
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? EmployeeNo { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public string? WechatQrCode { get; set; }
|
||||
}
|
||||
|
||||
public class OmsUserCreateInput : UserCreateDto
|
||||
{
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? EmployeeNo { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public string? WechatQrCode { get; set; }
|
||||
}
|
||||
|
||||
public class OmsUserUpdateInput : UserUpdateDto
|
||||
{
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public string? WechatQrCode { get; set; }
|
||||
}
|
||||
|
||||
public class OmsUserDetailDto : UserDto
|
||||
{
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? EmployeeNo { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public string? WechatQrCode { get; set; }
|
||||
}
|
||||
@ -37,21 +37,4 @@ public class CreateWarehouseInput
|
||||
/// 发货地址。
|
||||
/// </summary>
|
||||
public string? ShippingAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服手机号。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? ServicePhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服微信号。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? ServiceWechat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服微信附件集合。
|
||||
/// </summary>
|
||||
public List<string> ServiceWechatFiles { get; set; } = [];
|
||||
}
|
||||
|
||||
@ -43,21 +43,4 @@ public class UpdateWarehouseInput
|
||||
/// 发货地址。
|
||||
/// </summary>
|
||||
public string? ShippingAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服手机号。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? ServicePhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服微信号。
|
||||
/// </summary>
|
||||
[StringLength(64)]
|
||||
public string? ServiceWechat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服微信附件集合。
|
||||
/// </summary>
|
||||
public List<string> ServiceWechatFiles { get; set; } = [];
|
||||
}
|
||||
|
||||
@ -35,21 +35,6 @@ public class WarehouseOutput
|
||||
/// </summary>
|
||||
public string? ShippingAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服手机号。
|
||||
/// </summary>
|
||||
public string? ServicePhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服微信号。
|
||||
/// </summary>
|
||||
public string? ServiceWechat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服微信附件集合。
|
||||
/// </summary>
|
||||
public List<string> ServiceWechatFiles { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
|
||||
@ -40,21 +40,6 @@ public class WarehousePaginatedOutput
|
||||
/// </summary>
|
||||
public string? ShippingAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服手机号。
|
||||
/// </summary>
|
||||
public string? ServicePhone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服微信号。
|
||||
/// </summary>
|
||||
public string? ServiceWechat { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 客服微信附件集合。
|
||||
/// </summary>
|
||||
public List<string> ServiceWechatFiles { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间。
|
||||
/// </summary>
|
||||
|
||||
@ -28,12 +28,8 @@ public class OmsApplicationAutoMapperProfile : Profile
|
||||
CreateMap<Customer, CustomerOutput>();
|
||||
CreateMap<RechargeRecord, RechargeRecordOutput>();
|
||||
CreateMap<RechargeRecord, RechargeRecordPaginatedOutput>();
|
||||
CreateMap<WarehouseAddress, WarehousePaginatedOutput>()
|
||||
.ForMember(dest => dest.ServiceWechatFiles,
|
||||
opt => opt.MapFrom(src => JsonStringArrayHelper.Deserialize(src.ServiceWechatFiles)));
|
||||
CreateMap<WarehouseAddress, WarehouseOutput>()
|
||||
.ForMember(dest => dest.ServiceWechatFiles,
|
||||
opt => opt.MapFrom(src => JsonStringArrayHelper.Deserialize(src.ServiceWechatFiles)));
|
||||
CreateMap<WarehouseAddress, WarehousePaginatedOutput>();
|
||||
CreateMap<WarehouseAddress, WarehouseOutput>();
|
||||
CreateMap<InboundRecord, InboundRecordOutput>();
|
||||
CreateMap<OutboundRecord, OutboundRecordOutput>();
|
||||
CreateMap<LabelPrintRecord, LabelPrintRecordOutput>();
|
||||
|
||||
86
src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs
Normal file
86
src/Oms.Application/Rbac/CustomerServiceAdvisorResolver.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.Rbac.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Oms.Application.Dtos.Customers;
|
||||
|
||||
namespace Oms.Application.Rbac;
|
||||
|
||||
public class CustomerServiceAdvisorResolver(FakeRbacDbContext dbContext) : IScopedDependency
|
||||
{
|
||||
public async Task<CustomerServiceAdvisorOutput> ResolveAsync(
|
||||
string? customerServiceKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = customerServiceKey?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return new CustomerServiceAdvisorOutput();
|
||||
}
|
||||
|
||||
var row = await FindByUserIdAsync(key, cancellationToken)
|
||||
?? await FindByNameOrAccountAsync(key, cancellationToken);
|
||||
|
||||
if (row == null)
|
||||
{
|
||||
return new CustomerServiceAdvisorOutput { Name = key };
|
||||
}
|
||||
|
||||
return new CustomerServiceAdvisorOutput
|
||||
{
|
||||
Name = row.Name,
|
||||
WechatId = row.WechatId,
|
||||
WechatQrCode = row.WechatQrCode,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AdvisorRow?> FindByUserIdAsync(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!int.TryParse(key, out var userId) || userId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const string sql = """
|
||||
SELECT
|
||||
u.`Name` AS `Name`,
|
||||
u.`WechatId` AS `WechatId`,
|
||||
u.`WechatQrCode` AS `WechatQrCode`
|
||||
FROM `user` u
|
||||
WHERE u.`IsDeleted` = 0
|
||||
AND u.`userid` = {0}
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await dbContext.Database
|
||||
.SqlQueryRaw<AdvisorRow>(sql, userId)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<AdvisorRow?> FindByNameOrAccountAsync(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
u.`Name` AS `Name`,
|
||||
u.`WechatId` AS `WechatId`,
|
||||
u.`WechatQrCode` AS `WechatQrCode`
|
||||
FROM `user` u
|
||||
WHERE u.`IsDeleted` = 0
|
||||
AND (u.`Name` = {0} OR u.`Account` = {0})
|
||||
ORDER BY u.`CreateTime` ASC
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await dbContext.Database
|
||||
.SqlQueryRaw<AdvisorRow>(sql, key)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private sealed class AdvisorRow
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public string? WechatQrCode { get; set; }
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,9 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend
|
||||
u.`Account` AS `Account`,
|
||||
u.`Email` AS `Email`,
|
||||
u.`Avatar` AS `Avatar`,
|
||||
u.`Phone` AS `Phone`,
|
||||
u.`EmployeeNo` AS `EmployeeNo`,
|
||||
u.`WechatId` AS `WechatId`,
|
||||
u.`OrganizationId` AS `OrganizationId`,
|
||||
o.`Name` AS `OrganizationName`,
|
||||
u.`CreateTime` AS `CreatedAt`
|
||||
@ -87,6 +90,9 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend
|
||||
Account = row.Account,
|
||||
Email = row.Email,
|
||||
Avatar = row.Avatar,
|
||||
Phone = row.Phone,
|
||||
EmployeeNo = row.EmployeeNo,
|
||||
WechatId = row.WechatId,
|
||||
OrganizationId = row.OrganizationId?.ToString(),
|
||||
OrganizationName = row.OrganizationName,
|
||||
CreatedAt = row.CreatedAt,
|
||||
@ -143,6 +149,12 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend
|
||||
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? EmployeeNo { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public Guid? OrganizationId { get; set; }
|
||||
|
||||
public string? OrganizationName { get; set; }
|
||||
|
||||
37
src/Oms.Application/Rbac/OmsUserProfileMapper.cs
Normal file
37
src/Oms.Application/Rbac/OmsUserProfileMapper.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using Fake.Rbac.Application.Dtos.User;
|
||||
using Oms.Application.Dtos.Rbac;
|
||||
|
||||
namespace Oms.Application.Rbac;
|
||||
|
||||
internal static class OmsUserProfileMapper
|
||||
{
|
||||
public static OmsUserDetailDto ToDetailDto(UserDto user, UserProfileData? profile)
|
||||
{
|
||||
var detail = new OmsUserDetailDto
|
||||
{
|
||||
Id = user.Id,
|
||||
CreatedAt = user.CreatedAt,
|
||||
CreatedBy = user.CreatedBy,
|
||||
UpdatedAt = user.UpdatedAt,
|
||||
UpdatedBy = user.UpdatedBy,
|
||||
Name = user.Name,
|
||||
Account = user.Account,
|
||||
Email = user.Email,
|
||||
Avatar = user.Avatar,
|
||||
OrganizationId = user.OrganizationId,
|
||||
OrganizationName = user.OrganizationName,
|
||||
Roles = user.Roles,
|
||||
};
|
||||
|
||||
if (profile == null)
|
||||
{
|
||||
return detail;
|
||||
}
|
||||
|
||||
detail.Phone = profile.Phone;
|
||||
detail.EmployeeNo = profile.EmployeeNo;
|
||||
detail.WechatId = profile.WechatId;
|
||||
detail.WechatQrCode = profile.WechatQrCode;
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
226
src/Oms.Application/Rbac/UserProfileRepository.cs
Normal file
226
src/Oms.Application/Rbac/UserProfileRepository.cs
Normal file
@ -0,0 +1,226 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Fake.DependencyInjection;
|
||||
using Fake.Rbac.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Oms.Application.Dtos.Rbac;
|
||||
|
||||
namespace Oms.Application.Rbac;
|
||||
|
||||
public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDependency
|
||||
{
|
||||
private static readonly Regex EmployeeNoPattern = new(@"^NE\d{4}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
public async Task<UserProfileData?> GetAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT
|
||||
u.`Phone` AS `Phone`,
|
||||
u.`EmployeeNo` AS `EmployeeNo`,
|
||||
u.`WechatId` AS `WechatId`,
|
||||
u.`WechatQrCode` AS `WechatQrCode`
|
||||
FROM `user` u
|
||||
WHERE u.`Id` = {0} AND u.`IsDeleted` = 0
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
var row = await dbContext.Database
|
||||
.SqlQueryRaw<UserProfileRow>(sql, userId.ToString())
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (row == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new UserProfileData
|
||||
{
|
||||
Phone = row.Phone,
|
||||
EmployeeNo = row.EmployeeNo,
|
||||
WechatId = row.WechatId,
|
||||
WechatQrCode = row.WechatQrCode,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<Dictionary<Guid, UserProfileData>> GetManyAsync(
|
||||
IReadOnlyCollection<Guid> userIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (userIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var inClause = string.Join(",", userIds.Select(x => $"'{x}'"));
|
||||
var sql = $"""
|
||||
SELECT
|
||||
u.`Id` AS `UserId`,
|
||||
u.`Phone` AS `Phone`,
|
||||
u.`EmployeeNo` AS `EmployeeNo`,
|
||||
u.`WechatId` AS `WechatId`,
|
||||
u.`WechatQrCode` AS `WechatQrCode`
|
||||
FROM `user` u
|
||||
WHERE u.`Id` IN ({inClause}) AND u.`IsDeleted` = 0
|
||||
""";
|
||||
|
||||
var rows = await dbContext.Database.SqlQueryRaw<UserProfileListRow>(sql).ToListAsync(cancellationToken);
|
||||
return rows.ToDictionary(
|
||||
x => x.UserId,
|
||||
x => new UserProfileData
|
||||
{
|
||||
Phone = x.Phone,
|
||||
EmployeeNo = x.EmployeeNo,
|
||||
WechatId = x.WechatId,
|
||||
WechatQrCode = x.WechatQrCode,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SaveAsync(Guid userId, UserProfileData profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE `user`
|
||||
SET
|
||||
`Phone` = {1},
|
||||
`EmployeeNo` = {2},
|
||||
`WechatId` = {3},
|
||||
`WechatQrCode` = {4}
|
||||
WHERE `Id` = {0} AND `IsDeleted` = 0
|
||||
""";
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
sql,
|
||||
[
|
||||
userId.ToString(),
|
||||
NormalizeOptional(profile.Phone),
|
||||
NormalizeOptional(profile.EmployeeNo),
|
||||
NormalizeOptional(profile.WechatId),
|
||||
NormalizeOptional(profile.WechatQrCode),
|
||||
],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task UpdateContactAsync(
|
||||
Guid userId,
|
||||
UserProfileData profile,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE `user`
|
||||
SET
|
||||
`Phone` = {1},
|
||||
`WechatId` = {2},
|
||||
`WechatQrCode` = {3}
|
||||
WHERE `Id` = {0} AND `IsDeleted` = 0
|
||||
""";
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
sql,
|
||||
[
|
||||
userId.ToString(),
|
||||
NormalizeOptional(profile.Phone),
|
||||
NormalizeOptional(profile.WechatId),
|
||||
NormalizeOptional(profile.WechatQrCode),
|
||||
],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> IsEmployeeNoTakenAsync(
|
||||
string employeeNo,
|
||||
Guid? excludeUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = employeeNo.Trim().ToUpperInvariant();
|
||||
var sql = excludeUserId.HasValue
|
||||
? """
|
||||
SELECT COUNT(*) AS `Value`
|
||||
FROM `user` u
|
||||
WHERE u.`IsDeleted` = 0
|
||||
AND u.`EmployeeNo` = {0}
|
||||
AND u.`Id` <> {1}
|
||||
"""
|
||||
: """
|
||||
SELECT COUNT(*) AS `Value`
|
||||
FROM `user` u
|
||||
WHERE u.`IsDeleted` = 0
|
||||
AND u.`EmployeeNo` = {0}
|
||||
""";
|
||||
|
||||
var count = excludeUserId.HasValue
|
||||
? await dbContext.Database.SqlQueryRaw<long>(sql, normalized, excludeUserId.Value.ToString())
|
||||
.FirstAsync(cancellationToken)
|
||||
: await dbContext.Database.SqlQueryRaw<long>(sql, normalized).FirstAsync(cancellationToken);
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateNextEmployeeNoAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT u.`EmployeeNo` AS `EmployeeNo`
|
||||
FROM `user` u
|
||||
WHERE u.`IsDeleted` = 0
|
||||
AND u.`EmployeeNo` IS NOT NULL
|
||||
AND u.`EmployeeNo` <> ''
|
||||
""";
|
||||
|
||||
var rows = await dbContext.Database.SqlQueryRaw<EmployeeNoRow>(sql).ToListAsync(cancellationToken);
|
||||
var max = 0;
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var value = row.EmployeeNo?.Trim() ?? string.Empty;
|
||||
var match = EmployeeNoPattern.Match(value);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (int.TryParse(value[2..], out var number))
|
||||
{
|
||||
max = Math.Max(max, number);
|
||||
}
|
||||
}
|
||||
|
||||
var next = max + 1;
|
||||
return next > 9999 ? $"NE{next}" : $"NE{next:D4}";
|
||||
}
|
||||
|
||||
public static bool IsValidEmployeeNo(string employeeNo) => EmployeeNoPattern.IsMatch(employeeNo.Trim());
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private sealed class UserProfileRow
|
||||
{
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? EmployeeNo { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public string? WechatQrCode { get; set; }
|
||||
}
|
||||
|
||||
private sealed class UserProfileListRow
|
||||
{
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public string? EmployeeNo { get; set; }
|
||||
|
||||
public string? WechatId { get; set; }
|
||||
|
||||
public string? WechatQrCode { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EmployeeNoRow
|
||||
{
|
||||
public string? EmployeeNo { get; set; }
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Oms.Application.Dtos.Customers;
|
||||
using Oms.Application.Dtos.Shared;
|
||||
using Oms.Application.Rbac;
|
||||
using Oms.Application.Shared;
|
||||
using Oms.Domain.CustomersAggregate.Entities;
|
||||
using Oms.Domain.CustomersAggregate.Enums;
|
||||
@ -35,6 +36,9 @@ public class CustomerService : ApplicationService
|
||||
private OmsRbacUserDisplayNameResolver UserDisplayNameResolver =>
|
||||
LazyServiceProvider.GetRequiredService<OmsRbacUserDisplayNameResolver>();
|
||||
|
||||
private CustomerServiceAdvisorResolver ServiceAdvisorResolver =>
|
||||
LazyServiceProvider.GetRequiredService<CustomerServiceAdvisorResolver>();
|
||||
|
||||
/// <summary>
|
||||
/// 查询客户列表。
|
||||
/// </summary>
|
||||
@ -90,6 +94,28 @@ public class CustomerService : ApplicationService
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 查询当前登录客户所属客服(专属顾问)信息。
|
||||
/// </summary>
|
||||
/// <returns>专属顾问姓名、微信号、微信二维码。</returns>
|
||||
[HttpGet("/customer/service-advisor")]
|
||||
public async Task<CustomerServiceAdvisorOutput> GetServiceAdvisorAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userId = CurrentUser.GetId<int>();
|
||||
if (userId <= 0)
|
||||
{
|
||||
return new CustomerServiceAdvisorOutput();
|
||||
}
|
||||
|
||||
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||||
if (customer == null || string.IsNullOrWhiteSpace(customer.CustomerService))
|
||||
{
|
||||
return new CustomerServiceAdvisorOutput();
|
||||
}
|
||||
|
||||
return await ServiceAdvisorResolver.ResolveAsync(customer.CustomerService, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增客户。
|
||||
/// </summary>
|
||||
|
||||
192
src/Oms.Application/Services/Rbac/OmsUserService.cs
Normal file
192
src/Oms.Application/Services/Rbac/OmsUserService.cs
Normal file
@ -0,0 +1,192 @@
|
||||
using Fake.Application;
|
||||
using Fake.Rbac.Application.Dtos.Common;
|
||||
using Fake.Rbac.Application.Dtos.Role;
|
||||
using Fake.Rbac.Application.Dtos.User;
|
||||
using Fake.Rbac.Application.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Oms.Application.Dtos.Rbac;
|
||||
using Oms.Application.Rbac;
|
||||
|
||||
namespace Oms.Application.Services.Rbac;
|
||||
|
||||
/// <summary>
|
||||
/// 接管 RBAC 用户接口,扩展电话、工号、微信号、微信二维码字段。
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[ApiExplorerSettings(GroupName = "RBAC-User")]
|
||||
public class OmsUserService(UserService userService, UserProfileRepository profileRepository) : ApplicationService
|
||||
{
|
||||
[HttpGet("/rbac/user/get")]
|
||||
public async Task<OmsUserDetailDto> GetAsync([FromQuery] Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await userService.GetAsync(id, cancellationToken);
|
||||
var profile = await profileRepository.GetAsync(id, cancellationToken);
|
||||
return OmsUserProfileMapper.ToDetailDto(user, profile);
|
||||
}
|
||||
|
||||
[HttpGet("/rbac/user/get-list")]
|
||||
public async Task<PagedResultDto<OmsUserDetailDto>> GetListAsync(
|
||||
[FromQuery] UserPagedRequestDto input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await userService.GetListAsync(input, cancellationToken);
|
||||
if (result.Items.Count == 0)
|
||||
{
|
||||
return new PagedResultDto<OmsUserDetailDto>
|
||||
{
|
||||
Total = result.Total,
|
||||
Items = [],
|
||||
};
|
||||
}
|
||||
|
||||
var userIds = result.Items
|
||||
.Select(x => x.Id)
|
||||
.Where(x => x != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var profileMap = await profileRepository.GetManyAsync(userIds, cancellationToken);
|
||||
return new PagedResultDto<OmsUserDetailDto>
|
||||
{
|
||||
Total = result.Total,
|
||||
Items = result.Items
|
||||
.Select(user =>
|
||||
{
|
||||
profileMap.TryGetValue(user.Id, out var profile);
|
||||
return OmsUserProfileMapper.ToDetailDto(user, profile);
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("/rbac/user/get-users-by-role")]
|
||||
public Task<List<UserSimpleDto>> GetUsersByRoleAsync(
|
||||
[FromQuery] Guid roleId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
userService.GetUsersByRoleAsync(roleId, cancellationToken);
|
||||
|
||||
[HttpPost("/rbac/user/create")]
|
||||
public async Task<OmsUserDetailDto> CreateAsync(
|
||||
[FromBody] OmsUserCreateInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var employeeNo = await ResolveEmployeeNoForCreateAsync(input.EmployeeNo, cancellationToken);
|
||||
var user = await userService.CreateAsync(new UserCreateDto
|
||||
{
|
||||
Name = input.Name,
|
||||
Account = input.Account,
|
||||
Password = input.Password,
|
||||
Email = input.Email,
|
||||
Avatar = input.Avatar,
|
||||
OrganizationId = input.OrganizationId,
|
||||
RoleIds = input.RoleIds,
|
||||
}, cancellationToken);
|
||||
|
||||
await profileRepository.SaveAsync(
|
||||
user.Id,
|
||||
new UserProfileData
|
||||
{
|
||||
Phone = input.Phone,
|
||||
EmployeeNo = employeeNo,
|
||||
WechatId = input.WechatId,
|
||||
WechatQrCode = input.WechatQrCode,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var profile = await profileRepository.GetAsync(user.Id, cancellationToken);
|
||||
return OmsUserProfileMapper.ToDetailDto(user, profile);
|
||||
}
|
||||
|
||||
[HttpPut("/rbac/user/update")]
|
||||
public async Task<OmsUserDetailDto> UpdateAsync(
|
||||
[FromQuery] Guid id,
|
||||
[FromBody] OmsUserUpdateInput input,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await userService.UpdateAsync(
|
||||
id,
|
||||
new UserUpdateDto
|
||||
{
|
||||
Name = input.Name,
|
||||
Email = input.Email,
|
||||
Avatar = input.Avatar,
|
||||
OrganizationId = input.OrganizationId,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
await profileRepository.UpdateContactAsync(
|
||||
id,
|
||||
new UserProfileData
|
||||
{
|
||||
Phone = input.Phone,
|
||||
WechatId = input.WechatId,
|
||||
WechatQrCode = input.WechatQrCode,
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var profile = await profileRepository.GetAsync(id, cancellationToken);
|
||||
return OmsUserProfileMapper.ToDetailDto(user, profile);
|
||||
}
|
||||
|
||||
[HttpPut("/rbac/user/update-password")]
|
||||
public Task UpdatePasswordAsync(
|
||||
[FromQuery] Guid id,
|
||||
[FromBody] UpdatePasswordDto input,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
userService.UpdatePasswordAsync(id, input, cancellationToken);
|
||||
|
||||
[HttpPut("/rbac/user/update-avatar")]
|
||||
public Task UpdateAvatarAsync(
|
||||
[FromQuery] Guid id,
|
||||
[FromQuery] string avatarUrl,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
userService.UpdateAvatarAsync(id, avatarUrl, cancellationToken);
|
||||
|
||||
[HttpDelete("/rbac/user/delete")]
|
||||
public Task DeleteAsync([FromQuery] Guid id, CancellationToken cancellationToken = default) =>
|
||||
userService.DeleteAsync(id, cancellationToken);
|
||||
|
||||
[HttpDelete("/rbac/user/delete-batch")]
|
||||
public Task DeleteBatchAsync([FromQuery] List<Guid> ids, CancellationToken cancellationToken = default) =>
|
||||
userService.DeleteBatchAsync(ids, cancellationToken);
|
||||
|
||||
[HttpPost("/rbac/user/assign-roles")]
|
||||
public Task AssignRolesAsync(
|
||||
[FromQuery] Guid userId,
|
||||
[FromBody] List<Guid> roleIds,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
userService.AssignRolesAsync(userId, roleIds, cancellationToken);
|
||||
|
||||
[HttpGet("/rbac/user/get-user-roles")]
|
||||
public Task<List<RoleDto>> GetUserRolesAsync(
|
||||
[FromQuery] Guid userId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
userService.GetUserRolesAsync(userId, cancellationToken);
|
||||
|
||||
[HttpGet("/rbac/user/get-user-permissions")]
|
||||
public Task<List<string>> GetUserPermissionsAsync(
|
||||
[FromQuery] Guid userId,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
userService.GetUserPermissionsAsync(userId, cancellationToken);
|
||||
|
||||
private async Task<string> ResolveEmployeeNoForCreateAsync(
|
||||
string? requestedEmployeeNo,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var employeeNo = string.IsNullOrWhiteSpace(requestedEmployeeNo)
|
||||
? await profileRepository.GenerateNextEmployeeNoAsync(cancellationToken)
|
||||
: requestedEmployeeNo.Trim().ToUpperInvariant();
|
||||
|
||||
if (!UserProfileRepository.IsValidEmployeeNo(employeeNo))
|
||||
{
|
||||
throw new BusinessException("工号格式无效,应为 NE + 4 位数字");
|
||||
}
|
||||
|
||||
if (await profileRepository.IsEmployeeNoTakenAsync(employeeNo, null, cancellationToken))
|
||||
{
|
||||
throw new BusinessException($"工号已存在:{employeeNo}");
|
||||
}
|
||||
|
||||
return employeeNo;
|
||||
}
|
||||
}
|
||||
@ -94,9 +94,6 @@ public class WarehouseService : ApplicationService
|
||||
Recipient = input.Recipient,
|
||||
ContactPhone = input.ContactPhone,
|
||||
ShippingAddress = input.ShippingAddress,
|
||||
ServicePhone = input.ServicePhone,
|
||||
ServiceWechat = input.ServiceWechat,
|
||||
ServiceWechatFiles = JsonStringArrayHelper.Serialize(input.ServiceWechatFiles)
|
||||
};
|
||||
|
||||
await WarehouseAddressRepository.InsertAsync(entity);
|
||||
@ -122,9 +119,6 @@ public class WarehouseService : ApplicationService
|
||||
entity.Recipient = input.Recipient;
|
||||
entity.ContactPhone = input.ContactPhone;
|
||||
entity.ShippingAddress = input.ShippingAddress;
|
||||
entity.ServicePhone = input.ServicePhone;
|
||||
entity.ServiceWechat = input.ServiceWechat;
|
||||
entity.ServiceWechatFiles = JsonStringArrayHelper.Serialize(input.ServiceWechatFiles);
|
||||
|
||||
await WarehouseAddressRepository.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
@ -15,11 +15,4 @@ public class WarehouseAddress : OmsAuditedAggregateRoot
|
||||
public string? ContactPhone { get; set; }
|
||||
|
||||
public string? ShippingAddress { get; set; }
|
||||
|
||||
public string? ServicePhone { get; set; }
|
||||
|
||||
public string? ServiceWechat { get; set; }
|
||||
|
||||
[SugarColumn(ColumnDataType = "json", IsNullable = true)]
|
||||
public string? ServiceWechatFiles { get; set; }
|
||||
}
|
||||
|
||||
@ -34,9 +34,6 @@ public class WarehouseApiIntegrationTests
|
||||
Recipient = "Alice",
|
||||
ContactPhone = "13800138000",
|
||||
ShippingAddress = "London",
|
||||
ServicePhone = "020-123456",
|
||||
ServiceWechat = "oms-warehouse",
|
||||
ServiceWechatFiles = "[\"wx-1.png\",\"wx-2.png\"]",
|
||||
CreateTime = new DateTime(2026, 4, 29, 9, 0, 0),
|
||||
UpdateTime = new DateTime(2026, 4, 29, 10, 0, 0)
|
||||
}
|
||||
@ -61,11 +58,11 @@ public class WarehouseApiIntegrationTests
|
||||
payload!.Total.Should().Be(1);
|
||||
payload.Items.Should().ContainSingle();
|
||||
payload.Items[0].WarehouseTypeName.Should().Be("中转仓");
|
||||
payload.Items[0].ServiceWechatFiles.Should().ContainInOrder("wx-1.png", "wx-2.png");
|
||||
payload.Items[0].ShippingAddress.Should().Be("London");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetWarehouseDetail_ShouldReturnMappedAuditAndFileFields()
|
||||
public async Task GetWarehouseDetail_ShouldReturnMappedAuditFields()
|
||||
{
|
||||
var repository = new Mock<IWarehouseAddressRepository>(MockBehavior.Strict);
|
||||
repository
|
||||
@ -80,9 +77,6 @@ public class WarehouseApiIntegrationTests
|
||||
Recipient = "Bob",
|
||||
ContactPhone = "13900139000",
|
||||
ShippingAddress = "Shenzhen",
|
||||
ServicePhone = "0755-123456",
|
||||
ServiceWechat = "main-hub",
|
||||
ServiceWechatFiles = "[\"main.png\"]",
|
||||
CreateTime = new DateTime(2026, 4, 20, 9, 30, 0),
|
||||
CreateUserId = 66,
|
||||
UpdateTime = new DateTime(2026, 4, 21, 14, 0, 0),
|
||||
@ -102,11 +96,11 @@ public class WarehouseApiIntegrationTests
|
||||
payload.Should().NotBeNull();
|
||||
payload!.CreateBy.Should().Be("66");
|
||||
payload.UpdateBy.Should().Be("88");
|
||||
payload.ServiceWechatFiles.Should().ContainSingle().Which.Should().Be("main.png");
|
||||
payload.ShippingAddress.Should().Be("Shenzhen");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateWarehouse_ShouldSerializeFilesBeforePersisting()
|
||||
public async Task CreateWarehouse_ShouldPersistEntity()
|
||||
{
|
||||
WarehouseAddress? insertedEntity = null;
|
||||
var repository = new Mock<IWarehouseAddressRepository>(MockBehavior.Strict);
|
||||
@ -131,16 +125,13 @@ public class WarehouseApiIntegrationTests
|
||||
Recipient = "Cindy",
|
||||
ContactPhone = "13700137000",
|
||||
ShippingAddress = "Guangzhou",
|
||||
ServicePhone = "020-888888",
|
||||
ServiceWechat = "return-hub",
|
||||
ServiceWechatFiles = ["qr-a.png", "qr-b.png"]
|
||||
});
|
||||
|
||||
response.IsSuccessStatusCode.Should().BeTrue();
|
||||
insertedEntity.Should().NotBeNull();
|
||||
insertedEntity!.WarehouseName.Should().Be("New Warehouse");
|
||||
insertedEntity.WarehouseType.Should().Be(WarehouseTypeCodes.Return);
|
||||
insertedEntity.ServiceWechatFiles.Should().Be("[\"qr-a.png\",\"qr-b.png\"]");
|
||||
insertedEntity.ShippingAddress.Should().Be("Guangzhou");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -155,9 +146,6 @@ public class WarehouseApiIntegrationTests
|
||||
Recipient = "Old",
|
||||
ContactPhone = "13600136000",
|
||||
ShippingAddress = "Old Address",
|
||||
ServicePhone = "010-111111",
|
||||
ServiceWechat = "old-wechat",
|
||||
ServiceWechatFiles = "[\"old.png\"]"
|
||||
};
|
||||
|
||||
var repository = new Mock<IWarehouseAddressRepository>(MockBehavior.Strict);
|
||||
@ -188,9 +176,6 @@ public class WarehouseApiIntegrationTests
|
||||
Recipient = "Daisy",
|
||||
ContactPhone = "13500135000",
|
||||
ShippingAddress = "New Address",
|
||||
ServicePhone = "0755-999999",
|
||||
ServiceWechat = "new-wechat",
|
||||
ServiceWechatFiles = ["new-a.png", "new-b.png"]
|
||||
});
|
||||
|
||||
response.IsSuccessStatusCode.Should().BeTrue();
|
||||
@ -198,6 +183,6 @@ public class WarehouseApiIntegrationTests
|
||||
updatedEntity!.WarehouseName.Should().Be("Updated Warehouse");
|
||||
updatedEntity.WarehouseType.Should().Be(WarehouseTypeCodes.Main);
|
||||
updatedEntity.Recipient.Should().Be("Daisy");
|
||||
updatedEntity.ServiceWechatFiles.Should().Be("[\"new-a.png\",\"new-b.png\"]");
|
||||
updatedEntity.ShippingAddress.Should().Be("New Address");
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +54,9 @@ public class SimpleAdminApiModule : FakeModule
|
||||
options.ApplicationServices2Controller<FakeRbacApplicationModule>(settings =>
|
||||
{
|
||||
settings.RootPath = "rbac";
|
||||
settings.TypePredicate = type => type != typeof(Fake.Rbac.Application.Services.AuthService);
|
||||
settings.TypePredicate = type =>
|
||||
type != typeof(Fake.Rbac.Application.Services.AuthService) &&
|
||||
type != typeof(Fake.Rbac.Application.Services.UserService);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user