4 Commits bcbbf06469 ... d2847c91e9

Autor SHA1 Mensaje Fecha
  dodo hold d2847c91e9 chore(发布): 更新发布历史 hace 1 día
  dodo hold 3fa8d440b0 fix(转链日志): 截断超长文本字段 hace 1 día
  dodo hold 6ef6d92893 feat(AI风控): 记录并暂停达到日调用上限的账号 hace 1 día
  dodo hold bcda2990e8 fix(API调用报告): 按账号 ID 查询调用明细 hace 1 día

+ 58 - 10
molilian.api/Controllers/admin/TaobaoController.cs

@@ -202,6 +202,11 @@ namespace molilian.api.Controllers
 
             var proxyNodes = (ProxyNodesCore.AllList() ?? Array.Empty<ProxyNodesDTO>())
                 .ToList();
+            var aiOpenAccounts = result.List
+                .Where(AiOpenRiskControlCore.IsApplicableAccount)
+                .ToList();
+            var aiOpenRiskEvents = await AiOpenRiskControlCore.GetTodayEventsAsync(
+                aiOpenAccounts.Select(account => account.id));
 
             foreach (var item in result.List)
             {
@@ -213,6 +218,25 @@ namespace molilian.api.Controllers
                     .FirstOrDefault(proxyNode =>
                         ProxyNodesCore.IsMatchNode(proxyNode, item.nodeName))
                     ?.status;
+
+                if (AiOpenRiskControlCore.IsApplicableAccount(item))
+                {
+                    item.aiopen_risk = new AiOpenRiskStatusDTO
+                    {
+                        applicable = true
+                    };
+
+                    if (aiOpenRiskEvents.TryGetValue(item.id, out var riskEvent))
+                    {
+                        item.aiopen_risk.triggered = true;
+                        item.aiopen_risk.reason_code = riskEvent.reason_code;
+                        item.aiopen_risk.reason = riskEvent.reason;
+                        item.aiopen_risk.first_trigger_time = riskEvent.first_trigger_time;
+                        item.aiopen_risk.request_count_at_trigger = riskEvent.request_count_at_trigger;
+                        item.aiopen_risk.success_count_at_trigger = riskEvent.success_count_at_trigger;
+                        item.aiopen_risk.release_time = riskEvent.release_time;
+                    }
+                }
             }
             return new APIResult(new { data = result });
         }
@@ -744,10 +768,11 @@ namespace molilian.api.Controllers
             var result = new List<dynamic>();
             var reason_result = new List<dynamic>();
 
-            var report_date = form.PathReadArray<string>("query_date[]");
-            var accountName = form.Read("accountName", string.Empty);
-            var isHourtrend = form.Read<bool>("isLeaf", false);
-            var total_key = form.Read("total_key", "total");
+            var report_date = form.PathReadArray<string>("query_date[]");
+            var accountName = form.Read("accountName", string.Empty);
+            var accountId = form.Read("accountId", 0);
+            var isHourtrend = form.Read<bool>("isLeaf", false);
+            var total_key = form.Read("total_key", "total");
 
             DateTime stime = DateTime.MinValue;
             if (!DateTime.TryParse(report_date[0], out stime)) return new APIResult(new { data = result, data2 = reason_result });
@@ -802,12 +827,35 @@ namespace molilian.api.Controllers
                 }
                 return new APIResult(new { total, data = result, data2 = reason_result });
             }
-            else
-            {
-                string cacheKey = $":{total_key}:{accountName}:{_report_date}";
-                double total = await TkLogCore.GetTotalAsync(cacheKey, false);
-                if (total > 0)
-                {
+            else
+            {
+                // The report row is identified by tk_pool.id. This is independent of the
+                // riskStrategy/launchScene label (for example tbpush-220), which can be shared
+                // by multiple accounts.
+                string requestedAccountName = accountName;
+                if (accountId > 0)
+                {
+                    accountName = $"{TkChannelEnum.tb}_{accountId}";
+                }
+
+                string cacheKey = $":{total_key}:{accountName}:{_report_date}";
+                double total = await TkLogCore.GetTotalAsync(cacheKey, false);
+                if (total == 0 &&
+                    accountId > 0 &&
+                    !string.IsNullOrEmpty(requestedAccountName) &&
+                    !string.Equals(requestedAccountName, "all", StringComparison.OrdinalIgnoreCase) &&
+                    !string.Equals(requestedAccountName, TkChannelEnum.tb.ToString(), StringComparison.OrdinalIgnoreCase) &&
+                    !string.Equals(requestedAccountName, accountName, StringComparison.Ordinal))
+                {
+                    cacheKey = $":{total_key}:{requestedAccountName}:{_report_date}";
+                    total = await TkLogCore.GetTotalAsync(cacheKey, false);
+                    if (total > 0)
+                    {
+                        accountName = requestedAccountName;
+                    }
+                }
+                if (total > 0)
+                {
                     string[] keys = ["success"];
 
                     cacheKey = $":{total_key}:{accountName}:message:{_report_date}";

+ 7 - 1
molilian.api/Controllers/public/TkEndpointController.cs

@@ -46,7 +46,13 @@ namespace molilian.api.Controllers
             {
                 if (durationSeconds > 0)
                 {
-                    await TkEndpointManager.SuspendForDurationAsync(accountId, endpoint, TimeSpan.FromSeconds(durationSeconds), TkEndpointManager.SuspendReason.Active, notifyOtherNodes: false);
+                    await TkEndpointManager.SuspendForDurationAsync(
+                        accountId,
+                        endpoint,
+                        TimeSpan.FromSeconds(durationSeconds),
+                        TkEndpointManager.SuspendReason.Active,
+                        notifyOtherNodes: false,
+                        emitNotification: false);
                 }
                 else
                 {

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
molilian.api/Properties/PublishProfiles/latest.pubxml.user


+ 36 - 6
molilian.core/Core/log/base.cs

@@ -1,8 +1,9 @@
 using dodohold.core;
-using CSRedis;
-using System.Data;
-using System.Diagnostics;
-using YunhuiKit;
+using CSRedis;
+using System.Data;
+using System.Diagnostics;
+using System.Text;
+using YunhuiKit;
 using static ICSharpCode.SharpZipLib.Zip.ExtendedUnixData;
 
 
@@ -21,8 +22,37 @@ namespace molilian.core
         private const int StatsHourlyExpireSeconds = 7 * 86400;
         private const int StatsDailyExpireSeconds = 90 * 86400;
         private const int StatsMonthlyExpireSeconds = 366 * 86400;
-
-        public static async Task<int> BatchInsertLogDBAsync(int limit)
+        private const int MySqlTextMaxBytes = 65_535;
+
+        /// <summary>
+        /// Truncates a value to the maximum byte length supported by a MySQL TEXT column.
+        /// The limit is measured in UTF-8 bytes rather than UTF-16 characters so Chinese
+        /// characters and surrogate pairs are handled correctly.
+        /// </summary>
+        private static string? TruncateMySqlText(string? value)
+        {
+            if (string.IsNullOrEmpty(value))
+            {
+                return value;
+            }
+
+            int byteCount = 0;
+            int charCount = 0;
+            foreach (Rune rune in value.EnumerateRunes())
+            {
+                if (byteCount + rune.Utf8SequenceLength > MySqlTextMaxBytes)
+                {
+                    break;
+                }
+
+                byteCount += rune.Utf8SequenceLength;
+                charCount += rune.Utf16SequenceLength;
+            }
+
+            return charCount == value.Length ? value : value[..charCount];
+        }
+
+        public static async Task<int> BatchInsertLogDBAsync(int limit)
         {
             await semaphore.WaitAsync().ConfigureAwait(false);
             try

+ 1 - 1
molilian.core/Core/log/cps.cs

@@ -104,7 +104,7 @@ namespace molilian.core
                 .Add("accountId", data.accountId)
                 .Add("accountName", data.accountName)
                 .Add("proxy_node", data.proxy_node)
-                .Add("rawContent", data.rawContent)
+                .Add("rawContent", TruncateMySqlText(data.rawContent))
                 .Add("rawContent2", data.rawContent2)
                 .Add("success", data.success)
                 .Add("message", data.message)

+ 1 - 1
molilian.core/Core/log/deeplink.cs

@@ -216,7 +216,7 @@ namespace molilian.core
                 .Add("success", data.success)
                 .Add("message", data.message)
                 .Add("reason", data.reason)
-                .Add("content", content)
+                .Add("content", TruncateMySqlText(content))
                 .Add("ip", data.ip)
                 .Add("oaid", data.oaid)
                 .Add("deeplink_url", data.deeplink_url)

+ 4 - 4
molilian.core/Core/log/dy.cs

@@ -29,11 +29,11 @@ namespace molilian.core
                         .Add("accountId", data.accountId)
                         .Add("accountName", data.accountName)
                         .Add("proxy_node", data.proxy_node)
-                        .Add("rawContent", data.rawContent)
+                        .Add("rawContent", TruncateMySqlText(data.rawContent))
                         .Add("success", data.success)
                         .Add("message", data.message)
                         .Add("reason", data.reason)
-                        .Add("content", data.content)
+                        .Add("content", TruncateMySqlText(data.content))
                         .Add("itemId", data.itemId)
                         .Add("itemName", data.itemName)
                         .Add("pic", data.pic)
@@ -90,11 +90,11 @@ namespace molilian.core
                         .Add("accountId", data.accountId)
                         .Add("accountName", data.accountName)
                         .Add("proxy_node", data.proxy_node)
-                        .Add("rawContent", data.rawContent)
+                        .Add("rawContent", TruncateMySqlText(data.rawContent))
                         .Add("success", data.success)
                         .Add("message", data.message)
                         .Add("reason", data.reason)
-                        .Add("content", data.content)
+                        .Add("content", TruncateMySqlText(data.content))
                         .Add("itemId", data.itemId)
                         .Add("itemName", data.itemName)
                         .Add("pic", data.pic)

+ 2 - 2
molilian.core/Core/log/jd.cs

@@ -259,11 +259,11 @@ namespace molilian.core
                 .Add("proxy_name", data.proxy_name)
                 .Add("proxy_node", data.proxy_node)
                 .Add("curl_proxy", data.curl_proxy)
-                .Add("rawContent", data.rawContent)
+                .Add("rawContent", TruncateMySqlText(data.rawContent))
                 .Add("success", data.success)
                 .Add("message", data.message)
                 .Add("reason", data.reason)
-                .Add("content", data.content)
+                .Add("content", TruncateMySqlText(data.content))
                 .Add("itemId", data.itemId)
                 .Add("itemName", data.itemName)
                 .Add("pic", data.pic)

+ 2 - 2
molilian.core/Core/log/ks.cs

@@ -163,11 +163,11 @@ namespace molilian.core
                 .Add("accountId", data.accountId)
                 .Add("accountName", data.accountName)
                 .Add("proxy_node", data.proxy_node)
-                .Add("rawContent", data.rawContent)
+                .Add("rawContent", TruncateMySqlText(data.rawContent))
                 .Add("success", data.success)
                 .Add("message", data.message)
                 .Add("reason", data.reason)
-                .Add("content", data.content)
+                .Add("content", TruncateMySqlText(data.content))
                 .Add("itemId", data.itemId)
                 .Add("itemName", data.itemName)
                 .Add("pic", data.pic)

+ 2 - 2
molilian.core/Core/log/pdd.cs

@@ -168,12 +168,12 @@ namespace molilian.core
                 .Add("accountId", data.accountId)
                 .Add("accountName", data.accountName)
                 .Add("proxy_node", data.proxy_node)
-                .Add("rawContent", data.rawContent)
+                .Add("rawContent", TruncateMySqlText(data.rawContent))
                 .Add("rawContent2", data.rawContent2)
                 .Add("success", data.success)
                 .Add("message", data.message)
                 .Add("reason", data.reason)
-                .Add("content", data.content)
+                .Add("content", TruncateMySqlText(data.content))
                 .Add("itemId", data.itemId)
                 .Add("itemName", data.itemName)
                 .Add("pic", data.pic)

+ 2 - 2
molilian.core/Core/log/taobao.cs

@@ -169,11 +169,11 @@ namespace molilian.core
                 .Add("accountName", data.accountName)
                 .Add("proxy_node", data.proxy_node)
                 .Add("tkEndpoint", data.tkEndpoint)
-                .Add("rawContent", data.rawContent)
+                .Add("rawContent", TruncateMySqlText(data.rawContent))
                 .Add("success", data.success)
                 .Add("message", data.message)
                 .Add("reason", data.reason)
-                .Add("content", data.content)
+                .Add("content", TruncateMySqlText(data.content))
                 .Add("mktId", data.mktId)
                 .Add("itemId", data.itemId)
                 .Add("itemName", data.itemName)

+ 4 - 4
molilian.core/Core/log/tool.cs

@@ -29,11 +29,11 @@ namespace molilian.core
                         new DBContext.Table(connection, "tool_parse_logs_test")
                             .Add("end_point", data.end_point)
                             .Add("channel", (int)data.channel)
-                            .Add("rawContent", data.rawContent)
+                            .Add("rawContent", TruncateMySqlText(data.rawContent))
                             .Add("success", data.success)
                             .Add("message", data.message)
                             .Add("reason", data.reason)
-                            .Add("content", data.content)
+                            .Add("content", TruncateMySqlText(data.content))
                             .Add("taoToken", data.taoToken)
                             .Add("shortLinkurl", data.shortLinkurl)
                             .Add("deeplink_url", data.deeplink_url)
@@ -49,11 +49,11 @@ namespace molilian.core
                         new DBContext.Table(connection, daily_table)
                             .Add("end_point", data.end_point)
                             .Add("channel", (int)data.channel)
-                            .Add("rawContent", data.rawContent)
+                            .Add("rawContent", TruncateMySqlText(data.rawContent))
                             .Add("success", data.success)
                             .Add("message", data.message)
                             .Add("reason", data.reason)
-                            .Add("content", data.content)
+                            .Add("content", TruncateMySqlText(data.content))
                             .Add("taoToken", data.taoToken)
                             .Add("shortLinkurl", data.shortLinkurl)
                             .Add("deeplink_url", data.deeplink_url)

+ 3 - 3
molilian.core/Core/log/第三方旧接口.cs

@@ -52,7 +52,7 @@ namespace molilian.core
                         .Add("channel", (int)data.channel)
                         .Add("accountId", data.accountId)
                         .Add("accountName", data.accountName)
-                        .Add("rawContent", data.rawContent)
+                        .Add("rawContent", TruncateMySqlText(data.rawContent))
                         .Add("rawContent2", data.rawContent2)
                         .Add("success", data.success)
                         .Add("message", data.message)
@@ -160,12 +160,12 @@ namespace molilian.core
                   .Add("channel", (int)data.channel)
                   .Add("accountId", data.accountId)
                   .Add("accountName", data.accountName)
-                  .Add("rawContent", data.rawContent)
+                  .Add("rawContent", TruncateMySqlText(data.rawContent))
                   .Add("rawContent2", data.rawContent2)
                   .Add("success", data.success)
                   .Add("message", data.message)
                   .Add("reason", data.reason)
-                  .Add("content", data.content)
+                  .Add("content", TruncateMySqlText(data.content))
                   .Add("couponAmount", data.couponAmount)
                   .Add("itemId", data.itemId)
                   .Add("itemName", data.itemName)

+ 165 - 0
molilian.core/Core/taoke/AiOpenRiskControlCore.cs

@@ -0,0 +1,165 @@
+using Dapper;
+using dodohold.core;
+
+namespace molilian.core
+{
+    public static class AiOpenRiskControlCore
+    {
+        public const int EndpointId = 8;
+        public const string EndpointName = "aiopen";
+        public const string DailyLimitReasonCode = "AI_OPEN_DAILY_LIMIT";
+
+        public static bool IsApplicableAccount(TkPoolDTO? account)
+        {
+            if (account == null || string.IsNullOrWhiteSpace(account.parseEndpoint))
+            {
+                return false;
+            }
+
+            int[] endpointIds = account.parseEndpoint
+                .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+                .Select(value => int.TryParse(value, out int endpointId) ? endpointId : 0)
+                .Where(endpointId => endpointId > 0)
+                .Distinct()
+                .ToArray();
+
+            return endpointIds.Length == 1 && endpointIds[0] == EndpointId;
+        }
+
+        public static async Task RecordDailyLimitAndSuspendAsync(
+            TkPoolDTO account,
+            string reason,
+            int bizErrorCode,
+            int resultCode)
+        {
+            if (!IsApplicableAccount(account))
+            {
+                return;
+            }
+
+            DateTime triggerTime = DateTime.Now;
+            DateTime releaseTime = triggerTime.Date.AddDays(1);
+            TimeSpan suspendDuration = releaseTime - triggerTime;
+
+            // Stop assigning this account locally before performing report aggregation.
+            // The first DB writer below broadcasts the same TTL to all other nodes.
+            await TkEndpointManager.SuspendForDurationAsync(
+                account.id,
+                EndpointName,
+                suspendDuration,
+                TkEndpointManager.SuspendReason.RemoteDailyLimit,
+                notifyOtherNodes: false,
+                emitNotification: false);
+
+            var endpointConfigs = await TkEndpointCore.GetEndpointsByAccountReadonlyAsync(
+                account.id,
+                parseEndpoints: account.parseEndpoint);
+            int endpointCounterId = endpointConfigs
+                .FirstOrDefault(endpoint => endpoint.ep_id == EndpointId)
+                ?.id ?? EndpointId;
+
+            int requestCount = await RiskControlCore.GetAllNodesTkEndpointCallsAsync(
+                account.id,
+                endpointCounterId,
+                triggerTime.ToString("yyyyMMdd"));
+            int successCount = await TkLogCore.GetTotalAsync(
+                $":parse_total:{TkChannelEnum.tb}_{account.id}:success:{triggerTime:yyyyMMdd}");
+
+            bool shouldBroadcast = false;
+            try
+            {
+                using var conn = CenterHub.GetOpenConnection();
+                const string sql = @"
+INSERT IGNORE INTO tk_aiopen_risk_daily
+    (report_date, account_id, account_name, endpoint_id, endpoint_name,
+     reason_code, reason, biz_error_code, result_code, first_trigger_time,
+     request_count_at_trigger, success_count_at_trigger, release_time,
+     create_time)
+VALUES
+    (@report_date, @account_id, @account_name, @endpoint_id, @endpoint_name,
+     @reason_code, LEFT(@reason, 512), @biz_error_code, @result_code, @first_trigger_time,
+     @request_count_at_trigger, @success_count_at_trigger, @release_time,
+     @create_time);";
+
+                int inserted = await conn.ExecuteAsync(sql, new
+                {
+                    report_date = triggerTime.Date,
+                    account_id = account.id,
+                    account_name = account.company,
+                    endpoint_id = EndpointId,
+                    endpoint_name = EndpointName,
+                    reason_code = DailyLimitReasonCode,
+                    reason,
+                    biz_error_code = bizErrorCode,
+                    result_code = resultCode,
+                    first_trigger_time = triggerTime,
+                    request_count_at_trigger = requestCount,
+                    success_count_at_trigger = successCount,
+                    release_time = releaseTime,
+                    create_time = triggerTime
+                });
+                shouldBroadcast = inserted > 0;
+            }
+            catch (Exception ex)
+            {
+                // Suspending all nodes is more important than report persistence. Broadcast
+                // even when the migration has not yet been applied or DB is down.
+                shouldBroadcast = true;
+                _ = new LoggerLibrary("AiOpenRiskControl", "save_error")
+                    .Info($"accountId={account.id}, requestCount={requestCount}, reason={reason}")
+                    .Info(ex.Message, ex.StackTrace)
+                    .SaveAsync();
+            }
+
+            if (shouldBroadcast)
+            {
+                await TkEndpointManager.SuspendForDurationAsync(
+                    account.id,
+                    EndpointName,
+                    suspendDuration,
+                    TkEndpointManager.SuspendReason.RemoteDailyLimit,
+                    notifyOtherNodes: true,
+                    emitNotification: true);
+            }
+        }
+
+        public static async Task<Dictionary<int, AiOpenRiskDailyDTO>> GetTodayEventsAsync(IEnumerable<int> accountIds)
+        {
+            int[] ids = accountIds.Where(id => id > 0).Distinct().ToArray();
+            if (ids.Length == 0)
+            {
+                return [];
+            }
+
+            try
+            {
+                using var conn = CenterHub.GetOpenConnection();
+                const string sql = @"
+SELECT id, report_date, account_id, account_name, endpoint_id, endpoint_name,
+       reason_code, reason, biz_error_code, result_code, first_trigger_time,
+       request_count_at_trigger, success_count_at_trigger, release_time,
+       create_time
+FROM tk_aiopen_risk_daily
+WHERE report_date = @report_date
+  AND account_id IN @account_ids
+  AND reason_code = @reason_code;";
+
+                var rows = await conn.QueryAsync<AiOpenRiskDailyDTO>(sql, new
+                {
+                    report_date = DateTime.Now.Date,
+                    account_ids = ids,
+                    reason_code = DailyLimitReasonCode
+                });
+
+                return rows.ToDictionary(row => row.account_id);
+            }
+            catch (Exception ex)
+            {
+                _ = new LoggerLibrary("AiOpenRiskControl", "query_error")
+                    .Info(ex.Message, ex.StackTrace)
+                    .SaveAsync();
+                return [];
+            }
+        }
+    }
+}

+ 50 - 7
molilian.core/Core/taoke/TkEndpointManager.cs

@@ -62,6 +62,26 @@ public partial class TkEndpointManager
         if (TryGetCachedSuspendState(key, out var isSuspended))
             return isSuspended;
 
+        // A daily-limit account must remain unavailable after a process restart. Perform one
+        // synchronous Redis check for AI Open on cache miss; subsequent checks use the cache.
+        if (string.Equals(endpoint, AiOpenRiskControlCore.EndpointName, StringComparison.Ordinal))
+        {
+            try
+            {
+                isSuspended = RedisHelper.Exists(key);
+                CacheSuspendState(
+                    key,
+                    isSuspended,
+                    isSuspended ? SuspendStateCacheDuration : SuspendStateFailureBackoffDuration);
+                return isSuspended;
+            }
+            catch (Exception)
+            {
+                CacheSuspendState(key, false, SuspendStateFailureBackoffDuration);
+                return false;
+            }
+        }
+
         // 热路径不再同步访问 Redis。缓存未命中时先降级放行,再异步探测 Redis 状态。
         _ = RefreshSuspendStateAsync(key);
         CacheSuspendState(key, false, SuspendStateFailureBackoffDuration);
@@ -170,7 +190,16 @@ public partial class TkEndpointManager
             var selectedEndpoint = availableEndpoints[newIndex];
 
             // 异步更新调用统计(不阻塞当前请求)
-            _ = UpdateEndpointStatsAsync(accountId, selectedEndpoint, now);
+            if (selectedEndpoint.ep_id == AiOpenRiskControlCore.EndpointId)
+            {
+                // The returned daily counter is used as the observed AI Open trigger point,
+                // so endpoint 8 must finish its atomic Redis increment before the HTTP call.
+                await UpdateEndpointStatsAsync(accountId, selectedEndpoint, now);
+            }
+            else
+            {
+                _ = UpdateEndpointStatsAsync(accountId, selectedEndpoint, now);
+            }
             return (selectedEndpoint.ep_id, selectedEndpoint.endpoint);
         }
 
@@ -245,7 +274,7 @@ public partial class TkEndpointManager
             suspendDuration = reason switch
             {
                 SuspendReason.HourlyLimit => CalculateHourlySuspendDuration(),
-                SuspendReason.DailyLimit => CalculateDailySuspendDuration(),
+                SuspendReason.DailyLimit or SuspendReason.RemoteDailyLimit => CalculateDailySuspendDuration(),
                 _ => TimeSpan.FromMinutes(holdMinutes.GetValueOrDefault(endpoint, 240))
             };
         }
@@ -253,7 +282,13 @@ public partial class TkEndpointManager
         await SuspendForDurationAsync(accountId, endpoint, suspendDuration, reason, notifyOtherNodes);
     }
 
-    public static async Task SuspendForDurationAsync(int accountId, string endpoint, TimeSpan suspendDuration, SuspendReason reason = SuspendReason.Passive, bool notifyOtherNodes = true)
+    public static async Task SuspendForDurationAsync(
+        int accountId,
+        string endpoint,
+        TimeSpan suspendDuration,
+        SuspendReason reason = SuspendReason.Passive,
+        bool notifyOtherNodes = true,
+        bool emitNotification = true)
     {
         if (string.IsNullOrWhiteSpace(endpoint)) return;
 
@@ -274,8 +309,11 @@ public partial class TkEndpointManager
             _ = EndPointCore.NotifyChangeSuspend(accountId, endpoint, release: false, durationSeconds: (int)Math.Ceiling(suspendDuration.TotalSeconds));
         }
 
-        _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}_{endpoint}", reason.ToString()).SaveAsync();
-        _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId}_{endpoint} ({reason})");
+        if (emitNotification)
+        {
+            _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}_{endpoint}", reason.ToString()).SaveAsync();
+            _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId}_{endpoint} ({reason})");
+        }
     }
 
 
@@ -367,8 +405,13 @@ public partial class TkEndpointManager
         /// <summary>
         /// 每日总量限制触发的暂停
         /// </summary>
-        DailyLimit
-    }
+        DailyLimit,
+
+        /// <summary>
+        /// 上游明确返回当日调用达到上限
+        /// </summary>
+        RemoteDailyLimit
+    }
 
     private class AccountEndpointState
     {

+ 33 - 0
molilian.core/DTO/alimama/AiOpenRiskDailyDTO.cs

@@ -0,0 +1,33 @@
+namespace molilian.core
+{
+    public class AiOpenRiskDailyDTO
+    {
+        public long id { get; set; }
+        public DateTime report_date { get; set; }
+        public int account_id { get; set; }
+        public string account_name { get; set; } = string.Empty;
+        public int endpoint_id { get; set; }
+        public string endpoint_name { get; set; } = string.Empty;
+        public string reason_code { get; set; } = string.Empty;
+        public string reason { get; set; } = string.Empty;
+        public int biz_error_code { get; set; }
+        public int result_code { get; set; }
+        public DateTime first_trigger_time { get; set; }
+        public int request_count_at_trigger { get; set; }
+        public int success_count_at_trigger { get; set; }
+        public DateTime release_time { get; set; }
+        public DateTime create_time { get; set; }
+    }
+
+    public class AiOpenRiskStatusDTO
+    {
+        public bool applicable { get; set; }
+        public bool triggered { get; set; }
+        public string reason_code { get; set; } = string.Empty;
+        public string reason { get; set; } = string.Empty;
+        public DateTime? first_trigger_time { get; set; }
+        public int request_count_at_trigger { get; set; }
+        public int success_count_at_trigger { get; set; }
+        public DateTime? release_time { get; set; }
+    }
+}

+ 5 - 4
molilian.core/DTO/alimama/TkDataDTO.cs

@@ -64,10 +64,11 @@ namespace molilian.core
         /// <summary>
         /// 初筛3
         /// </summary>
-        Prelim3 = 112,
-        ParseDeny = 113,
-
-    }
+        Prelim3 = 112,
+        ParseDeny = 113,
+        RemoteDailyLimit = 114,
+
+    }
 
 
     public class TkDataDTO

+ 4 - 3
molilian.core/DTO/alimama/TkPoolDTO.cs

@@ -75,7 +75,8 @@
         public bool useCouponLinkFirst { get; set; } = true;
         public string parse_type { get; set; } = string.Empty;
         public string strategy_id { get; set; } = string.Empty;
-        public string riskCookie { get; set; } = string.Empty;
-
-    }
+        public string riskCookie { get; set; } = string.Empty;
+        public AiOpenRiskStatusDTO? aiopen_risk { get; set; }
+
+    }
 }

+ 54 - 6
molilian.core/Plus/Alimama/parse_endpoint/endpoint_aiopen.cs

@@ -150,25 +150,53 @@ namespace molilian.core
                     ? data.ElementRead("taokeMaterialUniversalLinkConvert")
                     : inner.ElementRead("taokeMaterialUniversalLinkConvert");
 
+                // Error responses such as bizErrorCode=403 are returned directly in text,
+                // rather than under data.taokeMaterialUniversalLinkConvert.
+                if (business.ValueKind != JsonValueKind.Object && IsAiOpenBusinessPayload(inner))
+                {
+                    business = inner;
+                }
+
                 if (business.ValueKind != JsonValueKind.Object)
                 {
-                    string reason = inner.Read("message", "接口响应缺少转链结果");
+                    string reason = ReadAiOpenErrorDescription(inner, "接口响应缺少转链结果");
                     LogAiOpenFailure(content, reason, responseBody);
                     return SetAiOpenFailure(result, "fail", reason, TkSubCodeEnum.Other);
                 }
 
                 bool success = business.Read<bool>("success", false);
                 string message = business.Read("message", string.Empty);
+                string businessReason = ReadAiOpenErrorDescription(business, message);
                 if (!success)
                 {
                     message = string.IsNullOrEmpty(message) ? "转链失败" : message;
-                    TkSubCodeEnum subCode = message.Contains("不支持", StringComparison.Ordinal)
-                        || message.Contains("商品ID", StringComparison.Ordinal)
-                        || message.Contains("有效推广链接", StringComparison.Ordinal)
+                    businessReason = string.IsNullOrEmpty(businessReason) ? message : businessReason;
+
+                    int bizErrorCode = business.Read("bizErrorCode", 0);
+                    int resultCode = business.Read("resultCode", 0);
+                    bool remoteDailyLimit = bizErrorCode == 403
+                        && resultCode == 400
+                        && businessReason.Contains("调用已达上限", StringComparison.Ordinal);
+
+                    TkSubCodeEnum subCode = remoteDailyLimit
+                        ? TkSubCodeEnum.RemoteDailyLimit
+                        : businessReason.Contains("不支持", StringComparison.Ordinal)
+                        || businessReason.Contains("商品ID", StringComparison.Ordinal)
+                        || businessReason.Contains("有效推广链接", StringComparison.Ordinal)
                         ? TkSubCodeEnum.NoConvert
                         : TkSubCodeEnum.Other;
-                    LogAiOpenFailure(content, message, responseBody);
-                    return SetAiOpenFailure(result, message, message, subCode);
+
+                    LogAiOpenFailure(content, businessReason, responseBody);
+                    if (remoteDailyLimit && AiOpenRiskControlCore.IsApplicableAccount(_account))
+                    {
+                        await AiOpenRiskControlCore.RecordDailyLimitAndSuspendAsync(
+                            _account,
+                            businessReason,
+                            bizErrorCode,
+                            resultCode);
+                    }
+
+                    return SetAiOpenFailure(result, message, businessReason, subCode);
                 }
 
                 string cpsShortUrl = business.Read("cpsShortUrl", string.Empty);
@@ -337,6 +365,26 @@ namespace molilian.core
             return result;
         }
 
+        private static bool IsAiOpenBusinessPayload(JsonElement value)
+        {
+            return value.ValueKind == JsonValueKind.Object
+                && (value.TryGetProperty("success", out _)
+                    || value.TryGetProperty("bizErrorCode", out _)
+                    || value.TryGetProperty("resultCode", out _));
+        }
+
+        private static string ReadAiOpenErrorDescription(JsonElement value, string fallback)
+        {
+            string description = value.Read("bizErrorDesc", string.Empty);
+            if (!string.IsNullOrWhiteSpace(description))
+            {
+                return description.Trim();
+            }
+
+            string message = value.Read("message", string.Empty);
+            return string.IsNullOrWhiteSpace(message) ? fallback : message.Trim();
+        }
+
         private static void LogAiOpenFailure(string content, string reason, string body)
         {
             if (string.IsNullOrEmpty(body))

+ 22 - 0
sql/20260905_create_tk_aiopen_risk_daily.sql

@@ -0,0 +1,22 @@
+-- 请在 CenterDB(后台报表库)执行。
+CREATE TABLE IF NOT EXISTS `tk_aiopen_risk_daily` (
+    `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+    `report_date` DATE NOT NULL COMMENT '统计日期(Asia/Shanghai)',
+    `account_id` INT NOT NULL COMMENT 'tk_pool.id',
+    `account_name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '触发时账号名称',
+    `endpoint_id` INT NOT NULL DEFAULT 8 COMMENT 'tk_endpoint_config.ep_id',
+    `endpoint_name` VARCHAR(64) NOT NULL DEFAULT 'aiopen' COMMENT '端点名称',
+    `reason_code` VARCHAR(64) NOT NULL COMMENT '稳定风控代码',
+    `reason` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '上游bizErrorDesc',
+    `biz_error_code` INT NOT NULL DEFAULT 0 COMMENT '上游bizErrorCode',
+    `result_code` INT NOT NULL DEFAULT 0 COMMENT '上游resultCode',
+    `first_trigger_time` DATETIME(3) NOT NULL COMMENT '当日首次触发时间',
+    `request_count_at_trigger` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '包含触发请求的当日端点调用数',
+    `success_count_at_trigger` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '触发时当日成功数',
+    `release_time` DATETIME(3) NOT NULL COMMENT '预计自动释放时间',
+    `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_date_account_reason` (`report_date`, `account_id`, `reason_code`),
+    KEY `idx_report_date_trigger_time` (`report_date`, `first_trigger_time`),
+    KEY `idx_account_trigger_time` (`account_id`, `first_trigger_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='AI Open账号每日首次风控事件';

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio