102 lines
3.5 KiB
Go
102 lines
3.5 KiB
Go
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])
|
||
}
|