小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 10:57:01 +08:00
parent 0cb70a7618
commit 3bb7fa8db3
96 changed files with 13890 additions and 213 deletions

101
internal/logic/wxmsg.go Normal file
View File

@@ -0,0 +1,101 @@
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])
}