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