feat(notify): 微信订阅消息推送(驳回通知已对接)
- 新增 server/services/wechatSubscribeService.ts:access_token 缓存 + 模板配置 + 字段裁剪 + subscribe/send · 内置默认模板 SONG_REJECTED(模板 ID erAK**…,审核内容=thing6←歌名,驳回原因=thing4←驳回原因) · 环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 可按键覆盖默认模板 · thing 字段按微信限制裁到 20 字(超长截 19 字 + 省略号),避免 47003 · 发送永不抛异常;43101(用户没授权/没额度)只记 info - 新增 server/api/user/wechat-subscribe-templates.get.ts:给小程序返回模板 ID(客户端要拿它去申请授权) - 新增 server/api/admin/wechat/test-subscribe.post.ts:超管自测接口(token/openid/字段/微信原文一次看清) - notificationService.ts:歌曲被选中/已播放/被驳回/系统通知 四处顺带发微信订阅消息 - .env.example + 部署流水线:WECHAT_MINI_SUBSCRIBE_TEMPLATES 可用仓库 Secret 下发
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
buildTemplateData,
|
||||
getMiniAccessToken,
|
||||
getSubscribeTemplates,
|
||||
getWechatOpenid,
|
||||
sendRawSubscribe
|
||||
} from '~~/server/services/wechatSubscribeService'
|
||||
|
||||
/**
|
||||
* 微信订阅消息的**自测接口**(仅超级管理员)—— 站长部署完用它「试一下」。
|
||||
*
|
||||
* 一次调用能看出四件事:
|
||||
* 1. access_token 能不能拿到(appid / secret 是否有效)
|
||||
* 2. 目标用户有没有绑微信(有没有 openid;纯密码用户必然是 null)
|
||||
* 3. 模板字段名 / 长度对不对(微信会回 47003 / 43101 之类的错误码)
|
||||
* 4. 微信返回的原文
|
||||
*
|
||||
* body(都可省):
|
||||
* { userId?: number, kind?: string, openid?: string, templateId?: string, data?: object }
|
||||
* · 默认给自己发、用配置里的第一个模板
|
||||
* · 想直接指定 openid / 模板 ID / data 也可以(用于排查)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({ statusCode: 403, message: '只有超级管理员可以测试微信订阅消息' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const templates = getSubscribeTemplates()
|
||||
const kinds = Object.keys(templates)
|
||||
const kind = body?.kind || kinds[0] || ''
|
||||
const conf = kind ? templates[kind] : null
|
||||
const templateId = body?.templateId || conf?.id || ''
|
||||
const targetUserId = Number(body?.userId) || Number(user.id)
|
||||
|
||||
const token = await getMiniAccessToken()
|
||||
const openid = body?.openid || (await getWechatOpenid(targetUserId))
|
||||
|
||||
if (!templateId) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-template',
|
||||
hint: '服务器环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 没配置模板 ID(见 wechatSubscribeService.ts 头部注释)',
|
||||
configuredKinds: kinds,
|
||||
tokenOk: !!token,
|
||||
openid: openid ? openid.slice(0, 6) + '****' : null
|
||||
}
|
||||
}
|
||||
|
||||
if (!openid) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'no-wechat-openid',
|
||||
hint: '该用户没有绑定微信(userIdentities 里没有 provider=wechat-mp 的记录),微信发不到他那里',
|
||||
kind: kind,
|
||||
templateId: templateId,
|
||||
tokenOk: !!token,
|
||||
targetUserId: targetUserId
|
||||
}
|
||||
}
|
||||
|
||||
const data =
|
||||
body?.data && typeof body.data === 'object'
|
||||
? body.data
|
||||
: buildTemplateData(conf || { id: templateId }, {
|
||||
songTitle: '测试歌曲',
|
||||
message: '这是一条来自 VoiceHub 的测试通知'
|
||||
})
|
||||
|
||||
const result = await sendRawSubscribe(openid, templateId, data)
|
||||
|
||||
return {
|
||||
ok: result.ok,
|
||||
errcode: result.errcode,
|
||||
errmsg: result.errmsg,
|
||||
hint:
|
||||
result.errcode === 0
|
||||
? '发送成功,去微信里看「服务通知」'
|
||||
: result.errcode === 43101
|
||||
? '用户没有可用授权(一次性订阅:需要用户在小程序里点过「开启微信通知」才行)'
|
||||
: '看 errmsg:47003 = 模板字段名/长度不对;40003 = openid 不对;40001 = token 失效',
|
||||
configuredKinds: kinds,
|
||||
kind: kind,
|
||||
templateId: templateId,
|
||||
targetUserId: targetUserId,
|
||||
openid: openid.slice(0, 6) + '****',
|
||||
tokenOk: !!token,
|
||||
data: data,
|
||||
raw: result.raw
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getSubscribeTemplateIds, isWechatSubscribeEnabled } from '~~/server/services/wechatSubscribeService'
|
||||
|
||||
/**
|
||||
* 小程序申请「订阅消息」授权时需要的模板 ID。
|
||||
*
|
||||
* 模板 ID 不是秘密:它本来就要出现在客户端 `wx.requestSubscribeMessage({ tmplIds })` 里。
|
||||
* 没配置模板(环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 为空)时 enabled=false,
|
||||
* 小程序那边会静默隐藏「开启微信通知」的入口。
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
const user = event.context.user
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
return {
|
||||
enabled: isWechatSubscribeEnabled(),
|
||||
tmplIds: getSubscribeTemplateIds()
|
||||
}
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '~/drizzle/schema'
|
||||
import { and, eq, gte, inArray } from 'drizzle-orm'
|
||||
import { sendBatchMeowNotifications, sendMeowNotificationToUser } from './meowNotificationService'
|
||||
import { sendWechatSubscribeToUser } from './wechatSubscribeService'
|
||||
import { sendBatchEmailNotifications, sendEmailNotificationToUser } from './smtpService'
|
||||
import { formatDateTime, getBeijingTime } from '~/utils/timeUtils'
|
||||
|
||||
@@ -204,6 +205,12 @@ export async function createSongSelectedNotification(
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
|
||||
try {
|
||||
await sendWechatSubscribeToUser(userId, 'SONG_SELECTED', { songTitle: songInfo.title, message })
|
||||
} catch (error) {
|
||||
console.error('发送微信订阅消息失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送 邮件通知
|
||||
try {
|
||||
@@ -323,6 +330,12 @@ export async function createSongPlayedNotification(songId: number, ipAddress?: s
|
||||
} catch (error) {
|
||||
console.error(`发送 MeoW 通知失败 (User: ${targetUserId}):`, error)
|
||||
}
|
||||
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
|
||||
try {
|
||||
await sendWechatSubscribeToUser(targetUserId, 'SONG_PLAYED', { songTitle: song.title, message: userMessage })
|
||||
} catch (error) {
|
||||
console.error('发送微信订阅消息失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送 邮件通知
|
||||
try {
|
||||
@@ -521,6 +534,12 @@ export async function createSongRejectedNotification(
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
|
||||
try {
|
||||
await sendWechatSubscribeToUser(userId, 'SONG_REJECTED', { songTitle: songInfo.title, reason: reason, message })
|
||||
} catch (error) {
|
||||
console.error('发送微信订阅消息失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
@@ -612,6 +631,12 @@ export async function createSystemNotification(
|
||||
} catch (error) {
|
||||
console.error('发送 MeoW 通知失败:', error)
|
||||
}
|
||||
// 同步发送 微信订阅消息(只有绑了微信、且刚点过授权的用户收得到,见 wechatSubscribeService)
|
||||
try {
|
||||
await sendWechatSubscribeToUser(userId, 'SYSTEM_NOTICE', { songTitle: title, message: content })
|
||||
} catch (error) {
|
||||
console.error('发送微信订阅消息失败:', error)
|
||||
}
|
||||
|
||||
// 同步发送邮件通知
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { notificationSettings, userIdentities } from '~/drizzle/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
/**
|
||||
* 微信**订阅消息** —— 小程序唯一能主动触达用户的官方通道。
|
||||
*
|
||||
* ⚠️ 平台侧的四条硬限制(不是实现问题,绕不过去):
|
||||
* 1. 必须由**用户点击**触发 `wx.requestSubscribeMessage` 授权,不能静默订阅;
|
||||
* 2. 「一次性订阅」= **一次授权只能发一条**,发完要用户再点一次;
|
||||
* 3. 只有**绑定了微信**的账号才有 openid(`userIdentities.provider = 'wechat-mp'`),
|
||||
* 纯账号密码用户发不出去(小程序那边要引导他们先绑微信);
|
||||
* 4. 模板字段的类型和长度由模板决定:`thing` 最长 20 字、`time` 必须 `YYYY-MM-DD HH:mm`,
|
||||
* 字段名与长度写错会被微信拒(47003),所以这里按模板配置逐个裁剪。
|
||||
*
|
||||
* 配置(服务器环境变量;模板一项缺失就整体静默关闭,不影响其它通知渠道):
|
||||
* WECHAT_MINI_APPID / WECHAT_MINI_SECRET —— 已有(微信登录在用)
|
||||
* WECHAT_MINI_SUBSCRIBE_TEMPLATES —— JSON:
|
||||
* {
|
||||
* "SONG_PLAYED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time", "thing3": "message" } },
|
||||
* "SONG_SELECTED": { "id": "模板ID", "fields": { "thing1": "songTitle", "time2": "time" } }
|
||||
* }
|
||||
* `fields` 的**键**是模板里的字段名(公众平台选用模板时能看到,如 thing1/time2/character_string3),
|
||||
* **值**是变量名,取自调用方传进来的 vars(songTitle / message / time 是内置的)。
|
||||
*/
|
||||
|
||||
const PROVIDER = 'wechat-mp'
|
||||
const TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token'
|
||||
const SEND_URL = 'https://api.weixin.qq.com/cgi-bin/message/subscribe/send'
|
||||
|
||||
type TemplateConf = { id: string; fields?: Record<string, string> }
|
||||
|
||||
/**
|
||||
* **内置默认模板**(站长在公众平台选好的,可直接用;环境变量里的配置会按键覆盖它)。
|
||||
*
|
||||
* SONG_REJECTED = 歌曲被驳回:
|
||||
* 模板字段「审核内容」= thing6 → songTitle
|
||||
* 模板字段「驳回原因」= thing4 → reason
|
||||
* ⚠️ thing 类型字段微信限 **20 个字符**,超了会被拒(47003),
|
||||
* 所以 clampField 会截断(超过 20 字时截 19 字 + 省略号)。
|
||||
*/
|
||||
const DEFAULT_TEMPLATES: Record<string, TemplateConf> = {
|
||||
SONG_REJECTED: {
|
||||
id: 'erAK99M38ZeD-F1mOIgN2M7iAPVTU0YXo-adlkSwg_c',
|
||||
fields: { thing6: 'songTitle', thing4: 'reason' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析模板配置:内置默认 + 环境变量(同键以环境变量为准;坏 JSON 只报错一次) */
|
||||
let templatesCache: Record<string, TemplateConf> | null = null
|
||||
export function getSubscribeTemplates(): Record<string, TemplateConf> {
|
||||
if (templatesCache) return templatesCache
|
||||
const merged: Record<string, TemplateConf> = Object.assign({}, DEFAULT_TEMPLATES)
|
||||
const raw = process.env.WECHAT_MINI_SUBSCRIBE_TEMPLATES
|
||||
if (!raw) {
|
||||
templatesCache = merged
|
||||
return templatesCache
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
Object.keys(parsed).forEach((k) => {
|
||||
const conf = parsed[k]
|
||||
if (conf && conf.id) merged[k] = conf
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WeChatSubscribe] WECHAT_MINI_SUBSCRIBE_TEMPLATES 不是合法 JSON,已忽略(仍用内置默认模板)')
|
||||
}
|
||||
templatesCache = merged
|
||||
return templatesCache
|
||||
}
|
||||
|
||||
/** 有没有配置任何模板(没配置时所有发送都直接跳过) */
|
||||
export function isWechatSubscribeEnabled(): boolean {
|
||||
return Object.keys(getSubscribeTemplates()).length > 0
|
||||
}
|
||||
|
||||
/** 模板 ID 列表(给小程序去申请授权用) */
|
||||
export function getSubscribeTemplateIds(): string[] {
|
||||
return Object.values(getSubscribeTemplates())
|
||||
.map((t) => t && t.id)
|
||||
.filter((id): id is string => !!id)
|
||||
}
|
||||
|
||||
// ---------------- access_token(2 小时有效,必须缓存) ----------------
|
||||
|
||||
let tokenCache: { token: string; expiresAt: number } | null = null
|
||||
/** 提前 5 分钟过期,避免边界上用到刚失效的 token */
|
||||
const TOKEN_SAFETY_MS = 5 * 60 * 1000
|
||||
|
||||
export async function getMiniAccessToken(force = false): Promise<string | null> {
|
||||
if (!force && tokenCache && tokenCache.expiresAt > Date.now()) return tokenCache.token
|
||||
|
||||
const appid = process.env.WECHAT_MINI_APPID
|
||||
const secret = process.env.WECHAT_MINI_SECRET
|
||||
if (!appid || !secret) {
|
||||
console.error('[WeChatSubscribe] 未配置 WECHAT_MINI_APPID / WECHAT_MINI_SECRET')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
/*
|
||||
* 与 jscode2session 同一类坑:微信这个接口的 content-type 也不一定是 application/json,
|
||||
* ofetch 不解析就返回字符串(线上「微信登录一直 401」的根因就是这个)。
|
||||
* 这里显式 responseType: 'json',并且再做一次字符串兜底解析。
|
||||
*/
|
||||
let res: any = await $fetch(TOKEN_URL, {
|
||||
query: { grant_type: 'client_credential', appid, secret },
|
||||
timeout: 8000,
|
||||
responseType: 'json'
|
||||
})
|
||||
if (typeof res === 'string') {
|
||||
try {
|
||||
res = JSON.parse(res)
|
||||
} catch {
|
||||
res = null
|
||||
}
|
||||
}
|
||||
if (!res || !res.access_token) {
|
||||
console.error('[WeChatSubscribe] 获取 access_token 失败:', res?.errcode, res?.errmsg)
|
||||
return null
|
||||
}
|
||||
tokenCache = {
|
||||
token: res.access_token,
|
||||
expiresAt: Date.now() + Math.max(60, (res.expires_in || 7200) * 1000) - TOKEN_SAFETY_MS
|
||||
}
|
||||
return tokenCache.token
|
||||
} catch (err: any) {
|
||||
console.error('[WeChatSubscribe] 获取 access_token 异常:', err?.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 取值 / 裁剪 ----------------
|
||||
|
||||
/** 模板字段的长度限制(微信侧的限制,超了直接 47003) */
|
||||
function clampField(key: string, value: unknown): string {
|
||||
const text = value === null || value === undefined ? '' : String(value)
|
||||
// thing:20 字以内;超了截 19 字 + 省略号(省略号也算 1 个字,正好 20)
|
||||
if (/^thing/.test(key)) return text.length > 20 ? text.slice(0, 19) + '…' : text
|
||||
return text.slice(0, 32)
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD HH:mm'(微信 time 型字段要求的格式,北京时间) */
|
||||
export function beijingNow(): string {
|
||||
const now = new Date(Date.now() + 8 * 60 * 60 * 1000)
|
||||
const p = (n: number) => (n < 10 ? '0' + n : String(n))
|
||||
return (
|
||||
now.getUTCFullYear() +
|
||||
'-' +
|
||||
p(now.getUTCMonth() + 1) +
|
||||
'-' +
|
||||
p(now.getUTCDate()) +
|
||||
' ' +
|
||||
p(now.getUTCHours()) +
|
||||
':' +
|
||||
p(now.getUTCMinutes())
|
||||
)
|
||||
}
|
||||
|
||||
/** 用模板配置 + 变量拼出微信要的 data */
|
||||
export function buildTemplateData(
|
||||
conf: TemplateConf,
|
||||
vars: Record<string, unknown>
|
||||
): Record<string, { value: string }> {
|
||||
const fields = conf.fields || { thing1: 'songTitle', time2: 'time', thing3: 'message' }
|
||||
const all: Record<string, unknown> = Object.assign({ time: beijingNow() }, vars)
|
||||
const data: Record<string, { value: string }> = {}
|
||||
for (const key of Object.keys(fields)) {
|
||||
data[key] = { value: clampField(key, all[fields[key]]) }
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/** 取用户的小程序 openid(没绑微信就没有) */
|
||||
export async function getWechatOpenid(userId: number): Promise<string | null> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ providerUserId: userIdentities.providerUserId })
|
||||
.from(userIdentities)
|
||||
.where(and(eq(userIdentities.userId, userId), eq(userIdentities.provider, PROVIDER)))
|
||||
.limit(1)
|
||||
return rows[0]?.providerUserId || null
|
||||
} catch (err: any) {
|
||||
console.error('[WeChatSubscribe] 查询 openid 失败:', err?.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 发送 ----------------
|
||||
|
||||
export type SendResult = { ok: boolean; errcode?: number; errmsg?: string; skipped?: string }
|
||||
|
||||
/**
|
||||
* 给某个用户发一条订阅消息。
|
||||
* **永不抛异常**(通知失败不该影响主流程),结果用返回值 + 日志体现。
|
||||
*
|
||||
* @param kind 模板键(与 WECHAT_MINI_SUBSCRIBE_TEMPLATES 里的键对应)
|
||||
* @param vars 变量:songTitle / message / time 为内置,其余按模板 fields 配置取
|
||||
*/
|
||||
export async function sendWechatSubscribeToUser(
|
||||
userId: number,
|
||||
kind: string,
|
||||
vars: Record<string, unknown>
|
||||
): Promise<SendResult> {
|
||||
try {
|
||||
const conf = getSubscribeTemplates()[kind]
|
||||
if (!conf || !conf.id) return { ok: false, skipped: 'no-template' }
|
||||
|
||||
// 用户的通知总开关(与 MeoW / 邮件共用同一份设置)
|
||||
const settings = await db
|
||||
.select({ enabled: notificationSettings.enabled })
|
||||
.from(notificationSettings)
|
||||
.where(eq(notificationSettings.userId, userId))
|
||||
.limit(1)
|
||||
if (settings[0] && settings[0].enabled === false) {
|
||||
return { ok: false, skipped: 'disabled-by-user' }
|
||||
}
|
||||
|
||||
const openid = await getWechatOpenid(userId)
|
||||
if (!openid) return { ok: false, skipped: 'no-wechat-openid' }
|
||||
|
||||
const token = await getMiniAccessToken()
|
||||
if (!token) return { ok: false, skipped: 'no-access-token' }
|
||||
|
||||
const data = buildTemplateData(conf, Object.assign({ songTitle: 'VoiceHub' }, vars))
|
||||
return await postSubscribe(token, openid, conf.id, data, userId, kind)
|
||||
} catch (err: any) {
|
||||
console.error('[WeChatSubscribe] 发送异常:', err?.message)
|
||||
return { ok: false, errcode: -1, errmsg: err?.message }
|
||||
}
|
||||
}
|
||||
|
||||
async function postSubscribe(
|
||||
token: string,
|
||||
openid: string,
|
||||
templateId: string,
|
||||
data: Record<string, { value: string }>,
|
||||
userId: number,
|
||||
kind: string
|
||||
): Promise<SendResult> {
|
||||
let res: any = await $fetch(SEND_URL, {
|
||||
method: 'POST',
|
||||
query: { access_token: token },
|
||||
body: {
|
||||
touser: openid,
|
||||
template_id: templateId,
|
||||
page: 'pages/home/home',
|
||||
data,
|
||||
miniprogram_state: process.env.NODE_ENV === 'production' ? 'formal' : 'developer'
|
||||
},
|
||||
timeout: 8000,
|
||||
responseType: 'json'
|
||||
})
|
||||
if (typeof res === 'string') {
|
||||
try {
|
||||
res = JSON.parse(res)
|
||||
} catch {
|
||||
res = { errcode: -1, errmsg: 'unparsable response' }
|
||||
}
|
||||
}
|
||||
|
||||
const errcode = res?.errcode
|
||||
if (errcode === 0) {
|
||||
console.log('[WeChatSubscribe] 已发送 ' + kind + ' → user ' + userId)
|
||||
return { ok: true, errcode: 0 }
|
||||
}
|
||||
/*
|
||||
* 43101 = 用户拒收 / 没有剩余授权次数 —— 一次性订阅的正常状态
|
||||
* (用户没点授权、或上一次已经用掉了)。属于**预期内**,只记 info。
|
||||
*/
|
||||
if (errcode === 43101) {
|
||||
console.log('[WeChatSubscribe] ' + kind + ' → user ' + userId + ' 没有可用授权(43101),跳过')
|
||||
} else {
|
||||
console.error(
|
||||
'[WeChatSubscribe] 发送失败 ' + kind + ' → user ' + userId + ': ' + errcode + ' ' + res?.errmsg
|
||||
)
|
||||
}
|
||||
return { ok: false, errcode, errmsg: res?.errmsg }
|
||||
}
|
||||
|
||||
/** 测试用:按模板 ID 直发,把微信的原始响应带回去(字段映射对不对一眼看出来) */
|
||||
export async function sendRawSubscribe(
|
||||
openid: string,
|
||||
templateId: string,
|
||||
data: Record<string, { value: string }>
|
||||
): Promise<SendResult & { raw?: any }> {
|
||||
try {
|
||||
const token = await getMiniAccessToken()
|
||||
if (!token) return { ok: false, skipped: 'no-access-token' }
|
||||
const result = await postSubscribe(token, openid, templateId, data, 0, 'TEST')
|
||||
return Object.assign({ raw: { errcode: result.errcode, errmsg: result.errmsg } }, result)
|
||||
} catch (err: any) {
|
||||
return { ok: false, errcode: -1, errmsg: err?.message }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user