feat: 仓配开放 API 与后台用户启停
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
All checks were successful
Build and Deploy / deploy (push) Successful in 1m1s
新增 warehouseOpenApi(Token/入库/件重尺/红外图),并在 fk_user 支持 IsEnabled 禁用登录。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ff9c589956
commit
e85dfb80b5
24
sql/migrate_user_is_enabled.sql
Normal file
24
sql/migrate_user_is_enabled.sql
Normal 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;
|
||||||
108
src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs
Normal file
108
src/Oms.Application/Dtos/OpenApi/OpenApiTokenDtos.cs
Normal 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;
|
||||||
|
}
|
||||||
@ -26,6 +26,11 @@ public class InternalUserListItemDto
|
|||||||
|
|
||||||
public DateTime? CreatedAt { get; set; }
|
public DateTime? CreatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
|
|
||||||
public List<InternalUserRoleDto> Roles { get; set; } = [];
|
public List<InternalUserRoleDto> Roles { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,11 @@ public class UserProfileData
|
|||||||
/// 企业微信客服接入链接(微信外接入)。
|
/// 企业微信客服接入链接(微信外接入)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? WecomKfUrl { get; set; }
|
public string? WecomKfUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用;缺省按启用处理。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OmsUserCreateInput : UserCreateDto
|
public class OmsUserCreateInput : UserCreateDto
|
||||||
@ -29,6 +34,11 @@ public class OmsUserCreateInput : UserCreateDto
|
|||||||
public string? WechatQrCode { get; set; }
|
public string? WechatQrCode { get; set; }
|
||||||
|
|
||||||
public string? WecomKfUrl { get; set; }
|
public string? WecomKfUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用,默认 true。
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsEnabled { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OmsUserUpdateInput : UserUpdateDto
|
public class OmsUserUpdateInput : UserUpdateDto
|
||||||
@ -40,6 +50,11 @@ public class OmsUserUpdateInput : UserUpdateDto
|
|||||||
public string? WechatQrCode { get; set; }
|
public string? WechatQrCode { get; set; }
|
||||||
|
|
||||||
public string? WecomKfUrl { get; set; }
|
public string? WecomKfUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用;不传则不改。
|
||||||
|
/// </summary>
|
||||||
|
public bool? IsEnabled { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OmsUserDetailDto : UserDto
|
public class OmsUserDetailDto : UserDto
|
||||||
@ -53,6 +68,11 @@ public class OmsUserDetailDto : UserDto
|
|||||||
public string? WechatQrCode { get; set; }
|
public string? WechatQrCode { get; set; }
|
||||||
|
|
||||||
public string? WecomKfUrl { get; set; }
|
public string? WecomKfUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -65,3 +85,14 @@ public class ResetUserPasswordInput
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string NewPassword { get; set; } = string.Empty;
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 设置用户启用状态入参。
|
||||||
|
/// </summary>
|
||||||
|
public class SetUserEnabledInput
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 是否启用。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsEnabled { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Fake.Rbac.Infrastructure;
|
|
||||||
using Fake.EntityFrameworkCore;
|
using Fake.EntityFrameworkCore;
|
||||||
|
using Fake.Rbac.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Oms.Application.Dtos.Rbac;
|
using Oms.Application.Dtos.Rbac;
|
||||||
|
|
||||||
@ -21,6 +21,7 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
";
|
";
|
||||||
|
|
||||||
public async Task<InternalUserListResultDto> GetInternalUsersAsync(
|
public async Task<InternalUserListResultDto> GetInternalUsersAsync(
|
||||||
string? keyword,
|
string? keyword,
|
||||||
Guid? organizationId,
|
Guid? organizationId,
|
||||||
@ -66,7 +67,8 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend
|
|||||||
u.`WechatId` AS `WechatId`,
|
u.`WechatId` AS `WechatId`,
|
||||||
u.`OrganizationId` AS `OrganizationId`,
|
u.`OrganizationId` AS `OrganizationId`,
|
||||||
o.`Name` AS `OrganizationName`,
|
o.`Name` AS `OrganizationName`,
|
||||||
u.`CreateTime` AS `CreatedAt`
|
u.`CreateTime` AS `CreatedAt`,
|
||||||
|
COALESCE(u.`IsEnabled`, 1) AS `IsEnabled`
|
||||||
FROM `{FakeTables.User}` u
|
FROM `{FakeTables.User}` u
|
||||||
LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0
|
LEFT JOIN `{FakeTables.Organization}` o ON o.`Id` = u.`OrganizationId` AND o.`IsDeleted` = 0
|
||||||
{where}
|
{where}
|
||||||
@ -95,6 +97,7 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend
|
|||||||
OrganizationId = row.OrganizationId?.ToString(),
|
OrganizationId = row.OrganizationId?.ToString(),
|
||||||
OrganizationName = row.OrganizationName,
|
OrganizationName = row.OrganizationName,
|
||||||
CreatedAt = row.CreatedAt,
|
CreatedAt = row.CreatedAt,
|
||||||
|
IsEnabled = row.IsEnabled != 0,
|
||||||
Roles = roleMap.TryGetValue(row.Id, out var roles) ? roles : []
|
Roles = roleMap.TryGetValue(row.Id, out var roles) ? roles : []
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@ -159,6 +162,8 @@ public class InternalUserRepository(FakeRbacDbContext dbContext) : IScopedDepend
|
|||||||
public string? OrganizationName { get; set; }
|
public string? OrganizationName { get; set; }
|
||||||
|
|
||||||
public DateTime? CreatedAt { get; set; }
|
public DateTime? CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public int IsEnabled { get; set; } = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class UserRoleRow
|
private sealed class UserRoleRow
|
||||||
|
|||||||
@ -33,6 +33,7 @@ internal static class OmsUserProfileMapper
|
|||||||
detail.WechatId = profile.WechatId;
|
detail.WechatId = profile.WechatId;
|
||||||
detail.WechatQrCode = profile.WechatQrCode;
|
detail.WechatQrCode = profile.WechatQrCode;
|
||||||
detail.WecomKfUrl = profile.WecomKfUrl;
|
detail.WecomKfUrl = profile.WecomKfUrl;
|
||||||
|
detail.IsEnabled = profile.IsEnabled;
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using Fake.Application;
|
||||||
using Fake.DependencyInjection;
|
using Fake.DependencyInjection;
|
||||||
using Fake.Rbac.Infrastructure;
|
|
||||||
using Fake.EntityFrameworkCore;
|
using Fake.EntityFrameworkCore;
|
||||||
|
using Fake.Rbac.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Oms.Application.Dtos.Rbac;
|
using Oms.Application.Dtos.Rbac;
|
||||||
|
|
||||||
@ -22,7 +23,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
u.`EmployeeNo` AS `EmployeeNo`,
|
u.`EmployeeNo` AS `EmployeeNo`,
|
||||||
u.`WechatId` AS `WechatId`,
|
u.`WechatId` AS `WechatId`,
|
||||||
u.`WechatQrCode` AS `WechatQrCode`,
|
u.`WechatQrCode` AS `WechatQrCode`,
|
||||||
u.`WecomKfUrl` AS `WecomKfUrl`
|
u.`WecomKfUrl` AS `WecomKfUrl`,
|
||||||
|
COALESCE(u.`IsEnabled`, 1) AS `IsEnabled`
|
||||||
FROM `{FakeTables.User}` u
|
FROM `{FakeTables.User}` u
|
||||||
WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0
|
WHERE u.`Id` = {{0}} AND u.`IsDeleted` = 0
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -43,6 +45,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
WechatId = row.WechatId,
|
WechatId = row.WechatId,
|
||||||
WechatQrCode = row.WechatQrCode,
|
WechatQrCode = row.WechatQrCode,
|
||||||
WecomKfUrl = row.WecomKfUrl,
|
WecomKfUrl = row.WecomKfUrl,
|
||||||
|
IsEnabled = row.IsEnabled != 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +66,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
u.`EmployeeNo` AS `EmployeeNo`,
|
u.`EmployeeNo` AS `EmployeeNo`,
|
||||||
u.`WechatId` AS `WechatId`,
|
u.`WechatId` AS `WechatId`,
|
||||||
u.`WechatQrCode` AS `WechatQrCode`,
|
u.`WechatQrCode` AS `WechatQrCode`,
|
||||||
u.`WecomKfUrl` AS `WecomKfUrl`
|
u.`WecomKfUrl` AS `WecomKfUrl`,
|
||||||
|
COALESCE(u.`IsEnabled`, 1) AS `IsEnabled`
|
||||||
FROM `{FakeTables.User}` u
|
FROM `{FakeTables.User}` u
|
||||||
WHERE u.`Id` IN ({inClause}) AND u.`IsDeleted` = 0
|
WHERE u.`Id` IN ({inClause}) AND u.`IsDeleted` = 0
|
||||||
";
|
";
|
||||||
@ -78,6 +82,7 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
WechatId = x.WechatId,
|
WechatId = x.WechatId,
|
||||||
WechatQrCode = x.WechatQrCode,
|
WechatQrCode = x.WechatQrCode,
|
||||||
WecomKfUrl = x.WecomKfUrl,
|
WecomKfUrl = x.WecomKfUrl,
|
||||||
|
IsEnabled = x.IsEnabled != 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,9 +95,11 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
`EmployeeNo` = {{2}},
|
`EmployeeNo` = {{2}},
|
||||||
`WechatId` = {{3}},
|
`WechatId` = {{3}},
|
||||||
`WechatQrCode` = {{4}},
|
`WechatQrCode` = {{4}},
|
||||||
`WecomKfUrl` = {{5}}
|
`WecomKfUrl` = {{5}},
|
||||||
|
`IsEnabled` = {{6}}
|
||||||
WHERE `Id` = {{0}} AND `IsDeleted` = 0
|
WHERE `Id` = {{0}} AND `IsDeleted` = 0
|
||||||
";
|
";
|
||||||
|
|
||||||
await dbContext.Database.ExecuteSqlRawAsync(
|
await dbContext.Database.ExecuteSqlRawAsync(
|
||||||
sql,
|
sql,
|
||||||
[
|
[
|
||||||
@ -103,10 +110,52 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
(object?)NormalizeOptional(profile.WechatId),
|
(object?)NormalizeOptional(profile.WechatId),
|
||||||
(object?)NormalizeOptional(profile.WechatQrCode),
|
(object?)NormalizeOptional(profile.WechatQrCode),
|
||||||
(object?)NormalizeOptional(profile.WecomKfUrl),
|
(object?)NormalizeOptional(profile.WecomKfUrl),
|
||||||
|
profile.IsEnabled ? 1 : 0,
|
||||||
],
|
],
|
||||||
cancellationToken);
|
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(
|
public async Task UpdateContactAsync(
|
||||||
Guid userId,
|
Guid userId,
|
||||||
UserProfileData profile,
|
UserProfileData profile,
|
||||||
@ -186,8 +235,6 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
|
|
||||||
if (int.TryParse(match.Groups[1].Value, out var number))
|
if (int.TryParse(match.Groups[1].Value, out var number))
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
max = Math.Max(max, number);
|
max = Math.Max(max, number);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,6 +266,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
public string? WechatQrCode { get; set; }
|
public string? WechatQrCode { get; set; }
|
||||||
|
|
||||||
public string? WecomKfUrl { get; set; }
|
public string? WecomKfUrl { get; set; }
|
||||||
|
|
||||||
|
public int IsEnabled { get; set; } = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class UserProfileListRow
|
private sealed class UserProfileListRow
|
||||||
@ -234,6 +283,8 @@ public class UserProfileRepository(FakeRbacDbContext dbContext) : IScopedDepende
|
|||||||
public string? WechatQrCode { get; set; }
|
public string? WechatQrCode { get; set; }
|
||||||
|
|
||||||
public string? WecomKfUrl { get; set; }
|
public string? WecomKfUrl { get; set; }
|
||||||
|
|
||||||
|
public int IsEnabled { get; set; } = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class EmployeeNoRow
|
private sealed class EmployeeNoRow
|
||||||
|
|||||||
@ -14,9 +14,10 @@ using Oms.Domain.OrdersAggregate.Consts;
|
|||||||
namespace Oms.Application.Services.OpenApi;
|
namespace Oms.Application.Services.OpenApi;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 对外开放 API(Swagger 文档分组:openApi)。
|
/// 客户对外开放 API(Swagger 文档分组:openApi)。
|
||||||
/// 统一响应:{ code, msg, data }。
|
/// 统一响应:{ code, msg, data }。
|
||||||
/// 客户下单鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。
|
/// 鉴权:Header appKey + timestamp + sign(HMAC-SHA256,5 分钟窗口);Secret 不上传。
|
||||||
|
/// 仓配 Token 接口见分组 warehouseOpenApi。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ApiExplorerSettings(GroupName = "openApi")]
|
[ApiExplorerSettings(GroupName = "openApi")]
|
||||||
public class OpenApiService : ApplicationService
|
public class OpenApiService : ApplicationService
|
||||||
|
|||||||
201
src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs
Normal file
201
src/Oms.Application/Services/OpenApi/WarehouseOpenApiService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -82,7 +82,7 @@ public class InboundRecordService : ApplicationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 新增入库记录。
|
/// 新增入库记录(入库扫描页「入库」按钮;仓配开放接口 POST /openapi/warehouse/inbound 同源)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">入参:入库记录创建参数。</param>
|
/// <param name="input">入参:入库记录创建参数。</param>
|
||||||
/// <returns>入库记录结果。</returns>
|
/// <returns>入库记录结果。</returns>
|
||||||
|
|||||||
@ -363,6 +363,221 @@ public sealed class OrderFileManager(
|
|||||||
return ".pdf";
|
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(
|
public async Task<List<OrderFileItemOutput>> UploadAsync(
|
||||||
int orderId,
|
int orderId,
|
||||||
string fileType,
|
string fileType,
|
||||||
@ -469,54 +684,15 @@ public sealed class OrderFileManager(
|
|||||||
throw new BusinessException("上传文件不能为空");
|
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();
|
await using var stream = file.OpenReadStream();
|
||||||
var key = await fileStorage.SaveObjectAsync(stream, relativeKey, file.ContentType);
|
await using var ms = new MemoryStream();
|
||||||
return (key, Path.GetFileName(file.FileName), file.ContentType, file.Length);
|
await stream.CopyToAsync(ms);
|
||||||
|
return await SaveBytesAsync(
|
||||||
|
ms.ToArray(),
|
||||||
|
file.FileName,
|
||||||
|
file.ContentType,
|
||||||
|
orderId,
|
||||||
|
fileType);
|
||||||
}
|
}
|
||||||
|
|
||||||
private OrderFileItemOutput MapItem(OrderFile x) =>
|
private OrderFileItemOutput MapItem(OrderFile x) =>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Fake.Application;
|
using Fake.Application;
|
||||||
using Fake.Domain.Exceptions;
|
using Fake.Domain.Exceptions;
|
||||||
|
using Fake.ObjectStorage;
|
||||||
using Fake.Rbac.Application.Dtos.Common;
|
using Fake.Rbac.Application.Dtos.Common;
|
||||||
using Fake.Rbac.Application.Dtos.Role;
|
using Fake.Rbac.Application.Dtos.Role;
|
||||||
using Fake.Rbac.Application.Dtos.User;
|
using Fake.Rbac.Application.Dtos.User;
|
||||||
@ -8,7 +10,6 @@ using Fake.Rbac.Domain.Managers;
|
|||||||
using Fake.Rbac.Infrastructure;
|
using Fake.Rbac.Infrastructure;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Fake.ObjectStorage;
|
|
||||||
using Oms.Application.Dtos.Rbac;
|
using Oms.Application.Dtos.Rbac;
|
||||||
using Oms.Application.Rbac;
|
using Oms.Application.Rbac;
|
||||||
using Oms.Application.Shared;
|
using Oms.Application.Shared;
|
||||||
@ -157,6 +158,7 @@ public class OmsUserService(
|
|||||||
WechatId = input.WechatId,
|
WechatId = input.WechatId,
|
||||||
WechatQrCode = input.WechatQrCode,
|
WechatQrCode = input.WechatQrCode,
|
||||||
WecomKfUrl = input.WecomKfUrl,
|
WecomKfUrl = input.WecomKfUrl,
|
||||||
|
IsEnabled = input.IsEnabled ?? true,
|
||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
@ -242,6 +244,12 @@ public class OmsUserService(
|
|||||||
},
|
},
|
||||||
cancellationToken);
|
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);
|
var profile = await profileRepository.GetAsync(id, cancellationToken);
|
||||||
return MapUserDetail(user, profile);
|
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>
|
||||||
/// 修改用户密码。
|
/// 修改用户密码。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -391,4 +422,18 @@ public class OmsUserService(
|
|||||||
detail.Avatar = fileStorage.GetPublicUrl(detail.Avatar);
|
detail.Avatar = fileStorage.GetPublicUrl(detail.Avatar);
|
||||||
return detail;
|
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("不能禁用当前登录账号");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs
Normal file
34
src/Oms.Application/Shared/OpenApiMeasureUnitConverter.cs
Normal 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}")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Oms.Application/Shared/OpenApiTokenIssuer.cs
Normal file
47
src/Oms.Application/Shared/OpenApiTokenIssuer.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -433,6 +433,8 @@ public partial class AuthService(
|
|||||||
private async Task<OmsLoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
|
private async Task<OmsLoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user = await accountManager.ValidateCredentialsAsync(account, password, 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);
|
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user