From 207696d4171b6e85416b5618ea4437edc1d2e941 Mon Sep 17 00:00:00 2001 From: chfei Date: Mon, 2 Nov 2026 17:19:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=91=E7=AB=AF=E6=95=B0=E6=8D=AE=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=9D=E8=A7=84=E6=A3=80=E6=B5=8B=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/GoodsController.cs | 15 +++++++ .../CloudProduct/Dtos/GoodsViolationDtos.cs | 30 +++++++++++++ .../Entities/GoodsViolationLogEntity.cs | 23 ++++++++++ .../Repositorys/GoodsViolationRepository.cs | 39 ++++++++++++---- .../Services/GoodsViolationService.cs | 45 ++++++++++++++++++- .../CloudProduct.Checks/IntegrationChecks.cs | 19 ++++++++ Tests/CloudProduct.Checks/Program.cs | 12 +++++ 7 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 Plugins/CloudProduct/Entities/GoodsViolationLogEntity.cs diff --git a/Plugins/CloudProduct/Controllers/GoodsController.cs b/Plugins/CloudProduct/Controllers/GoodsController.cs index 3af2471..92a62d7 100644 --- a/Plugins/CloudProduct/Controllers/GoodsController.cs +++ b/Plugins/CloudProduct/Controllers/GoodsController.cs @@ -46,6 +46,21 @@ namespace Store.Modules.CloudProduct.Controllers public Task> ReportViolations([FromBody] ReportGoodsViolationItem[] items) => _violations.ReportAsync(items); + /// 记录已完成的违规上报(1)、自动上货剔除(2)或店铺自动下架(3)。 + [HttpPost] + [EndpointSummary("批量上报违规日志,每次最多 1000 条")] + public async Task RecordViolationLog([FromBody] RecordViolationLogRequest[] items) + { + await _violations.RecordLogsAsync(items); + return ApiResult.Ok(); + } + + /// 按店铺编号数组查询违规日志,可筛选商品,按日志编号倒序翻页。 + [HttpPost] + [EndpointSummary("按商铺批量查询违规日志,每页最多 200 条")] + public Task> GetViolationLogs([FromBody] GetViolationLogsRequest request) + => _violations.GetLogsAsync(request.ShopIds, request.GoodsId, request.BeforeId, request.Size); + /// 批量解除商品违规,恢复正常商品索引;失败后由后台索引任务重试。 [HttpPost] [EndpointSummary("解除商品违规并恢复索引")] diff --git a/Plugins/CloudProduct/Dtos/GoodsViolationDtos.cs b/Plugins/CloudProduct/Dtos/GoodsViolationDtos.cs index 371ea69..209a0f9 100644 --- a/Plugins/CloudProduct/Dtos/GoodsViolationDtos.cs +++ b/Plugins/CloudProduct/Dtos/GoodsViolationDtos.cs @@ -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; } + /// 当前商品命中的违规原因,供客户端记录实际处理动作。 + public int[] ReasonIds { get; set; } = []; } public class RemoveGoodsViolationsResult @@ -34,3 +37,30 @@ public class RemoveGoodsViolationsResult /// 商品不存在、已删除或恢复期间再次被上报违规的编号。 public long[] SkippedGoodsIds { get; set; } = []; } + +/// 日志记录的实际业务动作,与商品违规原因分别保存。 +public enum ViolationLogAction +{ + Report = 1, + AutoListingExcluded = 2, + AutoOffShelf = 3 +} + +/// 客户端在店铺动作完成后提交一条日志。 +public sealed class RecordViolationLogRequest +{ + public string ShopId { get; set; } = string.Empty; + public long GoodsId { get; set; } + /// 违规类型在 violation_reason 中的编号。 + public int ReasonId { get; set; } + public ViolationLogAction Action { get; set; } +} + +/// 按商铺编号查询违规日志;翻页时传入上一页最后一条日志的编号。 +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; +} diff --git a/Plugins/CloudProduct/Entities/GoodsViolationLogEntity.cs b/Plugins/CloudProduct/Entities/GoodsViolationLogEntity.cs new file mode 100644 index 0000000..3789737 --- /dev/null +++ b/Plugins/CloudProduct/Entities/GoodsViolationLogEntity.cs @@ -0,0 +1,23 @@ +using FreeSql.DataAnnotations; +using Store.Modules.CloudProduct.Dtos; + +namespace Store.Modules.CloudProduct.Entities; + +/// 不可变的违规事件历史,解除商品违规时保留。 +[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; } + /// 对应 violation_reason.id。 + [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; } +} diff --git a/Plugins/CloudProduct/Repositorys/GoodsViolationRepository.cs b/Plugins/CloudProduct/Repositorys/GoodsViolationRepository.cs index 02f9994..ed13392 100644 --- a/Plugins/CloudProduct/Repositorys/GoodsViolationRepository.cs +++ b/Plugins/CloudProduct/Repositorys/GoodsViolationRepository.cs @@ -13,13 +13,19 @@ public class GoodsViolationRepository( public Task> GetReasonsAsync() => Orm.Select() .OrderBy(x => x.Id).ToListAsync(x => new ViolationReasonDto { - Name = x.Name, + Id = x.Id, Name = x.Name, IsViolation = x.IsViolation }); public Task> GetViolationIdsAsync(long[] ids) => Select .Where(x => ids.Contains(x.GoodsId)).Distinct().ToListAsync(x => x.GoodsId); + public async Task> 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> 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(); var reasonRepository = unit.GetRepository(); - var reasonIds = new Dictionary(StringComparer.Ordinal); + var dictionary = new Dictionary(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(); 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 CountReasonsAsync(uint[] ids) => Orm.Select() + .Where(x => ids.Contains(x.Id)).CountAsync(); + + public async Task InsertLogsAsync(GoodsViolationLogEntity[] logs) + { + // 即使 ORM 拆分 INSERT,各批次也在同一事务内提交。 + using var unit = Orm.CreateUnitOfWork(); + await unit.GetRepository().InsertAsync(logs); + unit.Commit(); + } + + // 游标按自增主键倒序翻页,同一时间的事件也有确定顺序。 + public Task> GetLogsAsync(string[] shopIds, long? goodsId, long beforeId, int size) + => Orm.Select().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 MarkDeletedAsync(long[] ids) => Orm.Update() .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> GetBatchAsync(long afterId, int size, bool pendingOnly) => Select diff --git a/Plugins/CloudProduct/Services/GoodsViolationService.cs b/Plugins/CloudProduct/Services/GoodsViolationService.cs index c624813..aa75ede 100644 --- a/Plugins/CloudProduct/Services/GoodsViolationService.cs +++ b/Plugins/CloudProduct/Services/GoodsViolationService.cs @@ -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(); } + /// 整批校验后统一写入日志,每个事件保留一条记录。 + 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); + } + + /// 分页读取违规事件,beforeId 为上一页最后一条日志编号。 + public Task> 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> 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 RemoveAsync(long[]? ids) diff --git a/Tests/CloudProduct.Checks/IntegrationChecks.cs b/Tests/CloudProduct.Checks/IntegrationChecks.cs index 783d1d3..da2ac6d 100644 --- a/Tests/CloudProduct.Checks/IntegrationChecks.cs +++ b/Tests/CloudProduct.Checks/IntegrationChecks.cs @@ -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().Where(x => x.Name == ignoredName).FirstAsync()).Id; + Check(!await db.Select().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().Where(x => x.Name == names[0]).FirstAsync()).Id); reasons.Add((await db.Select().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().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync(); + await db.Delete().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync(); await db.Delete().Where(x => ids.Contains(x.GoodsId)).ExecuteAffrowsAsync(); } await db.Delete().Where(x => x.Name == ignoredName).ExecuteAffrowsAsync(); diff --git a/Tests/CloudProduct.Checks/Program.cs b/Tests/CloudProduct.Checks/Program.cs index 2ccdde7..39bbc4b 100644 --- a/Tests/CloudProduct.Checks/Program.cs +++ b/Tests/CloudProduct.Checks/Program.cs @@ -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.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());