云端数据 增加违规检测日志
This commit is contained in:
1 parent
1fa91a9cfb
commit
207696d417
7 files changed
+173
-10
No files matched your search
@@ -46,6 +46,21 @@ namespace Store.Modules.CloudProduct.Controllers
|
||||
public Task<List<ReportGoodsViolationsResult>> ReportViolations([FromBody] ReportGoodsViolationItem[] items)
|
||||
=> _violations.ReportAsync(items);
|
||||
|
||||
/// <summary>记录已完成的违规上报(1)、自动上货剔除(2)或店铺自动下架(3)。</summary>
|
||||
[HttpPost]
|
||||
[EndpointSummary("批量上报违规日志,每次最多 1000 条")]
|
||||
public async Task<ApiResult> RecordViolationLog([FromBody] RecordViolationLogRequest[] items)
|
||||
{
|
||||
await _violations.RecordLogsAsync(items);
|
||||
return ApiResult.Ok();
|
||||
}
|
||||
|
||||
/// <summary>按店铺编号数组查询违规日志,可筛选商品,按日志编号倒序翻页。</summary>
|
||||
[HttpPost]
|
||||
[EndpointSummary("按商铺批量查询违规日志,每页最多 200 条")]
|
||||
public Task<List<GoodsViolationLogEntity>> GetViolationLogs([FromBody] GetViolationLogsRequest request)
|
||||
=> _violations.GetLogsAsync(request.ShopIds, request.GoodsId, request.BeforeId, request.Size);
|
||||
|
||||
/// <summary>批量解除商品违规,恢复正常商品索引;失败后由后台索引任务重试。</summary>
|
||||
[HttpPost]
|
||||
[EndpointSummary("解除商品违规并恢复索引")]
|
||||
|
||||
@@ -2,6 +2,7 @@ namespace Store.Modules.CloudProduct.Dtos;
|
||||
|
||||
public class ViolationReasonDto
|
||||
{
|
||||
public uint Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public bool IsViolation { get; set; }
|
||||
}
|
||||
@@ -24,6 +25,8 @@ public class GoodsViolationStatus
|
||||
{
|
||||
public long GoodsId { get; set; }
|
||||
public bool IsViolation { get; set; }
|
||||
/// <summary>当前商品命中的违规原因,供客户端记录实际处理动作。</summary>
|
||||
public int[] ReasonIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public class RemoveGoodsViolationsResult
|
||||
@@ -34,3 +37,30 @@ public class RemoveGoodsViolationsResult
|
||||
/// <summary>商品不存在、已删除或恢复期间再次被上报违规的编号。</summary>
|
||||
public long[] SkippedGoodsIds { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>日志记录的实际业务动作,与商品违规原因分别保存。</summary>
|
||||
public enum ViolationLogAction
|
||||
{
|
||||
Report = 1,
|
||||
AutoListingExcluded = 2,
|
||||
AutoOffShelf = 3
|
||||
}
|
||||
|
||||
/// <summary>客户端在店铺动作完成后提交一条日志。</summary>
|
||||
public sealed class RecordViolationLogRequest
|
||||
{
|
||||
public string ShopId { get; set; } = string.Empty;
|
||||
public long GoodsId { get; set; }
|
||||
/// <summary>违规类型在 violation_reason 中的编号。</summary>
|
||||
public int ReasonId { get; set; }
|
||||
public ViolationLogAction Action { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>按商铺编号查询违规日志;翻页时传入上一页最后一条日志的编号。</summary>
|
||||
public sealed class GetViolationLogsRequest
|
||||
{
|
||||
public string[] ShopIds { get; set; } = [];
|
||||
public long? GoodsId { get; set; }
|
||||
public long BeforeId { get; set; }
|
||||
public int Size { get; set; } = 50;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
using Store.Modules.CloudProduct.Dtos;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Entities;
|
||||
|
||||
/// <summary>不可变的违规事件历史,解除商品违规时保留。</summary>
|
||||
[Table(Name = "goods_violation_log")]
|
||||
public class GoodsViolationLogEntity
|
||||
{
|
||||
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
|
||||
public long Id { get; set; }
|
||||
[Column(Name = "shop_id", StringLength = 64)]
|
||||
public string? ShopId { get; set; }
|
||||
[Column(Name = "goods_id")]
|
||||
public long GoodsId { get; set; }
|
||||
/// <summary>对应 violation_reason.id。</summary>
|
||||
[Column(Name = "reason_id")]
|
||||
public int ReasonId { get; set; }
|
||||
[Column(Name = "action", MapType = typeof(int))]
|
||||
public ViolationLogAction Action { get; set; }
|
||||
[Column(Name = "created_time")]
|
||||
public DateTime CreatedTime { get; set; }
|
||||
}
|
||||
@@ -13,13 +13,19 @@ public class GoodsViolationRepository(
|
||||
public Task<List<ViolationReasonDto>> GetReasonsAsync() => Orm.Select<ViolationReasonEntity>()
|
||||
.OrderBy(x => x.Id).ToListAsync(x => new ViolationReasonDto
|
||||
{
|
||||
Name = x.Name,
|
||||
Id = x.Id, Name = x.Name,
|
||||
IsViolation = x.IsViolation
|
||||
});
|
||||
|
||||
public Task<List<long>> GetViolationIdsAsync(long[] ids) => Select
|
||||
.Where(x => ids.Contains(x.GoodsId)).Distinct().ToListAsync(x => x.GoodsId);
|
||||
|
||||
public async Task<ILookup<long, int>> GetReasonIdsByGoodsAsync(long[] ids)
|
||||
{
|
||||
var rows = await Select.Where(x => ids.Contains(x.GoodsId)).ToListAsync(x => new { x.GoodsId, x.ReasonId });
|
||||
return rows.ToLookup(x => x.GoodsId, x => checked((int)x.ReasonId));
|
||||
}
|
||||
|
||||
public async Task<List<long>> GetOffShelfGoodsIdsAsync(long[] ids)
|
||||
{
|
||||
var records = await Select.Where(x => ids.Contains(x.GoodsId)).ToListAsync();
|
||||
@@ -54,25 +60,25 @@ public class GoodsViolationRepository(
|
||||
using var unit = Orm.CreateUnitOfWork();
|
||||
var repository = unit.GetRepository<GoodsViolationEntity>();
|
||||
var reasonRepository = unit.GetRepository<ViolationReasonEntity>();
|
||||
var reasonIds = new Dictionary<string, uint>(StringComparer.Ordinal);
|
||||
var dictionary = new Dictionary<string, ViolationReasonEntity>(StringComparer.Ordinal);
|
||||
foreach (var name in items.Values.SelectMany(x => x).Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
// 让数据库按字典表的排序规则匹配名称,与唯一索引保持一致。
|
||||
var reason = await reasonRepository.Select.Where(x => x.Name == name).FirstAsync();
|
||||
// 新类型只收录到字典,绝不因首次上报自动启用商品封禁。
|
||||
reason ??= await reasonRepository.InsertAsync(new ViolationReasonEntity { Name = name, IsViolation = false });
|
||||
// 数据库排序规则可能忽略部分字符差异;判定时仍要求名称完整相等。
|
||||
if (reason.IsViolation && string.Equals(reason.Name, name, StringComparison.Ordinal))
|
||||
reasonIds.Add(name, reason.Id);
|
||||
dictionary.Add(name, reason);
|
||||
}
|
||||
var ids = items.Keys.ToArray();
|
||||
var existing = (await repository.Select.Where(x => ids.Contains(x.GoodsId))
|
||||
.ToListAsync()).ToLookup(x => x.GoodsId);
|
||||
var now = DateTime.UtcNow;
|
||||
var now = DateTime.Now;
|
||||
var recordedIds = new List<long>();
|
||||
foreach (var (goodsId, reasons) in items)
|
||||
{
|
||||
var accepted = reasons.Where(reasonIds.ContainsKey).Select(x => reasonIds[x]).Distinct().ToArray();
|
||||
// 判定要求名称完整匹配。
|
||||
var accepted = reasons.Where(name => dictionary[name].IsViolation && dictionary[name].Name == name)
|
||||
.Select(name => dictionary[name].Id).Distinct().ToArray();
|
||||
// 忽略原因仅记录字典,不新增或改变该商品已有的违规状态。
|
||||
if (accepted.Length == 0) continue;
|
||||
var rows = existing[goodsId].ToArray();
|
||||
@@ -101,9 +107,26 @@ public class GoodsViolationRepository(
|
||||
return recordedIds.ToArray();
|
||||
}
|
||||
|
||||
public Task<long> CountReasonsAsync(uint[] ids) => Orm.Select<ViolationReasonEntity>()
|
||||
.Where(x => ids.Contains(x.Id)).CountAsync();
|
||||
|
||||
public async Task InsertLogsAsync(GoodsViolationLogEntity[] logs)
|
||||
{
|
||||
// 即使 ORM 拆分 INSERT,各批次也在同一事务内提交。
|
||||
using var unit = Orm.CreateUnitOfWork();
|
||||
await unit.GetRepository<GoodsViolationLogEntity>().InsertAsync(logs);
|
||||
unit.Commit();
|
||||
}
|
||||
|
||||
// 游标按自增主键倒序翻页,同一时间的事件也有确定顺序。
|
||||
public Task<List<GoodsViolationLogEntity>> GetLogsAsync(string[] shopIds, long? goodsId, long beforeId, int size)
|
||||
=> Orm.Select<GoodsViolationLogEntity>().Where(x => shopIds.Contains(x.ShopId!))
|
||||
.WhereIf(goodsId.HasValue, x => x.GoodsId == goodsId)
|
||||
.WhereIf(beforeId > 0, x => x.Id < beforeId).OrderByDescending(x => x.Id).Limit(size).ToListAsync();
|
||||
|
||||
public Task<int> MarkDeletedAsync(long[] ids) => Orm.Update<GoodsViolationEntity>()
|
||||
.Set(x => x.IsIndexDeleted, true)
|
||||
.Set(x => x.UpdatedTime, DateTime.UtcNow)
|
||||
.Set(x => x.UpdatedTime, DateTime.Now)
|
||||
.Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
||||
|
||||
public Task<List<long>> GetBatchAsync(long afterId, int size, bool pendingOnly) => Select
|
||||
|
||||
@@ -2,6 +2,7 @@ using MAX.Core.DependencyInjection;
|
||||
using MAX.Framework.Commons;
|
||||
using MAX.Framework.Dtos;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Store.Modules.CloudProduct.Entities;
|
||||
using Store.Modules.CloudProduct.Dtos;
|
||||
using Store.Modules.CloudProduct.Repositorys;
|
||||
|
||||
@@ -56,13 +57,53 @@ public class GoodsViolationService(
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>整批校验后统一写入日志,每个事件保留一条记录。</summary>
|
||||
public async Task RecordLogsAsync(RecordViolationLogRequest[]? items)
|
||||
{
|
||||
if (items == null || items.Length is < 1 or > 1000)
|
||||
throw new AppException("每次请提供1-1000条违规日志", ResultCode.ValidationError);
|
||||
|
||||
var logs = new GoodsViolationLogEntity[items.Length];
|
||||
var now = DateTime.Now;
|
||||
for (var i = 0; i < items.Length; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
if (item == null || string.IsNullOrWhiteSpace(item.ShopId) || item.ShopId.Trim().Length > 64
|
||||
|| item.GoodsId <= 0 || item.ReasonId <= 0
|
||||
|| item.Action is not (ViolationLogAction.Report or ViolationLogAction.AutoListingExcluded or ViolationLogAction.AutoOffShelf))
|
||||
throw new AppException($"第{i + 1}条日志须提供有效店铺编号、商品编号、违规类型和动作(1、2或3)", ResultCode.ValidationError);
|
||||
logs[i] = new GoodsViolationLogEntity
|
||||
{
|
||||
ShopId = item.ShopId.Trim(), GoodsId = item.GoodsId, ReasonId = item.ReasonId,
|
||||
Action = item.Action, CreatedTime = now
|
||||
};
|
||||
}
|
||||
var reasonIds = logs.Select(x => (uint)x.ReasonId).Distinct().ToArray();
|
||||
if (await repository.CountReasonsAsync(reasonIds) != reasonIds.Length)
|
||||
throw new AppException("违规类型不存在,请从违规字典中选择", ResultCode.ValidationError);
|
||||
await repository.InsertLogsAsync(logs);
|
||||
}
|
||||
|
||||
/// <summary>分页读取违规事件,beforeId 为上一页最后一条日志编号。</summary>
|
||||
public Task<List<GoodsViolationLogEntity>> GetLogsAsync(string[]? shopIds, long? goodsId, long beforeId, int size)
|
||||
{
|
||||
if (shopIds == null || shopIds.Length is < 1 or > 1000
|
||||
|| shopIds.Any(id => string.IsNullOrWhiteSpace(id) || id.Trim().Length > 64)
|
||||
|| goodsId <= 0 || beforeId < 0 || size is < 1 or > 200)
|
||||
throw new AppException("请提供1-1000个有效商铺编号、有效的商品及分页参数,每页允许1-200条", ResultCode.ValidationError);
|
||||
return repository.GetLogsAsync(shopIds.Select(id => id.Trim()).Distinct().ToArray(), goodsId, beforeId, size);
|
||||
}
|
||||
|
||||
public async Task<List<GoodsViolationStatus>> GetStatusAsync(long[]? ids)
|
||||
{
|
||||
if (ids == null || ids.Length is < 1 or > 1000 || ids.Any(x => x <= 0))
|
||||
throw new AppException("请提供1-1000个正数商品编号", ResultCode.ValidationError);
|
||||
var distinct = ids.Distinct().ToArray();
|
||||
var violations = (await repository.GetViolationIdsAsync(distinct)).ToHashSet();
|
||||
return distinct.Select(x => new GoodsViolationStatus { GoodsId = x, IsViolation = violations.Contains(x) }).ToList();
|
||||
var reasons = await repository.GetReasonIdsByGoodsAsync(distinct);
|
||||
return distinct.Select(id => new GoodsViolationStatus
|
||||
{
|
||||
GoodsId = id, IsViolation = reasons.Contains(id), ReasonIds = reasons[id].ToArray()
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<RemoveGoodsViolationsResult> RemoveAsync(long[]? ids)
|
||||
|
||||
@@ -120,6 +120,22 @@ internal static class IntegrationChecks
|
||||
new() { GoodsId = first, Reasons = ignoredName }
|
||||
]);
|
||||
Check(result.Count == 1 && result[0].GoodsId == first && result[0].IsOffShelf, "batch report and deduplication");
|
||||
var ignoredReasonId = (await db.Select<ViolationReasonEntity>().Where(x => x.Name == ignoredName).FirstAsync()).Id;
|
||||
Check(!await db.Select<GoodsViolationLogEntity>().Where(x => x.GoodsId == first).AnyAsync(), "reporting alone creates no log");
|
||||
await violations.RecordLogsAsync([
|
||||
new() { ShopId = "shop-a", GoodsId = first, ReasonId = (int)existingReasonId, Action = ViolationLogAction.Report },
|
||||
new() { ShopId = "shop-c", GoodsId = first, ReasonId = (int)ignoredReasonId, Action = ViolationLogAction.Report }
|
||||
]);
|
||||
var reportLogs = await violations.GetLogsAsync(["shop-a", "shop-c"], first, 0, 50);
|
||||
Check(reportLogs.Count == 2 && reportLogs.All(x => x.Action == ViolationLogAction.Report)
|
||||
&& reportLogs.Any(x => x.ShopId == "shop-a" && x.ReasonId == existingReasonId)
|
||||
&& reportLogs.Any(x => x.ShopId == "shop-c" && x.ReasonId == ignoredReasonId), "keep report source and ignored reasons");
|
||||
foreach (var action in new[] { ViolationLogAction.AutoListingExcluded, ViolationLogAction.AutoOffShelf })
|
||||
await violations.RecordLogsAsync([new() { ShopId = "shop-b", GoodsId = first, ReasonId = (int)existingReasonId, Action = action }]);
|
||||
var latest = (await violations.GetLogsAsync(["shop-b"], first, 0, 1)).Single();
|
||||
var previous = (await violations.GetLogsAsync(["shop-b"], first, latest.Id, 1)).Single();
|
||||
Check(latest.Action == ViolationLogAction.AutoOffShelf && previous.Action == ViolationLogAction.AutoListingExcluded,
|
||||
"client actions are filtered by shop and paginated newest first");
|
||||
Check(!await Exists(first), "report deletes real OpenSearch document");
|
||||
reasons.Add((await db.Select<ViolationReasonEntity>().Where(x => x.Name == names[0]).FirstAsync()).Id);
|
||||
reasons.Add((await db.Select<ViolationReasonEntity>().Where(x => x.Name == names[1]).FirstAsync()).Id);
|
||||
@@ -165,6 +181,8 @@ internal static class IntegrationChecks
|
||||
|
||||
var status = await violations.GetStatusAsync([first, first + 1, first, first + 100]);
|
||||
Check(status.Count == 3 && status[0].IsViolation && !status[1].IsViolation && !status[2].IsViolation, "batch status including missing goods");
|
||||
Check(status[0].ReasonIds.ToHashSet().SetEquals(before.Select(id => (int)id))
|
||||
&& status[1].ReasonIds.Length == 0 && status[2].ReasonIds.Length == 0, "status returns only current reasons for each product");
|
||||
Check((await Reasons(first)).Length == 3, "scalar reasons stored in separate rows");
|
||||
Check((await violations.GetGoodsIdsByReasonAsync(reasons[2], first - 1)).Contains(first), "scalar reason query");
|
||||
try
|
||||
@@ -310,6 +328,7 @@ internal static class IntegrationChecks
|
||||
if (ownsIds)
|
||||
{
|
||||
await db.Delete<GoodsEntity>().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
||||
await db.Delete<GoodsViolationLogEntity>().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
||||
await db.Delete<GoodsViolationEntity>().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync();
|
||||
}
|
||||
await db.Delete<ViolationReasonEntity>().Where(x => x.Name == ignoredName).ExecuteAffrowsAsync();
|
||||
|
||||
@@ -81,6 +81,18 @@ try
|
||||
catch (JsonException) { checks++; }
|
||||
Check(!typeof(GoodsController).GetMethods().Any(x => x.Name.Contains("ByReason")), "Reason query must not be exposed");
|
||||
var removalService = new GoodsViolationService(null!, null!, null!, NullLogger<GoodsViolationService>.Instance);
|
||||
Check(JsonSerializer.Serialize(request, new JsonSerializerOptions(JsonSerializerDefaults.Web)).Contains("shopId") == false,
|
||||
"report payload excludes log metadata");
|
||||
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 1, Action = (ViolationLogAction)4 }]).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.RecordLogsAsync([new() { GoodsId = 1, ReasonId = 1, Action = ViolationLogAction.AutoOffShelf }]).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.RecordLogsAsync([]).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.RecordLogsAsync(new RecordViolationLogRequest[1001]).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 1, Action = ViolationLogAction.Report }, null!]).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.GetLogsAsync(["a"], 1, -1, 50).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.GetLogsAsync(["a"], null, 0, 201).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.GetLogsAsync([], null, 0, 50).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.GetLogsAsync(["a", " "], null, 0, 50).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.RecordLogsAsync([new() { ShopId = "A", GoodsId = 1, ReasonId = 0, Action = ViolationLogAction.AutoOffShelf }]).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.RemoveAsync(null).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.RemoveAsync([]).GetAwaiter().GetResult());
|
||||
Reject(() => removalService.RemoveAsync([0]).GetAwaiter().GetResult());
|
||||
|
||||
Reference in new issue
Block a user