1- using System ;
1+ using System ;
22using System . Collections . Generic ;
33using System . IO ;
44using System . Net . Http ;
@@ -20,7 +20,7 @@ namespace BetterGenshinImpact.Service.Notifier;
2020
2121/// <summary>
2222/// QQ 官方 REST 通知器。
23- /// 通过 QQ 开放平台 API 将 BetterGI 事件推送到用户的 QQ 私聊(C2C)。
23+ /// 通过 QQ 开放平台 API 将 BetterGI 事件推送到用户的 QQ 私聊(C2C)与群聊(群 OpenID) 。
2424/// 支持文本消息和截图图片消息(分片上传)。
2525/// 本通知器已通过三轮 AI 代码审查。
2626/// </summary>
@@ -31,28 +31,33 @@ public sealed class QqNotifier : INotifier
3131 public string Name { get ; } = "QQ" ;
3232
3333 private const string TokenUrl = "https://bots.qq.com/app/getAppAccessToken" ;
34- private const string ApiBase = "https://api.sgroup.qq.com/v2/users/{openid}" ;
34+ private const string C2CBase = "https://api.sgroup.qq.com/v2/users/{openid}" ;
35+ private const string GroupBase = "https://api.sgroup.qq.com/v2/groups/{openid}" ;
3536 private const int MaxRetry = 3 ;
3637
3738 private readonly HttpClient _httpClient ;
3839 private readonly string _appId ;
3940 private readonly string _clientSecret ;
4041 private readonly string _openId ;
42+ private readonly string _groupOpenId ;
4143
4244 private string ? _cachedToken ;
4345 private DateTime _tokenExpiry = DateTime . MinValue ;
4446 private readonly SemaphoreSlim _tokenSemaphore = new ( 1 , 1 ) ;
4547
46- public QqNotifier ( HttpClient httpClient , string appId , string clientSecret , string openId )
48+ public QqNotifier ( HttpClient httpClient , string appId , string clientSecret , string openId , string groupOpenId )
4749 {
4850 _httpClient = httpClient ;
4951 _appId = appId ;
5052 _clientSecret = clientSecret ;
5153 _openId = openId ;
54+ _groupOpenId = groupOpenId ;
5255 }
5356
5457 /// <summary>
55- /// 发送通知:先发文本,再发截图(如有)。截图失败时降级为纯文本。
58+ /// 发送通知:对每个已配置目标(C2C / 群)先发文本,再发截图(如有)。
59+ /// 单个目标的截图失败时降级为纯文本,不阻断其他目标;
60+ /// 单个目标的文本失败时记 Warning 后继续下一目标,使 C2C 与群互不影响。
5661 /// </summary>
5762 public async Task SendAsync ( BaseNotificationData content )
5863 {
@@ -62,27 +67,51 @@ public async Task SendAsync(BaseNotificationData content)
6267 if ( string . IsNullOrWhiteSpace ( _clientSecret ) )
6368 throw new NotifierException ( "QQ AppSecret 为空" ) ;
6469
65- if ( string . IsNullOrWhiteSpace ( _openId ) )
66- throw new NotifierException ( "QQ OpenID 为空 " ) ;
70+ if ( string . IsNullOrWhiteSpace ( _openId ) && string . IsNullOrWhiteSpace ( _groupOpenId ) )
71+ throw new NotifierException ( "QQ OpenID 与群 OpenID 均为空 " ) ;
6772
6873 var ct = CancellationToken . None ;
74+ var targets = BuildTargets ( ) ;
6975 try
7076 {
7177 var text = GenerateMessage ( content ) ;
72- await SendTextAsync ( text , ct ) ;
73-
74- if ( content . Screenshot != null )
78+ var successCount = 0 ;
79+ var lastError = string . Empty ;
80+ foreach ( var target in targets )
7581 {
7682 try
7783 {
78- await SendImageAsync ( content . Screenshot , ct ) ;
84+ await SendTextAsync ( target , text , ct ) ;
85+ successCount ++ ;
86+
87+ if ( content . Screenshot != null )
88+ {
89+ try
90+ {
91+ await SendImageAsync ( target , content . Screenshot , ct ) ;
92+ }
93+ catch ( System . Exception ex )
94+ {
95+ // 单个目标的图片发送失败时降级为纯文本,不阻断其他目标
96+ Logger . LogWarning ( "QQ 图片发送失败(目标 {target}),降级为纯文本: {ex}" , target , ex . Message ) ;
97+ }
98+ }
99+ }
100+ catch ( NotifierException )
101+ {
102+ throw ;
79103 }
80104 catch ( System . Exception ex )
81105 {
82- // 图片发送失败时降级为纯文本,不阻断通知
83- Logger . LogWarning ( "QQ 图片发送失败,降级为纯文本: {ex}" , ex . Message ) ;
106+ // 单个目标的文本发送失败时记 Warning 后 continue 下一目标,使 C2C 与群互不影响
107+ lastError = ex . Message ;
108+ Logger . LogWarning ( "QQ 文本发送失败(目标 {target}),跳过该目标: {ex}" , target , ex . Message ) ;
84109 }
85110 }
111+
112+ // 全部目标发送失败时向调用方抛出异常,避免通知管理器误报成功
113+ if ( successCount == 0 )
114+ throw new NotifierException ( $ "发送 QQ 消息失败: { targets . Count } 个目标全部失败,最后错误: { lastError } ") ;
86115 }
87116 catch ( NotifierException )
88117 {
@@ -94,6 +123,19 @@ public async Task SendAsync(BaseNotificationData content)
94123 }
95124 }
96125
126+ /// <summary>
127+ /// 构造发送目标 baseUrl 列表:C2C OpenID 非空加入私聊,群 OpenID 非空加入群聊。
128+ /// </summary>
129+ private List < string > BuildTargets ( )
130+ {
131+ var targets = new List < string > ( 2 ) ;
132+ if ( ! string . IsNullOrWhiteSpace ( _openId ) )
133+ targets . Add ( C2CBase . Replace ( "{openid}" , _openId ) ) ;
134+ if ( ! string . IsNullOrWhiteSpace ( _groupOpenId ) )
135+ targets . Add ( GroupBase . Replace ( "{openid}" , _groupOpenId ) ) ;
136+ return targets ;
137+ }
138+
97139 /// <summary>
98140 /// 生成通知文本。时间戳放在第一行,推送内容在第二行,排版更紧凑。
99141 /// </summary>
@@ -137,7 +179,7 @@ private async Task<string> RefreshTokenAsync(CancellationToken ct)
137179 using var content = new StringContent ( body , Encoding . UTF8 , "application/json" ) ;
138180 using var request = new HttpRequestMessage ( HttpMethod . Post , TokenUrl ) { Content = content } ;
139181 using var response = await _httpClient . SendAsync ( request , ct ) ;
140- response . EnsureSuccessStatusCode ( ) ;
182+ await EnsureSuccessWithBodyAsync ( response , ct ) ;
141183 var json = await response . Content . ReadAsStringAsync ( ct ) ;
142184 using var doc = JsonDocument . Parse ( json ) ;
143185 var root = doc . RootElement ;
@@ -160,21 +202,40 @@ private async Task<HttpRequestMessage> BuildAuthedRequest(HttpMethod method, str
160202 }
161203
162204 /// <summary>
163- /// 发送纯文本消息(msg_type=0)。注意:此请求非幂等,不重试,避免重复消息。
205+ /// 校验响应成功;失败时先读取响应体再抛异常(保留状态码供重试判断),
206+ /// 便于携带 QQ 服务端返回的具体错误信息。
207+ /// </summary>
208+ private static async Task EnsureSuccessWithBodyAsync ( HttpResponseMessage response , CancellationToken ct )
209+ {
210+ if ( response . IsSuccessStatusCode )
211+ return ;
212+
213+ var statusCode = response . StatusCode ;
214+ var body = await response . Content . ReadAsStringAsync ( ct ) ;
215+ throw new HttpRequestException (
216+ $ "QQ API 请求失败 ({ ( int ) statusCode } ):{ ( string . IsNullOrWhiteSpace ( body ) ? response . ReasonPhrase : body ) } ",
217+ null ,
218+ statusCode ) ;
219+ }
220+
221+ /// <summary>
222+ /// 发送纯文本消息(msg_type=0)到指定目标 baseUrl(C2C 或群)。
223+ /// 注意:此请求非幂等,不重试,避免重复消息。
164224 /// </summary>
165- private async Task SendTextAsync ( string text , CancellationToken ct )
225+ private async Task SendTextAsync ( string baseUrl , string text , CancellationToken ct )
166226 {
167227 var body = JsonSerializer . Serialize ( new { msg_type = 0 , content = text } ) ;
168228 using var jsonContent = new StringContent ( body , Encoding . UTF8 , "application/json" ) ;
169- using var request = await BuildAuthedRequest ( HttpMethod . Post , $ "{ ApiBase . Replace ( "{openid}" , _openId ) } /messages", jsonContent , ct ) ;
229+ using var request = await BuildAuthedRequest ( HttpMethod . Post , $ "{ baseUrl } /messages", jsonContent , ct ) ;
170230 using var response = await _httpClient . SendAsync ( request , ct ) ;
171- response . EnsureSuccessStatusCode ( ) ;
231+ await EnsureSuccessWithBodyAsync ( response , ct ) ;
172232 }
173233
174234 /// <summary>
175- /// 发送截图图片消息(msg_type=7):先分片上传图片拿到 file_info,再发富媒体消息。
235+ /// 发送截图图片消息(msg_type=7)到指定目标 baseUrl(C2C 或群):
236+ /// 先分片上传图片拿到 file_info,再发富媒体消息。
176237 /// </summary>
177- private async Task SendImageAsync ( Image < Rgb24 > screenshot , CancellationToken ct )
238+ private async Task SendImageAsync ( string baseUrl , Image < Rgb24 > screenshot , CancellationToken ct )
178239 {
179240 byte [ ] imageBytes ;
180241 using ( var ms = new MemoryStream ( ) )
@@ -183,26 +244,25 @@ private async Task SendImageAsync(Image<Rgb24> screenshot, CancellationToken ct)
183244 imageBytes = ms . ToArray ( ) ;
184245 }
185246
186- var fileInfo = await UploadImageChunkedAsync ( imageBytes , ct ) ;
247+ var fileInfo = await UploadImageChunkedAsync ( baseUrl , imageBytes , ct ) ;
187248
188249 var body = JsonSerializer . Serialize ( new
189250 {
190251 msg_type = 7 ,
191252 media = new { file_info = fileInfo }
192253 } ) ;
193254 using var jsonContent = new StringContent ( body , Encoding . UTF8 , "application/json" ) ;
194- using var request = await BuildAuthedRequest ( HttpMethod . Post , $ "{ ApiBase . Replace ( "{openid}" , _openId ) } /messages", jsonContent , ct ) ;
255+ using var request = await BuildAuthedRequest ( HttpMethod . Post , $ "{ baseUrl } /messages", jsonContent , ct ) ;
195256 using var response = await _httpClient . SendAsync ( request , ct ) ;
196- response . EnsureSuccessStatusCode ( ) ;
257+ await EnsureSuccessWithBodyAsync ( response , ct ) ;
197258 }
198259
199260 /// <summary>
200261 /// 分片上传图片:prepare → 逐片 PUT → part_finish → 合并拿 file_info。
201262 /// 上传阶段幂等,可重试。
202263 /// </summary>
203- private async Task < string > UploadImageChunkedAsync ( byte [ ] imageBytes , CancellationToken ct )
264+ private async Task < string > UploadImageChunkedAsync ( string baseUrl , byte [ ] imageBytes , CancellationToken ct )
204265 {
205- var baseUrl = ApiBase . Replace ( "{openid}" , _openId ) ;
206266 var fileName = "screenshot.jpg" ;
207267 var md5 = Convert . ToHexString ( MD5 . HashData ( imageBytes ) ) . ToLower ( ) ;
208268 var sha1 = Convert . ToHexString ( SHA1 . HashData ( imageBytes ) ) . ToLower ( ) ;
@@ -240,7 +300,7 @@ private async Task<UploadPrepareResult> PrepareUploadAsync(string baseUrl, strin
240300 md5_10m = md5First10m
241301 } ) , Encoding . UTF8 , "application/json" ) , ct ) ;
242302 using var response = await _httpClient . SendAsync ( request , ct ) ;
243- response . EnsureSuccessStatusCode ( ) ;
303+ await EnsureSuccessWithBodyAsync ( response , ct ) ;
244304 var json = await response . Content . ReadAsStringAsync ( ct ) ;
245305 using var doc = JsonDocument . Parse ( json ) ;
246306 var uploadId = doc . RootElement . GetProperty ( "upload_id" ) . GetString ( ) ! ;
@@ -294,7 +354,7 @@ private async Task UploadChunkAsync(string presignedUrl, byte[] chunk, Cancellat
294354 using var putContent = new ByteArrayContent ( chunk ) ;
295355 putContent . Headers . ContentType = new MediaTypeHeaderValue ( "application/octet-stream" ) ;
296356 using var putResponse = await _httpClient . PutAsync ( presignedUrl , putContent , ct ) ;
297- putResponse . EnsureSuccessStatusCode ( ) ;
357+ await EnsureSuccessWithBodyAsync ( putResponse , ct ) ;
298358 }
299359
300360 /// <summary>
@@ -311,7 +371,7 @@ private async Task FinishChunkAsync(string baseUrl, string uploadId, int partInd
311371 md5 = chunkMd5
312372 } ) , Encoding . UTF8 , "application/json" ) , ct ) ;
313373 using var response = await _httpClient . SendAsync ( request , ct ) ;
314- response . EnsureSuccessStatusCode ( ) ;
374+ await EnsureSuccessWithBodyAsync ( response , ct ) ;
315375 }
316376
317377 /// <summary>
@@ -322,7 +382,7 @@ private async Task<string> MergeUploadAsync(string baseUrl, string uploadId, Can
322382 using var request = await BuildAuthedRequest ( HttpMethod . Post , $ "{ baseUrl } /files", new StringContent (
323383 JsonSerializer . Serialize ( new { file_type = 1 , upload_id = uploadId } ) , Encoding . UTF8 , "application/json" ) , ct ) ;
324384 using var response = await _httpClient . SendAsync ( request , ct ) ;
325- response . EnsureSuccessStatusCode ( ) ;
385+ await EnsureSuccessWithBodyAsync ( response , ct ) ;
326386 var json = await response . Content . ReadAsStringAsync ( ct ) ;
327387 using var doc = JsonDocument . Parse ( json ) ;
328388 return doc . RootElement . GetProperty ( "file_info" ) . GetString ( ) ! ;
0 commit comments