1.05 #7

Merged
zzh merged 19 commits from test into master 2026-10-15 13:59:09 +08:00
15 changed files with 790 additions and 59 deletions
Showing only changes of commit e85dfb80b5 - Show all commits

View File

@ -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;

View File

@ -0,0 +1,108 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.OpenApi;
/// <summary>
/// 开放接口获取 Token 入参。
/// </summary>
public class OpenApiGetTokenInput
{
/// <summary>
/// 用户名(OMS 后台账号)。
/// </summary>
[Required]
[StringLength(64)]
public string Username { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
[Required]
[StringLength(128)]
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 开放接口简易结果(件重尺上传等)。
/// </summary>
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 };
}
/// <summary>
/// 仓配上传件重尺入参(入库接口可忽略 mode,固定走入库扫描「入库」逻辑)。
/// </summary>
public class OpenApiUploadWeightInput
{
/// <summary>
/// 订单号(服务单号 / 跟踪号 / 下游单号,同入库扫描扫码框)。
/// </summary>
[Required]
[StringLength(64)]
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 收货实重(同入库扫描「实重」)。
/// </summary>
public decimal Weight { get; set; }
/// <summary>
/// 长(同入库扫描尺寸)。
/// </summary>
public decimal Length { get; set; }
/// <summary>
/// 宽。
/// </summary>
public decimal Width { get; set; }
/// <summary>
/// 高。
/// </summary>
public decimal Height { get; set; }
/// <summary>
/// 模式:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。调用 /inbound 时可省略。
/// </summary>
public int Mode { get; set; }
/// <summary>
/// 重量单位:KG(默认)/ G / LB。
/// </summary>
[StringLength(8)]
public string? WeightUnit { get; set; }
/// <summary>
/// 长度单位:CM(默认)/ MM / IN / M。
/// </summary>
[StringLength(8)]
public string? LengthUnit { get; set; }
}
/// <summary>
/// 仓配上传红外图片入参(落订单附件 infraredImage,转存 COS)。
/// </summary>
public class OpenApiUploadImageInput
{
/// <summary>
/// 订单号(服务单号 / 跟踪号 / 下游单号)。
/// </summary>
[Required]
[StringLength(64)]
public string OrderNo { get; set; } = string.Empty;
/// <summary>
/// 图片 Base64(可带 data:image/...;base64, 前缀;jpg/png/gif/webp/bmp/pdf)。
/// </summary>
[Required]
public string ImageBase64 { get; set; } = string.Empty;
}

View File

@ -26,6 +26,11 @@ public class InternalUserListItemDto
public DateTime? CreatedAt { get; set; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
public List<InternalUserRoleDto> Roles { get; set; } = [];
}

View File

@ -16,6 +16,11 @@ public class UserProfileData
/// 企业微信客服接入链接(微信外接入)。
/// </summary>
public string? WecomKfUrl { get; set; }
/// <summary>
/// 是否启用;缺省按启用处理。
/// </summary>
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; }
/// <summary>
/// 是否启用,默认 true。
/// </summary>
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; }
/// <summary>
/// 是否启用;不传则不改。
/// </summary>
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; }
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; } = true;
}
/// <summary>
@ -65,3 +85,14 @@ public class ResetUserPasswordInput
/// </summary>
public string NewPassword { get; set; } = string.Empty;
}
/// <summary>
/// 设置用户启用状态入参。
/// </summary>
public class SetUserEnabledInput
{
/// <summary>
/// 是否启用。
/// </summary>
public bool IsEnabled { get; set; }
}

View File

@ -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<InternalUserListResultDto> 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

View File

@ -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;
}
}

View File

@ -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<long>(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

View File

@ -14,9 +14,10 @@ using Oms.Domain.OrdersAggregate.Consts;
namespace Oms.Application.Services.OpenApi;
/// <summary>
/// 对外开放 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。
/// </summary>
[ApiExplorerSettings(GroupName = "openApi")]
public class OpenApiService : ApplicationService

View File

@ -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;
/// <summary>
/// 仓配对外开放 API(Swagger 文档分组:warehouseOpenApi)。
/// 鉴权:Authorization: Bearer {token}(账号密码换取,有效期 48 小时)。
/// 与客户 HMAC 分组 openApi 相互独立。
/// </summary>
[ApiExplorerSettings(GroupName = "warehouseOpenApi")]
public class WarehouseOpenApiService : ApplicationService
{
private AccountManager AccountManager =>
LazyServiceProvider.GetRequiredService<AccountManager>();
private IJwtService JwtService =>
LazyServiceProvider.GetRequiredService<IJwtService>();
private OpenApiTokenIssuer TokenIssuer =>
LazyServiceProvider.GetRequiredService<OpenApiTokenIssuer>();
private IOrderRepository OrderRepository =>
LazyServiceProvider.GetRequiredService<IOrderRepository>();
private OrderFileManager OrderFileManager =>
LazyServiceProvider.GetRequiredService<OrderFileManager>();
private InboundRecordService InboundRecordService =>
LazyServiceProvider.GetRequiredService<InboundRecordService>();
private OutboundRecordService OutboundRecordService =>
LazyServiceProvider.GetRequiredService<OutboundRecordService>();
/// <summary>
/// 获取 Token(有效期 48 小时)。后续请求 Header:Authorization: Bearer {token}。
/// </summary>
[AllowAnonymous]
[HttpPost("/openapi/warehouse/token")]
[Consumes("application/json")]
[Produces("application/json")]
public async Task<string> 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<UserProfileRepository>();
await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken);
var claims = await JwtService.GenerateClaimsByUserIdAsync(user.Id, cancellationToken);
return TokenIssuer.Issue(claims);
}
/// <summary>
/// 入库上传件重尺。与操作台「入库扫描」页「入库」按钮同源(POST /operation/inbound/create)。
/// 写入收货件重尺、入库记录,并触发入库计费/自动触发。重量默认 kg,长度默认 cm。
/// Header:Authorization: Bearer {token}。
/// </summary>
[Authorize]
[HttpPost("/openapi/warehouse/inbound")]
[Consumes("application/json")]
[Produces("application/json")]
public Task<OpenApiSimpleResult> InboundAsync([FromBody] OpenApiUploadWeightInput input) =>
UploadWeightCoreAsync(input, forceMode: 0);
/// <summary>
/// 上传件重尺。mode:0=入库(同「入库」按钮),1=出库(同「出库」按钮)。
/// Header:Authorization: Bearer {token}。
/// </summary>
[Authorize]
[HttpPost("/openapi/warehouse/weight/upload")]
[Consumes("application/json")]
[Produces("application/json")]
public Task<OpenApiSimpleResult> UploadWeightAsync([FromBody] OpenApiUploadWeightInput input) =>
UploadWeightCoreAsync(input, forceMode: null);
private async Task<OpenApiSimpleResult> 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);
}
}
/// <summary>
/// 上传红外图片。Base64 解码后转存 COS,写入订单附件 fileType=infraredImage(Source=upload),
/// 与订单详情「附件」Tab →「红外图片」→「点击上传」(POST /order/files/upload)同源展示。
/// 成功无响应体。Header:Authorization: Bearer {token}。
/// </summary>
[Authorize]
[HttpPost("/openapi/warehouse/image/upload")]
[Consumes("application/json")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> 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<int>() : 0;
// 固定红外图片类型,路径 uploads/order-files/{orderId}/infraredImage/...
await OrderFileManager.AddFromBase64Async(
order.Id,
OrderFileTypeCodes.InfraredImage,
input.ImageBase64,
operatorUserId,
cancellationToken);
return new NoContentResult();
}
}

View File

@ -82,7 +82,7 @@ public class InboundRecordService : ApplicationService
}
/// <summary>
/// 新增入库记录。
/// 新增入库记录(入库扫描页「入库」按钮;仓配开放接口 POST /openapi/warehouse/inbound 同源)。
/// </summary>
/// <param name="input">入参:入库记录创建参数。</param>
/// <returns>入库记录结果。</returns>

View File

@ -363,6 +363,221 @@ public sealed class OrderFileManager(
return ".pdf";
}
/// <summary>
/// 从 Base64 写入订单附件:先转存对象存储(COS),再落 t_order_file。
/// 与订单详情附件 Tab「点击上传」(POST /order/files/upload)同源校验与路径约定。
/// </summary>
public async Task<OrderFileItemOutput> 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");
}
/// <summary>
/// 与 multipart 上传共用:校验扩展名/大小后写入 COS,返回逻辑 object key。
/// </summary>
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<List<OrderFileItemOutput>> 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) =>

View File

@ -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(
}
}
/// <summary>
/// 启用或禁用用户。禁用后无法登录后台。
/// </summary>
[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);
}
/// <summary>
/// 修改用户密码。
/// </summary>
@ -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("不能禁用当前登录账号");
}
}
}

View File

@ -0,0 +1,34 @@
using Fake.Application;
namespace Oms.Application.Shared;
/// <summary>
/// 开放接口件重尺单位换算为系统内部单位(kg / cm)。
/// </summary>
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}")
};
}
}

View File

@ -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;
/// <summary>
/// 仓配开放接口 JWT 签发(固定 48 小时有效期,签名密钥与门户共用)。
/// </summary>
public class OpenApiTokenIssuer(IOptions<FakeJwtOptions> 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<Claim> 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);
}
}

View File

@ -433,6 +433,8 @@ public partial class AuthService(
private async Task<OmsLoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
{
var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken);
var profileRepository = LazyServiceProvider.GetRequiredService<Oms.Application.Rbac.UserProfileRepository>();
await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken);
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
}