Files
tougao/application/common/UnsubscribeService.php
2026-04-29 15:07:56 +08:00

106 lines
3.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\common;
use think\Env;
/**
* 退订工具:生成/校验签名 token构造退订 URL支持 expert / user 两类受众)。
*
* .env 配置([unsubscribe] 段):
* UNSUBSCRIBE_SECRET 用于签名的密钥(必须自行设置一个长随机字符串)
* UNSUBSCRIBE_BASE_URL 退订入口 URL指向 Unsubscribe 控制器的 index 方法,例如:
* https://api.tmrjournals.com/api/Unsubscribe/index
*
* URL 格式:
* {UNSUBSCRIBE_BASE_URL}?k={kind}&id={audience_id}&t={sha256(kind|id|email_lower|secret)}
*
* kind:
* expert → 外部专家库 t_expert.expert_id
* user → 系统内部用户 t_user.user_id编委 / 主编 / 青年编委等)
*
* 设计要点:
* - 不依赖 session纯 HMAC 风格签名token 永久有效(直到密钥更换或邮箱变更)。
* - 收到请求后服务端按 kind+id 查邮箱重新计算签名比对hash_equals 防时序攻击。
* - 邮箱大小写敏感性:统一 strtolower 后再签名/校验。
*/
class UnsubscribeService
{
const KIND_EXPERT = 'expert';
const KIND_USER = 'user';
/**
* 读取签名密钥;未配置时使用一个固定默认值(仅供本地测试,生产必须覆盖)。
*/
public static function getSecret()
{
$secret = trim((string)Env::get('unsubscribe.unsubscribe_secret', ''));
if ($secret === '') {
$secret = 'tmrjournals-default-unsubscribe-secret-change-me';
}
return $secret;
}
/**
* 读取退订入口 URL指向 Unsubscribe/index未配置返回空串。
*/
public static function getBaseUrl()
{
return rtrim(trim((string)Env::get('unsubscribe.unsubscribe_base_url', '')), '/');
}
/**
* 规范化 kind未知值降级为 expert保持向后兼容
*/
public static function normalizeKind($kind)
{
$kind = strtolower(trim((string)$kind));
return $kind === self::KIND_USER ? self::KIND_USER : self::KIND_EXPERT;
}
/**
* 生成签名 token。
*/
public static function buildToken($kind, $audienceId, $email)
{
$kind = self::normalizeKind($kind);
$audienceId = intval($audienceId);
$email = strtolower(trim((string)$email));
return hash('sha256', $kind . '|' . $audienceId . '|' . $email . '|' . self::getSecret());
}
/**
* 校验签名 token恒定时间比较防时序攻击。
*/
public static function verifyToken($kind, $audienceId, $email, $token)
{
$kind = self::normalizeKind($kind);
$audienceId = intval($audienceId);
$email = strtolower(trim((string)$email));
$token = strtolower(trim((string)$token));
if ($audienceId <= 0 || $email === '' || $token === '') {
return false;
}
$expected = self::buildToken($kind, $audienceId, $email);
return hash_equals($expected, $token);
}
/**
* 构造完整退订 URL用于邮件模板变量 {{unsubscribe_url}})。
* BASE_URL 未配置或参数无效时返回空串。
*/
public static function buildUrl($kind, $audienceId, $email)
{
$kind = self::normalizeKind($kind);
$audienceId = intval($audienceId);
$email = trim((string)$email);
if ($audienceId <= 0 || $email === '') return '';
$base = self::getBaseUrl();
if ($base === '') return '';
return $base
. '?k=' . $kind
. '&id=' . $audienceId
. '&t=' . self::buildToken($kind, $audienceId, $email);
}
}