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]) }