投诉回调收不到、回复一直 400、complete 调早了:微信支付消费者投诉 2.0 服务端排障
前提:已接 APIv3(商户私钥签名、平台证书/公钥验签、APIv3 密钥解密)。只讲投诉单的接收、回复、完结,不涉及退款/争议资金处理。
一、后台能看到投诉单,系统里没有
投诉通知回调地址不是商户平台里填一下就生效的,必须调创建投诉通知回调接口提交 URL。没调这个接口,微信不会推。文档写得很直白:回调地址通过创建投诉通知回调接口中提交的参数 URL 设置,设置规范和回调 IP 列表见「回调通知注意事项」。
回调是 POST,body 为 JSON,结构同其他 APIv3 通知:
id、create_time、event_type、resource_type、summary、
resource{algorithm, ciphertext, original_type, associated_data, nonce}
event_type 只有两种:COMPLAINT.CREATE(产生新投诉)、COMPLAINT.STATE_CHANGE(状态变化)。original_type 固定 complaint。
resource 用 APIv3 密钥 + nonce + associated_data 做 AEAD_AES_256_GCM 解密,解出来只有两个字段:complaint_id、action_type。金额、用户留言、订单号都不在通知里——必须拿 complaint_id 调查询投诉单详情。
action_type 枚举:
CREATE_COMPLAINT、CONTINUE_COMPLAINT、USER_RESPONSE、RESPONSE_BY_PLATFORM、SELLER_REFUND、MERCHANT_RESPONSE、MERCHANT_CONFIRM_COMPLETE、USER_APPLY_PLATFORM_SERVICE、USER_CANCEL_PLATFORM_SERVICE、PLATFORM_SERVICE_FINISHED;申请退款单相关还会来 MERCHANT_APPROVE_REFUND、MERCHANT_REJECT_REFUND、REFUND_SUCCESS,这几类会更新投诉单状态,收到后同样要查详情。
二、应答:5 秒、幂等、别放行探测流量
5 秒内完成验签并应答。验签用 Wechatpay-Timestamp / Wechatpay-Nonce / body 组串,Wechatpay-Serial 选证书(公钥序列号形如 PUB_KEY_ID_3000000001)。
- 验签通过:200 或 204,无 body。
- 失败:5XX 或 4XX,body
{"code":"FAIL","message":"失败"}。
失败或超时后,通知频率 60S × 10 次,之后 300S 间隔重试,最多持续 2 小时,最多 10 次(文档原文如此,实际次数以文档为准)。
会重复投递,幂等必须做:先查本地该 complaint_id 的处理状态,已处理直接返回成功;并发加锁,防函数重入。
签名探测流量以 WECHATPAY/SIGNTEST/ 开头,照常验签失败返回 4XX,别放行。
三、回复接口一直 400 的几种原因
POST /v3/merchant-service/complaints-v2/{complaint_id}/response
Header:Authorization(APIv3 签名)、Accept: application/json、Content-Type: application/json。
Body:
complainted_mchid:被诉商户号,必填 string(64)response_content:必填。接口文档写 500 字符以内,开发指引里的示例注释写「限制 200 个字符以内」,两处不一致。按 500 传但留余量最稳。response_images:选填,数组,元素是「商户上传反馈图片」接口返回的media_id,最多 4 张jump_url+jump_url_text:文字链,灰度功能,需联系行业运营mini_program_jump_info{appid, path, text}:小程序跳转,灰度
成功返回 204 无 body。错误码只列了四个:PARAM_ERROR 400、INVALID_REQUEST 400、SIGN_ERROR 401、SYSTEM_ERROR 500。
对着这四个码排查,常见 400 是:
complainted_mchid填了自己主商户号,而不是被诉商户号(服务商/多商户号场景最容易踩)response_images直接填了图片 URL,而不是media_idresponse_content超过字符上限complaint_id用了别的单号
首次回复成功后,投诉单状态从「待处理」变「处理中」。所以「回复了就以为完事」是错的,单子还停在进行中。
四、complete 什么时候调
POST /v3/merchant-service/complaints-v2/{complaint_id}/complete
Body 只有 complainted_mchid,返回 204。
文档明确:务必在真实处理完用户投诉后再调,会向用户下发满意度评价,重复投诉率是考核客诉处理质量的重要指标之一。
提前调 → 用户不满意 → 重新投诉 → 单量翻倍,而且历史单的满意度评价一起被拉低。反方向也有问题:处理完了但一直不调 complete,单子挂在进行中,同样不好。
五、回调没收到怎么补
调查询投诉单详情用 complaint_id 查;没有 ID 就调查询投诉单列表按时间范围拉。文档 Q&A 给的限频:商户反馈 API 每分钟 60 次,查询投诉单列表 API 每分钟 200 次。
每天定时对账一次:列表接口拉昨天到今天的单,跟本地投诉表比对,缺的补进队列。
六、代码骨架(Go,片段未在本机实测)
// 回调:验签 → 解密 resource → 取 complaint_id → 幂等 INSERT → 异步查详情 → 204
func HandleComplaintNotify(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !verify(r.Header.Get("Wechatpay-Timestamp"),
r.Header.Get("Wechatpay-Nonce"), string(body),
r.Header.Get("Wechatpay-Serial"), r.Header.Get("Wechatpay-Signature")) {
w.WriteHeader(401)
w.Write([]byte(`{"code":"FAIL","message":"失败"}`))
return
}
var n Notify
json.Unmarshal(body, &n)
plain := decryptAEAD(n.Resource.Ciphertext, n.Resource.Nonce, n.Resource.AssociatedData)
var res struct {
ComplaintID string `json:"complaint_id"`
ActionType string `json:"action_type"`
}
json.Unmarshal(plain, &res)
if !repo.TryInsert(res.ComplaintID) { // INSERT ... ON CONFLICT DO NOTHING
w.WriteHeader(204)
return
}
go queue.Push(res.ComplaintID) // 异步查详情
w.WriteHeader(204)
}
// 回复:注意 complainted_mchid 用被诉商户号,图片先走上传接口拿 media_id
func ReplyComplaint(complaintID, subMchID, content string, mediaIDs []string) error {
payload := map[string]any{
"complainted_mchid": subMchID,
"response_content": content, // 文档 500,示例注释 200,留余量
}
if len(mediaIDs) > 0 {
payload["response_images"] = mediaIDs[:min(4, len(mediaIDs))]
}
return PostV3("/v3/merchant-service/complaints-v2/"+complaintID+"/response", payload) // 期望 204
}
七、文档链接
- 投诉通知回调:
- 回复投诉:
- 完结投诉:
- 服务商参考: