修复充值和查价

This commit is contained in:
zzh 2026-06-25 19:10:44 +08:00
parent 12ee25ad57
commit 00835bfadd
9 changed files with 322 additions and 38 deletions

View File

@ -0,0 +1,44 @@
-- t_order / t_order_fee 小包预报计费字段补齐(可重复执行)
-- 在 OMS 业务库执行,例如:mysql -h<host> -u<user> -p<db> < migrate_t_order_parcel_columns.sql
SET @db = DATABASE();
-- t_order.ExportCustomsMode
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'ExportCustomsMode');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order` ADD COLUMN `ExportCustomsMode` VARCHAR(32) NULL COMMENT ''出口报关方式'' AFTER `CargoAttribute`',
'SELECT ''skip ExportCustomsMode''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- t_order.PredeclareFailReason
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'PredeclareFailReason');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order` ADD COLUMN `PredeclareFailReason` VARCHAR(512) NULL COMMENT ''预报失败原因'' AFTER `IsRemote`',
'SELECT ''skip PredeclareFailReason''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- t_order.SalesProductRulesVersion
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'SalesProductRulesVersion');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order` ADD COLUMN `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT ''销售产品规则快照版本'' AFTER `PredeclareFailReason`',
'SELECT ''skip SalesProductRulesVersion''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- t_order.SalesProductPriceVersion
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'SalesProductPriceVersion');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order` ADD COLUMN `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT ''销售产品价格快照版本'' AFTER `SalesProductRulesVersion`',
'SELECT ''skip SalesProductPriceVersion''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- t_order_fee.ChargingStage
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order_fee' AND COLUMN_NAME = 'ChargingStage');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order_fee` ADD COLUMN `ChargingStage` VARCHAR(16) NULL COMMENT ''计费阶段:forecast/inbound/outbound'' AFTER `PriceEffectiveTime`',
'SELECT ''skip ChargingStage''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -1,11 +1,39 @@
-- 小包预报计费:订单版本字段 + 费用计费阶段
-- 若列已存在可跳过对应语句
-- 与 migrate_t_order_parcel_columns.sql 相同,保留此文件名便于按文档引用
-- t_order / t_order_fee 小包预报计费字段补齐(可重复执行)
ALTER TABLE `t_order`
ADD COLUMN IF NOT EXISTS `ExportCustomsMode` VARCHAR(32) NULL COMMENT '出口报关方式' AFTER `CargoAttribute`,
ADD COLUMN IF NOT EXISTS `PredeclareFailReason` VARCHAR(512) NULL COMMENT '预报失败原因' AFTER `IsRemote`,
ADD COLUMN IF NOT EXISTS `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT '销售产品规则快照版本' AFTER `PredeclareFailReason`,
ADD COLUMN IF NOT EXISTS `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT '销售产品价格快照版本' AFTER `SalesProductRulesVersion`;
SET @db = DATABASE();
ALTER TABLE `t_order_fee`
ADD COLUMN IF NOT EXISTS `ChargingStage` VARCHAR(16) NULL COMMENT '计费阶段:forecast/inbound/outbound' AFTER `PriceEffectiveTime`;
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'ExportCustomsMode');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order` ADD COLUMN `ExportCustomsMode` VARCHAR(32) NULL COMMENT ''出口报关方式'' AFTER `CargoAttribute`',
'SELECT ''skip ExportCustomsMode''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'PredeclareFailReason');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order` ADD COLUMN `PredeclareFailReason` VARCHAR(512) NULL COMMENT ''预报失败原因'' AFTER `IsRemote`',
'SELECT ''skip PredeclareFailReason''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'SalesProductRulesVersion');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order` ADD COLUMN `SalesProductRulesVersion` VARCHAR(64) NULL COMMENT ''销售产品规则快照版本'' AFTER `PredeclareFailReason`',
'SELECT ''skip SalesProductRulesVersion''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order' AND COLUMN_NAME = 'SalesProductPriceVersion');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order` ADD COLUMN `SalesProductPriceVersion` VARCHAR(64) NULL COMMENT ''销售产品价格快照版本'' AFTER `SalesProductRulesVersion`',
'SELECT ''skip SalesProductPriceVersion''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db AND TABLE_NAME = 't_order_fee' AND COLUMN_NAME = 'ChargingStage');
SET @sql := IF(@exist = 0,
'ALTER TABLE `t_order_fee` ADD COLUMN `ChargingStage` VARCHAR(16) NULL COMMENT ''计费阶段:forecast/inbound/outbound'' AFTER `PriceEffectiveTime`',
'SELECT ''skip ChargingStage''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -24,8 +24,7 @@ public class SubmitRechargeFormInput
public string? Remark { get; set; }
/// <summary>
/// 充值截图文件。
/// 充值截图文件(可选;未上传时存 "0")。
/// </summary>
[Required]
public IFormFile RechargeScreenshot { get; set; } = null!;
public IFormFile? RechargeScreenshot { get; set; }
}

View File

@ -413,7 +413,12 @@ public partial class OrderService : ApplicationService
/// <returns>出参:订单查询表达式。</returns>
private Expression<Func<Order, bool>> BuildPaginatedPredicate(OrderPaginatedInput input)
{
return BuildOrderPredicate(input.Status, input.Keyword, input.CreateTimeStart, input.CreateTimeEnd);
return BuildOrderPredicate(
input.Status,
input.Keyword,
input.CreateTimeStart,
input.CreateTimeEnd,
OmsSessionHelper.TryGetCustomerCreateUserId(CurrentUser));
}
/// <summary>
@ -423,7 +428,12 @@ public partial class OrderService : ApplicationService
/// <returns>出参:订单查询表达式。</returns>
private Expression<Func<Order, bool>> BuildExportPredicate(OrderExportInput input)
{
return BuildOrderPredicate(input.Status, input.Keyword, input.CreateTimeStart, input.CreateTimeEnd);
return BuildOrderPredicate(
input.Status,
input.Keyword,
input.CreateTimeStart,
input.CreateTimeEnd,
OmsSessionHelper.TryGetCustomerCreateUserId(CurrentUser));
}
/// <summary>
@ -445,12 +455,15 @@ public partial class OrderService : ApplicationService
string? status,
string? keyword,
DateTime? createTimeStart,
DateTime? createTimeEnd)
DateTime? createTimeEnd,
int? createUserId = null)
{
status = status?.Trim();
keyword = keyword?.Trim();
var hasStatusFilter = !string.IsNullOrWhiteSpace(status) && status != "all";
var hasKeywordFilter = !string.IsNullOrWhiteSpace(keyword);
var hasCreateUserFilter = createUserId.HasValue;
var createUserIdValue = createUserId.GetValueOrDefault();
// 显式拆开时间分支,避免 SqlSugar 在翻译 Nullable<DateTime> 判空时生成异常 SQL。
if (createTimeStart.HasValue && createTimeEnd.HasValue)
@ -458,6 +471,7 @@ public partial class OrderService : ApplicationService
var start = createTimeStart.Value;
var end = createTimeEnd.Value;
return order =>
(!hasCreateUserFilter || order.CreateUserId == createUserIdValue) &&
(!hasStatusFilter || order.OrderStatus == status) &&
(!hasKeywordFilter ||
order.ServiceNo.Contains(keyword!) ||
@ -471,6 +485,7 @@ public partial class OrderService : ApplicationService
{
var start = createTimeStart.Value;
return order =>
(!hasCreateUserFilter || order.CreateUserId == createUserIdValue) &&
(!hasStatusFilter || order.OrderStatus == status) &&
(!hasKeywordFilter ||
order.ServiceNo.Contains(keyword!) ||
@ -483,6 +498,7 @@ public partial class OrderService : ApplicationService
{
var end = createTimeEnd.Value;
return order =>
(!hasCreateUserFilter || order.CreateUserId == createUserIdValue) &&
(!hasStatusFilter || order.OrderStatus == status) &&
(!hasKeywordFilter ||
order.ServiceNo.Contains(keyword!) ||
@ -492,6 +508,7 @@ public partial class OrderService : ApplicationService
}
return order =>
(!hasCreateUserFilter || order.CreateUserId == createUserIdValue) &&
(!hasStatusFilter || order.OrderStatus == status) &&
(!hasKeywordFilter ||
order.ServiceNo.Contains(keyword!) ||
@ -499,6 +516,20 @@ public partial class OrderService : ApplicationService
(order.TrackNo ?? string.Empty).Contains(keyword!));
}
private void EnsureCustomerCanAccessOrder(Order order)
{
var createUserId = OmsSessionHelper.TryGetCustomerCreateUserId(CurrentUser);
if (createUserId is null)
{
return;
}
if (order.CreateUserId != createUserId.Value)
{
throw new BusinessException("无权访问该订单");
}
}
private async Task<Order> GetOrderOrThrowAsync(int id)
{
var entity = await OrderRepository.FirstOrDefaultAsync(x => x.Id == id);
@ -507,6 +538,7 @@ public partial class OrderService : ApplicationService
throw new BusinessException("订单不存在");
}
EnsureCustomerCanAccessOrder(entity);
return entity;
}

View File

@ -30,17 +30,10 @@ public class RechargeService : ApplicationService
[Consumes("multipart/form-data")]
public async Task<RechargeRecordOutput> SubmitAsync([FromForm] SubmitRechargeFormInput input)
{
var customerId = input.CustomerId ?? CurrentUser.GetId<int>();
if (customerId <= 0)
{
throw new BusinessException("客户ID不能为空");
}
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == customerId);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
var customer = await OmsSessionHelper.ResolveSessionCustomerAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
if (input.RechargeAmount < 0.01M)
{
@ -148,7 +141,11 @@ public class RechargeService : ApplicationService
[HttpGet("/recharge/recharge-record/list")]
public async Task<OmsPageResult<RechargeRecordPaginatedOutput>> GetPaginatedListAsync([FromQuery] RechargeRecordPaginatedInput input)
{
var predicate = BuildPaginatedPredicate(input);
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var predicate = BuildPaginatedPredicate(input, customerId);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
@ -203,7 +200,11 @@ public class RechargeService : ApplicationService
[HttpGet("/recharge/export")]
public async Task<FileContentResult> ExportAsync([FromQuery] RechargeExportInput input)
{
var records = await RechargeRecordRepository.GetListAsync(BuildExportPredicate(input));
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(
CustomerRepository,
CurrentUser,
input.CustomerId);
var records = await RechargeRecordRepository.GetListAsync(BuildExportPredicate(input, customerId));
var customerIds = records.Select(x => x.CustomerId).Distinct().ToArray();
var customers = customerIds.Length == 0
? []
@ -245,10 +246,12 @@ public class RechargeService : ApplicationService
/// </summary>
/// <param name="input">入参:充值分页查询条件对象。</param>
/// <returns>出参:充值记录查询表达式。</returns>
private Expression<Func<RechargeRecord, bool>> BuildPaginatedPredicate(RechargeRecordPaginatedInput input)
private Expression<Func<RechargeRecord, bool>> BuildPaginatedPredicate(
RechargeRecordPaginatedInput input,
int? customerId)
{
var serialNumber = input.SerialNumber?.Trim();
return BuildRechargePredicate(input.CustomerId, input.RechargeStatus, serialNumber, input.StartTime, input.EndTime);
return BuildRechargePredicate(customerId, input.RechargeStatus, serialNumber, input.StartTime, input.EndTime);
}
private async Task<RechargeRecord> GetRechargeRecordOrThrowAsync(int id)
@ -302,9 +305,9 @@ public class RechargeService : ApplicationService
/// </summary>
/// <param name="input">入参:充值导出筛选条件对象。</param>
/// <returns>出参:充值记录查询表达式。</returns>
private Expression<Func<RechargeRecord, bool>> BuildExportPredicate(RechargeExportInput input)
private Expression<Func<RechargeRecord, bool>> BuildExportPredicate(RechargeExportInput input, int? customerId)
{
return BuildRechargePredicate(input.CustomerId, null, null, input.StartTime, input.EndTime);
return BuildRechargePredicate(customerId, null, null, input.StartTime, input.EndTime);
}
/// <summary>

View File

@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Oms.Domain.OrdersAggregate.Consts;
using Oms.Domain.SystemAggregate.Consts;
@ -6,12 +7,20 @@ namespace Oms.Application.Shared;
public static class OmsDocHelper
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public static int NormalizePage(int page) => page <= 0 ? 1 : page;
public static int NormalizePageSize(int pageSize) => pageSize <= 0 ? 20 : Math.Min(pageSize, 200);
public static DateTime? NullIfDefault(DateTime value) => value == default ? null : value;
/// <summary>
/// 将库内 JSON 字符串转为 API 可输出的 CLR 对象(避免 JsonElement 被 Newtonsoft 序列化成 valueKind)。
/// </summary>
public static object? DeserializeJsonObject(string? json)
{
if (string.IsNullOrWhiteSpace(json))
@ -19,7 +28,8 @@ public static class OmsDocHelper
return null;
}
return JsonSerializer.Deserialize<object>(json);
using var document = JsonDocument.Parse(json);
return ConvertJsonElement(document.RootElement);
}
public static T? DeserializeJson<T>(string? json)
@ -29,19 +39,62 @@ public static class OmsDocHelper
return default;
}
return JsonSerializer.Deserialize<T>(json);
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
/// <summary>
/// 将请求体中的动态 JSON(JsonElement / JToken / Dictionary 等)规范为可落库的 JSON 字符串。
/// </summary>
public static string? SerializeJson<T>(T? value)
{
if (value == null)
if (value is null)
{
return null;
}
return JsonSerializer.Serialize(value);
if (value is JsonElement element)
{
return element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined
? null
: element.GetRawText();
}
if (value is JsonNode node)
{
return node.ToJsonString();
}
if (value is string text)
{
return string.IsNullOrWhiteSpace(text) ? null : text;
}
var typeName = value.GetType().FullName;
if (typeName is not null && typeName.StartsWith("Newtonsoft.Json.Linq.", StringComparison.Ordinal))
{
return value.ToString();
}
return JsonSerializer.Serialize(value, JsonOptions);
}
private static object? ConvertJsonElement(JsonElement element) => element.ValueKind switch
{
JsonValueKind.Object => element.EnumerateObject()
.ToDictionary(property => property.Name, property => ConvertJsonElement(property.Value)),
JsonValueKind.Array => element.EnumerateArray()
.Select(ConvertJsonElement)
.ToList(),
JsonValueKind.String => element.GetString(),
JsonValueKind.Number => element.TryGetInt64(out var integer)
? integer
: element.GetDecimal(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null or JsonValueKind.Undefined => null,
_ => element.GetRawText()
};
public static List<string> DeserializeStringList(string? json) => JsonStringArrayHelper.Deserialize(json);
public static string? GetOrderStatusName(string statusCode)

View File

@ -17,11 +17,11 @@ public static class OmsFileUploadHelper
/// <summary>
/// 保存充值截图,返回以 /uploads 开头的相对访问路径。
/// </summary>
public static async Task<string> SaveRechargeScreenshotAsync(IFormFile file, IWebHostEnvironment env)
public static async Task<string> SaveRechargeScreenshotAsync(IFormFile? file, IWebHostEnvironment env)
{
if (file == null || file.Length <= 0)
{
throw new BusinessException("请上传充值截图");
return "0";
}
if (file.Length > MaxImageBytes)

View File

@ -0,0 +1,105 @@
using Fake.Application;
using Fake.Users;
using Oms.Domain.CustomersAggregate.Entities;
using Oms.Domain.CustomersAggregate.Repositories;
namespace Oms.Application.Shared;
/// <summary>
/// 从 JWT Claims 解析当前会话类型(管理员 / 客户)。
/// </summary>
public static class OmsSessionHelper
{
public const string LoginTypeClaim = "loginType";
private const string CustomerLoginType = "1";
public static bool IsCustomerLogin(ICurrentUser currentUser) =>
string.Equals(
currentUser.FindClaimOrNull(LoginTypeClaim)?.Value,
CustomerLoginType,
StringComparison.Ordinal);
/// <summary>
/// 客户登录时返回 mapId(对应 t_customer.UserId / 订单 CreateUserId);管理员返回 null。
/// </summary>
public static int? TryGetCustomerMapId(ICurrentUser currentUser)
{
if (!IsCustomerLogin(currentUser))
{
return null;
}
var userId = currentUser.GetId<int>();
return userId > 0 ? userId : null;
}
/// <summary>
/// 客户登录时返回 mapId(对应订单 CreateUserId);管理员返回 null(不过滤)。
/// </summary>
public static int? TryGetCustomerCreateUserId(ICurrentUser currentUser) =>
TryGetCustomerMapId(currentUser);
/// <summary>
/// 解析当前会话对应的 t_customer 记录(客户按 UserId=mapId;管理员可按 CustomerId 代提交)。
/// </summary>
public static async Task<Customer> ResolveSessionCustomerAsync(
ICustomerRepository customerRepository,
ICurrentUser currentUser,
int? requestedCustomerId = null)
{
var mapId = TryGetCustomerMapId(currentUser);
if (mapId.HasValue)
{
var sessionCustomer = await customerRepository.FirstOrDefaultAsync(x => x.UserId == mapId.Value);
if (sessionCustomer == null)
{
throw new BusinessException("未找到客户信息");
}
if (requestedCustomerId is > 0 && requestedCustomerId.Value != sessionCustomer.Id)
{
throw new BusinessException("无权为该客户提交充值");
}
return sessionCustomer;
}
var customerId = requestedCustomerId ?? currentUser.GetId<int>();
if (customerId <= 0)
{
throw new BusinessException("客户ID不能为空");
}
var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customerId);
if (customer == null)
{
throw new BusinessException("客户不存在");
}
return customer;
}
/// <summary>
/// 客户登录时强制返回 t_customer.Id;管理员返回入参 customerId。
/// </summary>
public static async Task<int?> ResolveListCustomerIdAsync(
ICustomerRepository customerRepository,
ICurrentUser currentUser,
int? requestedCustomerId)
{
var mapId = TryGetCustomerMapId(currentUser);
if (!mapId.HasValue)
{
return requestedCustomerId;
}
var customer = await customerRepository.FirstOrDefaultAsync(x => x.UserId == mapId.Value);
if (customer == null)
{
throw new BusinessException("未找到客户信息");
}
return customer.Id;
}
}

View File

@ -56,6 +56,26 @@ public class OmsDocHelperTests
result["amount"].GetDecimal().Should().Be(128.5m);
}
[Fact]
public void SerializeJson_ShouldPreserveJsonElementObjectInsteadOfValueKind()
{
using var document = JsonDocument.Parse("""{"分区":"3","重量":"1.5"}""");
var json = OmsDocHelper.SerializeJson(document.RootElement);
json.Should().Be("""{"分区":"3","重量":"1.5"}""");
}
[Fact]
public void DeserializeJsonObject_ShouldReturnDictionaryNotJsonElement()
{
var result = OmsDocHelper.DeserializeJsonObject("""{"普通客户":1,"VIP1":2}""");
result.Should().BeOfType<Dictionary<string, object?>>();
var map = (Dictionary<string, object?>)result!;
map["普通客户"].Should().Be(1L);
map["VIP1"].Should().Be(2L);
}
[Fact]
public void DeserializeStringList_ShouldReturnEmptyListWhenJsonMissing()
{