diff --git a/db.sql b/db.sql index b2c744a..f55db57 100644 --- a/db.sql +++ b/db.sql @@ -1329,6 +1329,8 @@ CREATE TABLE `t_warehouse_address` ( `Recipient` VARCHAR(64) NULL COMMENT '收件人', `ContactPhone` VARCHAR(32) NULL COMMENT '联系电话', `ShippingAddress` TEXT COMMENT '收货地址', + `Sort` INT NULL DEFAULT NULL COMMENT '展示排序,越小越靠前', + `Remark` VARCHAR(512) NULL DEFAULT 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 '创建人', diff --git a/sql/admin1008.sql b/sql/admin1008.sql index b516377..9c49440 100644 --- a/sql/admin1008.sql +++ b/sql/admin1008.sql @@ -624,3 +624,44 @@ ALTER TABLE `t_customer_export_qualification` MODIFY COLUMN `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', MODIFY COLUMN `UpdateUserId` INT NULL COMMENT '更新人', MODIFY COLUMN `UpdateTime` DATETIME NULL COMMENT '更新时间'; + +-- ============================================================================= +-- migrate_warehouse_address_sort_remark.sql +-- 仓库地址:展示排序(选填ï¼? 备注(选填ï¼? +-- ============================================================================= +SET @db := DATABASE(); + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Sort' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`', + 'SELECT ''Sort already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Remark' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`', + 'SELECT ''Remark already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + diff --git a/sql/migrate_warehouse_address_sort_remark.sql b/sql/migrate_warehouse_address_sort_remark.sql new file mode 100644 index 0000000..e4c047b --- /dev/null +++ b/sql/migrate_warehouse_address_sort_remark.sql @@ -0,0 +1,39 @@ +-- ============================================================================= +-- migrate_warehouse_address_sort_remark.sql +-- 仓库地址:展示排序(选填)+ 备注(选填) +-- ============================================================================= +SET @db := DATABASE(); + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Sort' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Sort` INT NULL DEFAULT NULL COMMENT ''展示排序,越小越靠前'' AFTER `ShippingAddress`', + 'SELECT ''Sort already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @col := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 't_warehouse_address' + AND COLUMN_NAME = 'Remark' +); +SET @sql := IF( + @col = 0, + 'ALTER TABLE `t_warehouse_address` + ADD COLUMN `Remark` VARCHAR(512) NULL DEFAULT NULL COMMENT ''备注'' AFTER `Sort`', + 'SELECT ''Remark already exists'' AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs b/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs index 50c4a90..6e2c66b 100644 --- a/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs +++ b/src/Oms.Application/Dtos/System/CreateWarehouseInput.cs @@ -37,4 +37,15 @@ public class CreateWarehouseInput /// 发货地址。 /// public string? ShippingAddress { get; set; } + + /// + /// 展示排序(越小越靠前;可不填)。 + /// + public int? Sort { get; set; } + + /// + /// 备注(可不填)。 + /// + [StringLength(512)] + public string? Remark { get; set; } } diff --git a/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs b/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs index ea8b6af..77b89c5 100644 --- a/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs +++ b/src/Oms.Application/Dtos/System/UpdateWarehouseInput.cs @@ -43,4 +43,15 @@ public class UpdateWarehouseInput /// 发货地址。 /// public string? ShippingAddress { get; set; } + + /// + /// 展示排序(越小越靠前;可不填)。 + /// + public int? Sort { get; set; } + + /// + /// 备注(可不填)。 + /// + [StringLength(512)] + public string? Remark { get; set; } } diff --git a/src/Oms.Application/Dtos/System/WarehouseOutput.cs b/src/Oms.Application/Dtos/System/WarehouseOutput.cs index 95bf877..26314b6 100644 --- a/src/Oms.Application/Dtos/System/WarehouseOutput.cs +++ b/src/Oms.Application/Dtos/System/WarehouseOutput.cs @@ -35,6 +35,16 @@ public class WarehouseOutput /// public string? ShippingAddress { get; set; } + /// + /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。 + /// + public int? Sort { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs b/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs index a6b792e..1067733 100644 --- a/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs +++ b/src/Oms.Application/Dtos/System/WarehousePaginatedOutput.cs @@ -40,6 +40,16 @@ public class WarehousePaginatedOutput /// public string? ShippingAddress { get; set; } + /// + /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。 + /// + public int? Sort { get; set; } + + /// + /// 备注。 + /// + public string? Remark { get; set; } + /// /// 创建时间。 /// diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index 8abb5fb..46a3439 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -5,11 +5,13 @@ using Fake.Rbac.Application.Dtos.Role; using Fake.Rbac.Application.Dtos.User; using Fake.Rbac.Application.Services; using Fake.Rbac.Domain.Managers; +using Fake.Rbac.Infrastructure; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Fake.ObjectStorage; using Oms.Application.Dtos.Rbac; using Oms.Application.Rbac; +using Oms.Application.Shared; using Oms.Application.Storage; using Oms.Domain.SystemAggregate.Entities; using Oms.Domain.SystemAggregate.Enums; @@ -27,6 +29,7 @@ public class OmsUserService( AccountManager accountManager, UserProfileRepository profileRepository, IEmployeeRepository employeeRepository, + FakeRbacDbContext rbacDbContext, IObjectStorage fileStorage) : ApplicationService { /// @@ -94,34 +97,78 @@ public class OmsUserService( [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 = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), - OrganizationId = input.OrganizationId, - RoleIds = input.RoleIds, - }, cancellationToken); + var account = input.Account?.Trim() ?? string.Empty; + OmsRbacRegistrationGuard.ValidateAdminUserFields( + input.Name, + account, + input.Password, + input.Email, + input.Phone, + input.WechatId, + requirePassword: true); - await profileRepository.SaveAsync( - user.Id, - new UserProfileData - { - Phone = input.Phone, - EmployeeNo = employeeNo, - WechatId = input.WechatId, - WechatQrCode = input.WechatQrCode, - WecomKfUrl = input.WecomKfUrl, - }, + var employeeNo = await ResolveEmployeeNoForCreateAsync(input.EmployeeNo, cancellationToken); + await OmsRbacRegistrationGuard.EnsureLoginIdentityAvailableAsync( + rbacDbContext, + account, + input.Email, + forAdminUserCreate: true, + excludeUserId: null, + cancellationToken); + await OmsRbacRegistrationGuard.EnsurePhoneAvailableAsync( + rbacDbContext, + input.Phone, + excludeUserId: null, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureWechatIdAvailableAsync( + rbacDbContext, + input.WechatId, + excludeUserId: null, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureOrganizationExistsAsync( + rbacDbContext, + input.OrganizationId, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureRolesExistAsync( + rbacDbContext, + input.RoleIds, cancellationToken); - await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken); + try + { + var user = await userService.CreateAsync(new UserCreateDto + { + Name = input.Name!.Trim(), + Account = account, + Password = input.Password, + // 禁止 Email='':uk_user_email 会把空串当成占用 + Email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(), + Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), + OrganizationId = input.OrganizationId, + RoleIds = input.RoleIds, + }, cancellationToken); - var profile = await profileRepository.GetAsync(user.Id, cancellationToken); - return MapUserDetail(user, profile); + await profileRepository.SaveAsync( + user.Id, + new UserProfileData + { + Phone = input.Phone, + EmployeeNo = employeeNo, + WechatId = input.WechatId, + WechatQrCode = input.WechatQrCode, + WecomKfUrl = input.WecomKfUrl, + }, + cancellationToken); + + await EnsureEmployeeProfileAsync(user.Id, input, cancellationToken); + + var profile = await profileRepository.GetAsync(user.Id, cancellationToken); + return MapUserDetail(user, profile); + } + catch (Exception ex) when (ex is not BusinessException) + { + throw OmsRbacRegistrationGuard.ToSaveBusinessException(ex, forAdminUserCreate: true); + } } /// @@ -133,30 +180,75 @@ public class OmsUserService( [FromBody] OmsUserUpdateInput input, CancellationToken cancellationToken = default) { - var user = await userService.UpdateAsync( - id, - new UserUpdateDto - { - Name = input.Name, - Email = input.Email, - Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), - OrganizationId = input.OrganizationId, - }, + if (id == Guid.Empty) + { + throw new BusinessException("用户 Id 无效"); + } + + // 更新不改登录账号/密码 + OmsRbacRegistrationGuard.ValidateAdminUserFields( + input.Name, + account: "placeholder", + password: null, + input.Email, + input.Phone, + input.WechatId, + requirePassword: false); + + var email = string.IsNullOrWhiteSpace(input.Email) ? null : input.Email.Trim(); + await OmsRbacRegistrationGuard.EnsureEmailAvailableAsync( + rbacDbContext, + email, + excludeUserId: id, + forAdminUserCreate: true, cancellationToken); - await profileRepository.UpdateContactAsync( - id, - new UserProfileData - { - Phone = input.Phone, - WechatId = input.WechatId, - WechatQrCode = input.WechatQrCode, - WecomKfUrl = input.WecomKfUrl, - }, + await OmsRbacRegistrationGuard.EnsurePhoneAvailableAsync( + rbacDbContext, + input.Phone, + excludeUserId: id, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureWechatIdAvailableAsync( + rbacDbContext, + input.WechatId, + excludeUserId: id, + cancellationToken); + await OmsRbacRegistrationGuard.EnsureOrganizationExistsAsync( + rbacDbContext, + input.OrganizationId, cancellationToken); - var profile = await profileRepository.GetAsync(id, cancellationToken); - return MapUserDetail(user, profile); + try + { + var user = await userService.UpdateAsync( + id, + new UserUpdateDto + { + Name = input.Name!.Trim(), + Email = email, + Avatar = OmsObjectStorageExtensions.ToStorageKey(input.Avatar), + OrganizationId = input.OrganizationId, + }, + cancellationToken); + + await profileRepository.UpdateContactAsync( + id, + new UserProfileData + { + Phone = input.Phone, + WechatId = input.WechatId, + WechatQrCode = input.WechatQrCode, + WecomKfUrl = input.WecomKfUrl, + }, + cancellationToken); + + var profile = await profileRepository.GetAsync(id, cancellationToken); + return MapUserDetail(user, profile); + } + catch (Exception ex) when (ex is not BusinessException) + { + throw OmsRbacRegistrationGuard.ToSaveBusinessException(ex, forAdminUserCreate: true); + } } /// @@ -259,12 +351,12 @@ public class OmsUserService( if (!UserProfileRepository.IsValidEmployeeNo(employeeNo)) { - throw new BusinessException("工号格式无效,应为 IDE + 至少 3 位数字,如 IDE001"); + throw new BusinessException($"工号「{employeeNo}」格式无效,应为 IDE + 至少 3 位数字,如 IDE001"); } if (await profileRepository.IsEmployeeNoTakenAsync(employeeNo, null, cancellationToken)) { - throw new BusinessException($"工号已存在:{employeeNo}"); + throw new BusinessException($"工号「{employeeNo}」已存在,请更换工号后再创建"); } return employeeNo; diff --git a/src/Oms.Application/Services/System/WarehouseService.cs b/src/Oms.Application/Services/System/WarehouseService.cs index 8532769..64dc16f 100644 --- a/src/Oms.Application/Services/System/WarehouseService.cs +++ b/src/Oms.Application/Services/System/WarehouseService.cs @@ -33,13 +33,16 @@ public class WarehouseService : ApplicationService var pageIndex = OmsDocHelper.NormalizePage(input.Page); var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); - var entities = await WarehouseAddressRepository.GetPagedListAsync( + // 有 Sort 的按升序靠前;无 Sort 的靠后;同序再按 Id + var (entities, totalCount) = await RepositoryPageHelper.GetPageAsync( + WarehouseAddressRepository, predicate, pageIndex, pageSize, - new Dictionary { [nameof(WarehouseAddress.UpdateTime)] = false, [nameof(WarehouseAddress.Id)] = false }); + q => q.OrderBy(x => x.Sort == null) + .ThenBy(x => x.Sort) + .ThenBy(x => x.Id)); - var totalCount = await WarehouseAddressRepository.CountAsync(predicate); var items = entities.Select(entity => { var output = ObjectMapper.Map(entity); @@ -97,6 +100,8 @@ public class WarehouseService : ApplicationService deleted.Recipient = input.Recipient; deleted.ContactPhone = input.ContactPhone; deleted.ShippingAddress = input.ShippingAddress; + deleted.Sort = input.Sort; + deleted.Remark = NormalizeRemark(input.Remark); await WarehouseAddressRepository.UpdateAsync(deleted); return; } @@ -108,6 +113,8 @@ public class WarehouseService : ApplicationService Recipient = input.Recipient, ContactPhone = input.ContactPhone, ShippingAddress = input.ShippingAddress, + Sort = input.Sort, + Remark = NormalizeRemark(input.Remark), }; await WarehouseAddressRepository.InsertAsync(entity); @@ -141,6 +148,8 @@ public class WarehouseService : ApplicationService entity.Recipient = input.Recipient; entity.ContactPhone = input.ContactPhone; entity.ShippingAddress = input.ShippingAddress; + entity.Sort = input.Sort; + entity.Remark = NormalizeRemark(input.Remark); await WarehouseAddressRepository.UpdateAsync(entity); } @@ -180,4 +189,10 @@ public class WarehouseService : ApplicationService return entity; } + + private static string? NormalizeRemark(string? remark) + { + var value = remark?.Trim(); + return string.IsNullOrEmpty(value) ? null : value; + } } diff --git a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs index e726af9..6241a72 100644 --- a/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs +++ b/src/Oms.Application/Shared/OmsRbacRegistrationGuard.cs @@ -1,3 +1,5 @@ +using System.Net.Mail; +using System.Text.RegularExpressions; using Fake.Application; using Fake.Rbac.Infrastructure; using Microsoft.EntityFrameworkCore; @@ -5,32 +7,276 @@ using Microsoft.EntityFrameworkCore; namespace Oms.Application.Shared; /// -/// 微信/客户注册前校验 RBAC 登录账号,避免 user 表已存在但 t_customer 未创建时 EF 抛出英文 DbUpdateException。 +/// 微信/客户注册、后台建用户前校验 RBAC 登录身份,避免 EF 抛出英文 DbUpdateException。 /// public static class OmsRbacRegistrationGuard { + private static readonly Regex PhonePattern = new(@"^1\d{10}$", RegexOptions.Compiled); + public static async Task EnsureLoginIdentityAvailableAsync( FakeRbacDbContext rbacDbContext, string account, string? email, CancellationToken cancellationToken = default) + { + await EnsureLoginIdentityAvailableAsync( + rbacDbContext, + account, + email, + forAdminUserCreate: false, + excludeUserId: null, + cancellationToken); + } + + /// + /// 校验登录账号/邮箱是否可用。 + /// 为 true 时提示面向后台建员工用户(会说明可能已被客户账号占用)。 + /// + public static async Task EnsureLoginIdentityAvailableAsync( + FakeRbacDbContext rbacDbContext, + string account, + string? email, + bool forAdminUserCreate, + CancellationToken cancellationToken = default) + { + await EnsureLoginIdentityAvailableAsync( + rbacDbContext, + account, + email, + forAdminUserCreate, + excludeUserId: null, + cancellationToken); + } + + public static async Task EnsureLoginIdentityAvailableAsync( + FakeRbacDbContext rbacDbContext, + string account, + string? email, + bool forAdminUserCreate, + Guid? excludeUserId, + CancellationToken cancellationToken = default) { var normalizedAccount = account.Trim(); var normalizedEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim(); - if (await ExistsActiveUserByAccountAsync(rbacDbContext, normalizedAccount, cancellationToken)) + if (await ExistsUserByAccountAsync(rbacDbContext, normalizedAccount, excludeUserId, cancellationToken)) { - throw new BusinessException("登录账号已存在,请直接绑定或使用其他账号"); + throw new BusinessException( + forAdminUserCreate + ? $"登录账号「{normalizedAccount}」已存在。客户门户注册也会占用同一登录账号,请更换账号后再创建员工用户" + : "登录账号已存在,请直接绑定或使用其他账号"); } if (normalizedEmail != null - && await ExistsActiveUserByEmailAsync(rbacDbContext, normalizedEmail, cancellationToken)) + && await ExistsUserByEmailAsync(rbacDbContext, normalizedEmail, excludeUserId, cancellationToken)) { - throw new BusinessException("邮箱已被占用,请更换邮箱或直接绑定已有账号"); + throw new BusinessException( + forAdminUserCreate + ? $"邮箱「{normalizedEmail}」已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再创建员工用户" + : "邮箱已被占用,请更换邮箱或直接绑定已有账号"); } } - public static BusinessException ToSaveBusinessException(Exception ex) + /// + /// 后台创建/更新员工用户时的字段格式与必填校验(不含唯一性)。 + /// + public static void ValidateAdminUserFields( + string? name, + string? account, + string? password, + string? email, + string? phone, + string? wechatId, + bool requirePassword) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new BusinessException("姓名不能为空"); + } + + if (name.Trim().Length > 64) + { + throw new BusinessException($"姓名过长(当前 {name.Trim().Length} 字,最多 64 字)"); + } + + if (string.IsNullOrWhiteSpace(account)) + { + throw new BusinessException("登录账号不能为空"); + } + + if (account.Trim().Length > 64) + { + throw new BusinessException($"登录账号过长(当前 {account.Trim().Length} 字,最多 64 字)"); + } + + if (requirePassword) + { + if (string.IsNullOrWhiteSpace(password)) + { + throw new BusinessException("密码不能为空"); + } + + if (password.Length < 6) + { + throw new BusinessException("密码长度不能少于6位"); + } + + if (password.Length > 128) + { + throw new BusinessException("密码过长,请控制在 128 位以内"); + } + } + + if (!string.IsNullOrWhiteSpace(email)) + { + var trimmedEmail = email.Trim(); + if (trimmedEmail.Length > 128) + { + throw new BusinessException($"邮箱过长(当前 {trimmedEmail.Length} 字,最多 128 字)"); + } + + if (!IsValidEmail(trimmedEmail)) + { + throw new BusinessException($"邮箱格式不正确:{trimmedEmail}"); + } + } + + if (!string.IsNullOrWhiteSpace(phone)) + { + var trimmedPhone = phone.Trim(); + if (!PhonePattern.IsMatch(trimmedPhone)) + { + throw new BusinessException($"手机号格式不正确:{trimmedPhone}(应为 11 位中国大陆手机号)"); + } + } + + if (!string.IsNullOrWhiteSpace(wechatId) && wechatId.Trim().Length > 64) + { + throw new BusinessException($"微信号过长(当前 {wechatId.Trim().Length} 字,最多 64 字)"); + } + } + + public static async Task EnsureOrganizationExistsAsync( + FakeRbacDbContext rbacDbContext, + Guid? organizationId, + CancellationToken cancellationToken = default) + { + if (organizationId is null || organizationId == Guid.Empty) + { + return; + } + + var hit = await rbacDbContext.Database + .SqlQuery( + $""" + SELECT 1 AS Value + FROM `organization` + WHERE `Id` = {organizationId.Value.ToString()} AND `IsDeleted` = 0 + LIMIT 1 + """) + .FirstOrDefaultAsync(cancellationToken); + + if (!hit.HasValue) + { + throw new BusinessException($"所属组织不存在或已删除({organizationId}),请重新选择组织"); + } + } + + public static async Task EnsureRolesExistAsync( + FakeRbacDbContext rbacDbContext, + IEnumerable? roleIds, + CancellationToken cancellationToken = default) + { + if (roleIds == null) + { + return; + } + + var ids = roleIds.Where(x => x != Guid.Empty).Distinct().ToList(); + if (ids.Count == 0) + { + return; + } + + foreach (var roleId in ids) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $""" + SELECT 1 AS Value + FROM `role` + WHERE `Id` = {roleId.ToString()} AND `IsDeleted` = 0 + LIMIT 1 + """) + .FirstOrDefaultAsync(cancellationToken); + + if (!hit.HasValue) + { + throw new BusinessException($"角色不存在或已删除({roleId}),请重新选择角色"); + } + } + } + + public static async Task EnsureEmailAvailableAsync( + FakeRbacDbContext rbacDbContext, + string? email, + Guid? excludeUserId, + bool forAdminUserCreate, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(email)) + { + return; + } + + var normalizedEmail = email.Trim(); + if (await ExistsUserByEmailAsync(rbacDbContext, normalizedEmail, excludeUserId, cancellationToken)) + { + throw new BusinessException( + forAdminUserCreate + ? $"邮箱「{normalizedEmail}」已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再保存" + : "邮箱已被占用,请更换邮箱或直接绑定已有账号"); + } + } + + public static async Task EnsurePhoneAvailableAsync( + FakeRbacDbContext rbacDbContext, + string? phone, + Guid? excludeUserId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(phone)) + { + return; + } + + var normalized = phone.Trim(); + if (await ExistsUserByPhoneAsync(rbacDbContext, normalized, excludeUserId, cancellationToken)) + { + throw new BusinessException( + $"手机号「{normalized}」已被占用。客户门户注册也会占用同一手机号,请更换手机号后再保存"); + } + } + + public static async Task EnsureWechatIdAvailableAsync( + FakeRbacDbContext rbacDbContext, + string? wechatId, + Guid? excludeUserId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(wechatId)) + { + return; + } + + var normalized = wechatId.Trim(); + if (await ExistsUserByWechatIdAsync(rbacDbContext, normalized, excludeUserId, cancellationToken)) + { + throw new BusinessException($"微信号「{normalized}」已被占用,请更换后再保存"); + } + } + + public static BusinessException ToSaveBusinessException(Exception ex, bool forAdminUserCreate = false) { var message = ExtractDeepMessage(ex); if (ContainsDuplicateKey(message)) @@ -39,19 +285,41 @@ public static class OmsRbacRegistrationGuard || message.Contains("uk_user_account", StringComparison.OrdinalIgnoreCase) || message.Contains("IX_user_Account", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("登录账号已存在,请直接绑定或使用其他账号"); + return new BusinessException( + forAdminUserCreate + ? "登录账号已存在。客户门户注册也会占用同一登录账号,请更换账号后再创建员工用户" + : "登录账号已存在,请直接绑定或使用其他账号"); } if (message.Contains("Email", StringComparison.OrdinalIgnoreCase) || message.Contains("uk_user_email", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("邮箱已被占用,请更换邮箱或直接绑定已有账号"); + return new BusinessException( + forAdminUserCreate + ? "邮箱已被占用。客户门户注册也会占用同一邮箱,请更换邮箱后再创建员工用户" + : "邮箱已被占用,请更换邮箱或直接绑定已有账号"); } if (message.Contains("Phone", StringComparison.OrdinalIgnoreCase) + || message.Contains("uk_user_phone", StringComparison.OrdinalIgnoreCase) || message.Contains("uk_cus_phone", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("手机号已存在"); + return new BusinessException( + forAdminUserCreate + ? "手机号已被占用。客户门户注册也会占用同一手机号,请更换手机号后再保存" + : "手机号已存在"); + } + + if (message.Contains("EmployeeNo", StringComparison.OrdinalIgnoreCase) + || message.Contains("uk_user_employee", StringComparison.OrdinalIgnoreCase)) + { + return new BusinessException("工号已被占用,请更换工号后再保存"); + } + + if (message.Contains("WechatId", StringComparison.OrdinalIgnoreCase) + || message.Contains("uk_user_wechat", StringComparison.OrdinalIgnoreCase)) + { + return new BusinessException("微信号已被占用,请更换后再保存"); } if (message.Contains("uk_cus_email", StringComparison.OrdinalIgnoreCase)) @@ -66,41 +334,125 @@ public static class OmsRbacRegistrationGuard return new BusinessException("当前微信已绑定账号"); } - return new BusinessException("账号信息已存在,请直接绑定或更换手机号/邮箱"); + return new BusinessException( + forAdminUserCreate + ? "账号信息与已有用户冲突(登录账号/邮箱/手机号等唯一字段),请检查后更换" + : "账号信息已存在,请直接绑定或更换手机号/邮箱"); } if (message.Contains("Data too long", StringComparison.OrdinalIgnoreCase) || message.Contains("1406", StringComparison.OrdinalIgnoreCase)) { - if (message.Contains("Avatar", StringComparison.OrdinalIgnoreCase)) + var field = ResolveTooLongField(message, forAdminUserCreate); + if (field != null) { - return new BusinessException("微信头像地址过长,请稍后重试或联系客服"); + return new BusinessException(field); } - if (message.Contains("Nickname", StringComparison.OrdinalIgnoreCase) - || message.Contains("CustomerName", StringComparison.OrdinalIgnoreCase) - || message.Contains("Name", StringComparison.OrdinalIgnoreCase)) - { - return new BusinessException("客户名称或微信昵称过长,请修改后重试"); - } - - return new BusinessException("注册信息字段过长,请缩短客户名称后重试"); + return new BusinessException( + forAdminUserCreate + ? "保存失败:某个字段内容过长,请缩短后重试" + : "注册信息字段过长,请缩短客户名称后重试"); } if (message.Contains("foreign key constraint fails", StringComparison.OrdinalIgnoreCase) || message.Contains("1452", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("客户角色未正确初始化,请联系管理员"); + if (message.Contains("organization", StringComparison.OrdinalIgnoreCase) + || message.Contains("Organization", StringComparison.OrdinalIgnoreCase)) + { + return new BusinessException("所属组织不存在或已删除,请重新选择组织"); + } + + if (message.Contains("role", StringComparison.OrdinalIgnoreCase) + || message.Contains("Role", StringComparison.OrdinalIgnoreCase)) + { + return new BusinessException("角色不存在或已删除,请重新选择角色"); + } + + return new BusinessException("关联数据无效(组织/角色),请检查后重试"); } if (message.Contains("Cannot insert duplicate key", StringComparison.OrdinalIgnoreCase) || message.Contains("2627", StringComparison.OrdinalIgnoreCase)) { - return new BusinessException("账号信息已存在,请直接绑定或更换手机号/邮箱"); + return new BusinessException( + forAdminUserCreate + ? "账号信息与已有用户冲突(登录账号/邮箱/手机号等唯一字段),请检查后更换" + : "账号信息已存在,请直接绑定或更换手机号/邮箱"); } var reason = ExtractActionableReason(ex); - return new BusinessException($"注册失败:{reason}"); + return new BusinessException( + forAdminUserCreate ? $"保存用户失败:{reason}" : $"注册失败:{reason}"); + } + + private static string? ResolveTooLongField(string message, bool forAdminUserCreate) + { + if (message.Contains("Avatar", StringComparison.OrdinalIgnoreCase)) + { + return forAdminUserCreate ? "头像地址过长,请更换后重试" : "微信头像地址过长,请稍后重试或联系客服"; + } + + if (message.Contains("Account", StringComparison.OrdinalIgnoreCase)) + { + return "登录账号过长,请缩短后重试"; + } + + if (message.Contains("Email", StringComparison.OrdinalIgnoreCase)) + { + return "邮箱过长,请缩短后重试"; + } + + if (message.Contains("Phone", StringComparison.OrdinalIgnoreCase)) + { + return "手机号过长,请检查后重试"; + } + + if (message.Contains("EmployeeNo", StringComparison.OrdinalIgnoreCase)) + { + return "工号过长,请检查后重试"; + } + + if (message.Contains("WechatId", StringComparison.OrdinalIgnoreCase)) + { + return "微信号过长,请缩短后重试"; + } + + if (message.Contains("WechatQrCode", StringComparison.OrdinalIgnoreCase) + || message.Contains("WecomKfUrl", StringComparison.OrdinalIgnoreCase)) + { + return "微信二维码或客服链接过长,请缩短后重试"; + } + + if (message.Contains("Password", StringComparison.OrdinalIgnoreCase) + || message.Contains("PasswordHash", StringComparison.OrdinalIgnoreCase)) + { + return "密码相关字段过长,请缩短密码后重试"; + } + + if (message.Contains("Nickname", StringComparison.OrdinalIgnoreCase) + || message.Contains("CustomerName", StringComparison.OrdinalIgnoreCase) + || message.Contains("`Name`", StringComparison.OrdinalIgnoreCase) + || message.Contains(" column 'Name'", StringComparison.OrdinalIgnoreCase)) + { + return forAdminUserCreate ? "姓名过长,请缩短后重试" : "客户名称或微信昵称过长,请修改后重试"; + } + + return null; + } + + private static bool IsValidEmail(string email) + { + try + { + var addr = new MailAddress(email); + return string.Equals(addr.Address, email, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } } private static string ExtractActionableReason(Exception ex) @@ -130,29 +482,93 @@ public static class OmsRbacRegistrationGuard return reason.Length <= 160 ? reason : reason[..160]; } - private static async Task ExistsActiveUserByAccountAsync( + private static async Task ExistsUserByAccountAsync( FakeRbacDbContext rbacDbContext, string account, + Guid? excludeUserId, CancellationToken cancellationToken) { // 唯一索引不按 IsDeleted 区分,软删除账号仍会触发 Duplicate entry。 - var hit = await rbacDbContext.Database + if (excludeUserId is Guid id && id != Guid.Empty) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} AND `Id` <> {id.ToString()} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return hit.HasValue; + } + + var any = await rbacDbContext.Database .SqlQuery( $"SELECT 1 AS Value FROM `user` WHERE `Account` = {account} LIMIT 1") .FirstOrDefaultAsync(cancellationToken); - return hit.HasValue; + return any.HasValue; } - private static async Task ExistsActiveUserByEmailAsync( + private static async Task ExistsUserByEmailAsync( FakeRbacDbContext rbacDbContext, string email, + Guid? excludeUserId, CancellationToken cancellationToken) { - var hit = await rbacDbContext.Database + if (excludeUserId is Guid id && id != Guid.Empty) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} AND `Id` <> {id.ToString()} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return hit.HasValue; + } + + var any = await rbacDbContext.Database .SqlQuery( $"SELECT 1 AS Value FROM `user` WHERE `Email` = {email} LIMIT 1") .FirstOrDefaultAsync(cancellationToken); - return hit.HasValue; + return any.HasValue; + } + + private static async Task ExistsUserByPhoneAsync( + FakeRbacDbContext rbacDbContext, + string phone, + Guid? excludeUserId, + CancellationToken cancellationToken) + { + if (excludeUserId is Guid id && id != Guid.Empty) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} AND `Id` <> {id.ToString()} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return hit.HasValue; + } + + var any = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `Phone` = {phone} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; + } + + private static async Task ExistsUserByWechatIdAsync( + FakeRbacDbContext rbacDbContext, + string wechatId, + Guid? excludeUserId, + CancellationToken cancellationToken) + { + if (excludeUserId is Guid id && id != Guid.Empty) + { + var hit = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} AND `Id` <> {id.ToString()} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return hit.HasValue; + } + + var any = await rbacDbContext.Database + .SqlQuery( + $"SELECT 1 AS Value FROM `user` WHERE `WechatId` = {wechatId} LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + return any.HasValue; } private static string ExtractDeepMessage(Exception ex) diff --git a/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs b/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs index c9d981e..91d436d 100644 --- a/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs +++ b/src/Oms.Domain/SystemAggregate/Entities/WarehouseAddress.cs @@ -15,4 +15,16 @@ public class WarehouseAddress : OmsAuditedAggregateRoot public string? ContactPhone { get; set; } public string? ShippingAddress { get; set; } + + /// + /// 展示排序(越小越靠前;为空则排在有排序值的记录之后)。 + /// + [SugarColumn(IsNullable = true)] + public int? Sort { get; set; } + + /// + /// 备注(选填,展示用)。 + /// + [SugarColumn(Length = 512, IsNullable = true)] + public string? Remark { get; set; } }