Files
qitongxue-api/internal/logic/wxmsg.go
2026-09-29 10:57:01 +08:00

102 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package logic
import (
"context"
"encoding/json"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
// ============================================================================
// 订阅消息发送(笔记定时提醒)
//
// 依据:PRD-02 §4.1.2、架构 §8.3。
// - access_token 复用 xpay.go 的 wxAccessToken()(stable_token + 缓存),不重复造;
// - 模板 ID 走配置 wx.subscribe.noteRemindTemplateId;
// - ★ 模板 ID 为空 → 不发送、打 Warning 日志、正常返回(降级),绝不 panic。
// ============================================================================
// noteSubscribeSendPath 订阅消息发送接口路径(B 端接口,需 access_token)
const noteSubscribeSendPath = "/cgi-bin/message/subscribe/send"
// NoteRemindSendReq 一条提醒的推送参数
type NoteRemindSendReq struct {
NoteId int64
Openid string
Title string
Content string
Time string
}
// RemindTemplateId 读取订阅消息模板 ID;未配置返回空串。
func RemindTemplateId(ctx context.Context) string {
return g.Cfg().MustGet(ctx, "wx.subscribe.noteRemindTemplateId", "").String()
}
// SendNoteRemind 发送笔记提醒;从配置解析模板 ID 后交给底层发送。
// 模板未配置时底层降级(返回 nil,不 panic)。
func SendNoteRemind(ctx context.Context, req NoteRemindSendReq) error {
return sendNoteRemindWithTemplate(ctx, req, RemindTemplateId(ctx))
}
// sendNoteRemindWithTemplate 以指定模板 ID 发送订阅消息。
//
// tplId 为空是**合法的降级路径**(PRD-02 R6-12):仅告警日志并正常返回 nil,
// 由调用方(remind.go)把笔记置为「发送失败/未发送」并保留站内提醒标记。
// 单独抽出该函数是为了让降级路径可被单元测试直接覆盖(不依赖真实微信环境 / DB)。
func sendNoteRemindWithTemplate(ctx context.Context, req NoteRemindSendReq, tplId string) error {
if tplId == "" {
g.Log().Warningf(ctx,
"[note-remind] 未配置订阅消息模板 wx.subscribe.noteRemindTemplateId,降级为站内提醒 noteId=%d", req.NoteId)
return nil
}
if req.Openid == "" {
return gerror.New("用户 openid 为空,无法发送订阅消息")
}
token, err := wxAccessToken(ctx)
if err != nil {
return err
}
// 模板字段键需与 MP 后台创建的模板一致:thing1 标题 / time2 提醒时间 / thing3 正文摘要。
// 该映射属需环境化的集成参数(架构 §十 M1),上线前需与模板对齐。
data := g.Map{
"thing1": g.Map{"value": clampRunes(req.Title, 20)},
"time2": g.Map{"value": req.Time},
"thing3": g.Map{"value": clampRunes(req.Content, 20)},
}
body := g.Map{
"touser": req.Openid,
"template_id": tplId,
"page": "pkg-note/list",
"data": data,
}
resp, err := g.Client().Post(ctx, xpayAPIBase+noteSubscribeSendPath+"?access_token="+token, body)
if err != nil {
return gerror.Newf("调用 subscribe/send 失败: %v", err)
}
defer resp.Close()
var out struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err = json.Unmarshal(resp.ReadAll(), &out); err != nil {
return gerror.Newf("解析 subscribe/send 响应失败: %v", err)
}
if out.ErrCode != 0 {
return gerror.Newf("订阅消息发送失败: %d %s", out.ErrCode, out.ErrMsg)
}
return nil
}
// clampRunes 按字符数截断(订阅消息 thing 类型上限 20 个字符)
func clampRunes(s string, max int) string {
rs := []rune(s)
if len(rs) <= max {
return s
}
return string(rs[:max])
}