增加 AI识图检测,个人收藏产品检测
This commit is contained in:
commit
09098efd34
45 files changed
+3433
-1132
No files matched your search
@@ -6,7 +6,7 @@ using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Work.Store.Api;
|
||||
using System.Threading.Tasks;
|
||||
using Work.Api.Wx;
|
||||
|
||||
@@ -17,35 +17,13 @@ namespace Work.Services
|
||||
/// </summary>
|
||||
public class CloudGoodsService
|
||||
{
|
||||
private static string ServerUrl = "http://103.90.155.78:9001";
|
||||
static CloudGoodsService()
|
||||
{
|
||||
if (EnvironmentHelper.IsDevelopment)
|
||||
{
|
||||
ServerUrl = "http://localhost:14312";
|
||||
}
|
||||
}
|
||||
public static async Task<PageData<CloudGoods>> GetGoods(GoodsSearchRequest request)
|
||||
{
|
||||
var html = await GetHtml.PostJsonAsync(ServerUrl + "/api/CloudGoods/SearchGoods", request);
|
||||
|
||||
var ret = JsonConvert.DeserializeObject<Result<PageData<CloudGoods>>>(html);
|
||||
if (ret.Code != 200)
|
||||
{
|
||||
throw new Exception(ret.Msg);
|
||||
}
|
||||
return ret.Data;
|
||||
return await StoreApiClient.Instance.CloudGoods.GetGoods(request);
|
||||
}
|
||||
public static string GetGoodsDetail(long goodsId)
|
||||
{
|
||||
var html = GetHtml.PostJson(ServerUrl + $"/api/CloudGoods/GetGoodsRawData?goodsId={goodsId}", "");
|
||||
|
||||
var ret = JsonConvert.DeserializeObject<Result<string>>(html);
|
||||
if (ret.Code != 200)
|
||||
{
|
||||
throw new Exception(ret.Msg);
|
||||
}
|
||||
return ret.Data;
|
||||
return StoreApiClient.Instance.CloudGoods.GetGoodsDetail(goodsId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -338,17 +316,7 @@ namespace Work.Services
|
||||
throw new ArgumentException("违规商品列表不能包含空项。", nameof(items));
|
||||
}
|
||||
|
||||
var html = await GetHtml.PostJsonAsync(ServerUrl + "/api/CloudGoods/ReportViolations", items, timeOut: 5);
|
||||
var ret = JsonConvert.DeserializeObject<Result<List<ReportGoodsViolationResult>>>(html);
|
||||
if (ret == null)
|
||||
{
|
||||
throw new Exception("批量上报违规商品返回为空。");
|
||||
}
|
||||
if (ret.Code != 200)
|
||||
{
|
||||
throw new Exception(ret.Msg);
|
||||
}
|
||||
return ret.Data ?? throw new Exception("批量上报违规商品返回数据为空。");
|
||||
return await StoreApiClient.Instance.CloudGoods.ReportViolations(items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -361,17 +329,7 @@ namespace Work.Services
|
||||
throw new ArgumentNullException(nameof(goodsIds));
|
||||
}
|
||||
|
||||
var html = GetHtml.PostJson(ServerUrl + "/api/CloudGoods/GetViolationStatus", goodsIds, timeOut: 5);
|
||||
var ret = JsonConvert.DeserializeObject<Result<List<GoodsViolationStatus>>>(html);
|
||||
if (ret == null)
|
||||
{
|
||||
throw new Exception("批量查询商品违规状态返回为空。");
|
||||
}
|
||||
if (ret.Code != 200)
|
||||
{
|
||||
throw new Exception(ret.Msg);
|
||||
}
|
||||
return ret.Data ?? throw new Exception("批量查询商品违规状态返回数据为空。");
|
||||
return StoreApiClient.Instance.CloudGoods.GetViolationStatus(goodsIds);
|
||||
}
|
||||
|
||||
/// <summary>逐原因记录店铺动作。日志请求失败不能改变剔除决定或中断后续下架。</summary>
|
||||
@@ -424,23 +382,13 @@ namespace Work.Services
|
||||
{
|
||||
if (requests == null || requests.Count == 0 || requests.Any(item => item == null))
|
||||
throw new ArgumentException("请提供有效的违规日志。", nameof(requests));
|
||||
var html = await GetHtml.PostJsonAsync(ServerUrl + "/api/CloudGoods/RecordViolationLog", requests, timeOut: 5);
|
||||
var ret = JsonConvert.DeserializeObject<Result>(html) ?? throw new Exception("记录违规日志返回为空。");
|
||||
if (ret.Code != 200)
|
||||
throw new Exception(ret.Msg);
|
||||
await StoreApiClient.Instance.CloudGoods.RecordViolationLogs(requests);
|
||||
}
|
||||
|
||||
/// <summary>读取日志筛选和显示使用的违规原因字典。</summary>
|
||||
public static async Task<List<ViolationReason>> GetViolationReasons()
|
||||
{
|
||||
using (var web = new System.Net.WebClient { Encoding = Encoding.UTF8 })
|
||||
{
|
||||
var html = await web.DownloadStringTaskAsync(ServerUrl + "/api/CloudGoods/GetViolationReasons");
|
||||
var ret = JsonConvert.DeserializeObject<Result<List<ViolationReason>>>(html);
|
||||
if (ret?.Code != 200 || ret.Data == null)
|
||||
throw new Exception(ret?.Msg ?? "查询违规类型返回为空。");
|
||||
return ret.Data;
|
||||
}
|
||||
return await StoreApiClient.Instance.CloudGoods.GetViolationReasons();
|
||||
}
|
||||
|
||||
public class ViolationReason
|
||||
@@ -457,12 +405,7 @@ namespace Work.Services
|
||||
if (shopIds == null || shopIds.Length == 0 || shopIds.Any(string.IsNullOrWhiteSpace))
|
||||
throw new ArgumentException("请提供有效的店铺编号。", nameof(shopIds));
|
||||
var request = new GetViolationLogsRequest { ShopIds = shopIds, GoodsId = goodsId, BeforeId = beforeId, Size = size };
|
||||
var html = await GetHtml.PostJsonAsync(ServerUrl + "/api/CloudGoods/GetViolationLogs", request, timeOut: 5);
|
||||
var ret = JsonConvert.DeserializeObject<Result<List<GoodsViolationLogEntity>>>(html)
|
||||
?? throw new Exception("查询违规日志返回为空。");
|
||||
if (ret.Code != 200)
|
||||
throw new Exception(ret.Msg);
|
||||
return ret.Data ?? throw new Exception("查询违规日志返回数据为空。");
|
||||
return await StoreApiClient.Instance.CloudGoods.GetViolationLogs(request);
|
||||
}
|
||||
|
||||
#region user
|
||||
@@ -475,14 +418,13 @@ namespace Work.Services
|
||||
public static async Task<PageData<CloudGoods>> SearchFavorites(UserFavoritesGoodsSearchRequest request)
|
||||
{
|
||||
request.UserName = GetUserName();
|
||||
var html = await GetHtml.PostJsonAsync(ServerUrl + "/api/CloudGoods/SearchFavorites", request);
|
||||
|
||||
var ret = JsonConvert.DeserializeObject<Result<PageData<CloudGoods>>>(html);
|
||||
if (ret.Code != 200)
|
||||
var page = await StoreApiClient.Instance.CloudGoods.SearchFavorites(request);
|
||||
foreach (var item in page.List)
|
||||
{
|
||||
throw new Exception(ret.Msg);
|
||||
item.DetectionResult = item.IsSafe == null ? "未检测" : item.IsSafe.Value ? "安全" : "违规";
|
||||
}
|
||||
return ret.Data;
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -494,18 +436,7 @@ namespace Work.Services
|
||||
|
||||
public static async Task<int> Favorite(List<long> goodsIds)
|
||||
{
|
||||
var html = await GetHtml.PostJsonAsync(ServerUrl + $"/api/CloudGoods/AddFavorite", new
|
||||
{
|
||||
UserName = GetUserName(),
|
||||
GoodsIds = goodsIds,
|
||||
});
|
||||
|
||||
var ret = JsonConvert.DeserializeObject<Result<int>>(html);
|
||||
if (ret.Code != 200)
|
||||
{
|
||||
throw new Exception(ret.Msg);
|
||||
}
|
||||
return ret.Data;
|
||||
return await StoreApiClient.Instance.CloudGoods.Favorite(GetUserName(), goodsIds);
|
||||
}
|
||||
/// <summary>
|
||||
/// 取消收藏
|
||||
@@ -516,18 +447,7 @@ namespace Work.Services
|
||||
|
||||
public static async Task<int> RemoveFavorites(List<long> goodsIds)
|
||||
{
|
||||
var html = await GetHtml.PostJsonAsync(ServerUrl + $"/api/CloudGoods/RemoveFavorites", new
|
||||
{
|
||||
UserName = GetUserName(),
|
||||
GoodsIds = goodsIds,
|
||||
});
|
||||
|
||||
var ret = JsonConvert.DeserializeObject<Result<int>>(html);
|
||||
if (ret.Code != 200)
|
||||
{
|
||||
throw new Exception(ret.Msg);
|
||||
}
|
||||
return ret.Data;
|
||||
return await StoreApiClient.Instance.CloudGoods.RemoveFavorites(GetUserName(), goodsIds);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -662,6 +582,8 @@ namespace Work.Services
|
||||
/// 平台类型
|
||||
/// </summary>
|
||||
public PlatformType Platform { get; set; }
|
||||
/// <summary>安全状态:0 全部(默认),1 安全,2 违规,3 未检测。</summary>
|
||||
public int SafeStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码(默认1)
|
||||
@@ -715,6 +637,7 @@ namespace Work.Services
|
||||
}
|
||||
public class CloudGoods : BindableBase
|
||||
{
|
||||
|
||||
public bool IsSelected
|
||||
{
|
||||
get => GetValue<bool>();
|
||||
@@ -805,5 +728,18 @@ namespace Work.Services
|
||||
/// 索引同步状态:0未同步 1已同步
|
||||
/// </summary>
|
||||
public byte IndexStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全检测
|
||||
/// </summary>
|
||||
public bool? IsSafe { get; set; }
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public string? DetectionResult
|
||||
{
|
||||
get => GetValue<string>();
|
||||
set => SetValue(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using AllBase.Model;
|
||||
using AllBase.Model.Result;
|
||||
using AllBase.Model.Result.PDDResult;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using TemuKJD.BLL.ImportConverts;
|
||||
using AllBase;
|
||||
using System.Collections.Generic;
|
||||
using TemuKJD.BLL;
|
||||
|
||||
namespace Work.Services;
|
||||
|
||||
public static class ComplianceProductReader
|
||||
{
|
||||
public static Product Read(ComplianceGoods item)
|
||||
{
|
||||
KJDDBProduct db;
|
||||
if (item.type == ComplianceGoods.Cloud)
|
||||
db = new KJDDBProduct { product_json = CloudGoodsService.GetGoodsDetail(long.Parse(item.id)) };
|
||||
else if (item.type == ComplianceGoods.Library)
|
||||
{
|
||||
db = GetProductDetail(item.id, out var message);
|
||||
if (!string.IsNullOrEmpty(message)) throw new InvalidDataException(message);
|
||||
}
|
||||
else throw new InvalidDataException("未知商品来源:" + item.type);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(db?.product_json)) throw new InvalidDataException("未取得商品详情。");
|
||||
var raw = db.product_json;
|
||||
var root = JObject.Parse(raw);
|
||||
PDDProductInfo info;
|
||||
string error = null;
|
||||
var converter = new PDDProdFormConverter();
|
||||
if (root.SelectToken("detail.galleries") is JArray)
|
||||
info = root.ToObject<PDDProductInfo>();
|
||||
else if (db.product_get_type_text == "厂商直采") info = converter.JstPddProdToPddConvert(out error, db);
|
||||
else if (db.product_get_type_text == "微信店采") info = converter.WXProdToPddConvert(out error, db);
|
||||
else if (db.product_get_type_text == "微信小店") info = converter.WXClientToPddConvert(out error, db);
|
||||
else if (db.product_get_type_text == "1688厂家") info = converter._1688ClientToPddConvert(out error, db);
|
||||
else if (db.product_get_type_text == "1688外部数据") info = converter._1688OutToPddConvert(out error, db);
|
||||
else
|
||||
{
|
||||
var front = ConverterToPddFactory.SetContentConvert(raw);
|
||||
if (front != null)
|
||||
{
|
||||
var originalId = front.GoodsId > 0 ? front.GoodsId.ToString() : db.spu_id;
|
||||
return new Product
|
||||
{
|
||||
ProductId = item.type == ComplianceGoods.Cloud ? item.id : originalId,
|
||||
Platform = item.platform.HasValue ? (PlatformType)item.platform.Value : front.Platform,
|
||||
Title = front.Title ?? item.name, Description = front.Description,
|
||||
MainImages = front.MainImages, DetailImages = front.DetailImages,
|
||||
Skus = front.Skus?.Select(x => new ProductSku { SkuId = x.SkuId, ImageUrl = x.SpecImage }).ToList()
|
||||
};
|
||||
}
|
||||
string converted;
|
||||
if (raw.Contains("\"spec_keys\"") && raw.Contains("\"spec_values\""))
|
||||
converted = converter.PddFrontToPddBackConvert(raw, isSaveDb: false);
|
||||
else if (raw.Contains("\"goods_id\"") && raw.Contains("\"gallery\""))
|
||||
converted = converter.PddFrontToPddBackConvert2(raw, isSaveDb: false);
|
||||
else if (raw.Contains("\"topGallery\"") && raw.Contains("\"detailGallery\""))
|
||||
converted = converter.PddFrontToPddBackConvert3(raw, isSaveDb: false);
|
||||
else if (raw.Contains("\"商品编号\"") && raw.Contains("\"商品链接\""))
|
||||
converted = converter.PddFrontToPddBackConvertV4(raw, isSaveDb: false);
|
||||
else throw new InvalidDataException("不支持的商品详情格式。");
|
||||
if (converted == null || !converted.StartsWith("success", StringComparison.Ordinal))
|
||||
throw new InvalidDataException(converted ?? "商品详情转换失败。");
|
||||
info = JObject.Parse(converted.Substring(7)).ToObject<PDDProductInfo>();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(error) || info?.detail == null)
|
||||
throw new InvalidDataException(error ?? "商品详情转换失败。");
|
||||
var detail = info.detail;
|
||||
var platform = db.product_get_type_text switch
|
||||
{
|
||||
"微信店采" or "微信小店" or "微信外部数据" or "微信" => PlatformType.WX,
|
||||
"厂商直采" or "1688厂家" or "1688外部数据" => PlatformType.None,
|
||||
_ => PlatformType.PDD
|
||||
};
|
||||
var productId = db.product_get_type_text == "厂商直采"
|
||||
? (string)root.SelectToken("pddData.itemSpuId") ?? db.spu_id
|
||||
: detail.goods_id > 0 ? detail.goods_id.ToString() : db.spu_id;
|
||||
return new Product
|
||||
{
|
||||
Platform = item.platform.HasValue ? (PlatformType)item.platform.Value : platform,
|
||||
ProductId = item.type == ComplianceGoods.Cloud ? item.id : productId,
|
||||
Title = detail.goods_name ?? item.name, Description = detail.goods_desc,
|
||||
MainImages = detail.galleries?.Where(x => x.type == 1).Select(x => x.url).ToList(),
|
||||
DetailImages = detail.galleries?.Where(x => x.type == 2).Select(x => x.url).ToList(),
|
||||
Skus = detail.sku?.Select(x => new ProductSku { SkuId = x.sku_id.ToString(), ImageUrl = x.thumb_url }).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public static KJDDBProduct GetProductDetail(string prodDbId, out string errMsg, bool noViolationProduct = false, bool noBanWx = false)
|
||||
{
|
||||
errMsg = "";
|
||||
|
||||
if (string.IsNullOrEmpty(prodDbId))
|
||||
{
|
||||
errMsg = "产品编号为空";
|
||||
return new KJDDBProduct();
|
||||
}
|
||||
|
||||
List<KJDDBProduct> list = new List<KJDDBProduct>();
|
||||
|
||||
var url = BaseClass.BuildObjString("product/get_pdd_product_detail_list");
|
||||
var data = UserLogin.GetSignaturex() + "&search_db_ids=" + prodDbId;
|
||||
if (noViolationProduct)
|
||||
data += "&wx_safe_status=安全商品,未校验";
|
||||
if (noBanWx)
|
||||
data += "&ban_wx=0";
|
||||
if (BaseClass.SaLevel > 0 || BaseClass.UserAuthority.Contains("爆款数据") || BaseClass.UserAuthority.Contains("学员数据") || BaseClass.ParentAuthority.Contains("学员数据"))
|
||||
data += "&special_prod_type=普通商品,爆款数据";
|
||||
|
||||
var html = GetHtml.PostUtf8(url, data);
|
||||
if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
|
||||
html = GetHtml.PostUtf8(url, data);
|
||||
if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
|
||||
html = GetHtml.PostUtf8(url, data);
|
||||
if (string.IsNullOrEmpty(html) || !html.Contains("\"code\":200"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var jArr = JObject.Parse(html)["data"];
|
||||
if (jArr.Count() == 0)
|
||||
return null;
|
||||
var sProd = JsonHelper.SerializeObject(jArr);
|
||||
|
||||
try
|
||||
{
|
||||
list = JsonHelper.DeserializeJsonToList<KJDDBProduct>(sProd);
|
||||
var item = list.FirstOrDefault();
|
||||
if (item == null)
|
||||
item = new KJDDBProduct();
|
||||
return item;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
using AllBase.Helper;
|
||||
using AllBase;
|
||||
using AllBase.Model;
|
||||
using Work.Store.Api;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Net.Http;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace Work.Services;
|
||||
|
||||
public sealed class ComplianceGoods
|
||||
{
|
||||
public const string Cloud = "云端收藏";
|
||||
public const string Library = "我的商品库";
|
||||
|
||||
public string id { get; set; }
|
||||
public string name { get; set; }
|
||||
public string image { get; set; }
|
||||
public string type { get; set; }
|
||||
public byte? platform { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ComplianceResult
|
||||
{
|
||||
public string Text { get; internal set; }
|
||||
public string[] Hits { get; internal set; } = Array.Empty<string>();
|
||||
public int HitImageNumber { get; internal set; }
|
||||
public string Error { get; internal set; }
|
||||
public string Status => Error != null ? "失败,可重试" : Hits.Length == 0 ? "安全" : "有风险";
|
||||
public string Summary => Error != null ? "失败:" + Error :
|
||||
Hits.Length == 0 ? Status : $"有风险:{HitSource}命中关键词:{string.Join(",", Hits)}";
|
||||
private string HitSource => HitImageNumber == 0 ? "标题" : $"第 {HitImageNumber} 张图片";
|
||||
}
|
||||
|
||||
public sealed class ComplianceBatchResult
|
||||
{
|
||||
public int Safe { get; internal set; }
|
||||
public int Risk { get; internal set; }
|
||||
public int Failed { get; internal set; }
|
||||
public int Unfinished { get; internal set; }
|
||||
}
|
||||
|
||||
public static class GoodsComplianceService
|
||||
{
|
||||
private static readonly HttpClient ImageClient = new HttpClient(new HttpClientHandler { UseCookies = false })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
MaxResponseContentBufferSize = 32 * 1024 * 1024
|
||||
};
|
||||
private static readonly SemaphoreSlim BatchGate = new SemaphoreSlim(1, 1);
|
||||
|
||||
public static async Task<ComplianceBatchResult> DetectSelectedAsync(IReadOnlyList<ComplianceGoods> items,
|
||||
Func<ComplianceGoods, Product> readProduct, Action<string, string> update, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (items == null || items.Count == 0) throw new ArgumentException("请选择商品。", nameof(items));
|
||||
if (readProduct == null) throw new ArgumentNullException(nameof(readProduct));
|
||||
if (update == null) throw new ArgumentNullException(nameof(update));
|
||||
if (!BatchGate.Wait(0)) throw new InvalidOperationException("已有商品正在检测。");
|
||||
try
|
||||
{
|
||||
var options = GlobalConfig.GlobalSettings.GoodsCompliance;
|
||||
var words = ValidateOptions(options);
|
||||
var path = QueuePath();
|
||||
var pending = ReadPending(path);
|
||||
if (items.Any(InvalidItem)) throw new ArgumentException("缺少商品 ID 或来源。", nameof(items));
|
||||
var selected = items.GroupBy(x => Tuple.Create(x.type, x.id)).Select(g => g.First()).ToList();
|
||||
var existing = new HashSet<Tuple<string, string>>(pending.Select(x => Tuple.Create(x.type, x.id)));
|
||||
pending.AddRange(selected.Where(x => existing.Add(Tuple.Create(x.type, x.id))));
|
||||
SavePending(path, pending);
|
||||
|
||||
var batch = new ComplianceBatchResult { Unfinished = selected.Count };
|
||||
foreach (ComplianceGoods item in selected)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
update(item.id, "读取详情中");
|
||||
ComplianceResult result;
|
||||
try
|
||||
{
|
||||
//item.type
|
||||
var product = await Task.Run(() => readProduct(item), cancellationToken);
|
||||
result = await DetectProductAsync(product, options, words,
|
||||
number => update(item.id, $"正在检测第 {number} 张图片"), cancellationToken);
|
||||
if (result.Error == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.type == ComplianceGoods.Library)
|
||||
{
|
||||
var url = BaseClass.BuildPDDObjString("product/SaveProductSafety");
|
||||
var data = UserLogin.GetSignature();
|
||||
data += "&user_name=" + BaseClass.UserName;
|
||||
data += "&id=" + item.id;
|
||||
string html = await GetHtml.PostJsonAsync(url, data);
|
||||
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
await StoreApiClient.Instance.CloudGoods.SaveProductSafety(new SaveProductSafetyRequest
|
||||
{
|
||||
UserName = GetUserName(),
|
||||
ProductId = product.ProductId,
|
||||
IsSafe = result.Hits.Length == 0
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Error = $"检测结果为{result.Status},上报失败:{ex.Message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
update(item.id, "已停止");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) { result = new ComplianceResult { Error = ex.Message }; }
|
||||
if (result.Error == null)
|
||||
{
|
||||
var remaining = pending.Where(x => x.type != item.type || x.id != item.id).ToList();
|
||||
try { SavePending(path, remaining); pending = remaining; }
|
||||
catch (Exception ex) { result.Error = ex.Message; }
|
||||
}
|
||||
if (result.Error != null) batch.Failed++;
|
||||
else if (result.Hits.Length > 0) batch.Risk++;
|
||||
else batch.Safe++;
|
||||
batch.Unfinished--;
|
||||
update(item.id, result.Summary);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
finally { BatchGate.Release(); }
|
||||
}
|
||||
|
||||
private static string QueuePath()
|
||||
{
|
||||
var username = BaseClass.UserName;
|
||||
if (string.IsNullOrWhiteSpace(username)) throw new InvalidOperationException("请先登录。");
|
||||
var user = Convert.ToBase64String(Encoding.UTF8.GetBytes(username)).Replace('/', '_').Replace('+', '-');
|
||||
return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "config", "goods_compliance_" + user + ".json");
|
||||
}
|
||||
|
||||
private static bool InvalidItem(ComplianceGoods item) =>
|
||||
item == null || string.IsNullOrWhiteSpace(item.id) || string.IsNullOrWhiteSpace(item.type);
|
||||
|
||||
private static List<ComplianceGoods> ReadPending(string path)
|
||||
{
|
||||
if (!File.Exists(path)) return new List<ComplianceGoods>();
|
||||
var items = JsonConvert.DeserializeObject<List<ComplianceGoods>>(File.ReadAllText(path));
|
||||
if (items == null || items.Any(InvalidItem))
|
||||
throw new InvalidDataException("待检测配置无效,请检查 " + path);
|
||||
return items;
|
||||
}
|
||||
|
||||
private static void SavePending(string path, List<ComplianceGoods> items)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
var temp = path + ".tmp";
|
||||
File.WriteAllText(temp, JsonConvert.SerializeObject(items));
|
||||
if (File.Exists(path)) File.Replace(temp, path, null);
|
||||
else File.Move(temp, path);
|
||||
}
|
||||
|
||||
public static async Task DetectAsync(IReadOnlyList<Product> products, ComplianceOptions options,
|
||||
Action<Product, ComplianceResult> completed, Action<Product> started = null)
|
||||
{
|
||||
if (products == null) throw new ArgumentNullException(nameof(products));
|
||||
if (completed == null) throw new ArgumentNullException(nameof(completed));
|
||||
var words = ValidateOptions(options);
|
||||
foreach (var product in products)
|
||||
{
|
||||
started?.Invoke(product);
|
||||
var result = await DetectProductAsync(product, options, words);
|
||||
if (result.Error == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await StoreApiClient.Instance.CloudGoods.SaveProductSafety(new SaveProductSafetyRequest
|
||||
{
|
||||
UserName = GetUserName(),
|
||||
ProductId = product.ProductId,
|
||||
IsSafe = result.Hits.Length == 0
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Error = $"检测结果为{result.Status},上报失败:{ex.Message}";
|
||||
}
|
||||
}
|
||||
completed(product, result);
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] ValidateOptions(ComplianceOptions options)
|
||||
{
|
||||
var words = Keywords(options?.Keywords);
|
||||
if (words.Length == 0) throw new ArgumentException("请先在系统设置的「商品 AI 检测」中保存 AI 检测关键词。");
|
||||
if (options.MainCount <= 0 || options.DetailCount < 0)
|
||||
throw new ArgumentException("请检查检测关键词和图片数量。");
|
||||
return words;
|
||||
}
|
||||
|
||||
private static async Task<ComplianceResult> DetectProductAsync(Product product, ComplianceOptions options,
|
||||
string[] words, Action<int> imageStarted = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new ComplianceResult();
|
||||
try
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (string.IsNullOrWhiteSpace(product?.ProductId)) throw new InvalidDataException("缺少商品 ID。");
|
||||
result.Hits = Matches(product.Title ?? string.Empty, words);
|
||||
if (result.Hits.Length > 0)
|
||||
{
|
||||
result.Text = product.Title;
|
||||
return result;
|
||||
}
|
||||
var urls = SelectImages(product.MainImages, options.MainCount)
|
||||
.Concat(SelectImages(product.DetailImages, options.DetailCount));
|
||||
if (options.IncludeSku) urls = urls.Concat(SelectImages(product.Skus?.Select(s => s?.ImageUrl), null));
|
||||
var texts = new List<string>();
|
||||
int imageNumber = 0;
|
||||
foreach (var url in urls.Distinct(StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
imageNumber++;
|
||||
imageStarted?.Invoke(imageNumber);
|
||||
try
|
||||
{
|
||||
var jpeg = await DownloadInspectionImageAsync(url);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var lines = await StoreApiClient.Instance.CloudGoods.RecognizeAsync(BaseClass.GetMd5(url), jpeg);
|
||||
|
||||
BaseClass.ImageAnalysisQuota -= 1;
|
||||
|
||||
var imageText = string.Join(Environment.NewLine, lines);
|
||||
texts.AddRange(lines);
|
||||
result.Hits = Matches(imageText, words);
|
||||
if (result.Hits.Length > 0)
|
||||
{
|
||||
result.HitImageNumber = imageNumber;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new InvalidDataException($"第 {imageNumber} 张图片检测失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (imageNumber == 0) throw new InvalidDataException("所选检测范围内没有图片。");
|
||||
result.Text = string.Join(Environment.NewLine, texts);
|
||||
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex) { result.Error = ex.Message; }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SelectImages(IEnumerable<string> urls, int? count) =>
|
||||
(urls ?? Enumerable.Empty<string>()).Where(url => !string.IsNullOrWhiteSpace(url)).Select(url => url.Trim())
|
||||
.Distinct(StringComparer.Ordinal).Take(count ?? int.MaxValue);
|
||||
|
||||
private static async Task<byte[]> DownloadInspectionImageAsync(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
throw new InvalidDataException("图片链接必须为 HTTP 或 HTTPS 地址。");
|
||||
var bytes = await ImageClient.GetByteArrayAsync(uri).ConfigureAwait(false);
|
||||
const int maxSide = 600;
|
||||
using (var input = new MemoryStream(bytes))
|
||||
using (var source = Image.FromStream(input))
|
||||
{
|
||||
var scale = Math.Min(1d, (double)maxSide / Math.Max(source.Width, source.Height));
|
||||
int width = Math.Max(1, (int)Math.Round(source.Width * scale));
|
||||
int height = Math.Max(1, (int)Math.Round(source.Height * scale));
|
||||
using var canvas = new Bitmap(width, height, PixelFormat.Format24bppRgb);
|
||||
using var output = new MemoryStream();
|
||||
using (var g = Graphics.FromImage(canvas))
|
||||
{
|
||||
g.Clear(Color.White);
|
||||
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
g.DrawImage(source, new Rectangle(0, 0, width, height),
|
||||
0, 0, source.Width, source.Height, GraphicsUnit.Pixel);
|
||||
}
|
||||
canvas.Save(output, ImageFormat.Jpeg);
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static string[] Keywords(string value) =>
|
||||
System.Text.RegularExpressions.Regex.Split(value ?? "", @"[,,;;|\s]+")
|
||||
.Where(x => x.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
public static string[] Matches(string recognized, string[] keywords) =>
|
||||
keywords.Where(x => recognized.IndexOf(x, StringComparison.OrdinalIgnoreCase) >= 0).ToArray();
|
||||
public static string GetUserName()
|
||||
{
|
||||
return string.IsNullOrEmpty(BaseClass.ParentUserName) ? BaseClass.UserName : BaseClass.ParentUserName;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
using AllBase;
|
||||
using AllBase.Helper;
|
||||
using AllBase.Model.Result;
|
||||
using AllBase.Model.Result.PDDResult;
|
||||
using AllBase.Model.Result;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Work.Store.Api;
|
||||
|
||||
namespace Work.Services;
|
||||
|
||||
@@ -17,17 +14,6 @@ namespace Work.Services;
|
||||
/// </summary>
|
||||
public class ImageCacheService
|
||||
{
|
||||
private static string ServerUrl = "http://103.90.155.78:9001";
|
||||
static ImageCacheService()
|
||||
{
|
||||
if (EnvironmentHelper.IsDevelopment)
|
||||
{
|
||||
ServerUrl = "http://localhost:14312";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 获取图片缓存
|
||||
/// </summary>
|
||||
@@ -37,10 +23,6 @@ public class ImageCacheService
|
||||
Dictionary<string, ImageCache> wxImgMapDic = new Dictionary<string, ImageCache>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
//if (EnvironmentHelper.IsProduction)
|
||||
//{
|
||||
// return wxImgMapDic;
|
||||
//}
|
||||
List<string> imageUrls = new List<string>();
|
||||
imageUrls.AddRange(product.detail.galleries.Where(g => g.type == 1).Take(9).Select(a => a.url));
|
||||
var temp_detailGalleries = product.detail.galleries.Where(g => g.type == 2).ToList();
|
||||
@@ -58,16 +40,11 @@ public class ImageCacheService
|
||||
|
||||
List<string> imageUrlMd5s = imageUrls.Select(a => BaseClass.GetMd5(a)).ToList();
|
||||
|
||||
var url = ServerUrl + "/api/ImageCache/GetImageCache";//获取缓存
|
||||
var html = GetHtml.PostJson(url, new { md5s = imageUrlMd5s });
|
||||
var ret = JsonConvert.DeserializeObject<Result<List<ImageCache>>>(html);
|
||||
if (ret.Code == 200)
|
||||
var caches = StoreApiClient.Instance.ImageCache.GetImageCaches(imageUrlMd5s);
|
||||
foreach (var item in caches)
|
||||
{
|
||||
foreach (var item in ret.Data)
|
||||
{
|
||||
item.IsCache = true;
|
||||
wxImgMapDic[item.Md5] = item;
|
||||
}
|
||||
item.IsCache = true;
|
||||
wxImgMapDic[item.Md5] = item;
|
||||
}
|
||||
}
|
||||
catch(Exception ex)
|
||||
@@ -86,38 +63,9 @@ public class ImageCacheService
|
||||
{
|
||||
try
|
||||
{
|
||||
//if (EnvironmentHelper.IsProduction)
|
||||
//{
|
||||
// return;
|
||||
//}
|
||||
var data = "";
|
||||
data = JsonHelper.SerializeObject(images.Where(a => a.IsCache == false).Select(a =>
|
||||
new
|
||||
{
|
||||
md5 = a.Md5,
|
||||
Platform = platform,
|
||||
PlatformUrl = a.PlatformUrl
|
||||
}
|
||||
));
|
||||
|
||||
var url = ServerUrl + "/api/ImageCache/AddOrUpdate";
|
||||
var html = GetHtml.PostJson(url, data);
|
||||
|
||||
|
||||
var ret = JsonConvert.DeserializeObject<Result<JToken>>(html);
|
||||
List<string> failed = new List<string>();
|
||||
if (ret.Code == 500)
|
||||
{
|
||||
if (ret.Data is JArray jArray)
|
||||
{
|
||||
failed = jArray.ToObject<List<string>>() ?? new List<string>();
|
||||
}
|
||||
else if (ret.Data is JValue jValue && jValue.Value is string str)
|
||||
{
|
||||
failed.Add(str);
|
||||
}
|
||||
}
|
||||
foreach (ImageCache item in images)
|
||||
var pending = images.Where(image => !image.IsCache).ToList();
|
||||
var failed = StoreApiClient.Instance.ImageCache.SaveImageCaches((int)platform, pending);
|
||||
foreach (var item in pending)
|
||||
{
|
||||
item.IsCache = !failed.Contains(item.Md5, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using CsharpHttpHelper;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Work.Store.Api;
|
||||
|
||||
/// <summary>保留业务错误数据,供批量接口识别部分失败项。</summary>
|
||||
public sealed class ApiException : HttpException
|
||||
{
|
||||
public int? Code { get; }
|
||||
public JToken ResponseData { get; }
|
||||
public string TraceId { get; }
|
||||
|
||||
public ApiException(string message, int? code, JToken responseData, string traceId = null) : base(message)
|
||||
{
|
||||
Code = code;
|
||||
ResponseData = responseData;
|
||||
TraceId = traceId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
using CsharpHttpHelper;
|
||||
using AllBase.Model.Result;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Work.Store.Api;
|
||||
|
||||
/// <summary>管理 API 的统一 HTTP 出口,负责 JSON、Bearer 认证和错误处理。</summary>
|
||||
public sealed class ApiTransport
|
||||
{
|
||||
private readonly string _baseUrl;
|
||||
private string _token;
|
||||
private LoginRequest _credentials;
|
||||
internal SemaphoreSlim SessionGate { get; } = new SemaphoreSlim(1, 1);
|
||||
|
||||
// 调用方持有 SessionGate。
|
||||
internal void SetSession(string token, LoginRequest credentials)
|
||||
{
|
||||
_token = token;
|
||||
_credentials = credentials;
|
||||
}
|
||||
|
||||
public ApiTransport(string baseUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https") ||
|
||||
uri.UserInfo.Length != 0 || uri.Query.Length != 0 || uri.Fragment.Length != 0)
|
||||
throw new ArgumentException("必须提供无凭据、查询参数和片段的 HTTP(S) 根地址。", nameof(baseUrl));
|
||||
|
||||
_baseUrl = uri.AbsoluteUri.TrimEnd('/') + "/";
|
||||
}
|
||||
|
||||
/// <summary>仅存于内存;手动赋值会清除自动登录凭据,设为 null 退出本地会话。</summary>
|
||||
public string Token
|
||||
{
|
||||
get
|
||||
{
|
||||
SessionGate.Wait();
|
||||
try { return _token; }
|
||||
finally { SessionGate.Release(); }
|
||||
}
|
||||
set
|
||||
{
|
||||
if (value != null && (string.IsNullOrWhiteSpace(value) || value.IndexOfAny(new[] { '\r', '\n' }) >= 0))
|
||||
throw new ArgumentException("Token 不能为空白或包含换行。", nameof(value));
|
||||
SessionGate.Wait();
|
||||
try { SetSession(value, null); }
|
||||
finally { SessionGate.Release(); }
|
||||
}
|
||||
}
|
||||
|
||||
public T Send<T>(string route, MethodType method, object body = null,
|
||||
IDictionary<string, string> query = null, bool authenticated = true,
|
||||
int timeoutMilliseconds = 30000)
|
||||
{
|
||||
var item = CreateRequest(route, method, body, query, timeoutMilliseconds);
|
||||
string token = null;
|
||||
if (authenticated)
|
||||
{
|
||||
//SessionGate.Wait();
|
||||
//try { token = _token ?? throw new HttpException("请先登录。") { IsOnline = false }; }
|
||||
//finally { SessionGate.Release(); }
|
||||
}
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
if (token != null) item.Header["Authorization"] = "Bearer " + token;
|
||||
var response = HttpHelper.Request(item);
|
||||
try { return ReadResult<T>(response); }
|
||||
catch (HttpException ex) when (authenticated && !ex.IsOnline)
|
||||
{
|
||||
SessionGate.Wait();
|
||||
try
|
||||
{
|
||||
if (_token == token)
|
||||
{
|
||||
_token = null;
|
||||
if (attempt == 0 && _credentials != null)
|
||||
_token = AuthApi.LoginCore(this, _credentials).Token;
|
||||
}
|
||||
if (attempt != 0 || _token == null) throw;
|
||||
token = _token;
|
||||
}
|
||||
finally { SessionGate.Release(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> SendAsync<T>(string route, MethodType method, object body = null,
|
||||
IDictionary<string, string> query = null, bool authenticated = true,
|
||||
int timeoutMilliseconds = 30000)
|
||||
{
|
||||
var item = CreateRequest(route, method, body, query, timeoutMilliseconds);
|
||||
string token = null;
|
||||
if (authenticated)
|
||||
{
|
||||
//await SessionGate.WaitAsync().ConfigureAwait(false);
|
||||
//try { token = _token ?? throw new HttpException("请先登录。") { IsOnline = false }; }
|
||||
//finally { SessionGate.Release(); }
|
||||
}
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
if (token != null) item.Header["Authorization"] = "Bearer " + token;
|
||||
var response = await HttpHelper.RequestAsync(item).ConfigureAwait(false);
|
||||
try { return ReadResult<T>(response); }
|
||||
catch (HttpException ex) when (authenticated && !ex.IsOnline)
|
||||
{
|
||||
await SessionGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_token == token)
|
||||
{
|
||||
_token = null;
|
||||
if (attempt == 0 && _credentials != null)
|
||||
_token = (await AuthApi.LoginCoreAsync(this, _credentials).ConfigureAwait(false)).Token;
|
||||
}
|
||||
if (attempt != 0 || _token == null) throw;
|
||||
token = _token;
|
||||
}
|
||||
finally { SessionGate.Release(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private HttpItem CreateRequest(string route, MethodType method, object body,
|
||||
IDictionary<string, string> query, int timeoutMilliseconds)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(route) ||
|
||||
!Regex.IsMatch(route, @"\A[A-Za-z0-9_-]+(/[A-Za-z0-9_-]+)*\z"))
|
||||
throw new ArgumentException("接口必须使用相对路由,例如 Auth/Login。", nameof(route));
|
||||
if (body != null) Validator.ValidateObject(body, new ValidationContext(body), true);
|
||||
if (timeoutMilliseconds <= 0) throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds));
|
||||
var url = new StringBuilder(_baseUrl).Append(route);
|
||||
if (query != null)
|
||||
{
|
||||
bool first = true;
|
||||
foreach (var pair in query)
|
||||
{
|
||||
if (pair.Value == null) continue;
|
||||
url.Append(first ? '?' : '&').Append(Uri.EscapeDataString(pair.Key))
|
||||
.Append('=').Append(Uri.EscapeDataString(pair.Value));
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
return new HttpItem
|
||||
{
|
||||
URL = url.ToString(),
|
||||
Method = method,
|
||||
ContentType = "application/json; charset=utf-8",
|
||||
Accept = "application/json",
|
||||
PostDataType = PostDataType.Byte,
|
||||
PostdataByte = body == null ? null : Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body)),
|
||||
Encoding = Encoding.UTF8,
|
||||
Timeout = timeoutMilliseconds,
|
||||
ReadWriteTimeout = timeoutMilliseconds,
|
||||
Allowautoredirect = false
|
||||
};
|
||||
}
|
||||
|
||||
private static T ReadResult<T>(HttpResult response)
|
||||
{
|
||||
int status = (int)response.StatusCode;
|
||||
if (status == 0) throw new HttpException("无法连接服务器或请求超时。");
|
||||
|
||||
Result<JToken> result = null;
|
||||
JsonException parseError = null;
|
||||
try { result = JsonConvert.DeserializeObject<Result<JToken>>(response.Html ?? ""); }
|
||||
catch (JsonException ex) { parseError = ex; }
|
||||
|
||||
if (status == 401 || (result?.Success == false && result.Code == 401))
|
||||
throw new HttpException(result?.Msg ?? "登录已失效,请重新登录。") { IsOnline = false };
|
||||
// 统一错误响应即使使用 HTTP 4xx/5xx,也保留业务码、数据和追踪 ID。
|
||||
if (result?.Success == false)
|
||||
throw new ApiException("请求失败:" + (result.Msg ?? $"业务码 {result.Code}。"),
|
||||
result.Code, result.Data, result.TraceId);
|
||||
if (status < 200 || status >= 300)
|
||||
throw new HttpException($"请求失败(HTTP {status}):{result?.Msg}");
|
||||
if (result?.Success == null)
|
||||
throw new HttpException("服务器响应不是有效的 Result 格式。", parseError);
|
||||
if (result.Data == null || result.Data.Type == JTokenType.Null) return default;
|
||||
try { return result.Data.ToObject<T>(); }
|
||||
catch (JsonException ex) { throw new HttpException("服务器响应 data 格式无效。", ex); }
|
||||
}
|
||||
|
||||
public T GetRequired<T>(string route, MethodType method, object body = null,
|
||||
IDictionary<string, string> query = null, bool authenticated = true,
|
||||
int timeoutMilliseconds = 30000) where T : class
|
||||
=> Send<T>(route, method, body, query, authenticated, timeoutMilliseconds)
|
||||
?? throw new HttpException("服务器响应缺少 data。");
|
||||
|
||||
public async Task<T> GetRequiredAsync<T>(string route, MethodType method, object body = null,
|
||||
IDictionary<string, string> query = null, bool authenticated = true,
|
||||
int timeoutMilliseconds = 30000) where T : class
|
||||
=> await SendAsync<T>(route, method, body, query, authenticated, timeoutMilliseconds).ConfigureAwait(false)
|
||||
?? throw new HttpException("服务器响应缺少 data。");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using CsharpHttpHelper;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
|
||||
namespace Work.Store.Api;
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
[Required]
|
||||
public string UserName { get; set; }
|
||||
|
||||
/// <summary>UTF-8 密码的 32 位大写 MD5,由调用方计算。</summary>
|
||||
[Required]
|
||||
public string Password { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string DeviceId { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
public string DeviceName { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LoginResult
|
||||
{
|
||||
public string TokenType { get; set; } = "Bearer";
|
||||
public string Token { get; set; }
|
||||
public uint ExpiresInMinutes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthApi
|
||||
{
|
||||
private readonly ApiTransport _transport;
|
||||
private readonly string _deviceId;
|
||||
|
||||
public AuthApi(string baseUrl) : this(new ApiTransport(baseUrl)) { }
|
||||
|
||||
public AuthApi(ApiTransport transport, string deviceId = null)
|
||||
{
|
||||
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
_deviceId = deviceId;
|
||||
}
|
||||
|
||||
/// <summary>同步登录,凭据快照仅存内存,供 401 时自动重登;界面调用需放在后台线程。</summary>
|
||||
public LoginResult Login(LoginRequest request)
|
||||
{
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
var credentials = new LoginRequest
|
||||
{
|
||||
UserName = request.UserName,
|
||||
Password = request.Password,
|
||||
DeviceId = _deviceId ?? request.DeviceId,
|
||||
DeviceName = request.DeviceName
|
||||
};
|
||||
_transport.SessionGate.Wait();
|
||||
try
|
||||
{
|
||||
var result = LoginCore(_transport, credentials);
|
||||
_transport.SetSession(result.Token, credentials);
|
||||
return result;
|
||||
}
|
||||
finally { _transport.SessionGate.Release(); }
|
||||
}
|
||||
|
||||
internal static LoginResult LoginCore(ApiTransport transport, LoginRequest request)
|
||||
{
|
||||
var result = transport.GetRequired<LoginResult>("Auth/Login", MethodType.Post, request, authenticated: false);
|
||||
return ValidateToken(result);
|
||||
}
|
||||
|
||||
internal static async Task<LoginResult> LoginCoreAsync(ApiTransport transport, LoginRequest request)
|
||||
{
|
||||
var result = await transport.GetRequiredAsync<LoginResult>("Auth/Login", MethodType.Post, request, authenticated: false).ConfigureAwait(false);
|
||||
return ValidateToken(result);
|
||||
}
|
||||
|
||||
private static LoginResult ValidateToken(LoginResult result)
|
||||
{
|
||||
if (result == null || string.IsNullOrWhiteSpace(result.Token) ||
|
||||
result.Token.IndexOfAny(new[] { '\r', '\n' }) >= 0 ||
|
||||
!string.Equals(result.TokenType, "Bearer", StringComparison.OrdinalIgnoreCase))
|
||||
throw new HttpException("登录响应缺少有效的 Bearer Token。");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using AllBase;
|
||||
using AllBase.Helper;
|
||||
using AllBase.Model.Result;
|
||||
using CsharpHttpHelper;
|
||||
using NaiBa.Util.Helper;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using Work.Services;
|
||||
using static System.Windows.Forms.VisualStyles.VisualStyleElement.StartPanel;
|
||||
|
||||
namespace Work.Store.Api;
|
||||
|
||||
/// <summary>云商品接口,沿用服务端不带版本号的路由。</summary>
|
||||
public sealed class CloudGoodsApi
|
||||
{
|
||||
private readonly ApiTransport _transport;
|
||||
|
||||
public CloudGoodsApi(ApiTransport transport)
|
||||
=> _transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
|
||||
public Task<PageData<CloudGoods>> GetGoods(GoodsSearchRequest request)
|
||||
=> _transport.GetRequiredAsync<PageData<CloudGoods>>("CloudGoods/SearchGoods", MethodType.Post, request);
|
||||
|
||||
public string GetGoodsDetail(long goodsId)
|
||||
=> _transport.GetRequired<string>("CloudGoods/GetGoodsRawData", MethodType.Post,
|
||||
query: new Dictionary<string, string> { ["goodsId"] = goodsId.ToString(CultureInfo.InvariantCulture) });
|
||||
|
||||
public Task<List<ReportGoodsViolationResult>> ReportViolations(List<ReportGoodsViolationItem> items)
|
||||
=> _transport.GetRequiredAsync<List<ReportGoodsViolationResult>>("CloudGoods/ReportViolations", MethodType.Post, items,
|
||||
timeoutMilliseconds: 5000);
|
||||
|
||||
public List<GoodsViolationStatus> GetViolationStatus(List<long> goodsIds)
|
||||
=> _transport.GetRequired<List<GoodsViolationStatus>>("CloudGoods/GetViolationStatus", MethodType.Post, goodsIds,
|
||||
timeoutMilliseconds: 5000);
|
||||
|
||||
public Task RecordViolationLogs(IReadOnlyList<RecordViolationLogRequest> requests)
|
||||
=> _transport.SendAsync<object>("CloudGoods/RecordViolationLog", MethodType.Post, requests,
|
||||
timeoutMilliseconds: 5000);
|
||||
|
||||
public Task<List<CloudGoodsService.ViolationReason>> GetViolationReasons()
|
||||
=> _transport.GetRequiredAsync<List<CloudGoodsService.ViolationReason>>("CloudGoods/GetViolationReasons", MethodType.Get);
|
||||
|
||||
public Task<List<GoodsViolationLogEntity>> GetViolationLogs(GetViolationLogsRequest request)
|
||||
=> _transport.GetRequiredAsync<List<GoodsViolationLogEntity>>("CloudGoods/GetViolationLogs", MethodType.Post, request,
|
||||
timeoutMilliseconds: 5000);
|
||||
|
||||
public Task<PageData<CloudGoods>> SearchFavorites(UserFavoritesGoodsSearchRequest request)
|
||||
=> _transport.GetRequiredAsync<PageData<CloudGoods>>("CloudGoods/SearchFavorites", MethodType.Post, request);
|
||||
|
||||
public Task<int> Favorite(string userName, List<long> goodsIds)
|
||||
=> _transport.SendAsync<int>("CloudGoods/AddFavorite", MethodType.Post,
|
||||
new { UserName = userName, GoodsIds = goodsIds });
|
||||
|
||||
public Task<int> RemoveFavorites(string userName, List<long> goodsIds)
|
||||
=> _transport.SendAsync<int>("CloudGoods/RemoveFavorites", MethodType.Post,
|
||||
new { UserName = userName, GoodsIds = goodsIds });
|
||||
|
||||
|
||||
/// <summary>识别上传图片中的文字和品牌 Logo。</summary>
|
||||
public async Task<List<string>> RecognizeAsync(string urlMd5, byte[] jpeg)
|
||||
{
|
||||
int sleep = 0;
|
||||
if (EnvironmentHelper.IsProduction)
|
||||
{
|
||||
sleep = RandomHelper.GetRandom(600, 1200);
|
||||
}
|
||||
var minimumDuration = Task.Delay(TimeSpan.FromMilliseconds(sleep));
|
||||
var request = new { UserName = BaseClass.UserName, Md5 = urlMd5, Image = Convert.ToBase64String(jpeg) };
|
||||
var recognitionTask = _transport.GetRequiredAsync<List<string>>("CloudGoods/InspectImage", MethodType.Post, request);
|
||||
await Task.WhenAll(minimumDuration, recognitionTask).ConfigureAwait(false);
|
||||
return await recognitionTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SaveProductSafety(SaveProductSafetyRequest request)
|
||||
{
|
||||
var ret = await _transport.SendAsync<int>("CloudGoods/SaveProductSafety", MethodType.Post, new
|
||||
{
|
||||
UserName = request.UserName,
|
||||
ProductId = request.ProductId,
|
||||
IsSafe = request.IsSafe
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using AllBase;
|
||||
using AllBase.Helper;
|
||||
using CsharpHttpHelper;
|
||||
using NaiBa.Util.Helper;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Work.Store.Api;
|
||||
|
||||
public sealed class GoodsComplianceApi
|
||||
{
|
||||
private readonly ApiTransport transport;
|
||||
internal GoodsComplianceApi(ApiTransport transport) => this.transport = transport;
|
||||
|
||||
///// <summary>上报平台商品的安全检测记录。</summary>
|
||||
//public Task SaveProductSafety(SaveProductSafetyRequest request)
|
||||
//{
|
||||
// request.UserName = BaseClass.UserName;
|
||||
// return transport.SendAsync<object>("ProductInspection/SaveProductSafety", MethodType.Post,
|
||||
// request ?? throw new ArgumentNullException(nameof(request)));
|
||||
//}
|
||||
|
||||
/// <summary>按平台和商品 ID 批量查询安全记录。</summary>
|
||||
public Task<List<ProductSafetyDto>> QueryProductSafety(QueryProductSafetyRequest request)
|
||||
{
|
||||
request.UserName = BaseClass.UserName;
|
||||
return transport.GetRequiredAsync<List<ProductSafetyDto>>("ProductInspection/QueryProductSafety", MethodType.Post,
|
||||
request ?? throw new ArgumentNullException(nameof(request)));
|
||||
}
|
||||
|
||||
/// <summary>识别上传图片中的文字和品牌 Logo。</summary>
|
||||
public async Task<List<string>> RecognizeAsync(string urlMd5, byte[] jpeg)
|
||||
{
|
||||
int sleep = 0;
|
||||
if (EnvironmentHelper.IsProduction)
|
||||
{
|
||||
sleep = RandomHelper.GetRandom(600, 1200);
|
||||
}
|
||||
var minimumDuration = Task.Delay(TimeSpan.FromMilliseconds(sleep));
|
||||
var request = new { UserName = BaseClass.UserName, Md5 = urlMd5, Image = Convert.ToBase64String(jpeg) };
|
||||
var recognitionTask = transport.GetRequiredAsync<List<string>>("ProductInspection/InspectImage", MethodType.Post, request);
|
||||
await Task.WhenAll(minimumDuration, recognitionTask).ConfigureAwait(false);
|
||||
return await recognitionTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SaveProductSafetyRequest
|
||||
{
|
||||
[Required] public string UserName { get; set; } = "";
|
||||
[Required] public string ProductId { get; set; } = "";
|
||||
[Required] public bool? IsSafe { get; set; }
|
||||
}
|
||||
|
||||
public sealed class QueryProductSafetyRequest
|
||||
{
|
||||
[Required] public string UserName { get; set; } = "";
|
||||
[Required] public string PlatformId { get; set; } = "";
|
||||
[Required] public string[] ProductIds { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed record ProductSafetyDto(string ProductId, bool IsSafe)
|
||||
{
|
||||
[Required] public string UserName { get; set; } = "";
|
||||
public string ProductId { get; } = ProductId;
|
||||
public bool IsSafe { get; } = IsSafe;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using CsharpHttpHelper;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Work.Services;
|
||||
|
||||
namespace Work.Store.Api;
|
||||
|
||||
/// <summary>图片缓存接口,返回批量保存失败的 MD5 列表。</summary>
|
||||
public sealed class ImageCacheApi
|
||||
{
|
||||
private readonly ApiTransport _transport;
|
||||
|
||||
public ImageCacheApi(ApiTransport transport)
|
||||
=> _transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
|
||||
public List<ImageCache> GetImageCaches(List<string> md5s)
|
||||
=> _transport.GetRequired<List<ImageCache>>("ImageCache/GetImageCache", MethodType.Post, new { md5s });
|
||||
|
||||
public List<string> SaveImageCaches(int platform, IReadOnlyList<ImageCache> images)
|
||||
{
|
||||
if (images == null) throw new ArgumentNullException(nameof(images));
|
||||
if (images.Count == 0) return new List<string>();
|
||||
try
|
||||
{
|
||||
_transport.Send<object>("ImageCache/AddOrUpdate", MethodType.Post,
|
||||
images.Select(image => new { md5 = image.Md5, Platform = platform, PlatformUrl = image.PlatformUrl }).ToArray());
|
||||
return new List<string>();
|
||||
}
|
||||
catch (ApiException ex) when (ex.Code == 500)
|
||||
{
|
||||
// 兼容旧服务的部分失败响应;没有明确失败项时保持整体失败。
|
||||
if (ex.ResponseData is JArray array && array.Count > 0 && array.All(item => item.Type == JTokenType.String))
|
||||
return array.ToObject<List<string>>();
|
||||
if (ex.ResponseData?.Type == JTokenType.String && !string.IsNullOrWhiteSpace((string)ex.ResponseData))
|
||||
return new List<string> { (string)ex.ResponseData };
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Win32;
|
||||
using AllBase.Helper;
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Work.Store.Api;
|
||||
|
||||
/// <summary>全局共用一个用户会话与请求出口,窗体通过 Instance 访问。</summary>
|
||||
public sealed class StoreApiClient
|
||||
{
|
||||
private static readonly string BaseUrl = EnvironmentHelper.IsProduction
|
||||
? "http://103.90.155.78:9001/api/"
|
||||
: "http://localhost:14312/api/";
|
||||
|
||||
public static StoreApiClient Instance { get; } = new StoreApiClient();
|
||||
|
||||
public ApiTransport Transport { get; }
|
||||
public AuthApi Auth { get; }
|
||||
public CloudGoodsApi CloudGoods { get; }
|
||||
//public GoodsComplianceApi GoodsCompliance { get; }
|
||||
public ImageCacheApi ImageCache { get; }
|
||||
/// <summary>当前 Windows 安装的 SHA-256 设备指纹,登录时作为 DeviceId 发送。</summary>
|
||||
public string DeviceId { get; }
|
||||
|
||||
private StoreApiClient()
|
||||
{
|
||||
DeviceId = CreateDeviceId();
|
||||
Transport = new ApiTransport(BaseUrl);
|
||||
Auth = new AuthApi(Transport, DeviceId);
|
||||
CloudGoods = new CloudGoodsApi(Transport);
|
||||
//GoodsCompliance = new GoodsComplianceApi(Transport);
|
||||
ImageCache = new ImageCacheApi(Transport);
|
||||
}
|
||||
|
||||
private static string CreateDeviceId()
|
||||
{
|
||||
using (var machine = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
|
||||
using (var key = machine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"))
|
||||
{
|
||||
if (!Guid.TryParse(key?.GetValue("MachineGuid") as string, out var machineGuid) || machineGuid == Guid.Empty)
|
||||
throw new InvalidOperationException("无法读取有效的 Windows MachineGuid,不能生成设备指纹。");
|
||||
|
||||
using (var sha256 = SHA256.Create())
|
||||
return BitConverter.ToString(sha256.ComputeHash(
|
||||
Encoding.UTF8.GetBytes("Store.Api:" + machineGuid.ToString("D")))).Replace("-", "");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>清除内存中的 Token 和自动登录凭据,不调用服务端退出接口。</summary>
|
||||
public void ClearSession() => Transport.Token = null;
|
||||
}
|
||||
@@ -101,6 +101,8 @@ namespace Work
|
||||
BaseClass.OemId = BaseRegex.GetStringJson(ref html, "oem_id");
|
||||
BaseClass.OemName = BaseRegex.GetStringJson(ref html, "oem_name");
|
||||
BaseClass.IsOemOwner = BaseRegex.GetBoolJson(ref html, "is_oem_owner");
|
||||
|
||||
BaseClass.ImageAnalysisQuota = Math.Max(0, BaseRegex.GetIntJson(ref html, "image_analysis_quota"));
|
||||
#if DEBUG
|
||||
if (!string.IsNullOrEmpty(BaseRegex.GetStringJson(ref html, "prod_api_url")) && !BaseClass.LoginApiUrl.Contains("localhost") && !BaseClass.LoginApiUrl.Contains("127.0.0.1"))
|
||||
{
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
<Reference Include="NaiBa.Util.Winform">
|
||||
<HintPath>..\DllReference\NaiBa.Util.Winform.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="System.ComponentModel.DataAnnotations" />
|
||||
<Reference Include="System.Data.SQLite">
|
||||
<HintPath>..\DllReference\System.Data.SQLite.DLL</HintPath>
|
||||
<SpecificVersion>False</SpecificVersion>
|
||||
@@ -77,4 +78,8 @@
|
||||
<Reference Include="System.Net.Http" />
|
||||
<Reference Include="System.Xml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="..\TEMUKJD\BLL\PDDProdFormConverter.cs" Link="ImportConverters\PDDProdFormConverter.cs" />
|
||||
<Compile Include="..\TEMUKJD\BLL\ImportConverters\*.cs" Link="ImportConverters\%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in new issue
Block a user