diff --git a/sql/migrate_user_is_enabled.sql b/sql/migrate_user_is_enabled.sql new file mode 100644 index 0000000..e3094cc --- /dev/null +++ b/sql/migrate_user_is_enabled.sql @@ -0,0 +1,24 @@ +-- 后台用户启用/禁用(可重复执行)。 +-- 表名:fk_user(Fake RBAC)。默认启用;禁用后拒绝账号密码登录与仓配 Token 换取。 + +SET @db := DATABASE(); + +SET @exists := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db + AND TABLE_NAME = 'fk_user' + AND COLUMN_NAME = 'IsEnabled' +); + +SET @sql := IF( + @exists = 0, + 'ALTER TABLE `fk_user` ADD COLUMN `IsEnabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''是否启用:1启用 0禁用'' AFTER `IsDeleted`', + 'SELECT ''fk_user.IsEnabled already exists'' AS info' +); + +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `fk_user` SET `IsEnabled` = 1 WHERE `IsEnabled` IS NULL; diff --git a/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs b/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs new file mode 100644 index 0000000..203e1ce --- /dev/null +++ b/src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs @@ -0,0 +1,108 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.OpenApi; + +/// +/// 开放接口获取 Token 入参。 +/// +public class OpenApiGetTokenInput +{ + /// + /// 用户名(OMS 后台账号)。 + /// + [Required] + [StringLength(64)] + public string Username { get; set; } = string.Empty; + + /// + /// 密码。 + /// + [Required] + [StringLength(128)] + public string Password { get; set; } = string.Empty; +} + +/// +/// 开放接口简易结果(件重尺上传等)。 +/// +public class OpenApiSimpleResult +{ + public bool Success { get; set; } + + public string Msg { get; set; } = string.Empty; + + public static OpenApiSimpleResult Ok(string msg = "success") => + new() { Success = true, Msg = msg }; + + public static OpenApiSimpleResult Fail(string msg) => + new() { Success = false, Msg = msg }; +} + +/// +/// 仓配上传件重尺入参(入库接口可忽略 mode,固定走入库扫描「入库」逻辑)。 +/// +public class OpenApiUploadWeightInput +{ + /// + /// 订单号(服务单号 / 跟踪号 / 下游单号,同入库扫描扫码框)。 + /// + [Required] + [StringLength(64)] + public string OrderNo { get; set; } = string.Empty; + + /// + /// 收货实重(同入库扫描「实重」)。 + /// + public decimal Weight { get; set; } + + /// + /// 长(同入库扫描尺寸)。 + /// + public decimal Length { get; set; } + + /// + /// 宽。 + /// + public decimal Width { get; set; } + + /// + /// 高。 + /// + public decimal Height { get; set; } + + /// + /// 模式:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。调用 /inbound 时可省略。 + /// + public int Mode { get; set; } + + /// + /// 重量单位:KG(默认)/ G / LB。 + /// + [StringLength(8)] + public string? WeightUnit { get; set; } + + /// + /// 长度单位:CM(默认)/ MM / IN / M。 + /// + [StringLength(8)] + public string? LengthUnit { get; set; } +} + +/// +/// 仓配上传红外图片入参(落订单附件 infraredImage,转存 COS)。 +/// +public class OpenApiUploadImageInput +{ + /// + /// 订单号(服务单号 / 跟踪号 / 下游单号)。 + /// + [Required] + [StringLength(64)] + public string OrderNo { get; set; } = string.Empty; + + /// + /// 图片 Base64(可带 data:image/...;base64, 前缀;jpg/png/gif/webp/bmp/pdf)。 + /// + [Required] + public string ImageBase64 { get; set; } = string.Empty; +} diff --git a/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs b/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs index 55fd380..851a240 100644 --- a/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs +++ b/src/Oms.Application/Dtos/Rbac/InternalUserListDtos.cs @@ -26,6 +26,11 @@ public class InternalUserListItemDto public DateTime? CreatedAt { get; set; } + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; + public List Roles { get; set; } = []; } diff --git a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs index 415831c..febeaee 100644 --- a/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs +++ b/src/Oms.Application/Dtos/Rbac/OmsUserProfileDtos.cs @@ -16,6 +16,11 @@ public class UserProfileData /// 企业微信客服接入链接(微信外接入)。 /// public string? WecomKfUrl { get; set; } + + /// + /// 是否启用;缺省按启用处理。 + /// + public bool IsEnabled { get; set; } = true; } public class OmsUserCreateInput : UserCreateDto @@ -29,6 +34,11 @@ public class OmsUserCreateInput : UserCreateDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用,默认 true。 + /// + public bool? IsEnabled { get; set; } } public class OmsUserUpdateInput : UserUpdateDto @@ -40,6 +50,11 @@ public class OmsUserUpdateInput : UserUpdateDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用;不传则不改。 + /// + public bool? IsEnabled { get; set; } } public class OmsUserDetailDto : UserDto @@ -53,6 +68,11 @@ public class OmsUserDetailDto : UserDto public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } = true; } /// @@ -65,3 +85,14 @@ public class ResetUserPasswordInput /// public string NewPassword { get; set; } = string.Empty; } + +/// +/// 设置用户启用状态入参。 +/// +public class SetUserEnabledInput +{ + /// + /// 是否启用。 + /// + public bool IsEnabled { get; set; } +} diff --git a/src/Oms.Application/Rbac/InternalUserRepository.cs b/src/Oms.Application/Rbac/InternalUserRepository.cs index d3866c0..3d6cc05 100644 --- a/src/Oms.Application/Rbac/InternalUserRepository.cs +++ b/src/Oms.Application/Rbac/InternalUserRepository.cs @@ -1,6 +1,6 @@ using Fake.DependencyInjection; -using Fake.Rbac.Infrastructure; using Fake.EntityFrameworkCore; +using Fake.Rbac.Infrastructure; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Rbac; @@ -21,6 +21,7 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend ) ) "; + public async Task GetInternalUsersAsync( string? keyword, Guid? organizationId, @@ -66,7 +67,8 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend u.`WechatId` AS `WechatId`, u.`OrganizationId` AS `OrganizationId`, o.`Name` AS `OrganizationName`, - u.`CreateTime` AS `CreatedAt` + u.`CreateTime` AS `CreatedAt`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` FROM `{FakeTables.User}` u LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0 {where} @@ -95,6 +97,7 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend OrganizationId = row.OrganizationId?.ToString(), OrganizationName = row.OrganizationName, CreatedAt = row.CreatedAt, + IsEnabled = row.IsEnabled != 0, Roles = roleMap.TryGetValue(row.Id, out var roles) ? roles : [] }).ToList(); @@ -159,6 +162,8 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend public string? OrganizationName { get; set; } public DateTime? CreatedAt { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class UserRoleRow diff --git a/src/Oms.Application/Rbac/OmsUserProfileMapper.cs b/src/Oms.Application/Rbac/OmsUserProfileMapper.cs index 463a194..56a3013 100644 --- a/src/Oms.Application/Rbac/OmsUserProfileMapper.cs +++ b/src/Oms.Application/Rbac/OmsUserProfileMapper.cs @@ -33,6 +33,7 @@ internal static class OmsUserProfileMapper detail.WechatId = profile.WechatId; detail.WechatQrCode = profile.WechatQrCode; detail.WecomKfUrl = profile.WecomKfUrl; + detail.IsEnabled = profile.IsEnabled; return detail; } } diff --git a/src/Oms.Application/Rbac/UserProfileRepository.cs b/src/Oms.Application/Rbac/UserProfileRepository.cs index 99949fa..817d8b1 100644 --- a/src/Oms.Application/Rbac/UserProfileRepository.cs +++ b/src/Oms.Application/Rbac/UserProfileRepository.cs @@ -1,7 +1,8 @@ using System.Text.RegularExpressions; +using Fake.Application; using Fake.DependencyInjection; -using Fake.Rbac.Infrastructure; using Fake.EntityFrameworkCore; +using Fake.Rbac.Infrastructure; using Microsoft.EntityFrameworkCore; using Oms.Application.Dtos.Rbac; @@ -22,7 +23,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende u.`EmployeeNo` AS `EmployeeNo`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, - u.`WecomKfUrl` AS `WecomKfUrl` + u.`WecomKfUrl` AS `WecomKfUrl`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` FROM `{FakeTables.User}` u WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 LIMIT 1 @@ -43,6 +45,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende WechatId = row.WechatId, WechatQrCode = row.WechatQrCode, WecomKfUrl = row.WecomKfUrl, + IsEnabled = row.IsEnabled != 0, }; } @@ -63,7 +66,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende u.`EmployeeNo` AS `EmployeeNo`, u.`WechatId` AS `WechatId`, u.`WechatQrCode` AS `WechatQrCode`, - u.`WecomKfUrl` AS `WecomKfUrl` + u.`WecomKfUrl` AS `WecomKfUrl`, + COALESCE(u.`IsEnabled`, 1) AS `IsEnabled` FROM `{FakeTables.User}` u WHERE u.`Id` IN ({inClause}) AND u.`IsDeleted` = 0 "; @@ -78,6 +82,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende WechatId = x.WechatId, WechatQrCode = x.WechatQrCode, WecomKfUrl = x.WecomKfUrl, + IsEnabled = x.IsEnabled != 0, }); } @@ -90,9 +95,11 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende `EmployeeNo` = {{2}}, `WechatId` = {{3}}, `WechatQrCode` = {{4}}, - `WecomKfUrl` = {{5}} + `WecomKfUrl` = {{5}}, + `IsEnabled` = {{6}} WHERE `Id` = {{0}} AND `IsDeleted` = 0 "; + await dbContext.Database.ExecuteSqlRawAsync( sql, [ @@ -103,10 +110,52 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende (object?)NormalizeOptional(profile.WechatId), (object?)NormalizeOptional(profile.WechatQrCode), (object?)NormalizeOptional(profile.WecomKfUrl), + profile.IsEnabled ? 1 : 0, ], cancellationToken); } + public async Task SetEnabledAsync(Guid userId, bool isEnabled, CancellationToken cancellationToken = default) + { + var sql = $@" + UPDATE `{FakeTables.User}` + SET `IsEnabled` = {{1}} + WHERE `Id` = {{0}} AND `IsDeleted` = 0 + "; + + var affected = await dbContext.Database.ExecuteSqlRawAsync( + sql, + [userId.ToString(), isEnabled ? 1 : 0], + cancellationToken); + if (affected <= 0) + { + throw new BusinessException("用户不存在"); + } + } + + public async Task EnsureEnabledOrThrowAsync(Guid userId, CancellationToken cancellationToken = default) + { + var sql = $@" + SELECT COALESCE(u.`IsEnabled`, 1) AS `Value` + FROM `{FakeTables.User}` u + WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0 + LIMIT 1 + "; + + var rows = await dbContext.Database + .SqlQueryRaw(sql, userId.ToString()) + .ToListAsync(cancellationToken); + if (rows.Count == 0) + { + throw new BusinessException("用户不存在"); + } + + if (rows[0] == 0) + { + throw new BusinessException("账号已禁用"); + } + } + public async Task UpdateContactAsync( Guid userId, UserProfileData profile, @@ -186,8 +235,6 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende if (int.TryParse(match.Groups[1].Value, out var number)) { - - max = Math.Max(max, number); } } @@ -219,6 +266,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class UserProfileListRow @@ -234,6 +283,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende public string? WechatQrCode { get; set; } public string? WecomKfUrl { get; set; } + + public int IsEnabled { get; set; } = 1; } private sealed class EmployeeNoRow diff --git a/src/Oms.Application/Services/OpenApi/OpenApiService.cs b/src/Oms.Application/Services/OpenApi/OpenApiService.cs index bba414f..cfe2f49 100644 --- a/src/Oms.Application/Services/OpenApi/OpenApiService.cs +++ b/src/Oms.Application/Services/OpenApi/OpenApiService.cs @@ -14,9 +14,10 @@ using Oms.Domain.OrdersAggregate.Consts; namespace Oms.Application.Services.OpenApi; /// -/// 对外开放 API(Swagger 文档分组:openApi)。 +/// 客户对外开放 API(Swagger 文档分组:openApi)。 /// 统一响应:{ code, msg, data }。 -/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。 +/// 鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。 +/// 仓配 Token 接口见分组 warehouseOpenApi。 /// [ApiExplorerSettings(GroupName = "openApi")] public class OpenApiService : ApplicationService diff --git a/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs b/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs new file mode 100644 index 0000000..35a81be --- /dev/null +++ b/src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs @@ -0,0 +1,201 @@ +using Fake.Application; +using Fake.Rbac.Application.Jwt; +using Fake.Rbac.Domain.Managers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.OpenApi; +using Oms.Application.Dtos.Operations; +using Oms.Application.Rbac; +using Oms.Application.Services.Operations; +using Oms.Application.Services.Orders; +using Oms.Application.Shared; +using Oms.Domain.OrdersAggregate.Consts; +using Oms.Domain.OrdersAggregate.Repositories; + +namespace Oms.Application.Services.OpenApi; + +/// +/// 仓配对外开放 API(Swagger 文档分组:warehouseOpenApi)。 +/// 鉴权:Authorization: Bearer {token}(账号密码换取,有效期 48 小时)。 +/// 与客户 HMAC 分组 openApi 相互独立。 +/// +[ApiExplorerSettings(GroupName = "warehouseOpenApi")] +public class WarehouseOpenApiService : ApplicationService +{ + private AccountManager AccountManager => + LazyServiceProvider.GetRequiredService(); + + private IJwtService JwtService => + LazyServiceProvider.GetRequiredService(); + + private OpenApiTokenIssuer TokenIssuer => + LazyServiceProvider.GetRequiredService(); + + private IOrderRepository OrderRepository => + LazyServiceProvider.GetRequiredService(); + + private OrderFileManager OrderFileManager => + LazyServiceProvider.GetRequiredService(); + + private InboundRecordService InboundRecordService => + LazyServiceProvider.GetRequiredService(); + + private OutboundRecordService OutboundRecordService => + LazyServiceProvider.GetRequiredService(); + + /// + /// 获取 Token(有效期 48 小时)。后续请求 Header:Authorization: Bearer {token}。 + /// + [AllowAnonymous] + [HttpPost("/openapi/warehouse/token")] + [Consumes("application/json")] + [Produces("application/json")] + public async Task GetTokenAsync( + [FromBody] OpenApiGetTokenInput input, + CancellationToken cancellationToken = default) + { + if (input == null + || string.IsNullOrWhiteSpace(input.Username) + || string.IsNullOrWhiteSpace(input.Password)) + { + throw new BusinessException("用户名或密码不能为空"); + } + + var user = await AccountManager.ValidateCredentialsAsync( + input.Username.Trim(), + input.Password, + cancellationToken); + var profileRepository = LazyServiceProvider.GetRequiredService(); + await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken); + var claims = await JwtService.GenerateClaimsByUserIdAsync(user.Id, cancellationToken); + return TokenIssuer.Issue(claims); + } + + /// + /// 入库上传件重尺。与操作台「入库扫描」页「入库」按钮同源(POST /operation/inbound/create)。 + /// 写入收货件重尺、入库记录,并触发入库计费/自动触发。重量默认 kg,长度默认 cm。 + /// Header:Authorization: Bearer {token}。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/inbound")] + [Consumes("application/json")] + [Produces("application/json")] + public Task InboundAsync([FromBody] OpenApiUploadWeightInput input) => + UploadWeightCoreAsync(input, forceMode: 0); + + /// + /// 上传件重尺。mode:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。 + /// Header:Authorization: Bearer {token}。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/weight/upload")] + [Consumes("application/json")] + [Produces("application/json")] + public Task UploadWeightAsync([FromBody] OpenApiUploadWeightInput input) => + UploadWeightCoreAsync(input, forceMode: null); + + private async Task UploadWeightCoreAsync( + OpenApiUploadWeightInput? input, + int? forceMode) + { + try + { + if (input == null || string.IsNullOrWhiteSpace(input.OrderNo)) + { + return OpenApiSimpleResult.Fail("订单号不能为空"); + } + + var mode = forceMode ?? input.Mode; + var weightKg = OpenApiMeasureUnitConverter.ToKilograms(input.Weight, input.WeightUnit); + var lengthCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Length, input.LengthUnit); + var widthCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Width, input.LengthUnit); + var heightCm = OpenApiMeasureUnitConverter.ToCentimeters(input.Height, input.LengthUnit); + var orderNo = input.OrderNo.Trim(); + + if (weightKg <= 0 || lengthCm <= 0 || widthCm <= 0 || heightCm <= 0) + { + return OpenApiSimpleResult.Fail("实重与长宽高须大于 0"); + } + + if (mode == 1) + { + await OutboundRecordService.CreateAsync(new CreateOutboundRecordInput + { + WaybillNo = orderNo, + Weight = weightKg, + Length = lengthCm, + Width = widthCm, + Height = heightCm + }); + return OpenApiSimpleResult.Ok("出库成功"); + } + + if (mode == 0) + { + // 与入库扫描页「入库」按钮同一实现:InboundRecordService.CreateAsync + var result = await InboundRecordService.CreateAsync(new CreateInboundRecordInput + { + WaybillNo = orderNo, + Weight = weightKg, + Length = lengthCm, + Width = widthCm, + Height = heightCm + }); + var statusName = string.IsNullOrWhiteSpace(result.OrderStatusName) + ? "入库成功" + : $"入库成功({result.OrderStatusName})"; + return OpenApiSimpleResult.Ok(statusName); + } + + return OpenApiSimpleResult.Fail("mode 仅支持 0(入库)或 1(出库)"); + } + catch (BusinessException ex) + { + return OpenApiSimpleResult.Fail(ex.Message); + } + catch (Exception ex) + { + return OpenApiSimpleResult.Fail( + string.IsNullOrWhiteSpace(ex.Message) ? "系统异常" : ex.Message); + } + } + + /// + /// 上传红外图片。Base64 解码后转存 COS,写入订单附件 fileType=infraredImage(Source=upload), + /// 与订单详情「附件」Tab →「红外图片」→「点击上传」(POST /order/files/upload)同源展示。 + /// 成功无响应体。Header:Authorization: Bearer {token}。 + /// + [Authorize] + [HttpPost("/openapi/warehouse/image/upload")] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task UploadImageAsync( + [FromBody] OpenApiUploadImageInput input, + CancellationToken cancellationToken = default) + { + if (input == null || string.IsNullOrWhiteSpace(input.OrderNo)) + { + throw new BusinessException("订单号不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.ImageBase64)) + { + throw new BusinessException("图片内容不能为空"); + } + + var order = await OrderRepository.GetByWaybillNoAsync(input.OrderNo.Trim(), cancellationToken) + ?? throw new BusinessException("未找到对应订单,请检查单号"); + + var operatorUserId = CurrentUser.IsAuthenticated ? CurrentUser.GetId() : 0; + // 固定红外图片类型,路径 uploads/order-files/{orderId}/infraredImage/... + await OrderFileManager.AddFromBase64Async( + order.Id, + OrderFileTypeCodes.InfraredImage, + input.ImageBase64, + operatorUserId, + cancellationToken); + + return new NoContentResult(); + } +} diff --git a/src/Oms.Application/Services/Operations/InboundRecordService.cs b/src/Oms.Application/Services/Operations/InboundRecordService.cs index 0770e99..30ead03 100644 --- a/src/Oms.Application/Services/Operations/InboundRecordService.cs +++ b/src/Oms.Application/Services/Operations/InboundRecordService.cs @@ -82,7 +82,7 @@ public class InboundRecordService : ApplicationService } /// - /// 新增入库记录。 + /// 新增入库记录(入库扫描页「入库」按钮;仓配开放接口 POST /openapi/warehouse/inbound 同源)。 /// /// 入参:入库记录创建参数。 /// 入库记录结果。 diff --git a/src/Oms.Application/Services/Orders/OrderFileManager.cs b/src/Oms.Application/Services/Orders/OrderFileManager.cs index 356230a..89cdeb9 100644 --- a/src/Oms.Application/Services/Orders/OrderFileManager.cs +++ b/src/Oms.Application/Services/Orders/OrderFileManager.cs @@ -363,6 +363,221 @@ public sealed class OrderFileManager( return ".pdf"; } + /// + /// 从 Base64 写入订单附件:先转存对象存储(COS),再落 t_order_file。 + /// 与订单详情附件 Tab「点击上传」(POST /order/files/upload)同源校验与路径约定。 + /// + public async Task AddFromBase64Async( + int orderId, + string fileType, + string imageBase64, + int operatorUserId, + CancellationToken cancellationToken = default) + { + if (!OrderFileTypeCodes.IsValid(fileType)) + { + throw new BusinessException("不支持的附件类型"); + } + + var type = OrderFileTypeCodes.Normalize(fileType); + if (string.IsNullOrWhiteSpace(imageBase64)) + { + throw new BusinessException("图片内容不能为空"); + } + + var (bytes, contentType, ext) = DecodeBase64Image(imageBase64); + if (bytes.Length == 0) + { + throw new BusinessException("图片内容不能为空"); + } + + var existing = await orderFileRepository.GetByOrderAndTypeListAsync(orderId, type, cancellationToken); + var max = OrderFileTypeCodes.GetMaxCount(type); + if (existing.Count >= max) + { + throw new BusinessException($"类型 {type} 最多 {max} 个文件"); + } + + var fileName = $"{type}_{DateTime.Now:yyyyMMddHHmmss}{ext}"; + var savedPath = await SaveBytesAsync(bytes, fileName, contentType, orderId, type, cancellationToken); + + var sort = existing.Count == 0 ? 0 : existing.Max(x => x.Sort) + 1; + var entity = new OrderFile + { + OrderId = orderId, + FileType = type, + FileName = savedPath.FileName, + FileUrl = savedPath.Url, + ContentType = savedPath.ContentType, + FileSize = savedPath.Size, + Sort = sort, + Source = OrderFileSourceCodes.Upload, + CreateUserId = operatorUserId, + CreateTime = DateTime.Now + }; + var saved = await orderFileRepository.InsertAsync(entity, cancellationToken); + return MapItem(saved); + } + + private static (byte[] Bytes, string ContentType, string Ext) DecodeBase64Image(string imageBase64) + { + var raw = imageBase64.Trim(); + string? headerContentType = null; + + if (raw.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var comma = raw.IndexOf(','); + if (comma <= 0) + { + throw new BusinessException("图片 Base64 格式错误"); + } + + var header = raw[..comma]; + raw = raw[(comma + 1)..]; + var mimeStart = header.IndexOf(':'); + var mimeEnd = header.IndexOf(';'); + if (mimeStart >= 0 && mimeEnd > mimeStart) + { + headerContentType = header[(mimeStart + 1)..mimeEnd].Trim(); + } + } + + byte[] bytes; + try + { + bytes = Convert.FromBase64String(raw); + } + catch (FormatException) + { + throw new BusinessException("图片 Base64 解码失败"); + } + + var sniffed = SniffImageFormat(bytes); + var contentType = headerContentType ?? sniffed.ContentType; + var ext = sniffed.Ext; + if (!string.IsNullOrWhiteSpace(headerContentType)) + { + ext = headerContentType.ToLowerInvariant() switch + { + "image/png" => ".png", + "image/webp" => ".webp", + "image/gif" => ".gif", + "image/bmp" => ".bmp", + "application/pdf" => ".pdf", + "image/jpeg" or "image/jpg" => ".jpg", + _ => sniffed.Ext + }; + } + + return (bytes, contentType, ext); + } + + private static (string ContentType, string Ext) SniffImageFormat(byte[] bytes) + { + if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + { + return ("image/jpeg", ".jpg"); + } + + if (bytes.Length >= 8 + && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + { + return ("image/png", ".png"); + } + + if (bytes.Length >= 6 + && bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) + { + return ("image/gif", ".gif"); + } + + if (bytes.Length >= 12 + && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 + && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) + { + return ("image/webp", ".webp"); + } + + if (bytes.Length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D) + { + return ("image/bmp", ".bmp"); + } + + if (bytes.Length >= 5 + && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46) + { + return ("application/pdf", ".pdf"); + } + + return ("image/jpeg", ".jpg"); + } + + /// + /// 与 multipart 上传共用:校验扩展名/大小后写入 COS,返回逻辑 object key。 + /// + private async Task<(string Url, string FileName, string? ContentType, long Size)> SaveBytesAsync( + byte[] bytes, + string fileName, + string? contentType, + int orderId, + string fileType, + CancellationToken cancellationToken = default) + { + if (bytes == null || bytes.Length <= 0) + { + throw new BusinessException("上传文件不能为空"); + } + + var ext = Path.GetExtension(fileName); + if (string.IsNullOrWhiteSpace(ext)) + { + ext = ".bin"; + } + + var isLabelType = fileType is OrderFileTypeCodes.SystemLabel + or OrderFileTypeCodes.DownstreamLabel + or OrderFileTypeCodes.LastMileLabel; + var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage + or OrderFileTypeCodes.TradeProof + or OrderFileTypeCodes.Pod; + + if (isLabelType) + { + if (!LabelExt.Contains(ext)) + { + throw new BusinessException("面单仅支持 pdf / jpg / png"); + } + + if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && bytes.Length > MaxPdfBytes) + { + throw new BusinessException("PDF 不能超过 10MB"); + } + + if (!string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && bytes.Length > MaxImageBytes) + { + throw new BusinessException("图片不能超过 5MB"); + } + } + else if (isInfraredOrProof) + { + if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase)) + { + throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf"); + } + + if (bytes.Length > MaxPdfBytes) + { + throw new BusinessException("文件不能超过 10MB"); + } + } + + var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; + var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; + await using var stream = new MemoryStream(bytes); + var key = await fileStorage.SaveObjectAsync(stream, relativeKey, contentType, cancellationToken); + return (key, Path.GetFileName(fileName), contentType, bytes.Length); + } + public async Task> UploadAsync( int orderId, string fileType, @@ -469,54 +684,15 @@ public sealed class OrderFileManager( throw new BusinessException("上传文件不能为空"); } - var ext = Path.GetExtension(file.FileName); - if (string.IsNullOrWhiteSpace(ext)) - { - ext = ".bin"; - } - - var isLabelType = fileType is OrderFileTypeCodes.SystemLabel - or OrderFileTypeCodes.DownstreamLabel - or OrderFileTypeCodes.LastMileLabel; - var isInfraredOrProof = fileType is OrderFileTypeCodes.InfraredImage - or OrderFileTypeCodes.TradeProof - or OrderFileTypeCodes.Pod; - - if (isLabelType) - { - if (!LabelExt.Contains(ext)) - { - throw new BusinessException("面单仅支持 pdf / jpg / png"); - } - - if (string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && file.Length > MaxPdfBytes) - { - throw new BusinessException("PDF 不能超过 10MB"); - } - - if (!string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase) && file.Length > MaxImageBytes) - { - throw new BusinessException("图片不能超过 5MB"); - } - } - else if (isInfraredOrProof) - { - if (!ImageExt.Contains(ext) && !string.Equals(ext, ".pdf", StringComparison.OrdinalIgnoreCase)) - { - throw new BusinessException("交易证明/红外图片/POD 仅支持图片或 pdf"); - } - - if (file.Length > MaxPdfBytes) - { - throw new BusinessException("文件不能超过 10MB"); - } - } - - var storedName = $"{Guid.NewGuid():N}{ext.ToLowerInvariant()}"; - var relativeKey = $"uploads/order-files/{orderId}/{fileType}/{storedName}"; await using var stream = file.OpenReadStream(); - var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType); - return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length); + await using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + return await SaveBytesAsync( + ms.ToArray(), + file.FileName, + file.ContentType, + orderId, + fileType); } private OrderFileItemOutput MapItem(OrderFile x) => diff --git a/src/Oms.Application/Services/Rbac/OmsUserService.cs b/src/Oms.Application/Services/Rbac/OmsUserService.cs index 46a3439..15d945c 100644 --- a/src/Oms.Application/Services/Rbac/OmsUserService.cs +++ b/src/Oms.Application/Services/Rbac/OmsUserService.cs @@ -1,5 +1,7 @@ +using System.Security.Claims; using Fake.Application; using Fake.Domain.Exceptions; +using Fake.ObjectStorage; using Fake.Rbac.Application.Dtos.Common; using Fake.Rbac.Application.Dtos.Role; using Fake.Rbac.Application.Dtos.User; @@ -8,7 +10,6 @@ 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; @@ -157,6 +158,7 @@ public class OmsUserService( WechatId = input.WechatId, WechatQrCode = input.WechatQrCode, WecomKfUrl = input.WecomKfUrl, + IsEnabled = input.IsEnabled ?? true, }, cancellationToken); @@ -242,6 +244,12 @@ public class OmsUserService( }, cancellationToken); + if (input.IsEnabled.HasValue) + { + EnsureCanChangeEnabled(id, input.IsEnabled.Value); + await profileRepository.SetEnabledAsync(id, input.IsEnabled.Value, cancellationToken); + } + var profile = await profileRepository.GetAsync(id, cancellationToken); return MapUserDetail(user, profile); } @@ -251,6 +259,29 @@ public class OmsUserService( } } + /// + /// 启用或禁用用户。禁用后无法登录后台。 + /// + [HttpPost("/rbac/user/set-enabled")] + public async Task SetEnabledAsync( + [FromQuery] Guid id, + [FromBody] SetUserEnabledInput input, + CancellationToken cancellationToken = default) + { + if (id == Guid.Empty) + { + throw new BusinessException("用户 Id 无效"); + } + + if (input == null) + { + throw new BusinessException("请求体不能为空"); + } + + EnsureCanChangeEnabled(id, input.IsEnabled); + await profileRepository.SetEnabledAsync(id, input.IsEnabled, cancellationToken); + } + /// /// 修改用户密码。 /// @@ -391,4 +422,18 @@ public class OmsUserService( detail.Avatar = fileStorage.GetPublicUrl(detail.Avatar); return detail; } + + private void EnsureCanChangeEnabled(Guid targetUserId, bool isEnabled) + { + if (isEnabled) + { + return; + } + + var currentIdText = CurrentUser.FindClaimOrNull(ClaimTypes.NameIdentifier)?.Value; + if (Guid.TryParse(currentIdText, out var currentId) && currentId == targetUserId) + { + throw new BusinessException("不能禁用当前登录账号"); + } + } } diff --git a/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs b/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs new file mode 100644 index 0000000..b372aa8 --- /dev/null +++ b/src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs @@ -0,0 +1,34 @@ +using Fake.Application; + +namespace Oms.Application.Shared; + +/// +/// 开放接口件重尺单位换算为系统内部单位(kg / cm)。 +/// +public static class OpenApiMeasureUnitConverter +{ + public static decimal ToKilograms(decimal weight, string? weightUnit) + { + var unit = string.IsNullOrWhiteSpace(weightUnit) ? "KG" : weightUnit.Trim().ToUpperInvariant(); + return unit switch + { + "KG" or "KGS" or "KILOGRAM" or "KILOGRAMS" => weight, + "G" or "GRAM" or "GRAMS" => weight / 1000m, + "LB" or "LBS" or "POUND" or "POUNDS" => weight * 0.45359237m, + _ => throw new BusinessException($"不支持的重量单位:{weightUnit}") + }; + } + + public static decimal ToCentimeters(decimal length, string? lengthUnit) + { + var unit = string.IsNullOrWhiteSpace(lengthUnit) ? "CM" : lengthUnit.Trim().ToUpperInvariant(); + return unit switch + { + "CM" or "CMS" or "CENTIMETER" or "CENTIMETERS" => length, + "MM" or "MILLIMETER" or "MILLIMETERS" => length / 10m, + "M" or "METER" or "METERS" => length * 100m, + "IN" or "INCH" or "INCHES" => length * 2.54m, + _ => throw new BusinessException($"不支持的长度单位:{lengthUnit}") + }; + } +} diff --git a/src/Oms.Application/Shared/OpenApiTokenIssuer.cs b/src/Oms.Application/Shared/OpenApiTokenIssuer.cs new file mode 100644 index 0000000..b1e2cba --- /dev/null +++ b/src/Oms.Application/Shared/OpenApiTokenIssuer.cs @@ -0,0 +1,47 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Fake.AspNetCore.Authentication; +using Fake.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace Oms.Application.Shared; + +/// +/// 仓配开放接口 JWT 签发(固定 48 小时有效期,签名密钥与门户共用)。 +/// +public class OpenApiTokenIssuer(IOptions jwtOptions) : ITransientDependency +{ + public static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(48); + + public const string TokenKindClaim = "token_kind"; + public const string TokenKindOpenApi = "openapi"; + + public string Issue(IEnumerable claims) + { + var options = jwtOptions.Value; + if (string.IsNullOrWhiteSpace(options.SecretKey)) + { + throw new InvalidOperationException("Jwt SecretKey 未配置"); + } + + var list = claims + .Where(x => !string.Equals(x.Type, TokenKindClaim, StringComparison.OrdinalIgnoreCase)) + .Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)) + .ToList(); + list.Add(new Claim(TokenKindClaim, TokenKindOpenApi)); + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SecretKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var now = DateTime.UtcNow; + var token = new JwtSecurityToken( + issuer: options.Issuer, + audience: options.Audience, + claims: list, + notBefore: now, + expires: now.Add(TokenLifetime), + signingCredentials: credentials); + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/SimpleAdmin.Api/Auth/AuthService.cs b/src/SimpleAdmin.Api/Auth/AuthService.cs index 1069f3b..449f59f 100644 --- a/src/SimpleAdmin.Api/Auth/AuthService.cs +++ b/src/SimpleAdmin.Api/Auth/AuthService.cs @@ -433,6 +433,8 @@ public partial class AuthService( private async Task LoginAdminAsync(string account, string password, CancellationToken cancellationToken) { var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken); + var profileRepository = LazyServiceProvider.GetRequiredService(); + await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken); return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken); }