增加微信通知功能
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/*************************************************
|
||||
* 微信公众号绑定(租户端)
|
||||
* 流程:生成二维码 → 微信扫码关注 → 公众号回复验证码 → 输入验证码完成绑定
|
||||
*************************************************/
|
||||
|
||||
/** 发起绑定:生成带参数二维码 */
|
||||
export function startWechatMpBind() {
|
||||
return request({
|
||||
url: "/backend/wechatMp/bind/start",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/** 查询绑定会话状态:0待扫码 1已扫码待核销 2已核销 3已过期 */
|
||||
export function getWechatMpBindStatus(scene) {
|
||||
return request({
|
||||
url: "/backend/wechatMp/bind/status",
|
||||
method: "get",
|
||||
params: { scene },
|
||||
});
|
||||
}
|
||||
|
||||
/** 核销验证码完成绑定 */
|
||||
export function confirmWechatMpBind(data) {
|
||||
return request({
|
||||
url: "/backend/wechatMp/bind/confirm",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 当前租户用户的绑定信息 */
|
||||
export function getMyWechatMpBinding() {
|
||||
return request({
|
||||
url: "/backend/wechatMp/my",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 解除当前用户的微信绑定 */
|
||||
export function unbindWechatMp() {
|
||||
return request({
|
||||
url: "/backend/wechatMp/unbind",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="wechat-bind-card">
|
||||
<template #header>
|
||||
<div class="card-head">
|
||||
<span class="card-title">微信绑定</span>
|
||||
<el-tag v-if="binding.bound" type="success" size="small">已绑定</el-tag>
|
||||
<el-tag v-else type="info" size="small">未绑定</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 已绑定 -->
|
||||
<div v-if="binding.bound" class="bound-box">
|
||||
<div class="bound-info">
|
||||
<p>微信昵称:{{ binding.nickname || "-" }}</p>
|
||||
<p>
|
||||
关注状态:
|
||||
<el-tag size="small" :type="Number(binding.subscribe) === 1 ? 'success' : 'danger'" effect="plain">
|
||||
{{ Number(binding.subscribe) === 1 ? "已关注" : "已取关" }}
|
||||
</el-tag>
|
||||
</p>
|
||||
<p>绑定时间:{{ binding.bind_time ? formatDateTime(binding.bind_time) : "-" }}</p>
|
||||
</div>
|
||||
<el-button type="danger" plain size="small" :loading="unbinding" @click="handleUnbind">
|
||||
解除绑定
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 未绑定:扫码关注 → 公众号回复验证码 → 输入完成绑定 -->
|
||||
<div v-else class="bind-box">
|
||||
<p class="tip">绑定微信后,可通过公众号接收系统提醒与公告推送。</p>
|
||||
<el-steps :active="bindStep" simple class="bind-steps">
|
||||
<el-step title="扫码关注公众号" />
|
||||
<el-step title="公众号回复验证码" />
|
||||
<el-step title="输入验证码完成绑定" />
|
||||
</el-steps>
|
||||
<div class="bind-area">
|
||||
<div class="qr-box">
|
||||
<img v-if="qrUrl" :src="qrUrl" class="qr-img" alt="公众号二维码" />
|
||||
<el-button v-else type="primary" size="small" :loading="starting" @click="handleStart">
|
||||
获取绑定二维码
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="code-box">
|
||||
<el-input v-model="code" placeholder="输入公众号回复的验证码" maxlength="8" class="code-input" />
|
||||
<el-button type="primary" size="small" :loading="confirming" @click="handleConfirm">
|
||||
完成绑定
|
||||
</el-button>
|
||||
<p class="tip">扫码关注后公众号会自动回复 6 位验证码({{ ttlText }})。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
// 该卡片展示到秒的时间:YYYY-MM-DD HH:mm:ss
|
||||
import { formatDateTime } from "@/utils/datetime";
|
||||
import {
|
||||
startWechatMpBind,
|
||||
getWechatMpBindStatus,
|
||||
confirmWechatMpBind,
|
||||
getMyWechatMpBinding,
|
||||
unbindWechatMp,
|
||||
} from "@/api/wechatMp";
|
||||
|
||||
const binding = ref({ bound: false });
|
||||
const starting = ref(false);
|
||||
const confirming = ref(false);
|
||||
const unbinding = ref(false);
|
||||
const qrUrl = ref("");
|
||||
const scene = ref("");
|
||||
const code = ref("");
|
||||
const bindStep = ref(0);
|
||||
const ttlText = ref("5 分钟内有效");
|
||||
let pollTimer = null;
|
||||
|
||||
const stopPoll = () => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadBinding = async () => {
|
||||
try {
|
||||
const res = await getMyWechatMpBinding();
|
||||
if (res.code === 200) {
|
||||
binding.value = res.data || { bound: false };
|
||||
}
|
||||
} catch {
|
||||
binding.value = { bound: false };
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
starting.value = true;
|
||||
try {
|
||||
const res = await startWechatMpBind();
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.msg || "获取二维码失败,请联系管理员检查公众号配置");
|
||||
return;
|
||||
}
|
||||
scene.value = res.data?.scene || "";
|
||||
qrUrl.value = res.data?.qrcode_url || "";
|
||||
bindStep.value = 0;
|
||||
const ttl = Number(res.data?.expires_in || 300);
|
||||
ttlText.value = ttl >= 60 ? `${Math.floor(ttl / 60)} 分钟内有效` : `${ttl} 秒内有效`;
|
||||
stopPoll();
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!scene.value) return;
|
||||
try {
|
||||
const st = await getWechatMpBindStatus(scene.value);
|
||||
const status = Number(st?.data?.status ?? 0);
|
||||
if (status === 1) {
|
||||
bindStep.value = 2; // 已扫码,公众号已回复验证码
|
||||
}
|
||||
if (status === 3) {
|
||||
stopPoll();
|
||||
qrUrl.value = "";
|
||||
ElMessage.warning("二维码已过期,请重新获取");
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败忽略
|
||||
}
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "获取二维码失败");
|
||||
} finally {
|
||||
starting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!scene.value) {
|
||||
ElMessage.warning("请先获取绑定二维码并扫码关注");
|
||||
return;
|
||||
}
|
||||
if (!code.value.trim()) {
|
||||
ElMessage.warning("请输入公众号回复的验证码");
|
||||
return;
|
||||
}
|
||||
confirming.value = true;
|
||||
try {
|
||||
const res = await confirmWechatMpBind({ scene: scene.value, code: code.value.trim() });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("微信绑定成功");
|
||||
stopPoll();
|
||||
qrUrl.value = "";
|
||||
code.value = "";
|
||||
scene.value = "";
|
||||
bindStep.value = 0;
|
||||
await loadBinding();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "绑定失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "绑定失败");
|
||||
} finally {
|
||||
confirming.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnbind = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定解除微信绑定吗?解除后将无法接收微信推送。", "提示", {
|
||||
type: "warning",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
unbinding.value = true;
|
||||
try {
|
||||
const res = await unbindWechatMp();
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("已解除绑定");
|
||||
await loadBinding();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "解绑失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "解绑失败");
|
||||
} finally {
|
||||
unbinding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadBinding);
|
||||
onUnmounted(stopPoll);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wechat-bind-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.bound-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bound-info p {
|
||||
margin: 4px 0;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.bind-steps {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bind-area {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.qr-box {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.code-box {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.code-box .tip {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,9 @@
|
||||
@bind-phone="bindPhoneDialogVisible = true"
|
||||
@bind-email="bindEmailDialogVisible = true"
|
||||
/>
|
||||
|
||||
<!-- 微信绑定:扫码关注公众号获取验证码,绑定后可接收提醒/公告 -->
|
||||
<WechatBindCard />
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
@@ -71,6 +74,7 @@ import EditProfileDialog from "./components/EditProfileDialog.vue";
|
||||
import ChangePasswordDialog from "./components/ChangePasswordDialog.vue";
|
||||
import BindPhoneDialog from "./components/BindPhoneDialog.vue";
|
||||
import BindEmailDialog from "./components/BindEmailDialog.vue";
|
||||
import WechatBindCard from "./components/WechatBindCard.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
@@ -53,6 +53,13 @@ acme_directory =
|
||||
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
|
||||
ssl_cert_dir = /www/wwwroot/ssl-certs
|
||||
|
||||
# ==================== 微信公众号(服务号) ====================
|
||||
# 服务号 AppSecret / EncodingAESKey 入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
|
||||
# 注意:更换该值后,历史已保存的公众号密钥将无法解密,需在「通知设置 → 微信配置」重新保存一次。
|
||||
wechat_mp_secret_key = 8f3c2a1d94b74e28a6c5f0187d3e9b4c72a5d0e6f13b8c47d92a6e5f0b7c3148
|
||||
# 微信服务器回调地址基址(默认回落 payment_callback_base,即 https://api.yunzer.cn):
|
||||
# wechat_mp_callback_base = https://api.yunzer.cn
|
||||
|
||||
# ==================== 支付模块 ====================
|
||||
# 渠道敏感参数(商户密钥/私钥/Secret)入库加密密钥:任意随机串即可,代码内部做 SHA-256 派生。
|
||||
# 注意:更换该值后,历史已保存的渠道参数将无法解密,需在各渠道配置页重新保存一次。
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/services/wechatmp"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// WechatMpCallbackController 微信公众号服务器回调(微信服务器调用,无需平台鉴权)
|
||||
//
|
||||
// 公众号后台「设置与开发 → 服务器配置」填写:
|
||||
// URL(服务器地址):https://<你的域名>/api/wechat/mp/callback
|
||||
// Token:与平台「通知设置 → 微信配置」中的 Token 保持一致
|
||||
// 消息加解密方式:明文 / 兼容 / 安全(三种均支持)
|
||||
type WechatMpCallbackController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// Callback 服务器地址验证(GET,回显 echostr)与消息/事件推送(POST,被动回复)
|
||||
func (c *WechatMpCallbackController) Callback() {
|
||||
cfg, err := wechatmp.LoadEnabledConfig()
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(403)
|
||||
c.Ctx.Output.Body([]byte("wechat mp not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
q := c.Ctx.Request.URL.Query()
|
||||
timestamp := q.Get("timestamp")
|
||||
nonce := q.Get("nonce")
|
||||
|
||||
// ---------------- GET:服务器地址校验 ----------------
|
||||
if strings.EqualFold(c.Ctx.Input.Method(), "GET") {
|
||||
echostr := q.Get("echostr")
|
||||
if cfg.EncryptMode == wechatmp.EncryptModeSafe {
|
||||
if !wechatmp.CheckMsgSignature(cfg.Token, timestamp, nonce, echostr, q.Get("msg_signature")) {
|
||||
c.Ctx.Output.SetStatus(403)
|
||||
c.Ctx.Output.Body([]byte("invalid signature"))
|
||||
return
|
||||
}
|
||||
plain, derr := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, echostr)
|
||||
if derr != nil {
|
||||
c.Ctx.Output.SetStatus(403)
|
||||
c.Ctx.Output.Body([]byte("decrypt failed"))
|
||||
return
|
||||
}
|
||||
c.Ctx.Output.Body([]byte(plain))
|
||||
return
|
||||
}
|
||||
if !wechatmp.CheckSignature(cfg.Token, timestamp, nonce, q.Get("signature")) {
|
||||
c.Ctx.Output.SetStatus(403)
|
||||
c.Ctx.Output.Body([]byte("invalid signature"))
|
||||
return
|
||||
}
|
||||
c.Ctx.Output.Body([]byte(echostr))
|
||||
return
|
||||
}
|
||||
|
||||
// ---------------- POST:消息 / 事件 ----------------
|
||||
body, _ := io.ReadAll(c.Ctx.Request.Body)
|
||||
rawBody := string(body)
|
||||
if strings.TrimSpace(rawBody) == "" {
|
||||
c.Ctx.Output.Body([]byte(""))
|
||||
return
|
||||
}
|
||||
|
||||
needEncryptReply := false
|
||||
if strings.Contains(rawBody, "<Encrypt>") {
|
||||
// 安全/兼容模式:外层为加密 XML,先验签再解密
|
||||
outer, perr := wechatmp.ParseInboundXML(rawBody)
|
||||
if perr != nil {
|
||||
c.Ctx.Output.Body([]byte(""))
|
||||
return
|
||||
}
|
||||
if !wechatmp.CheckMsgSignature(cfg.Token, timestamp, nonce, outer.Encrypt, q.Get("msg_signature")) {
|
||||
c.Ctx.Output.Body([]byte(""))
|
||||
return
|
||||
}
|
||||
plain, derr := wechatmp.DecryptMessage(cfg.AESKey, cfg.AppID, outer.Encrypt)
|
||||
if derr != nil {
|
||||
c.Ctx.Output.Body([]byte(""))
|
||||
return
|
||||
}
|
||||
rawBody = plain
|
||||
needEncryptReply = true
|
||||
} else if !wechatmp.CheckSignature(cfg.Token, timestamp, nonce, q.Get("signature")) {
|
||||
// 明文模式:签名校验不通过直接拒绝(微信会重试)
|
||||
c.Ctx.Output.Body([]byte(""))
|
||||
return
|
||||
}
|
||||
|
||||
msg, merr := wechatmp.ParseInboundXML(rawBody)
|
||||
if merr != nil {
|
||||
c.Ctx.Output.Body([]byte(""))
|
||||
return
|
||||
}
|
||||
|
||||
reply := wechatmp.HandleInbound(cfg, msg)
|
||||
if strings.TrimSpace(reply) == "" {
|
||||
// 无需回复:返回空串,微信视为处理成功
|
||||
c.Ctx.Output.Body([]byte(""))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
plainReply := wechatmp.BuildTextReply(msg.FromUserName, msg.ToUserName, reply, now)
|
||||
c.Ctx.Output.Header("Content-Type", "application/xml; charset=utf-8")
|
||||
if needEncryptReply {
|
||||
ts := strconv.FormatInt(now, 10)
|
||||
enc, eerr := wechatmp.BuildEncryptedReply(cfg, plainReply, ts, nonce)
|
||||
if eerr != nil {
|
||||
c.Ctx.Output.Body([]byte(""))
|
||||
return
|
||||
}
|
||||
c.Ctx.Output.Body([]byte(enc))
|
||||
return
|
||||
}
|
||||
c.Ctx.Output.Body([]byte(plainReply))
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services/wechatmp"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 租户端「微信公众号绑定」:
|
||||
// 扫码关注公众号 → 公众号被动回复验证码 → 本端输入验证码完成绑定
|
||||
// 绑定后(公众号认证后)可接收平台/租户侧的提醒、公告推送
|
||||
// =============================================================
|
||||
|
||||
// BackendWechatMpController 租户端微信公众号绑定
|
||||
type BackendWechatMpController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *BackendWechatMpController) claims() (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return nil, fmt.Errorf("未登录")
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, fmt.Errorf("认证信息格式错误")
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的token")
|
||||
}
|
||||
if claims.UserType != "backend" && claims.UserType != "app" {
|
||||
return nil, fmt.Errorf("无权访问")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (c *BackendWechatMpController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendWechatMpController) ok(data interface{}, msg string) {
|
||||
if msg == "" {
|
||||
msg = "success"
|
||||
}
|
||||
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||||
if data != nil {
|
||||
resp["data"] = data
|
||||
}
|
||||
c.Data["json"] = resp
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *BackendWechatMpController) readBody(out interface{}) bool {
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BindStart POST /backend/wechatMp/bind/start
|
||||
func (c *BackendWechatMpController) BindStart() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
uid := uint64(claims.UserID)
|
||||
tid := uint64(claims.TenantId)
|
||||
if uid == 0 || tid == 0 {
|
||||
c.jsonErr(401, 401, "未获取到用户信息")
|
||||
return
|
||||
}
|
||||
scene, _, qrURL, expireAt, err := wechatmp.CreateVerifyCode(models.WechatBindTypeTenantUser, uid, tid, 0)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "生成二维码失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"scene": scene,
|
||||
"qrcode_url": qrURL,
|
||||
"expire_at": expireAt,
|
||||
"expires_in": int(time.Until(expireAt).Seconds()),
|
||||
}, "生成成功")
|
||||
}
|
||||
|
||||
// BindStatus GET /backend/wechatMp/bind/status?scene=xxx
|
||||
func (c *BackendWechatMpController) BindStatus() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
scene := strings.TrimSpace(c.GetString("scene"))
|
||||
row, err := wechatmp.GetVerifyCode(scene)
|
||||
if err != nil {
|
||||
c.jsonErr(404, 404, err.Error())
|
||||
return
|
||||
}
|
||||
if row.BindType != models.WechatBindTypeTenantUser || row.BindID != uint64(claims.UserID) {
|
||||
c.jsonErr(403, 403, "该二维码不是当前账号发起,请重新获取")
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"status": row.Status,
|
||||
"expire_at": row.ExpireAt,
|
||||
}, "获取成功")
|
||||
}
|
||||
|
||||
// BindConfirm POST /backend/wechatMp/bind/confirm {scene, code}
|
||||
func (c *BackendWechatMpController) BindConfirm() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
Scene string `json:"scene"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !c.readBody(&p) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.Code) == "" {
|
||||
c.jsonErr(400, 400, "请输入公众号返回的验证码")
|
||||
return
|
||||
}
|
||||
follower, err := wechatmp.ConfirmVerifyCode(
|
||||
models.WechatBindTypeTenantUser,
|
||||
uint64(claims.UserID),
|
||||
uint64(claims.TenantId),
|
||||
p.Scene,
|
||||
p.Code,
|
||||
)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"openid": follower.OpenID,
|
||||
"nickname": follower.Nickname,
|
||||
"bind_time": follower.BindTime,
|
||||
}, "绑定成功")
|
||||
}
|
||||
|
||||
// MyBinding GET /backend/wechatMp/my
|
||||
func (c *BackendWechatMpController) MyBinding() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
follower, err := wechatmp.GetFollowerByUser(models.WechatBindTypeTenantUser, uint64(claims.UserID))
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "查询失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
if follower == nil || follower.BindTid != uint64(claims.TenantId) {
|
||||
c.ok(map[string]interface{}{"bound": false}, "获取成功")
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"bound": true,
|
||||
"openid": follower.OpenID,
|
||||
"nickname": follower.Nickname,
|
||||
"avatar": follower.Avatar,
|
||||
"subscribe": follower.Subscribe,
|
||||
"bind_time": follower.BindTime,
|
||||
"subscribe_time": follower.SubscribeTime,
|
||||
}, "获取成功")
|
||||
}
|
||||
|
||||
// Unbind POST /backend/wechatMp/unbind
|
||||
func (c *BackendWechatMpController) Unbind() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
if err := wechatmp.UnbindByUser(models.WechatBindTypeTenantUser, uint64(claims.UserID)); err != nil {
|
||||
c.jsonErr(500, 500, "解绑失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(nil, "已解绑")
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services/wechatmp"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 平台端「微信公众号(服务号)」:
|
||||
// 配置(AppID/Secret/Token/AESKey)、绑定(扫码关注取验证码)、粉丝管理、消息推送
|
||||
// 未认证阶段可完整跑通「扫码关注 → 公众号回复验证码 → 平台核销绑定」;
|
||||
// 认证后开启模板消息推送(提醒/公告)。
|
||||
// =============================================================
|
||||
|
||||
// PlatformWechatMpController 平台端微信公众号管理
|
||||
type PlatformWechatMpController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
func (c *PlatformWechatMpController) claims() (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return nil, fmt.Errorf("未登录")
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, fmt.Errorf("认证信息格式错误")
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的token")
|
||||
}
|
||||
if claims.UserType != "platform" {
|
||||
return nil, fmt.Errorf("无权访问")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (c *PlatformWechatMpController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *PlatformWechatMpController) ok(data interface{}, msg string) {
|
||||
if msg == "" {
|
||||
msg = "success"
|
||||
}
|
||||
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||||
if data != nil {
|
||||
resp["data"] = data
|
||||
}
|
||||
c.Data["json"] = resp
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
func (c *PlatformWechatMpController) readBody(out interface{}) bool {
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================ 配置 ============================
|
||||
|
||||
// GetConfig GET /platform/wechatMp/config
|
||||
func (c *PlatformWechatMpController) GetConfig() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"token": "",
|
||||
"aes_key": "",
|
||||
"encrypt_mode": wechatmp.EncryptModePlain,
|
||||
"template_id": "",
|
||||
"verified": 0,
|
||||
"enabled": 0,
|
||||
"remark": "",
|
||||
"configured": false,
|
||||
"callback_url": wechatmp.CallbackURL(),
|
||||
}
|
||||
if cfg, err := wechatmp.LoadConfig(); err == nil {
|
||||
data["app_id"] = cfg.AppID
|
||||
data["app_secret"] = wechatmp.MaskSecret(cfg.AppSecret)
|
||||
data["token"] = cfg.Token
|
||||
data["aes_key"] = wechatmp.MaskSecret(cfg.AESKey)
|
||||
data["encrypt_mode"] = cfg.EncryptMode
|
||||
data["template_id"] = cfg.TemplateID
|
||||
data["remark"] = cfg.Remark
|
||||
data["configured"] = cfg.AppID != ""
|
||||
if cfg.Verified {
|
||||
data["verified"] = 1
|
||||
}
|
||||
if cfg.Enabled {
|
||||
data["enabled"] = 1
|
||||
}
|
||||
}
|
||||
c.ok(data, "获取成功")
|
||||
}
|
||||
|
||||
// SaveConfig POST /platform/wechatMp/config
|
||||
func (c *PlatformWechatMpController) SaveConfig() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
Token string `json:"token"`
|
||||
AESKey string `json:"aes_key"`
|
||||
EncryptMode string `json:"encrypt_mode"`
|
||||
TemplateID string `json:"template_id"`
|
||||
Verified bool `json:"verified"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if !c.readBody(&p) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.AppID) == "" {
|
||||
c.jsonErr(400, 400, "AppID 不能为空")
|
||||
return
|
||||
}
|
||||
err := wechatmp.SaveConfig(wechatmp.SaveInput{
|
||||
AppID: p.AppID,
|
||||
AppSecret: p.AppSecret,
|
||||
Token: p.Token,
|
||||
AESKey: p.AESKey,
|
||||
EncryptMode: p.EncryptMode,
|
||||
TemplateID: p.TemplateID,
|
||||
Verified: p.Verified,
|
||||
Enabled: p.Enabled,
|
||||
Remark: p.Remark,
|
||||
})
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "保存失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(nil, "保存成功")
|
||||
}
|
||||
|
||||
// TestConnection POST /platform/wechatMp/test
|
||||
// 使用当前配置获取 access_token,验证 AppID/AppSecret 是否正确
|
||||
func (c *PlatformWechatMpController) TestConnection() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
cfg, err := wechatmp.LoadConfig()
|
||||
if err != nil || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
c.jsonErr(400, 400, "请先保存 AppID 与 AppSecret")
|
||||
return
|
||||
}
|
||||
token, err := wechatmp.GetAccessToken(cfg)
|
||||
if err != nil {
|
||||
msg := "连接失败:" + err.Error()
|
||||
if hint, ok := wechatmp.ExplainError(err); ok {
|
||||
msg += ";" + hint
|
||||
}
|
||||
c.jsonErr(400, 400, msg)
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"access_token_suffix": wechatmp.MaskSecret(token),
|
||||
"callback_url": wechatmp.CallbackURL(),
|
||||
"verified": boolToInt8(cfg.Verified),
|
||||
}, "连接成功:AppID 与 AppSecret 校验通过")
|
||||
}
|
||||
|
||||
// ============================ 绑定(当前平台用户) ============================
|
||||
|
||||
// BindStart POST /platform/wechatMp/bind/start
|
||||
// 生成带参二维码;用户扫码关注后公众号将被动回复验证码
|
||||
func (c *PlatformWechatMpController) BindStart() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
uid := uint64(claims.UserID)
|
||||
if uid == 0 {
|
||||
c.jsonErr(401, 401, "未获取到用户信息")
|
||||
return
|
||||
}
|
||||
scene, _, qrURL, expireAt, err := wechatmp.CreateVerifyCode(models.WechatBindTypePlatformUser, uid, 0, 0)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "生成二维码失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"scene": scene,
|
||||
"qrcode_url": qrURL,
|
||||
"expire_at": expireAt,
|
||||
"expires_in": int(time.Until(expireAt).Seconds()),
|
||||
"callback_url": wechatmp.CallbackURL(),
|
||||
}, "生成成功")
|
||||
}
|
||||
|
||||
// BindStatus GET /platform/wechatMp/bind/status?scene=xxx
|
||||
func (c *PlatformWechatMpController) BindStatus() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
scene := strings.TrimSpace(c.GetString("scene"))
|
||||
row, err := wechatmp.GetVerifyCode(scene)
|
||||
if err != nil {
|
||||
c.jsonErr(404, 404, err.Error())
|
||||
return
|
||||
}
|
||||
if row.BindType != models.WechatBindTypePlatformUser || row.BindID != uint64(claims.UserID) {
|
||||
c.jsonErr(403, 403, "该二维码不是当前账号发起,请重新获取")
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"status": row.Status,
|
||||
"expire_at": row.ExpireAt,
|
||||
}, "获取成功")
|
||||
}
|
||||
|
||||
// BindConfirm POST /platform/wechatMp/bind/confirm {scene, code}
|
||||
func (c *PlatformWechatMpController) BindConfirm() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
Scene string `json:"scene"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !c.readBody(&p) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.Code) == "" {
|
||||
c.jsonErr(400, 400, "请输入公众号返回的验证码")
|
||||
return
|
||||
}
|
||||
follower, err := wechatmp.ConfirmVerifyCode(models.WechatBindTypePlatformUser, uint64(claims.UserID), 0, p.Scene, p.Code)
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"openid": follower.OpenID,
|
||||
"nickname": follower.Nickname,
|
||||
"bind_time": follower.BindTime,
|
||||
}, "绑定成功")
|
||||
}
|
||||
|
||||
// MyBinding GET /platform/wechatMp/my
|
||||
func (c *PlatformWechatMpController) MyBinding() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
follower, err := wechatmp.GetFollowerByUser(models.WechatBindTypePlatformUser, uint64(claims.UserID))
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "查询失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
if follower == nil {
|
||||
c.ok(map[string]interface{}{"bound": false}, "获取成功")
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{
|
||||
"bound": true,
|
||||
"openid": follower.OpenID,
|
||||
"nickname": follower.Nickname,
|
||||
"avatar": follower.Avatar,
|
||||
"subscribe": follower.Subscribe,
|
||||
"bind_time": follower.BindTime,
|
||||
"subscribe_time": follower.SubscribeTime,
|
||||
}, "获取成功")
|
||||
}
|
||||
|
||||
// Unbind POST /platform/wechatMp/unbind
|
||||
func (c *PlatformWechatMpController) Unbind() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
if err := wechatmp.UnbindByUser(models.WechatBindTypePlatformUser, uint64(claims.UserID)); err != nil {
|
||||
c.jsonErr(500, 500, "解绑失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(nil, "已解绑")
|
||||
}
|
||||
|
||||
// ============================ 粉丝管理 ============================
|
||||
|
||||
// ListFollowers GET /platform/wechatMp/followers?page=&pageSize=&keyword=&bind_type=&subscribe=
|
||||
func (c *PlatformWechatMpController) ListFollowers() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("delete_time__isnull", true)
|
||||
if kw := strings.TrimSpace(c.GetString("keyword")); kw != "" {
|
||||
cond := orm.NewCondition().
|
||||
Or("nickname__icontains", kw).
|
||||
Or("openid__icontains", kw).
|
||||
Or("remark__icontains", kw)
|
||||
qs = qs.SetCond(cond)
|
||||
}
|
||||
if bindType := strings.TrimSpace(c.GetString("bind_type")); bindType != "" {
|
||||
qs = qs.Filter("bind_type", bindType)
|
||||
}
|
||||
if sub := strings.TrimSpace(c.GetString("subscribe")); sub == "0" || sub == "1" {
|
||||
qs = qs.Filter("subscribe", sub)
|
||||
}
|
||||
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
var rows []models.WechatMpFollower
|
||||
if _, err := qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows); err != nil {
|
||||
c.jsonErr(500, 500, "获取失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 绑定对象名称(平台用户 / 租户用户)
|
||||
nameMap := loadWechatBindNames(rows)
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for i := range rows {
|
||||
r := rows[i]
|
||||
nickname := r.Nickname
|
||||
if strings.TrimSpace(nickname) == "" {
|
||||
nickname = maskOpenID(r.OpenID)
|
||||
}
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": r.ID,
|
||||
"openid": r.OpenID,
|
||||
"nickname": nickname,
|
||||
"avatar": r.Avatar,
|
||||
"sex": r.Sex,
|
||||
"city": r.City,
|
||||
"subscribe": r.Subscribe,
|
||||
"subscribe_time": r.SubscribeTime,
|
||||
"unsubscribe_time": r.UnsubscribeTime,
|
||||
"bind_type": r.BindType,
|
||||
"bind_id": r.BindID,
|
||||
"bind_tid": r.BindTid,
|
||||
"bind_name": nameMap[r.BindType+"#"+fmt.Sprintf("%d", r.BindID)+"#"+fmt.Sprintf("%d", r.BindTid)],
|
||||
"bind_time": r.BindTime,
|
||||
"remark": r.Remark,
|
||||
"create_time": r.CreateTime,
|
||||
})
|
||||
}
|
||||
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize}, "获取成功")
|
||||
}
|
||||
|
||||
// SetFollowerRemark POST /platform/wechatMp/follower/remark {id, remark}
|
||||
func (c *PlatformWechatMpController) SetFollowerRemark() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
ID uint64 `json:"id"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if !c.readBody(&p) {
|
||||
return
|
||||
}
|
||||
if p.ID == 0 {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := models.Orm.QueryTable(new(models.WechatMpFollower)).
|
||||
Filter("id", p.ID).
|
||||
Update(map[string]interface{}{"remark": strings.TrimSpace(p.Remark), "update_time": now}); err != nil {
|
||||
c.jsonErr(500, 500, "保存失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(nil, "保存成功")
|
||||
}
|
||||
|
||||
// UnbindFollower POST /platform/wechatMp/follower/unbind {id}
|
||||
// 仅清除绑定关系(保留粉丝记录)
|
||||
func (c *PlatformWechatMpController) UnbindFollower() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
ID uint64 `json:"id"`
|
||||
}
|
||||
if !c.readBody(&p) {
|
||||
return
|
||||
}
|
||||
if p.ID == 0 {
|
||||
c.jsonErr(400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := models.Orm.QueryTable(new(models.WechatMpFollower)).
|
||||
Filter("id", p.ID).
|
||||
Update(map[string]interface{}{
|
||||
"bind_type": "", "bind_id": 0, "bind_tid": 0, "update_time": now,
|
||||
}); err != nil {
|
||||
c.jsonErr(500, 500, "解绑失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(nil, "已解绑")
|
||||
}
|
||||
|
||||
// ============================ 推送 ============================
|
||||
|
||||
// Push POST /platform/wechatMp/push
|
||||
// {title, content, remark, url, bind_type, bind_id, tid}
|
||||
func (c *PlatformWechatMpController) Push() {
|
||||
if _, err := c.claims(); err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Remark string `json:"remark"`
|
||||
URL string `json:"url"`
|
||||
BindType string `json:"bind_type"`
|
||||
BindID uint64 `json:"bind_id"`
|
||||
Tid uint64 `json:"tid"`
|
||||
}
|
||||
if !c.readBody(&p) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.Title) == "" && strings.TrimSpace(p.Content) == "" {
|
||||
c.jsonErr(400, 400, "请填写推送内容")
|
||||
return
|
||||
}
|
||||
cfg, err := wechatmp.LoadEnabledConfig()
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "请先启用并配置微信公众号:"+err.Error())
|
||||
return
|
||||
}
|
||||
sent, failed, perr := wechatmp.PushToFollowers(cfg, wechatmp.PushScope{
|
||||
BindType: strings.TrimSpace(p.BindType),
|
||||
BindID: p.BindID,
|
||||
Tid: p.Tid,
|
||||
}, wechatmp.PushMessage{
|
||||
Title: strings.TrimSpace(p.Title),
|
||||
Content: strings.TrimSpace(p.Content),
|
||||
Remark: strings.TrimSpace(p.Remark),
|
||||
URL: strings.TrimSpace(p.URL),
|
||||
})
|
||||
if perr != nil {
|
||||
c.jsonErr(400, 400, perr.Error())
|
||||
return
|
||||
}
|
||||
c.ok(map[string]interface{}{"sent": sent, "failed": failed},
|
||||
fmt.Sprintf("推送完成:成功 %d 条,失败 %d 条", sent, failed))
|
||||
}
|
||||
|
||||
// PushTest POST /platform/wechatMp/push/test {title, content, remark}
|
||||
// 推送给当前登录平台用户绑定的微信
|
||||
func (c *PlatformWechatMpController) PushTest() {
|
||||
claims, err := c.claims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
if !c.readBody(&p) {
|
||||
return
|
||||
}
|
||||
cfg, err := wechatmp.LoadEnabledConfig()
|
||||
if err != nil {
|
||||
c.jsonErr(400, 400, "请先启用并配置微信公众号:"+err.Error())
|
||||
return
|
||||
}
|
||||
follower, err := wechatmp.GetFollowerByUser(models.WechatBindTypePlatformUser, uint64(claims.UserID))
|
||||
if err != nil || follower == nil {
|
||||
c.jsonErr(400, 400, "当前账号尚未绑定微信,请先扫码绑定")
|
||||
return
|
||||
}
|
||||
if follower.Subscribe != 1 {
|
||||
c.jsonErr(400, 400, "该微信已取消关注,请重新关注后再试")
|
||||
return
|
||||
}
|
||||
if p.Title == "" {
|
||||
p.Title = "平台消息测试"
|
||||
}
|
||||
if p.Content == "" {
|
||||
p.Content = "这是一条来自平台的测试消息,收到说明推送链路正常。"
|
||||
}
|
||||
if err := wechatmp.PushToOpenID(cfg, follower.OpenID, wechatmp.PushMessage{
|
||||
Title: strings.TrimSpace(p.Title),
|
||||
Content: strings.TrimSpace(p.Content),
|
||||
Remark: strings.TrimSpace(p.Remark),
|
||||
}); err != nil {
|
||||
c.jsonErr(400, 400, "推送失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
c.ok(nil, "测试消息已发送")
|
||||
}
|
||||
|
||||
// ============================ 辅助 ============================
|
||||
|
||||
func boolToInt8(b bool) int8 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func maskOpenID(openid string) string {
|
||||
if len(openid) <= 8 {
|
||||
return openid
|
||||
}
|
||||
return openid[:4] + "****" + openid[len(openid)-4:]
|
||||
}
|
||||
|
||||
// loadWechatBindNames 批量加载粉丝绑定对象名称:key = bindType#bindID#bindTid
|
||||
func loadWechatBindNames(rows []models.WechatMpFollower) map[string]string {
|
||||
names := map[string]string{}
|
||||
if len(rows) == 0 {
|
||||
return names
|
||||
}
|
||||
adminIDs := make([]uint64, 0)
|
||||
tenantIDs := make([]uint64, 0)
|
||||
for _, r := range rows {
|
||||
switch r.BindType {
|
||||
case models.WechatBindTypePlatformUser:
|
||||
adminIDs = append(adminIDs, r.BindID)
|
||||
case models.WechatBindTypeTenantUser:
|
||||
tenantIDs = append(tenantIDs, r.BindID)
|
||||
}
|
||||
}
|
||||
if len(adminIDs) > 0 {
|
||||
var admins []models.AdminUser
|
||||
if _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id__in", adminIDs).All(&admins, "ID", "Account", "Name"); err == nil {
|
||||
for _, a := range admins {
|
||||
name := a.Account
|
||||
if a.Name != nil && strings.TrimSpace(*a.Name) != "" {
|
||||
name = *a.Name
|
||||
}
|
||||
names[models.WechatBindTypePlatformUser+"#"+fmt.Sprintf("%d", a.ID)+"#0"] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(tenantIDs) > 0 {
|
||||
var users []models.SystemTenantUser
|
||||
if _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("uid__in", tenantIDs).All(&users, "UID", "Tid", "Account", "Name"); err == nil {
|
||||
for _, u := range users {
|
||||
name := ""
|
||||
if u.Account != nil {
|
||||
name = *u.Account
|
||||
}
|
||||
if u.Name != nil && strings.TrimSpace(*u.Name) != "" {
|
||||
name = *u.Name
|
||||
}
|
||||
names[models.WechatBindTypeTenantUser+"#"+fmt.Sprintf("%d", u.Uid)+"#"+fmt.Sprintf("%d", u.Tid)] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
-- =============================================================
|
||||
-- 微信公众号(服务号)绑定与推送
|
||||
--
|
||||
-- 业务说明:
|
||||
-- 1. 服务号配置(yz_platform_wechat_mp_config):AppID / AppSecret / Token / EncodingAESKey,
|
||||
-- 其中 AppSecret 与 EncodingAESKey 以 AES-256-GCM 加密后存储
|
||||
-- (密钥取 app.conf 的 wechat_mp_secret_key,代码内部 SHA-256 派生)。
|
||||
-- 2. 关注用户(yz_platform_wechat_mp_follower):用户关注/取关事件沉淀,openid 维度唯一;
|
||||
-- 通过「扫码关注 → 公众号回复验证码 → 平台输入验证码」流程绑定到
|
||||
-- 平台用户(platform_user)或租户用户(tenant_user),认证后可定向推送。
|
||||
-- 3. 验证码(yz_platform_wechat_mp_verify_code):一次绑定会话一条,scene 绑定二维码场景值,
|
||||
-- 默认 5 分钟有效;状态:0待扫码 1已扫码待核销 2已核销 3已过期。
|
||||
--
|
||||
-- 未认证服务号限制:仅支持服务器配置、关注/扫码事件、被动回复、带参数二维码;
|
||||
-- 模板消息/订阅通知/客服消息需认证后用。
|
||||
--
|
||||
-- 本脚本可重复执行:建表用 IF NOT EXISTS,旧表名(yz_wechat_*)做存在性改名兼容。
|
||||
-- 运行:mysql --default-character-set=utf8mb4 -h<host> -P<port> -u<user> -p <db> < create_platform_wechat_mp.sql
|
||||
-- =============================================================
|
||||
|
||||
-- 0) 兼容早期表名(yz_wechat_mp_config / yz_wechat_follower / yz_wechat_verify_code):
|
||||
-- 旧表存在且新表不存在时直接改名,保留已配置数据。
|
||||
SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_mp_config');
|
||||
SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_config');
|
||||
SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_mp_config` TO `yz_platform_wechat_mp_config`', 'DO 0');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_follower');
|
||||
SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_follower');
|
||||
SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_follower` TO `yz_platform_wechat_mp_follower`', 'DO 0');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @old := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_wechat_verify_code');
|
||||
SET @new := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'yz_platform_wechat_mp_verify_code');
|
||||
SET @ddl := IF(@old > 0 AND @new = 0, 'RENAME TABLE `yz_wechat_verify_code` TO `yz_platform_wechat_mp_verify_code`', 'DO 0');
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 1) 服务号配置(单行)
|
||||
CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_config` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`app_id` varchar(64) NOT NULL DEFAULT '' COMMENT '公众号 AppID',
|
||||
`app_secret` varchar(512) NOT NULL DEFAULT '' COMMENT 'AppSecret(AES 加密存储)',
|
||||
`token` varchar(128) NOT NULL DEFAULT '' COMMENT '服务器配置 Token(自定义,用于签名校验)',
|
||||
`aes_key` varchar(512) NOT NULL DEFAULT '' COMMENT 'EncodingAESKey(43位,AES 加密存储)',
|
||||
`encrypt_mode` varchar(16) NOT NULL DEFAULT 'plain' COMMENT '消息加解密方式:plain明文 compatible兼容 safe安全',
|
||||
`template_id` varchar(128) NOT NULL DEFAULT '' COMMENT '模板消息ID(认证后在公众号后台申请后填入)',
|
||||
`verified` tinyint(4) NOT NULL DEFAULT 0 COMMENT '公众号是否已认证:1是 0否(认证后才可主动推送)',
|
||||
`enabled` tinyint(4) NOT NULL DEFAULT 0 COMMENT '是否启用:1启用 0停用',
|
||||
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号(服务号)配置表';
|
||||
|
||||
-- 2) 关注用户(粉丝)
|
||||
CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_follower` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`openid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信 openid(公众号维度用户标识)',
|
||||
`unionid` varchar(64) NOT NULL DEFAULT '' COMMENT '微信 unionid(开放平台绑定后才有)',
|
||||
`nickname` varchar(128) NOT NULL DEFAULT '' COMMENT '昵称(未认证公众号可能拉取不到,留空展示 openid 缩写)',
|
||||
`avatar` varchar(512) NOT NULL DEFAULT '' COMMENT '头像地址',
|
||||
`sex` tinyint(4) NOT NULL DEFAULT 0 COMMENT '性别:0未知 1男 2女',
|
||||
`city` varchar(64) NOT NULL DEFAULT '' COMMENT '城市',
|
||||
`province` varchar(64) NOT NULL DEFAULT '' COMMENT '省份',
|
||||
`country` varchar(64) NOT NULL DEFAULT '' COMMENT '国家',
|
||||
`subscribe` tinyint(4) NOT NULL DEFAULT 1 COMMENT '关注状态:1已关注 0已取关',
|
||||
`subscribe_time` datetime DEFAULT NULL COMMENT '关注时间',
|
||||
`unsubscribe_time` datetime DEFAULT NULL COMMENT '取关时间',
|
||||
`bind_type` varchar(32) NOT NULL DEFAULT '' COMMENT '绑定对象:platform_user平台用户 tenant_user租户用户 空=未绑定',
|
||||
`bind_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '绑定对象ID(平台用户ID / 租户用户ID)',
|
||||
`bind_tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '绑定租户ID(租户用户时记录)',
|
||||
`bind_time` datetime DEFAULT NULL COMMENT '绑定时间',
|
||||
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注(平台可编辑)',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
`delete_time` datetime DEFAULT NULL COMMENT '删除时间(软删除)',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_openid` (`openid`),
|
||||
KEY `idx_bind` (`bind_type`,`bind_id`),
|
||||
KEY `idx_subscribe` (`subscribe`),
|
||||
KEY `idx_delete_time` (`delete_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号关注用户(粉丝)表';
|
||||
|
||||
-- 3) 关注验证码(绑定会话)
|
||||
CREATE TABLE IF NOT EXISTS `yz_platform_wechat_mp_verify_code` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键ID',
|
||||
`scene` varchar(64) NOT NULL DEFAULT '' COMMENT '二维码场景值(随机串,唯一)',
|
||||
`code` varchar(8) NOT NULL DEFAULT '' COMMENT '验证码(6位数字,公众号被动回复给用户)',
|
||||
`openid` varchar(64) NOT NULL DEFAULT '' COMMENT '扫码用户 openid(扫码后写入)',
|
||||
`status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '状态:0待扫码 1已扫码待核销 2已核销 3已过期',
|
||||
`bind_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发起绑定方:platform_user / tenant_user',
|
||||
`bind_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '发起绑定方ID',
|
||||
`bind_tid` bigint(20) NOT NULL DEFAULT 0 COMMENT '发起绑定租户ID',
|
||||
`expire_at` datetime DEFAULT NULL COMMENT '有效期截止(默认5分钟)',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_scene` (`scene`),
|
||||
KEY `idx_openid` (`openid`),
|
||||
KEY `idx_create_time` (`create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信公众号关注取码/绑定验证码表';
|
||||
@@ -159,6 +159,11 @@ func Init(_ string) {
|
||||
// 平台端推广佣金(docs/sql/create_platform_commission.sql)
|
||||
new(PlatformCommissionRule),
|
||||
new(PlatformCommissionOrder),
|
||||
|
||||
// 微信公众号(服务号)绑定与推送(docs/sql/create_platform_wechat_mp.sql)
|
||||
new(WechatMpConfig),
|
||||
new(WechatMpFollower),
|
||||
new(WechatMpVerifyCode),
|
||||
)
|
||||
|
||||
// 创建全局 Ormer
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// =============================================================
|
||||
// 微信公众号(服务号)绑定与推送(表结构见 docs/sql/create_platform_wechat_mp.sql)
|
||||
// 1. 配置:AppID / AppSecret / Token / EncodingAESKey(敏感字段加密存储)
|
||||
// 2. 粉丝:关注/取关事件沉淀,可绑定平台用户或租户用户,认证后定向推送
|
||||
// 3. 验证码:扫码关注 → 公众号被动回复验证码 → 平台核销完成绑定
|
||||
// =============================================================
|
||||
|
||||
// 绑定对象类型
|
||||
const (
|
||||
WechatBindTypePlatformUser = "platform_user" // 平台用户(yz_system_admin_user)
|
||||
WechatBindTypeTenantUser = "tenant_user" // 租户用户(yz_system_tenant_user)
|
||||
)
|
||||
|
||||
// 验证码状态
|
||||
const (
|
||||
WechatVerifyStatusWaiting = 0 // 待扫码
|
||||
WechatVerifyStatusScanned = 1 // 已扫码待核销
|
||||
WechatVerifyStatusUsed = 2 // 已核销
|
||||
WechatVerifyStatusExpired = 3 // 已过期
|
||||
)
|
||||
|
||||
// WechatMpConfig 微信公众号(服务号)配置 yz_platform_wechat_mp_config(单行)
|
||||
type WechatMpConfig struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
AppID string `orm:"column(app_id);size(64)" json:"app_id"`
|
||||
AppSecret string `orm:"column(app_secret);size(512)" json:"-"` // 加密存储,不回显
|
||||
Token string `orm:"column(token);size(128)" json:"token"`
|
||||
AESKey string `orm:"column(aes_key);size(512)" json:"-"` // 加密存储,不回显
|
||||
EncryptMode string `orm:"column(encrypt_mode);size(16)" json:"encrypt_mode"` // plain / compatible / safe
|
||||
TemplateID string `orm:"column(template_id);size(128)" json:"template_id"`
|
||||
Verified int8 `orm:"column(verified);default(0)" json:"verified"`
|
||||
Enabled int8 `orm:"column(enabled);default(0)" json:"enabled"`
|
||||
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||
}
|
||||
|
||||
func (m *WechatMpConfig) TableName() string { return "yz_platform_wechat_mp_config" }
|
||||
|
||||
// WechatMpFollower 微信公众号关注用户(粉丝) yz_platform_wechat_mp_follower
|
||||
type WechatMpFollower struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
OpenID string `orm:"column(openid);size(64)" json:"openid"`
|
||||
UnionID string `orm:"column(unionid);size(64)" json:"unionid"`
|
||||
Nickname string `orm:"column(nickname);size(128)" json:"nickname"`
|
||||
Avatar string `orm:"column(avatar);size(512)" json:"avatar"`
|
||||
Sex int8 `orm:"column(sex);default(0)" json:"sex"`
|
||||
City string `orm:"column(city);size(64)" json:"city"`
|
||||
Province string `orm:"column(province);size(64)" json:"province"`
|
||||
Country string `orm:"column(country);size(64)" json:"country"`
|
||||
Subscribe int8 `orm:"column(subscribe);default(1)" json:"subscribe"`
|
||||
SubscribeTime *time.Time `orm:"column(subscribe_time);type(datetime);null" json:"subscribe_time"`
|
||||
UnsubscribeTime *time.Time `orm:"column(unsubscribe_time);type(datetime);null" json:"unsubscribe_time"`
|
||||
BindType string `orm:"column(bind_type);size(32)" json:"bind_type"`
|
||||
BindID uint64 `orm:"column(bind_id)" json:"bind_id"`
|
||||
BindTid uint64 `orm:"column(bind_tid)" json:"bind_tid"`
|
||||
BindTime *time.Time `orm:"column(bind_time);type(datetime);null" json:"bind_time"`
|
||||
Remark string `orm:"column(remark);size(255)" json:"remark"`
|
||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||
}
|
||||
|
||||
func (m *WechatMpFollower) TableName() string { return "yz_platform_wechat_mp_follower" }
|
||||
|
||||
// WechatMpVerifyCode 公众号关注取码/绑定验证码 yz_platform_wechat_mp_verify_code
|
||||
type WechatMpVerifyCode struct {
|
||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||
Scene string `orm:"column(scene);size(64)" json:"scene"`
|
||||
Code string `orm:"column(code);size(8)" json:"code"`
|
||||
OpenID string `orm:"column(openid);size(64)" json:"openid"`
|
||||
Status int8 `orm:"column(status);default(0)" json:"status"`
|
||||
BindType string `orm:"column(bind_type);size(32)" json:"bind_type"`
|
||||
BindID uint64 `orm:"column(bind_id)" json:"bind_id"`
|
||||
BindTid uint64 `orm:"column(bind_tid)" json:"bind_tid"`
|
||||
ExpireAt *time.Time `orm:"column(expire_at);type(datetime);null" json:"expire_at"`
|
||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||
}
|
||||
|
||||
func (m *WechatMpVerifyCode) TableName() string { return "yz_platform_wechat_mp_verify_code" }
|
||||
@@ -13,6 +13,7 @@ func Register() {
|
||||
RegisterRoleRoutes()
|
||||
RegisterPaymentRoutes()
|
||||
RegisterProductRoutes()
|
||||
RegisterWechatMpRoutes()
|
||||
}
|
||||
|
||||
// RegisterProductRoutes 注册租户端「产品购买」路由。
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"server/controllers"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// RegisterWechatMpRoutes 注册租户端「微信公众号绑定」路由。
|
||||
// 扫码关注公众号 → 公众号被动回复验证码 → 本端核销绑定(接口见 controllers/backend_wechat_mp.go)。
|
||||
func RegisterWechatMpRoutes() {
|
||||
beego.Router("/backend/wechatMp/bind/start", &controllers.BackendWechatMpController{}, "post:BindStart")
|
||||
beego.Router("/backend/wechatMp/bind/status", &controllers.BackendWechatMpController{}, "get:BindStatus")
|
||||
beego.Router("/backend/wechatMp/bind/confirm", &controllers.BackendWechatMpController{}, "post:BindConfirm")
|
||||
beego.Router("/backend/wechatMp/my", &controllers.BackendWechatMpController{}, "get:MyBinding")
|
||||
beego.Router("/backend/wechatMp/unbind", &controllers.BackendWechatMpController{}, "post:Unbind")
|
||||
}
|
||||
@@ -11,6 +11,9 @@ func Register() {
|
||||
// 支付模块(渠道配置 / 支付订单 / 对账 / 佣金 / 渠道回调)
|
||||
RegisterPaymentRoutes()
|
||||
|
||||
// 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调
|
||||
RegisterWechatMpRoutes()
|
||||
|
||||
// 平台登录相关
|
||||
beego.Router("/platform/login", &controllers.PlatformAuthController{}, "post:LoginPlatform")
|
||||
beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"server/controllers"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// RegisterWechatMpRoutes 注册微信公众号(服务号)相关路由(表结构见 docs/sql/create_platform_wechat_mp.sql)。
|
||||
// 注意:/api/wechat/mp/callback 供微信服务器回调,无平台鉴权(控制器内完成签名校验)。
|
||||
func RegisterWechatMpRoutes() {
|
||||
// 微信服务器回调(URL 填入公众号后台「服务器配置」,Token 与平台配置一致)
|
||||
beego.Router("/api/wechat/mp/callback", &controllers.WechatMpCallbackController{}, "get:Callback;post:Callback")
|
||||
|
||||
// 服务号配置
|
||||
beego.Router("/platform/wechatMp/config", &controllers.PlatformWechatMpController{}, "get:GetConfig;post:SaveConfig")
|
||||
beego.Router("/platform/wechatMp/test", &controllers.PlatformWechatMpController{}, "post:TestConnection")
|
||||
|
||||
// 绑定(平台用户)
|
||||
beego.Router("/platform/wechatMp/bind/start", &controllers.PlatformWechatMpController{}, "post:BindStart")
|
||||
beego.Router("/platform/wechatMp/bind/status", &controllers.PlatformWechatMpController{}, "get:BindStatus")
|
||||
beego.Router("/platform/wechatMp/bind/confirm", &controllers.PlatformWechatMpController{}, "post:BindConfirm")
|
||||
beego.Router("/platform/wechatMp/my", &controllers.PlatformWechatMpController{}, "get:MyBinding")
|
||||
beego.Router("/platform/wechatMp/unbind", &controllers.PlatformWechatMpController{}, "post:Unbind")
|
||||
|
||||
// 粉丝管理
|
||||
beego.Router("/platform/wechatMp/followers", &controllers.PlatformWechatMpController{}, "get:ListFollowers")
|
||||
beego.Router("/platform/wechatMp/follower/remark", &controllers.PlatformWechatMpController{}, "post:SetFollowerRemark")
|
||||
beego.Router("/platform/wechatMp/follower/unbind", &controllers.PlatformWechatMpController{}, "post:UnbindFollower")
|
||||
|
||||
// 消息推送(认证后可用:提醒 / 公告)
|
||||
beego.Router("/platform/wechatMp/push", &controllers.PlatformWechatMpController{}, "post:Push")
|
||||
beego.Router("/platform/wechatMp/push/test", &controllers.PlatformWechatMpController{}, "post:PushTest")
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package wechatmp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 微信公众平台服务端 API 调用(access_token / 带参二维码 / 模板消息 / 订阅通知 / 用户信息)
|
||||
// =============================================================
|
||||
|
||||
const apiBase = "https://api.weixin.qq.com"
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// APIError 微信接口业务错误(HTTP 200 但 errcode 非 0)。
|
||||
// Error() 输出保留 errcode / errmsg 原始信息,便于日志检索与前端展示。
|
||||
type APIError struct {
|
||||
API string // 调用的接口(如「获取 access_token」)
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("%s 失败:errcode=%d errmsg=%s", e.API, e.Code, e.Msg)
|
||||
}
|
||||
|
||||
// Explain 返回常见 errcode 的中文排查建议;未知错误码返回空串。
|
||||
func (e *APIError) Explain() string {
|
||||
switch e.Code {
|
||||
case 40013:
|
||||
return "AppID 无效:请核对微信后台「设置与开发 → 基本配置」中的 AppID"
|
||||
case 40125:
|
||||
return "AppSecret 错误:请在微信后台重置后复制新的 AppSecret 填入本页"
|
||||
case 40164, 61004:
|
||||
return "服务器出口 IP 不在白名单:请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)"
|
||||
case 45009:
|
||||
return "接口调用频次超限:请稍后重试"
|
||||
case 48001:
|
||||
return "接口未获授权:需认证服务号,或该账号未开通此接口能力"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExplainError 提取微信接口错误的中文排查建议;无可用建议时返回 ("", false)。
|
||||
func ExplainError(err error) (string, bool) {
|
||||
var ae *APIError
|
||||
if errors.As(err, &ae) {
|
||||
if hint := ae.Explain(); hint != "" {
|
||||
return hint, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// GetAccessToken 获取 access_token(内存缓存,提前 5 分钟刷新)
|
||||
func GetAccessToken(cfg *Config) (string, error) {
|
||||
if cfg == nil || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return "", fmt.Errorf("AppID / AppSecret 未配置")
|
||||
}
|
||||
tokenMu.Lock()
|
||||
if tokenCache.appID == cfg.AppID && tokenCache.token != "" && time.Now().Before(tokenCache.expiresAt) {
|
||||
t := tokenCache.token
|
||||
tokenMu.Unlock()
|
||||
return t, nil
|
||||
}
|
||||
tokenMu.Unlock()
|
||||
|
||||
api := fmt.Sprintf("%s/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
|
||||
apiBase, url.QueryEscape(cfg.AppID), url.QueryEscape(cfg.AppSecret))
|
||||
var resp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := httpGetJSON(api, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
return "", &APIError{API: "获取 access_token", Code: resp.ErrCode, Msg: resp.ErrMsg}
|
||||
}
|
||||
ttl := time.Duration(resp.ExpiresIn) * time.Second
|
||||
if ttl <= tokenMinTTL {
|
||||
ttl = time.Hour
|
||||
}
|
||||
tokenMu.Lock()
|
||||
tokenCache = tokenEntry{appID: cfg.AppID, token: resp.AccessToken, expiresAt: time.Now().Add(ttl - tokenMinTTL)}
|
||||
tokenMu.Unlock()
|
||||
return resp.AccessToken, nil
|
||||
}
|
||||
|
||||
// CreateQRCode 创建带参数临时二维码(字符串场景值),返回二维码图片地址。
|
||||
// scene 会通过「关注事件的 EventKey(qrscene_<scene>)/ 扫码事件的 EventKey(<scene>)」回传。
|
||||
func CreateQRCode(cfg *Config, scene string, expireSeconds int) (string, error) {
|
||||
token, err := GetAccessToken(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expireSeconds <= 0 || expireSeconds > 2592000 {
|
||||
expireSeconds = 300
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"expire_seconds": expireSeconds,
|
||||
"action_name": "QR_STR_SCENE",
|
||||
"action_info": map[string]interface{}{
|
||||
"scene": map[string]string{"scene_str": scene},
|
||||
},
|
||||
}
|
||||
api := fmt.Sprintf("%s/cgi-bin/qrcode/create?access_token=%s", apiBase, url.QueryEscape(token))
|
||||
var resp struct {
|
||||
Ticket string `json:"ticket"`
|
||||
ExpireSeconds int `json:"expire_seconds"`
|
||||
URL string `json:"url"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := httpPostJSON(api, payload, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Ticket == "" {
|
||||
return "", &APIError{API: "创建带参二维码", Code: resp.ErrCode, Msg: resp.ErrMsg}
|
||||
}
|
||||
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(resp.Ticket), nil
|
||||
}
|
||||
|
||||
// SendTemplateMessage 发送模板消息(需认证服务号 + 模板ID)
|
||||
// data 键为模板字段名(first / keyword1 / remark ...),值为文本内容。
|
||||
func SendTemplateMessage(cfg *Config, openid, jumpURL string, data map[string]string) error {
|
||||
if cfg.TemplateID == "" {
|
||||
return fmt.Errorf("未配置模板消息 ID(认证后在公众号后台申请)")
|
||||
}
|
||||
token, err := GetAccessToken(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := make(map[string]map[string]string, len(data))
|
||||
for k, v := range data {
|
||||
fields[k] = map[string]string{"value": v}
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"touser": openid,
|
||||
"template_id": cfg.TemplateID,
|
||||
"data": fields,
|
||||
}
|
||||
if jumpURL != "" {
|
||||
payload["url"] = jumpURL
|
||||
}
|
||||
api := fmt.Sprintf("%s/cgi-bin/message/template/send?access_token=%s", apiBase, url.QueryEscape(token))
|
||||
var resp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
MsgID int64 `json:"msgid"`
|
||||
}
|
||||
if err := httpPostJSON(api, payload, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return &APIError{API: "发送模板消息", Code: resp.ErrCode, Msg: resp.ErrMsg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendSubscribeMessage 发送订阅通知(一次性订阅,需用户先在页面完成订阅授权)
|
||||
func SendSubscribeMessage(cfg *Config, openid, templateID, page string, data map[string]string) error {
|
||||
if templateID == "" {
|
||||
return fmt.Errorf("未配置订阅通知模板 ID")
|
||||
}
|
||||
token, err := GetAccessToken(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := make(map[string]map[string]string, len(data))
|
||||
for k, v := range data {
|
||||
fields[k] = map[string]string{"value": v}
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"touser": openid,
|
||||
"template_id": templateID,
|
||||
"data": fields,
|
||||
}
|
||||
if page != "" {
|
||||
payload["page"] = page
|
||||
}
|
||||
api := fmt.Sprintf("%s/cgi-bin/message/subscribe/bizsend?access_token=%s", apiBase, url.QueryEscape(token))
|
||||
var resp struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := httpPostJSON(api, payload, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return &APIError{API: "发送订阅通知", Code: resp.ErrCode, Msg: resp.ErrMsg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserInfo 用户基本信息(昵称/头像等;未认证公众号可能接口受限,调用失败由上层忽略)
|
||||
type UserInfo struct {
|
||||
OpenID string `json:"openid"`
|
||||
Nickname string `json:"nickname"`
|
||||
Sex int `json:"sex"`
|
||||
City string `json:"city"`
|
||||
Province string `json:"province"`
|
||||
Country string `json:"country"`
|
||||
HeadImgURL string `json:"headimgurl"`
|
||||
Subscribe int `json:"subscribe"`
|
||||
}
|
||||
|
||||
// FetchUserInfo 拉取用户基本信息(失败不算错,由调用方容错)
|
||||
func FetchUserInfo(cfg *Config, openid string) (*UserInfo, error) {
|
||||
token, err := GetAccessToken(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
api := fmt.Sprintf("%s/cgi-bin/user/info?access_token=%s&openid=%s&lang=zh_CN",
|
||||
apiBase, url.QueryEscape(token), url.QueryEscape(openid))
|
||||
var resp struct {
|
||||
UserInfo
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := httpGetJSON(api, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.ErrCode != 0 {
|
||||
return nil, &APIError{API: "获取用户信息", Code: resp.ErrCode, Msg: resp.ErrMsg}
|
||||
}
|
||||
return &resp.UserInfo, nil
|
||||
}
|
||||
|
||||
// ============================ HTTP 辅助 ============================
|
||||
|
||||
func httpGetJSON(api string, out interface{}) error {
|
||||
resp, err := httpClient.Get(api)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取微信响应失败: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func httpPostJSON(api string, payload interface{}, out interface{}) error {
|
||||
bs, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := httpClient.Post(api, "application/json; charset=utf-8", bytes.NewReader(bs))
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求微信接口失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取微信响应失败: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("解析微信响应失败: %w(body: %s)", err, truncate(string(body), 200))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package wechatmp
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 关注绑定:扫码关注 → 公众号被动回复验证码 → 平台核销完成绑定
|
||||
// 一个绑定会话 = 一条 yz_platform_wechat_mp_verify_code(scene + 6位验证码 + 过期时间)
|
||||
// =============================================================
|
||||
|
||||
// DefaultVerifyTTL 验证码默认有效期
|
||||
const DefaultVerifyTTL = 5 * time.Minute
|
||||
|
||||
var (
|
||||
ErrSceneNotScanned = errors.New("尚未检测到扫码关注,请先使用微信扫码并关注公众号")
|
||||
ErrCodeUsed = errors.New("该验证码已使用,请重新获取二维码")
|
||||
ErrCodeExpired = errors.New("二维码已过期,请重新获取")
|
||||
ErrCodeMismatch = errors.New("验证码不正确")
|
||||
ErrSceneInvalid = errors.New("二维码无效或已失效")
|
||||
)
|
||||
|
||||
// CreateVerifyCode 创建绑定会话并生成带参二维码。
|
||||
// bindType 为 platform_user / tenant_user,bindID 为发起方用户 ID。
|
||||
func CreateVerifyCode(bindType string, bindID, bindTid uint64, ttl time.Duration) (scene, code, qrURL string, expireAt time.Time, err error) {
|
||||
cfg, err := LoadEnabledConfig()
|
||||
if err != nil {
|
||||
return "", "", "", time.Time{}, err
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultVerifyTTL
|
||||
}
|
||||
scene = randomScene()
|
||||
code = randomCode6()
|
||||
now := time.Now()
|
||||
expireAt = now.Add(ttl)
|
||||
|
||||
row := &models.WechatMpVerifyCode{
|
||||
Scene: scene,
|
||||
Code: code,
|
||||
Status: models.WechatVerifyStatusWaiting,
|
||||
BindType: bindType,
|
||||
BindID: bindID,
|
||||
BindTid: bindTid,
|
||||
ExpireAt: &expireAt,
|
||||
CreateTime: now,
|
||||
UpdateTime: &now,
|
||||
}
|
||||
if _, err = models.Orm.Insert(row); err != nil {
|
||||
return "", "", "", time.Time{}, fmt.Errorf("创建绑定会话失败: %w", err)
|
||||
}
|
||||
|
||||
qrURL, err = CreateQRCode(cfg, scene, int(ttl.Seconds())+120)
|
||||
if err != nil {
|
||||
return "", "", "", time.Time{}, err
|
||||
}
|
||||
return scene, code, qrURL, expireAt, nil
|
||||
}
|
||||
|
||||
// GetVerifyCode 按 scene 查询绑定会话(过期时惰性标记)
|
||||
func GetVerifyCode(scene string) (*models.WechatMpVerifyCode, error) {
|
||||
scene = strings.TrimSpace(scene)
|
||||
if scene == "" {
|
||||
return nil, ErrSceneInvalid
|
||||
}
|
||||
var row models.WechatMpVerifyCode
|
||||
if err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).Filter("scene", scene).One(&row); err != nil {
|
||||
return nil, ErrSceneInvalid
|
||||
}
|
||||
if row.Status != models.WechatVerifyStatusUsed && row.ExpireAt != nil && row.ExpireAt.Before(time.Now()) {
|
||||
row.Status = models.WechatVerifyStatusExpired
|
||||
now := time.Now()
|
||||
_, _ = models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("id", row.ID).
|
||||
Update(map[string]interface{}{"status": row.Status, "update_time": now})
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// ConfirmVerifyCode 核销验证码并完成绑定,返回粉丝记录
|
||||
func ConfirmVerifyCode(bindType string, bindID, bindTid uint64, scene, code string) (*models.WechatMpFollower, error) {
|
||||
row, err := GetVerifyCode(scene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if row.BindType != bindType || row.BindID != bindID {
|
||||
return nil, errors.New("该二维码不是当前账号发起,请重新获取")
|
||||
}
|
||||
switch row.Status {
|
||||
case models.WechatVerifyStatusWaiting:
|
||||
return nil, ErrSceneNotScanned
|
||||
case models.WechatVerifyStatusUsed:
|
||||
return nil, ErrCodeUsed
|
||||
case models.WechatVerifyStatusExpired:
|
||||
return nil, ErrCodeExpired
|
||||
}
|
||||
if strings.TrimSpace(code) != row.Code {
|
||||
return nil, ErrCodeMismatch
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("id", row.ID).
|
||||
Update(map[string]interface{}{"status": models.WechatVerifyStatusUsed, "update_time": now}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BindFollower(row.OpenID, bindType, bindID, bindTid)
|
||||
}
|
||||
|
||||
// BindFollower 将 openid 绑定到指定账号。
|
||||
// 同一账号只保留一个微信:旧的 openid 绑定关系会被清除。
|
||||
func BindFollower(openid, bindType string, bindID, bindTid uint64) (*models.WechatMpFollower, error) {
|
||||
openid = strings.TrimSpace(openid)
|
||||
if openid == "" {
|
||||
return nil, errors.New("openid 为空")
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
_, _ = models.Orm.QueryTable(new(models.WechatMpFollower)).
|
||||
Filter("bind_type", bindType).
|
||||
Filter("bind_id", bindID).
|
||||
Filter("openid__ne", openid).
|
||||
Update(map[string]interface{}{
|
||||
"bind_type": "", "bind_id": 0, "bind_tid": 0, "update_time": now,
|
||||
})
|
||||
|
||||
var row models.WechatMpFollower
|
||||
err := models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("openid", openid).One(&row)
|
||||
if err == orm.ErrNoRows {
|
||||
row = models.WechatMpFollower{
|
||||
OpenID: openid,
|
||||
Subscribe: 1,
|
||||
SubscribeTime: &now,
|
||||
CreateTime: now,
|
||||
}
|
||||
}
|
||||
row.BindType = bindType
|
||||
row.BindID = bindID
|
||||
row.BindTid = bindTid
|
||||
row.BindTime = &now
|
||||
row.UpdateTime = &now
|
||||
if row.ID == 0 {
|
||||
id, ierr := models.Orm.Insert(&row)
|
||||
if ierr != nil {
|
||||
return nil, ierr
|
||||
}
|
||||
row.ID = uint64(id)
|
||||
return &row, nil
|
||||
}
|
||||
if _, uerr := models.Orm.Update(&row, "BindType", "BindID", "BindTid", "BindTime", "UpdateTime"); uerr != nil {
|
||||
return nil, uerr
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// UnbindByUser 清除某账号的微信绑定(按绑定对象维度)
|
||||
func UnbindByUser(bindType string, bindID uint64) error {
|
||||
if bindID == 0 {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMpFollower)).
|
||||
Filter("bind_type", bindType).
|
||||
Filter("bind_id", bindID).
|
||||
Update(map[string]interface{}{
|
||||
"bind_type": "", "bind_id": 0, "bind_tid": 0, "update_time": now,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetFollowerByUser 查询某账号已绑定的粉丝记录(未绑定返回 nil, nil)
|
||||
func GetFollowerByUser(bindType string, bindID uint64) (*models.WechatMpFollower, error) {
|
||||
if bindID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var row models.WechatMpFollower
|
||||
err := models.Orm.QueryTable(new(models.WechatMpFollower)).
|
||||
Filter("bind_type", bindType).
|
||||
Filter("bind_id", bindID).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-id").
|
||||
One(&row)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// UpsertSubscribe 关注事件:创建/恢复粉丝记录
|
||||
func UpsertSubscribe(openid string, at time.Time) (*models.WechatMpFollower, error) {
|
||||
openid = strings.TrimSpace(openid)
|
||||
if openid == "" {
|
||||
return nil, errors.New("openid 为空")
|
||||
}
|
||||
var row models.WechatMpFollower
|
||||
err := models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("openid", openid).One(&row)
|
||||
if err == orm.ErrNoRows {
|
||||
row = models.WechatMpFollower{OpenID: openid, Subscribe: 1, SubscribeTime: &at, CreateTime: at, UpdateTime: &at}
|
||||
id, ierr := models.Orm.Insert(&row)
|
||||
if ierr != nil {
|
||||
return nil, ierr
|
||||
}
|
||||
row.ID = uint64(id)
|
||||
return &row, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Subscribe = 1
|
||||
row.SubscribeTime = &at
|
||||
row.UnsubscribeTime = nil
|
||||
row.UpdateTime = &at
|
||||
if _, err := models.Orm.Update(&row, "Subscribe", "SubscribeTime", "UnsubscribeTime", "UpdateTime"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// MarkUnsubscribe 取关事件
|
||||
func MarkUnsubscribe(openid string, at time.Time) error {
|
||||
openid = strings.TrimSpace(openid)
|
||||
if openid == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := models.Orm.QueryTable(new(models.WechatMpFollower)).
|
||||
Filter("openid", openid).
|
||||
Update(map[string]interface{}{
|
||||
"subscribe": 0, "unsubscribe_time": at, "update_time": at,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RefreshFollowerProfile 尝试拉取粉丝昵称/头像(未认证公众号可能受限,失败静默忽略)
|
||||
func RefreshFollowerProfile(cfg *Config, openid string) {
|
||||
if cfg == nil || openid == "" {
|
||||
return
|
||||
}
|
||||
info, err := FetchUserInfo(cfg, openid)
|
||||
if err != nil || info == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
up := map[string]interface{}{
|
||||
"sex": info.Sex,
|
||||
"city": info.City,
|
||||
"province": info.Province,
|
||||
"country": info.Country,
|
||||
"update_time": now,
|
||||
}
|
||||
if info.Nickname != "" {
|
||||
up["nickname"] = info.Nickname
|
||||
}
|
||||
if info.HeadImgURL != "" {
|
||||
up["avatar"] = info.HeadImgURL
|
||||
}
|
||||
_, _ = models.Orm.QueryTable(new(models.WechatMpFollower)).Filter("openid", openid).Update(up)
|
||||
}
|
||||
|
||||
// HandleInbound 处理入站消息/事件,返回需要被动回复的文本(空字符串表示不回复)
|
||||
func HandleInbound(cfg *Config, msg *InboundMessage) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
openid := strings.TrimSpace(msg.FromUserName)
|
||||
now := time.Now()
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(msg.MsgType)) {
|
||||
case "event":
|
||||
switch strings.ToUpper(strings.TrimSpace(msg.Event)) {
|
||||
case "subscribe":
|
||||
_, _ = UpsertSubscribe(openid, now)
|
||||
scene := strings.TrimPrefix(strings.TrimSpace(msg.EventKey), "qrscene_")
|
||||
if scene != "" {
|
||||
return handleScanScene(cfg, scene, openid)
|
||||
}
|
||||
return "欢迎关注云泽平台!\n如需接收系统提醒、公告推送,请回到系统页面点击「绑定微信」,使用微信扫码获取专属验证码。"
|
||||
case "scan":
|
||||
return handleScanScene(cfg, strings.TrimSpace(msg.EventKey), openid)
|
||||
case "unsubscribe":
|
||||
_ = MarkUnsubscribe(openid, now)
|
||||
return ""
|
||||
}
|
||||
case "text":
|
||||
return "如需绑定账号:请回到系统页面点击「绑定微信」,展示专属二维码后使用微信扫码,公众号会自动回复验证码。\n绑定成功后即可接收系统提醒、公告推送。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleScanScene 扫码(关注/已关注)后的统一处理:写入验证码会话 + 回复验证码
|
||||
func handleScanScene(cfg *Config, scene, openid string) string {
|
||||
if strings.TrimSpace(scene) == "" {
|
||||
return "未能识别二维码信息,请在系统页面重新获取二维码后再扫码。"
|
||||
}
|
||||
row, err := GetVerifyCode(scene)
|
||||
if err != nil {
|
||||
return "二维码无效或已失效,请在系统页面重新获取。"
|
||||
}
|
||||
switch row.Status {
|
||||
case models.WechatVerifyStatusUsed:
|
||||
return "该验证码已使用,请在系统页面重新获取二维码。"
|
||||
case models.WechatVerifyStatusExpired:
|
||||
return "二维码已过期,请在系统页面重新获取。"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if _, uerr := models.Orm.QueryTable(new(models.WechatMpVerifyCode)).
|
||||
Filter("id", row.ID).
|
||||
Update(map[string]interface{}{
|
||||
"openid": openid, "status": models.WechatVerifyStatusScanned, "update_time": now,
|
||||
}); uerr != nil {
|
||||
return "系统繁忙,请稍后重新扫码。"
|
||||
}
|
||||
// 拉取昵称头像(失败不影响绑定流程)
|
||||
RefreshFollowerProfile(cfg, openid)
|
||||
|
||||
minutes := int(time.Until(*row.ExpireAt).Minutes())
|
||||
if minutes < 1 {
|
||||
minutes = 1
|
||||
}
|
||||
return fmt.Sprintf("您的验证码是:%s\n请回到系统页面输入完成绑定(%d 分钟内有效)。", row.Code, minutes)
|
||||
}
|
||||
|
||||
// ============================ 随机值 ============================
|
||||
|
||||
func randomScene() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("s%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func randomCode6() string {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(900000))
|
||||
if err != nil {
|
||||
return "123456"
|
||||
}
|
||||
return fmt.Sprintf("%06d", n.Int64()+100000)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package wechatmp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 微信公众号(服务号)配置与 access_token
|
||||
// =============================================================
|
||||
|
||||
// 消息加解密方式
|
||||
const (
|
||||
EncryptModePlain = "plain" // 明文模式
|
||||
EncryptModeCompatible = "compatible" // 兼容模式
|
||||
EncryptModeSafe = "safe" // 安全模式
|
||||
)
|
||||
|
||||
// Config 服务号运行配置(敏感字段已解密)
|
||||
type Config struct {
|
||||
ID uint64
|
||||
AppID string
|
||||
AppSecret string // 明文
|
||||
Token string
|
||||
AESKey string // 明文(43 位 EncodingAESKey)
|
||||
EncryptMode string
|
||||
TemplateID string
|
||||
Verified bool
|
||||
Enabled bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
// ErrNotConfigured 未配置或未启用
|
||||
var ErrNotConfigured = errors.New("微信公众号未配置或未启用")
|
||||
|
||||
// LoadConfig 读取服务号配置(单行,敏感字段解密);未配置返回 ErrNotConfigured
|
||||
func LoadConfig() (*Config, error) {
|
||||
var row models.WechatMpConfig
|
||||
err := models.Orm.QueryTable(new(models.WechatMpConfig)).OrderBy("id").One(&row)
|
||||
if err != nil {
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
cfg := &Config{
|
||||
ID: row.ID,
|
||||
AppID: strings.TrimSpace(row.AppID),
|
||||
Token: strings.TrimSpace(row.Token),
|
||||
EncryptMode: strings.TrimSpace(row.EncryptMode),
|
||||
TemplateID: strings.TrimSpace(row.TemplateID),
|
||||
Verified: row.Verified == 1,
|
||||
Enabled: row.Enabled == 1,
|
||||
Remark: row.Remark,
|
||||
}
|
||||
if cfg.EncryptMode == "" {
|
||||
cfg.EncryptMode = EncryptModePlain
|
||||
}
|
||||
if row.AppSecret != "" {
|
||||
if plain, derr := DecryptSecret(row.AppSecret); derr == nil {
|
||||
cfg.AppSecret = plain
|
||||
} else {
|
||||
return nil, derr
|
||||
}
|
||||
}
|
||||
if row.AESKey != "" {
|
||||
if plain, derr := DecryptSecret(row.AESKey); derr == nil {
|
||||
cfg.AESKey = plain
|
||||
} else {
|
||||
return nil, derr
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadEnabledConfig 读取并校验启用状态
|
||||
func LoadEnabledConfig() (*Config, error) {
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveInput 保存配置入参(敏感字段支持掩码表示不改)
|
||||
type SaveInput struct {
|
||||
AppID string
|
||||
AppSecret string // 掩码 / 空串 = 保持不变
|
||||
Token string
|
||||
AESKey string // 掩码 / 空串 = 保持不变
|
||||
EncryptMode string
|
||||
TemplateID string
|
||||
Verified bool
|
||||
Enabled bool
|
||||
Remark string
|
||||
}
|
||||
|
||||
// SaveConfig 保存服务号配置(不存在则创建)
|
||||
func SaveConfig(in SaveInput) error {
|
||||
if strings.TrimSpace(in.AppID) == "" {
|
||||
return errors.New("AppID 不能为空")
|
||||
}
|
||||
mode := strings.TrimSpace(in.EncryptMode)
|
||||
if mode == "" {
|
||||
mode = EncryptModePlain
|
||||
}
|
||||
|
||||
var row models.WechatMpConfig
|
||||
err := models.Orm.QueryTable(new(models.WechatMpConfig)).OrderBy("id").One(&row)
|
||||
now := time.Now()
|
||||
isNew := false
|
||||
if err != nil {
|
||||
if err != orm.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
isNew = true
|
||||
row = models.WechatMpConfig{}
|
||||
}
|
||||
|
||||
// 敏感字段:掩码/空串表示保持原值
|
||||
secret := strings.TrimSpace(in.AppSecret)
|
||||
if secret != "" && !IsMasked(secret) {
|
||||
enc, eerr := EncryptSecret(secret)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
row.AppSecret = enc
|
||||
}
|
||||
aesKey := strings.TrimSpace(in.AESKey)
|
||||
if aesKey != "" && !IsMasked(aesKey) {
|
||||
enc, eerr := EncryptSecret(aesKey)
|
||||
if eerr != nil {
|
||||
return eerr
|
||||
}
|
||||
row.AESKey = enc
|
||||
}
|
||||
|
||||
row.AppID = strings.TrimSpace(in.AppID)
|
||||
row.Token = strings.TrimSpace(in.Token)
|
||||
row.EncryptMode = mode
|
||||
row.TemplateID = strings.TrimSpace(in.TemplateID)
|
||||
row.Remark = strings.TrimSpace(in.Remark)
|
||||
if in.Verified {
|
||||
row.Verified = 1
|
||||
} else {
|
||||
row.Verified = 0
|
||||
}
|
||||
if in.Enabled {
|
||||
row.Enabled = 1
|
||||
} else {
|
||||
row.Enabled = 0
|
||||
}
|
||||
row.UpdateTime = &now
|
||||
|
||||
if isNew {
|
||||
row.CreateTime = now
|
||||
_, err = models.Orm.Insert(&row)
|
||||
return err
|
||||
}
|
||||
_, err = models.Orm.Update(&row)
|
||||
return err
|
||||
}
|
||||
|
||||
// CallbackURL 微信服务器回调地址(供公众号后台「服务器配置」填写)。
|
||||
// 优先读取 app.conf 的 wechat_mp_callback_base,其次回落 payment_callback_base(同为对外 API 域名)。
|
||||
func CallbackURL() string {
|
||||
base, _ := beego.AppConfig.String("wechat_mp_callback_base")
|
||||
if strings.TrimSpace(base) == "" {
|
||||
base, _ = beego.AppConfig.String("payment_callback_base")
|
||||
}
|
||||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
if base == "" {
|
||||
return "/api/wechat/mp/callback"
|
||||
}
|
||||
return base + "/api/wechat/mp/callback"
|
||||
}
|
||||
|
||||
// ============================ access_token 缓存 ============================
|
||||
|
||||
type tokenEntry struct {
|
||||
appID string
|
||||
token string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
tokenMu sync.Mutex
|
||||
tokenCache tokenEntry
|
||||
tokenMinTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// resetTokenCacheForTest 仅供测试清理缓存
|
||||
func resetTokenCacheForTest() {
|
||||
tokenMu.Lock()
|
||||
defer tokenMu.Unlock()
|
||||
tokenCache = tokenEntry{}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package wechatmp
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 微信公众号敏感字段(AppSecret / EncodingAESKey)的对称加密与掩码
|
||||
//
|
||||
// yz_platform_wechat_mp_config.app_secret / aes_key 以 AES-256-GCM 加密后存储,
|
||||
// 格式:base64( "YZW1:" + nonce + ciphertext )。
|
||||
// 密钥来源:app.conf 的 wechat_mp_secret_key(任意长度字符串,内部做 SHA-256 派生);
|
||||
// 未配置时退回内置兜底密钥(保证开箱可用),上线前建议在 app.conf 配置该值。
|
||||
// 注意:更换密钥后,历史密文将无法解密,需要重新保存一次配置。
|
||||
// =============================================================
|
||||
|
||||
const (
|
||||
cipherPrefix = "YZW1:"
|
||||
fallbackSecretKey = "yunzer_wechat_mp_default_secret_key_v1"
|
||||
)
|
||||
|
||||
func configKey() []byte {
|
||||
raw, _ := beego.AppConfig.String("wechat_mp_secret_key")
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
raw = fallbackSecretKey
|
||||
}
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// EncryptSecret 加密敏感字段;空串原样返回
|
||||
func EncryptSecret(plain string) (string, error) {
|
||||
if plain == "" {
|
||||
return "", nil
|
||||
}
|
||||
block, err := aes.NewCipher(configKey())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil)
|
||||
buf := append([]byte(cipherPrefix), nonce...)
|
||||
buf = append(buf, ciphertext...)
|
||||
return base64.StdEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// DecryptSecret 解密敏感字段;空串原样返回
|
||||
func DecryptSecret(encoded string) (string, error) {
|
||||
if encoded == "" {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", errors.New("密文不是有效的 base64")
|
||||
}
|
||||
if len(raw) < len(cipherPrefix) || string(raw[:len(cipherPrefix)]) != cipherPrefix {
|
||||
return "", errors.New("密文格式不正确(缺少加密前缀)")
|
||||
}
|
||||
block, err := aes.NewCipher(configKey())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := raw[len(cipherPrefix) : len(cipherPrefix)+gcm.NonceSize()]
|
||||
ciphertext := raw[len(cipherPrefix)+gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("解密失败(wechat_mp_secret_key 是否被更换过?)")
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// MaskSecret 生成掩码:保留末 4 位
|
||||
func MaskSecret(v string) string {
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if len(v) <= 4 {
|
||||
return "******"
|
||||
}
|
||||
return "******" + v[len(v)-4:]
|
||||
}
|
||||
|
||||
// IsMasked 判断前端回传的值是否仍是掩码(表示「不修改原值」)
|
||||
func IsMasked(v string) bool {
|
||||
return strings.HasPrefix(v, "******")
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package wechatmp
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 微信被动消息:签名校验 / 安全模式 AES 加解密 / XML 解析与回复构造
|
||||
// 规范参考:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Message_encryption_and_decryption_instructions.html
|
||||
// =============================================================
|
||||
|
||||
// pkcs7BlockSize 微信官方填充块大小(注意不是 16,是 32)
|
||||
const pkcs7BlockSize = 32
|
||||
|
||||
// InboundMessage 接收到的消息/事件(已解密后的 XML 内容)
|
||||
type InboundMessage struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ToUserName string `xml:"ToUserName"`
|
||||
FromUserName string `xml:"FromUserName"`
|
||||
CreateTime int64 `xml:"CreateTime"`
|
||||
MsgType string `xml:"MsgType"` // text / image / event ...
|
||||
Content string `xml:"Content"`
|
||||
Event string `xml:"Event"` // subscribe / unsubscribe / SCAN / CLICK ...
|
||||
EventKey string `xml:"EventKey"` // qrscene_<scene> 或 <scene>
|
||||
Ticket string `xml:"Ticket"`
|
||||
MsgID int64 `xml:"MsgId"`
|
||||
Encrypt string `xml:"Encrypt"` // 安全模式下外层 XML 携带的密文
|
||||
}
|
||||
|
||||
// ParseInboundXML 解析(明文/已解密的)消息 XML
|
||||
func ParseInboundXML(raw string) (*InboundMessage, error) {
|
||||
var msg InboundMessage
|
||||
if err := xml.Unmarshal([]byte(raw), &msg); err != nil {
|
||||
return nil, fmt.Errorf("解析微信消息失败: %w", err)
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// CheckSignature 明文模式签名校验:sha1(sort(token, timestamp, nonce))
|
||||
func CheckSignature(token, timestamp, nonce, signature string) bool {
|
||||
if token == "" || signature == "" {
|
||||
return false
|
||||
}
|
||||
arr := []string{token, timestamp, nonce}
|
||||
sort.Strings(arr)
|
||||
sum := sha1.Sum([]byte(strings.Join(arr, "")))
|
||||
return hex.EncodeToString(sum[:]) == signature
|
||||
}
|
||||
|
||||
// CheckMsgSignature 安全/兼容模式签名校验:sha1(sort(token, timestamp, nonce, encrypt))
|
||||
func CheckMsgSignature(token, timestamp, nonce, encrypt, msgSignature string) bool {
|
||||
if token == "" || msgSignature == "" {
|
||||
return false
|
||||
}
|
||||
arr := []string{token, timestamp, nonce, encrypt}
|
||||
sort.Strings(arr)
|
||||
sum := sha1.Sum([]byte(strings.Join(arr, "")))
|
||||
return hex.EncodeToString(sum[:]) == msgSignature
|
||||
}
|
||||
|
||||
// MsgSignature 计算安全模式消息签名(回复加密时使用)
|
||||
func MsgSignature(token, timestamp, nonce, encrypt string) string {
|
||||
arr := []string{token, timestamp, nonce, encrypt}
|
||||
sort.Strings(arr)
|
||||
sum := sha1.Sum([]byte(strings.Join(arr, "")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ============================ AES 加解密 ============================
|
||||
|
||||
func decodeAESKey(encodingAESKey string) ([]byte, error) {
|
||||
key := strings.TrimSpace(encodingAESKey)
|
||||
if len(key) != 43 {
|
||||
return nil, fmt.Errorf("EncodingAESKey 长度应为 43 位(当前 %d)", len(key))
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(key + "=")
|
||||
}
|
||||
|
||||
// DecryptMessage 安全模式消息解密,返回明文 XML;appID 非空时会校验一致性
|
||||
func DecryptMessage(aesKey, appID, encryptBase64 string) (string, error) {
|
||||
key, err := decodeAESKey(aesKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(encryptBase64)
|
||||
if err != nil {
|
||||
return "", errors.New("密文不是有效的 base64")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < aes.BlockSize || len(raw)%aes.BlockSize != 0 {
|
||||
return "", errors.New("密文长度不合法")
|
||||
}
|
||||
iv := key[:aes.BlockSize]
|
||||
plain := make([]byte, len(raw))
|
||||
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, raw)
|
||||
plain, err = pkcs7Unpad(plain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(plain) < 20 {
|
||||
return "", errors.New("解密内容长度不合法")
|
||||
}
|
||||
msgLen := int(binary.BigEndian.Uint32(plain[16:20]))
|
||||
if msgLen < 0 || 20+msgLen > len(plain) {
|
||||
return "", errors.New("解密内容长度不合法")
|
||||
}
|
||||
msg := string(plain[20 : 20+msgLen])
|
||||
gotAppID := strings.TrimSpace(string(plain[20+msgLen:]))
|
||||
if appID != "" && gotAppID != appID {
|
||||
return "", fmt.Errorf("AppID 校验失败(收到 %s)", gotAppID)
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// EncryptMessage 安全模式消息加密,返回 base64 密文
|
||||
func EncryptMessage(aesKey, appID, msg string) (string, error) {
|
||||
key, err := decodeAESKey(aesKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
random := make([]byte, 16)
|
||||
if _, err := io.ReadFull(rand.Reader, random); err != nil {
|
||||
return "", err
|
||||
}
|
||||
msgBytes := []byte(msg)
|
||||
lenBytes := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(lenBytes, uint32(len(msgBytes)))
|
||||
|
||||
buf := make([]byte, 0, 16+4+len(msgBytes)+len(appID))
|
||||
buf = append(buf, random...)
|
||||
buf = append(buf, lenBytes...)
|
||||
buf = append(buf, msgBytes...)
|
||||
buf = append(buf, []byte(appID)...)
|
||||
buf = pkcs7Pad(buf)
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iv := key[:aes.BlockSize]
|
||||
ciphertext := make([]byte, len(buf))
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, buf)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
func pkcs7Pad(data []byte) []byte {
|
||||
pad := pkcs7BlockSize - len(data)%pkcs7BlockSize
|
||||
if pad <= 0 {
|
||||
pad = pkcs7BlockSize
|
||||
}
|
||||
out := make([]byte, 0, len(data)+pad)
|
||||
out = append(out, data...)
|
||||
for i := 0; i < pad; i++ {
|
||||
out = append(out, byte(pad))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("空数据")
|
||||
}
|
||||
pad := int(data[len(data)-1])
|
||||
if pad < 1 || pad > pkcs7BlockSize || pad > len(data) {
|
||||
return nil, errors.New("PKCS7 填充不合法")
|
||||
}
|
||||
for i := len(data) - pad; i < len(data); i++ {
|
||||
if int(data[i]) != pad {
|
||||
return nil, errors.New("PKCS7 填充不合法")
|
||||
}
|
||||
}
|
||||
return data[:len(data)-pad], nil
|
||||
}
|
||||
|
||||
// ============================ 被动回复构造 ============================
|
||||
|
||||
// BuildTextReply 构造文本消息回复 XML(明文模式直接返回)
|
||||
func BuildTextReply(toUser, fromUser, content string, timestamp int64) string {
|
||||
esc := func(s string) string {
|
||||
return strings.NewReplacer(
|
||||
"]]>", "]] >",
|
||||
).Replace(s)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName><CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>`,
|
||||
esc(toUser), esc(fromUser), timestamp, esc(content))
|
||||
}
|
||||
|
||||
// BuildEncryptedReply 安全模式回复:对明文回复加密并组装外层 XML
|
||||
func BuildEncryptedReply(cfg *Config, plainReply, timestamp, nonce string) (string, error) {
|
||||
encrypt, err := EncryptMessage(cfg.AESKey, cfg.AppID, plainReply)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sig := MsgSignature(cfg.Token, timestamp, nonce, encrypt)
|
||||
return fmt.Sprintf(
|
||||
`<xml><Encrypt><![CDATA[%s]]></Encrypt><MsgSignature><![CDATA[%s]]></MsgSignature><TimeStamp>%s</TimeStamp><Nonce><![CDATA[%s]]></Nonce></xml>`,
|
||||
encrypt, sig, timestamp, nonce), nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package wechatmp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// =============================================================
|
||||
// 消息推送(认证后可用):
|
||||
// 未认证服务号微信不允许主动推送(模板消息/订阅通知/客服消息均受限),
|
||||
// 本层只负责在认证后通过模板消息把提醒/公告发给已关注粉丝;
|
||||
// 未认证时返回 ErrNotVerified,由上层给出明确提示。
|
||||
// =============================================================
|
||||
|
||||
// ErrNotVerified 公众号未认证,无法主动推送
|
||||
var ErrNotVerified = errors.New("公众号未认证:模板消息推送需认证后使用(当前仅支持关注与被动回复)")
|
||||
|
||||
// PushMessage 推送内容(对应「标题/内容/备注」通用模板字段)
|
||||
type PushMessage struct {
|
||||
Title string // first:首行说明
|
||||
Content string // keyword1:主体内容
|
||||
Remark string // remark:备注
|
||||
URL string // 点击跳转地址(可选)
|
||||
}
|
||||
|
||||
// PushToOpenID 向指定粉丝推送一条模板消息
|
||||
func PushToOpenID(cfg *Config, openid string, msg PushMessage) error {
|
||||
if cfg == nil {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
if !cfg.Verified {
|
||||
return ErrNotVerified
|
||||
}
|
||||
if openid == "" {
|
||||
return errors.New("openid 为空")
|
||||
}
|
||||
if msg.Title == "" {
|
||||
msg.Title = "您有一条新的消息"
|
||||
}
|
||||
if len([]rune(msg.Title)) > 100 {
|
||||
msg.Title = string([]rune(msg.Title)[:100])
|
||||
}
|
||||
if len([]rune(msg.Content)) > 200 {
|
||||
msg.Content = string([]rune(msg.Content)[:200])
|
||||
}
|
||||
if len([]rune(msg.Remark)) > 100 {
|
||||
msg.Remark = string([]rune(msg.Remark)[:100])
|
||||
}
|
||||
return SendTemplateMessage(cfg, openid, msg.URL, map[string]string{
|
||||
"first": msg.Title,
|
||||
"keyword1": msg.Content,
|
||||
"remark": msg.Remark,
|
||||
})
|
||||
}
|
||||
|
||||
// PushScope 推送范围
|
||||
type PushScope struct {
|
||||
// BindType 为空表示全部已关注粉丝;platform_user / tenant_user 只推绑定对应类型账号的粉丝
|
||||
BindType string
|
||||
// BindID 非 0 时只推给该绑定账号(与 BindType 组合使用)
|
||||
BindID uint64
|
||||
// Tid 非 0 时只推给绑定该租户的用户(tenant_user 场景)
|
||||
Tid uint64
|
||||
}
|
||||
|
||||
// MaxBatchPush 单次批量推送上限
|
||||
const MaxBatchPush = 500
|
||||
|
||||
// PushToFollowers 按范围批量推送,返回成功/失败数量。
|
||||
// 仅推送给「已关注(subscribe=1)且未删除」的粉丝。
|
||||
func PushToFollowers(cfg *Config, scope PushScope, msg PushMessage) (sent, failed int, err error) {
|
||||
if cfg == nil {
|
||||
return 0, 0, ErrNotConfigured
|
||||
}
|
||||
if !cfg.Verified {
|
||||
return 0, 0, ErrNotVerified
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.WechatMpFollower)).
|
||||
Filter("subscribe", 1).
|
||||
Filter("delete_time__isnull", true)
|
||||
if scope.BindType != "" {
|
||||
qs = qs.Filter("bind_type", scope.BindType)
|
||||
}
|
||||
if scope.BindID > 0 {
|
||||
qs = qs.Filter("bind_id", scope.BindID)
|
||||
}
|
||||
if scope.Tid > 0 {
|
||||
qs = qs.Filter("bind_tid", scope.Tid)
|
||||
}
|
||||
|
||||
var rows []models.WechatMpFollower
|
||||
if _, qerr := qs.OrderBy("-id").Limit(MaxBatchPush).All(&rows); qerr != nil {
|
||||
return 0, 0, qerr
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return 0, 0, errors.New("没有匹配的已关注粉丝")
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
if perr := PushToOpenID(cfg, rows[i].OpenID, msg); perr != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
// 简单节流,避免触发微信频率限制
|
||||
if i%20 == 19 {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return sent, failed, nil
|
||||
}
|
||||
Vendored
+2
@@ -61,6 +61,8 @@ declare module 'vue' {
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElStep: typeof import('element-plus/es')['ElStep']
|
||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/*************************************************
|
||||
* 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送
|
||||
*************************************************/
|
||||
|
||||
/** 获取服务号配置(敏感字段返回掩码) */
|
||||
export function getWechatMpConfig() {
|
||||
return request({
|
||||
url: "/platform/wechatMp/config",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存服务号配置(掩码 / 空串表示不修改原值) */
|
||||
export function saveWechatMpConfig(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMp/config",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 连接测试:校验 AppID / AppSecret 是否能换取 access_token */
|
||||
export function testWechatMpConnection() {
|
||||
return request({
|
||||
url: "/platform/wechatMp/test",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/** 发起绑定:生成带参数二维码(扫码关注后公众号回复验证码) */
|
||||
export function startWechatMpBind() {
|
||||
return request({
|
||||
url: "/platform/wechatMp/bind/start",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/** 查询绑定会话状态:0待扫码 1已扫码待核销 2已核销 3已过期 */
|
||||
export function getWechatMpBindStatus(scene) {
|
||||
return request({
|
||||
url: "/platform/wechatMp/bind/status",
|
||||
method: "get",
|
||||
params: { scene },
|
||||
});
|
||||
}
|
||||
|
||||
/** 核销验证码完成绑定 */
|
||||
export function confirmWechatMpBind(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMp/bind/confirm",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 当前平台用户的绑定信息 */
|
||||
export function getMyWechatMpBinding() {
|
||||
return request({
|
||||
url: "/platform/wechatMp/my",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
/** 解除当前平台用户的微信绑定 */
|
||||
export function unbindWechatMp() {
|
||||
return request({
|
||||
url: "/platform/wechatMp/unbind",
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
/** 粉丝列表 */
|
||||
export function getWechatMpFollowers(params) {
|
||||
return request({
|
||||
url: "/platform/wechatMp/followers",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/** 设置粉丝备注 */
|
||||
export function setWechatMpFollowerRemark(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMp/follower/remark",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 解除粉丝绑定(保留粉丝记录) */
|
||||
export function unbindWechatMpFollower(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMp/follower/unbind",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量推送(认证后可用) */
|
||||
export function pushWechatMpMessage(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMp/push",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/** 测试推送:发送给当前账号绑定的微信 */
|
||||
export function pushWechatMpTest(data) {
|
||||
return request({
|
||||
url: "/platform/wechatMp/push/test",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
@@ -317,24 +317,213 @@
|
||||
</span>
|
||||
</template>
|
||||
<div class="tab-pane-content">
|
||||
<!-- 服务号配置 -->
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>微信公众号/企业微信推送</span>
|
||||
<span>微信公众号(服务号)配置</span>
|
||||
<el-tag v-if="Number(wechatCfg.verified) === 1" type="success" size="small">已认证</el-tag>
|
||||
<el-tag v-else type="warning" size="small" class="ml8">未认证:可关注取码,认证后可推送</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="formData.wechat" label-width="140px" label-position="right">
|
||||
<el-form label-width="140px" label-position="right">
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="formData.wechat.enabled" />
|
||||
<el-switch v-model="wechatCfg.enabled" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AppID / CorpID">
|
||||
<el-input v-model="formData.wechat.app_id" placeholder="请输入公众号 AppID 或企业微信 CorpID" />
|
||||
<el-form-item label="AppID" required>
|
||||
<el-input v-model="wechatCfg.app_id" placeholder="公众号后台「设置与开发 → 基本配置」中的 AppID" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="AppSecret / Secret">
|
||||
<el-input v-model="formData.wechat.app_secret" type="password" show-password placeholder="请输入 AppSecret" />
|
||||
<el-form-item label="AppSecret">
|
||||
<el-input v-model="wechatCfg.app_secret" type="password" show-password placeholder="已保存时显示掩码,保持不变则无需修改" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="模版ID / AgentID">
|
||||
<el-input v-model="formData.wechat.template_id" placeholder="请输入模版消息 ID 或企业微信应用 AgentID" />
|
||||
<el-form-item label="服务器 Token">
|
||||
<el-input v-model="wechatCfg.token" placeholder="3-32 位字母/数字,两端保持一致" clearable>
|
||||
<template #append>
|
||||
<el-button @click="wechatCfg.token = randomWechatToken(24)">随机生成</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="EncodingAESKey">
|
||||
<el-input v-model="wechatCfg.aes_key" placeholder="43 位;安全/兼容模式必填" clearable>
|
||||
<template #append>
|
||||
<el-button @click="wechatCfg.aes_key = randomWechatToken(43)">随机生成</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="消息加解密方式">
|
||||
<el-radio-group v-model="wechatCfg.encrypt_mode">
|
||||
<el-radio label="plain">明文模式</el-radio>
|
||||
<el-radio label="compatible">兼容模式</el-radio>
|
||||
<el-radio label="safe">安全模式</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="公众号已认证">
|
||||
<el-switch v-model="wechatCfg.verified" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip-inline">认证后才可主动推送(模板消息/订阅通知)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板消息ID">
|
||||
<el-input v-model="wechatCfg.template_id" placeholder="认证后在公众号后台申请,用于提醒/公告推送" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务器地址">
|
||||
<el-input v-model="wechatCfg.callback_url" readonly>
|
||||
<template #append>
|
||||
<el-button @click="copyWechatText(wechatCfg.callback_url)">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="form-tip">
|
||||
① 微信后台(公众平台 / 微信开发者平台)「设置与开发 → 基本配置」→ 复制 AppID / AppSecret 填入上方,保存并启用;<br />
|
||||
② 将本地址连同上方 Token / EncodingAESKey 填入微信后台「服务器配置」(加解密方式保持一致)——提交时微信会立即请求本地址校验,需本页已保存并启用;<br />
|
||||
③ 若「连接测试」提示 40164,请在微信后台「基本配置 → IP白名单」加入本服务器出口 IP(服务器执行 curl ifconfig.me 可查看)。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="wechatSaving" @click="saveWechatConfig">保存配置</el-button>
|
||||
<el-button :loading="wechatTesting" @click="testWechatConfig">连接测试</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 绑定我的微信 -->
|
||||
<el-card shadow="never" class="settings-card bind-card">
|
||||
<template #header>
|
||||
<div class="card-header"><span>绑定我的微信(接收提醒 / 公告)</span></div>
|
||||
</template>
|
||||
<template v-if="wechatBinding.bound">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="微信昵称">{{ wechatBinding.nickname || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关注状态">
|
||||
<el-tag size="small" :type="Number(wechatBinding.subscribe) === 1 ? 'success' : 'danger'">
|
||||
{{ Number(wechatBinding.subscribe) === 1 ? "已关注" : "已取关" }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="绑定时间">
|
||||
{{ wechatBinding.bind_time ? formatTime(wechatBinding.bind_time) : "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="openid">{{ wechatBinding.openid }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="bind-actions">
|
||||
<el-button type="primary" plain size="small" :loading="wechatPushTesting" @click="sendWechatTestPush">
|
||||
发送测试推送
|
||||
</el-button>
|
||||
<el-button type="danger" plain size="small" @click="unbindMyWechat">解除绑定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-steps :active="bindStep" simple class="bind-steps">
|
||||
<el-step title="微信扫码关注" />
|
||||
<el-step title="公众号回复验证码" />
|
||||
<el-step title="输入验证码完成绑定" />
|
||||
</el-steps>
|
||||
<div class="bind-area">
|
||||
<div class="bind-qr">
|
||||
<img v-if="bindQrUrl" :src="bindQrUrl" class="qr-img" alt="公众号二维码" />
|
||||
<el-button v-else type="primary" :loading="bindStarting" @click="startBind">获取绑定二维码</el-button>
|
||||
</div>
|
||||
<div class="bind-form">
|
||||
<el-input v-model="bindCode" placeholder="输入公众号回复的验证码" maxlength="8" class="bind-input" />
|
||||
<el-button type="primary" :loading="bindConfirming" @click="confirmBind">完成绑定</el-button>
|
||||
<p class="form-tip">扫码关注公众号后,公众号会自动回复 6 位验证码({{ bindTtlText }})。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<!-- 粉丝列表 -->
|
||||
<el-card shadow="never" class="settings-card bind-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>关注用户(粉丝)</span>
|
||||
<div class="follower-tools">
|
||||
<el-input v-model="followerQuery.keyword" size="small" placeholder="昵称 / openid / 备注" clearable @keyup.enter="loadFollowers" />
|
||||
<el-select v-model="followerQuery.bind_type" size="small" placeholder="绑定类型" clearable class="follower-select">
|
||||
<el-option label="平台用户" value="platform_user" />
|
||||
<el-option label="租户用户" value="tenant_user" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="loadFollowers">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="followers" size="small" v-loading="followersLoading">
|
||||
<el-table-column label="用户" min-width="140">
|
||||
<template #default="{ row }">{{ row.nickname || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="openid" label="openid" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="关注状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="Number(row.subscribe) === 1 ? 'success' : 'info'" effect="plain">
|
||||
{{ Number(row.subscribe) === 1 ? "已关注" : "已取关" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="绑定对象" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.bind_name">{{ row.bind_name }}</span>
|
||||
<span v-else class="muted">未绑定</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关注时间" width="160" align="center">
|
||||
<template #default="{ row }">{{ row.subscribe_time ? formatTime(row.subscribe_time) : "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" link @click="editFollowerRemark(row)">备注</el-button>
|
||||
<el-button v-if="row.bind_name" size="small" type="danger" link @click="unbindFollower(row)">
|
||||
解除绑定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
class="follower-pager"
|
||||
layout="total, prev, pager, next"
|
||||
:total="followerQuery.total"
|
||||
:current-page="followerQuery.page"
|
||||
:page-size="followerQuery.pageSize"
|
||||
@current-change="handleFollowerPage"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 消息推送 -->
|
||||
<el-card shadow="never" class="settings-card bind-card">
|
||||
<template #header>
|
||||
<div class="card-header"><span>消息推送(提醒 / 公告)</span></div>
|
||||
</template>
|
||||
<el-alert
|
||||
v-if="Number(wechatCfg.verified) !== 1"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="push-alert"
|
||||
title="公众号未认证:微信不允许主动推送。认证后配置「模板消息ID」即可使用;当前可先积累关注用户。"
|
||||
/>
|
||||
<el-form label-width="120px" label-position="right">
|
||||
<el-form-item label="推送范围">
|
||||
<el-select v-model="pushForm.bind_type" style="width: 200px">
|
||||
<el-option label="全部已关注粉丝" value="" />
|
||||
<el-option label="平台用户" value="platform_user" />
|
||||
<el-option label="租户用户" value="tenant_user" />
|
||||
</el-select>
|
||||
<span class="form-tip-inline">单次最多 500 人</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="pushForm.title" maxlength="50" placeholder="如:系统维护通知" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="pushForm.content" type="textarea" :rows="3" maxlength="200" placeholder="推送正文" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注 / 跳转">
|
||||
<el-input v-model="pushForm.remark" maxlength="50" placeholder="备注(可选)" />
|
||||
<el-input v-model="pushForm.url" class="push-url" placeholder="点击消息跳转地址(可选,如 https://www.yunzer.cn)" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="pushSending"
|
||||
:disabled="Number(wechatCfg.verified) !== 1"
|
||||
@click="sendPush"
|
||||
>
|
||||
发送推送
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -386,8 +575,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { reactive, ref, watch, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
Message,
|
||||
Cellphone,
|
||||
@@ -403,6 +592,22 @@ import { getSmsInfo, editSmsInfo, sendTestSms } from "@/api/sms";
|
||||
import { getEmailInfo, editEmailInfo, sendTestEmail } from "@/api/email";
|
||||
import { getSiteReminderConfig, saveSiteReminderConfig } from "@/api/sitereminder";
|
||||
import { getBarkConfig, saveBarkConfig, sendTestBark } from "@/api/sitesettings";
|
||||
import { formatTime } from "@/utils/datetime";
|
||||
import {
|
||||
getWechatMpConfig,
|
||||
saveWechatMpConfig,
|
||||
testWechatMpConnection,
|
||||
startWechatMpBind,
|
||||
getWechatMpBindStatus,
|
||||
confirmWechatMpBind,
|
||||
getMyWechatMpBinding,
|
||||
unbindWechatMp,
|
||||
getWechatMpFollowers,
|
||||
setWechatMpFollowerRemark,
|
||||
unbindWechatMpFollower,
|
||||
pushWechatMpMessage,
|
||||
pushWechatMpTest
|
||||
} from "@/api/wechatMp";
|
||||
|
||||
const STORAGE_KEY = "notification_settings_draft";
|
||||
const activeSubTab = ref("email");
|
||||
@@ -458,12 +663,6 @@ const formData = reactive({
|
||||
server_url: "https://api.day.app",
|
||||
device_key: "",
|
||||
},
|
||||
wechat: {
|
||||
enabled: false,
|
||||
app_id: "",
|
||||
app_secret: "",
|
||||
template_id: "",
|
||||
},
|
||||
telegram: {
|
||||
enabled: false,
|
||||
bot_token: "",
|
||||
@@ -548,7 +747,6 @@ const handleReset = () => {
|
||||
webhook: { enabled: false, url: "", method: "POST", token: "" },
|
||||
feishu: { enabled: false, webhook_url: "", secret: "" },
|
||||
bark: { enabled: false, server_url: "https://api.day.app", device_key: "" },
|
||||
wechat: { enabled: false, app_id: "", app_secret: "", template_id: "" },
|
||||
telegram: { enabled: false, bot_token: "", chat_id: "" },
|
||||
};
|
||||
|
||||
@@ -620,6 +818,11 @@ const handleSendTest = async () => {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 微信配置使用独立接口保存(页面内有独立的「保存配置」按钮)
|
||||
if (activeSubTab.value === 'wechat') {
|
||||
await saveWechatConfig();
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (activeSubTab.value === 'email') {
|
||||
@@ -727,12 +930,351 @@ const handleSendBarkTest = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
/* ==================== 微信公众号(服务号) ==================== */
|
||||
|
||||
const wechatCfg = reactive({
|
||||
app_id: "",
|
||||
app_secret: "",
|
||||
token: "",
|
||||
aes_key: "",
|
||||
encrypt_mode: "plain",
|
||||
template_id: "",
|
||||
verified: 0,
|
||||
enabled: 0,
|
||||
callback_url: "",
|
||||
configured: false,
|
||||
});
|
||||
const wechatSaving = ref(false);
|
||||
const wechatTesting = ref(false);
|
||||
|
||||
// 生成随机串(字母+数字):Token 取 24 位、EncodingAESKey 取 43 位。
|
||||
// 生成后需原样填入微信后台「服务器配置」,保持两端一致(微信侧提交时会请求回调地址校验)。
|
||||
const randomWechatToken = (len = 43) => {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const buf = new Uint8Array(len);
|
||||
window.crypto.getRandomValues(buf);
|
||||
let out = "";
|
||||
for (let i = 0; i < len; i++) out += chars[buf[i] % chars.length];
|
||||
return out;
|
||||
};
|
||||
|
||||
const wechatBinding = ref({ bound: false });
|
||||
const wechatPushTesting = ref(false);
|
||||
|
||||
const bindScene = ref("");
|
||||
const bindQrUrl = ref("");
|
||||
const bindCode = ref("");
|
||||
const bindStarting = ref(false);
|
||||
const bindConfirming = ref(false);
|
||||
const bindStep = ref(0);
|
||||
const bindTtlText = ref("5 分钟内有效");
|
||||
let bindPollTimer = null;
|
||||
|
||||
const followers = ref([]);
|
||||
const followersLoading = ref(false);
|
||||
const followerQuery = reactive({ keyword: "", bind_type: "", page: 1, pageSize: 10, total: 0 });
|
||||
|
||||
const pushForm = reactive({ bind_type: "", title: "", content: "", remark: "", url: "" });
|
||||
const pushSending = ref(false);
|
||||
|
||||
const loadWechatConfig = async () => {
|
||||
try {
|
||||
const res = await getWechatMpConfig();
|
||||
if (res.code === 200 && res.data) {
|
||||
Object.assign(wechatCfg, res.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("加载微信配置失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const saveWechatConfig = async () => {
|
||||
if (!wechatCfg.app_id) {
|
||||
ElMessage.warning("请先填写 AppID");
|
||||
return;
|
||||
}
|
||||
wechatSaving.value = true;
|
||||
try {
|
||||
const res = await saveWechatMpConfig({
|
||||
app_id: wechatCfg.app_id,
|
||||
app_secret: wechatCfg.app_secret,
|
||||
token: wechatCfg.token,
|
||||
aes_key: wechatCfg.aes_key,
|
||||
encrypt_mode: wechatCfg.encrypt_mode,
|
||||
template_id: wechatCfg.template_id,
|
||||
verified: Number(wechatCfg.verified) === 1,
|
||||
enabled: Number(wechatCfg.enabled) === 1,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("微信配置保存成功");
|
||||
await loadWechatConfig();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "保存失败");
|
||||
} finally {
|
||||
wechatSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const testWechatConfig = async () => {
|
||||
wechatTesting.value = true;
|
||||
try {
|
||||
const res = await testWechatMpConnection();
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "连接成功");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "连接失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "连接失败");
|
||||
} finally {
|
||||
wechatTesting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyWechatText = async (text) => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ElMessage.success("已复制");
|
||||
} catch {
|
||||
ElMessage.warning("复制失败,请手动选择复制");
|
||||
}
|
||||
};
|
||||
|
||||
const loadWechatBinding = async () => {
|
||||
try {
|
||||
const res = await getMyWechatMpBinding();
|
||||
if (res.code === 200) {
|
||||
wechatBinding.value = res.data || { bound: false };
|
||||
}
|
||||
} catch {
|
||||
wechatBinding.value = { bound: false };
|
||||
}
|
||||
};
|
||||
|
||||
const stopBindPoll = () => {
|
||||
if (bindPollTimer) {
|
||||
clearInterval(bindPollTimer);
|
||||
bindPollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startBind = async () => {
|
||||
bindStarting.value = true;
|
||||
try {
|
||||
const res = await startWechatMpBind();
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.msg || "获取二维码失败");
|
||||
return;
|
||||
}
|
||||
bindScene.value = res.data?.scene || "";
|
||||
bindQrUrl.value = res.data?.qrcode_url || "";
|
||||
bindStep.value = 0;
|
||||
const ttl = Number(res.data?.expires_in || 300);
|
||||
bindTtlText.value = ttl >= 60 ? `${Math.floor(ttl / 60)} 分钟内有效` : `${ttl} 秒内有效`;
|
||||
stopBindPoll();
|
||||
bindPollTimer = setInterval(async () => {
|
||||
if (!bindScene.value) return;
|
||||
try {
|
||||
const st = await getWechatMpBindStatus(bindScene.value);
|
||||
const status = Number(st?.data?.status ?? 0);
|
||||
if (status === 1) {
|
||||
bindStep.value = 2; // 已扫码,公众号已回复验证码,等待输入
|
||||
}
|
||||
if (status === 3) {
|
||||
stopBindPoll();
|
||||
bindQrUrl.value = "";
|
||||
ElMessage.warning("二维码已过期,请重新获取");
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败忽略
|
||||
}
|
||||
}, 3000);
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "获取二维码失败");
|
||||
} finally {
|
||||
bindStarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBind = async () => {
|
||||
if (!bindScene.value) {
|
||||
ElMessage.warning("请先获取绑定二维码");
|
||||
return;
|
||||
}
|
||||
if (!bindCode.value.trim()) {
|
||||
ElMessage.warning("请输入公众号回复的验证码");
|
||||
return;
|
||||
}
|
||||
bindConfirming.value = true;
|
||||
try {
|
||||
const res = await confirmWechatMpBind({ scene: bindScene.value, code: bindCode.value.trim() });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("绑定成功");
|
||||
stopBindPoll();
|
||||
bindQrUrl.value = "";
|
||||
bindCode.value = "";
|
||||
bindScene.value = "";
|
||||
bindStep.value = 0;
|
||||
await loadWechatBinding();
|
||||
await loadFollowers();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "绑定失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "绑定失败");
|
||||
} finally {
|
||||
bindConfirming.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const unbindMyWechat = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定解除当前账号的微信绑定吗?解除后将无法接收微信推送。", "提示", {
|
||||
type: "warning",
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await unbindWechatMp();
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("已解除绑定");
|
||||
await loadWechatBinding();
|
||||
await loadFollowers();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "解绑失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "解绑失败");
|
||||
}
|
||||
};
|
||||
|
||||
const sendWechatTestPush = async () => {
|
||||
wechatPushTesting.value = true;
|
||||
try {
|
||||
const res = await pushWechatMpTest({});
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "测试消息已发送");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "发送失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "发送失败");
|
||||
} finally {
|
||||
wechatPushTesting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadFollowers = async () => {
|
||||
followersLoading.value = true;
|
||||
try {
|
||||
const res = await getWechatMpFollowers({
|
||||
page: followerQuery.page,
|
||||
pageSize: followerQuery.pageSize,
|
||||
keyword: followerQuery.keyword,
|
||||
bind_type: followerQuery.bind_type,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
followers.value = res.data?.list || [];
|
||||
followerQuery.total = Number(res.data?.total || 0);
|
||||
} else {
|
||||
followers.value = [];
|
||||
}
|
||||
} catch {
|
||||
followers.value = [];
|
||||
} finally {
|
||||
followersLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFollowerPage = (page) => {
|
||||
followerQuery.page = page;
|
||||
loadFollowers();
|
||||
};
|
||||
|
||||
const editFollowerRemark = async (row) => {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt("设置备注(仅平台可见)", "粉丝备注", {
|
||||
inputValue: row.remark || "",
|
||||
inputPlaceholder: "如:张三(XX公司)",
|
||||
inputValidator: (v) => (v || "").length <= 100 || "备注过长",
|
||||
});
|
||||
const res = await setWechatMpFollowerRemark({ id: row.id, remark: value || "" });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("已保存");
|
||||
await loadFollowers();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "保存失败");
|
||||
}
|
||||
} catch {
|
||||
// 取消
|
||||
}
|
||||
};
|
||||
|
||||
const unbindFollower = async (row) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定解除「${row.bind_name || row.openid}」的绑定吗?(粉丝记录保留)`,
|
||||
"提示",
|
||||
{ type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await unbindWechatMpFollower({ id: row.id });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("已解除绑定");
|
||||
await loadFollowers();
|
||||
} else {
|
||||
ElMessage.error(res.msg || "解绑失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "解绑失败");
|
||||
}
|
||||
};
|
||||
|
||||
const sendPush = async () => {
|
||||
if (!pushForm.title && !pushForm.content) {
|
||||
ElMessage.warning("请填写推送标题或内容");
|
||||
return;
|
||||
}
|
||||
pushSending.value = true;
|
||||
try {
|
||||
const res = await pushWechatMpMessage({ ...pushForm });
|
||||
if (res.code === 200) {
|
||||
ElMessage.success(res.msg || "推送完成");
|
||||
} else {
|
||||
ElMessage.error(res.msg || "推送失败");
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.message || "推送失败");
|
||||
} finally {
|
||||
pushSending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadDraft();
|
||||
loadSmsConfig();
|
||||
loadEmailConfig();
|
||||
loadSiteReminderConfig();
|
||||
loadBarkConfig();
|
||||
loadWechatConfig();
|
||||
loadWechatBinding();
|
||||
});
|
||||
|
||||
// 切换到「微信配置」页时刷新配置 / 绑定 / 粉丝
|
||||
watch(activeSubTab, (tab) => {
|
||||
if (tab === "wechat") {
|
||||
loadWechatConfig();
|
||||
loadWechatBinding();
|
||||
loadFollowers();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -846,4 +1388,88 @@ onMounted(() => {
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
/* ===== 微信公众号(服务号) ===== */
|
||||
.bind-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.bind-actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bind-steps {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.bind-area {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bind-qr {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bind-form {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bind-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.follower-tools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.follower-select {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.follower-pager {
|
||||
margin-top: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.push-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.push-url {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ml8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user