文件
fenleixinxi/application/api/controller/V1.php
MiAn d871e73d23 feat: 重构会员权益与分销配置,新增套餐维度权益管理
1. 新增会员权益配置控制器 Memberbenefit 和 Publish,拆分运营配置模块
2. 新增套餐表字段 free_view_quota、post_discount,支持按套餐配置免费看和发帖折扣
3. 优化前端页面,新增套餐编辑表单、区域选择器、发布配置交互
4. 新增调试支持,支持 mock openid、本地 IP 绕过权限,便于自动化测试
5. 新增数据库工具脚本,用于检查配置、修复数据、验证会员功能
6. 优化会员接口,返回套餐维度的权益信息,支持无限次免费看场景
2026-10-26 09:21:51 +08:00

707 行
25 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace app\api\controller;
use app\common\controller\Api;
use think\Db;
use think\Response;
use think\exception\HttpResponseException;
/**
* v1 业务接口基类
*
* 职责:
* 1) 统一响应结构(覆盖 FastAdmin 原生 {code,msg,time,data})
* 成功:{"code":0,"message":"ok","data":...}
* 失败:{"code":<业务码>,"message":"...","data":null}
* 2) Authorization: Bearer {token} 鉴权(前端约定,FastAdmin 原生只认 HTTP_TOKEN)
* 3) 分页结构:{list,page,size,total,hasMore}
* 4) 业务配置读写(fa_fc_config)
*
* 契约见 docs/03-接口文档.md
*/
class V1 extends Api
{
/** 全部方法自行鉴权,不走 FastAdmin 的 uri 权限匹配 */
protected $noNeedLogin = ['*'];
protected $noNeedRight = ['*'];
/** @var \app\common\model\User|null 当前登录用户 */
protected $user = null;
/** @var bool 是否已尝试解析登录态 */
protected $resolved = false;
/** @var array|null 业务配置运行时缓存(setCfg 会同步更新) */
protected static $cfgCache = null;
/* ==================== 响应输出 ==================== */
/**
* 成功响应
* @param mixed $data
* @param string $message
*/
protected function ok($data = null, $message = 'ok')
{
$this->emit(['code' => 0, 'message' => $message, 'data' => $data], 200);
}
/**
* 业务失败响应(HTTP 默认 200,前端按 code 判定)
* @param string $message
* @param int $code
* @param int $httpStatus
*/
protected function fail($message, $code = 1, $httpStatus = 200)
{
$this->emit(['code' => $code, 'message' => (string)$message, 'data' => null], $httpStatus);
}
/** 输出 JSON 并终止 */
protected function emit(array $payload, $httpStatus = 200)
{
$response = Response::create($payload, 'json', $httpStatus)
->header(['Content-Type' => 'application/json;charset=utf-8']);
throw new HttpResponseException($response);
}
/* ==================== 鉴权 ==================== */
/**
* 提取当前请求的认证 Token
* 兼容多种请求头与环境变量,防止 Nginx/Apache 过滤 Authorization 导致 401 循环:
* 1. Authorization: Bearer {token}
* 2. Header: token: {token}
* 3. Header: x-token: {token}
* 4. $_SERVER['HTTP_AUTHORIZATION'] / $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
* 5. $_SERVER['HTTP_TOKEN']
*
* @return string 提取出的 Token 字符串
*/
protected function bearerToken()
{
// 1. 标准 Authorization 请求头
$auth = $this->request->header('authorization', '');
if ($auth) {
if (stripos($auth, 'bearer ') === 0) {
return trim(substr($auth, 7));
}
return trim($auth);
}
// 2. 兼容 FastAdmin 原生及常规 Header(Nginx 默认会保留普通 Header)
$token = $this->request->header('token', '');
if ($token) {
return trim($token);
}
$xToken = $this->request->header('x-token', '');
if ($xToken) {
return trim($xToken);
}
// 3. 兼容服务器环境变量中的 Authorization(Apache/Nginx FastCGI 特殊映射)
$serverAuth = $this->request->server('HTTP_AUTHORIZATION', $this->request->server('REDIRECT_HTTP_AUTHORIZATION', ''));
if ($serverAuth) {
if (stripos($serverAuth, 'bearer ') === 0) {
return trim(substr($serverAuth, 7));
}
return trim($serverAuth);
}
// 4. 兼容 $_SERVER['HTTP_TOKEN']
$serverToken = $this->request->server('HTTP_TOKEN', '');
if ($serverToken) {
return trim($serverToken);
}
return '';
}
/** 解析当前用户(不强制登录) */
protected function user()
{
if ($this->resolved) {
return $this->user;
}
$this->resolved = true;
$token = $this->bearerToken();
// 不再接受 ?token= 传参(避免令牌进入访问日志/Referer),仅认 Authorization: Bearer
if (!$token) {
return $this->user = null;
}
// 刷新令牌(rf_ 前缀)不能作为访问令牌使用
if (strpos($token, 'rf_') === 0) {
return $this->user = null;
}
$row = Db::name('user_token')->where('token', $token)->find();
if (!$row) {
return $this->user = null;
}
if ($row['expiretime'] && $row['expiretime'] < time()) {
return $this->user = null;
}
$user = \app\common\model\User::get($row['user_id']);
if (!$user || $user->status != 'normal') {
return $this->user = null;
}
return $this->user = $user;
}
/** 强制登录:未登录直接 401 */
protected function auth()
{
$user = $this->user();
if (!$user) {
$this->fail('登录已过期,请重新登录', 401, 401);
}
return $user;
}
/**
* 后台管理接口鉴权(/api/v1/admin/** 专用)
*
* 背景:V1 基类对所有动作关闭了 FastAdmin 的 URI 权限校验($noNeedLogin = ['*']),
* 因此管理端接口必须显式校验调用者身份,否则任何人可读写后台数据。
*
* 放行规则(满足其一即可):
* 1. 用户 ID 在 fc_config 的 admin.userIds 白名单内(逗号分隔);
* 2. 用户手机号在 fc_config 的 admin.mobiles 白名单内(逗号分隔)。
*
* @return \app\common\model\User
*/
protected function adminOnly()
{
// 调试模式与本地回环支持自动化测试及管理联调
if (config('app_debug') && in_array($this->request->ip(), ['127.0.0.1', '::1'], true)) {
return $this->user();
}
$user = $this->auth();
$ids = array_filter(array_map('intval', preg_split('/[,,\s]+/', (string)$this->cfg('admin.userIds', ''))));
if ($ids && in_array((int)$user['id'], $ids, true)) {
return $user;
}
$mobiles = array_filter(array_map('trim', preg_split('/[,,\s]+/', (string)$this->cfg('admin.mobiles', ''))));
if ($mobiles && in_array((string)$user['mobile'], $mobiles, true)) {
return $user;
}
$this->fail('无权访问管理接口', 403, 403);
}
/** 当前用户 ID */
protected function userId()
{
return (int)$this->auth()->id;
}
/** 签发 token 并写入 fa_user_token */
protected function issueToken($userId, $days = 30)
{
$token = bin2hex(random_bytes(20));
Db::name('user_token')->insert([
'token' => $token,
'user_id' => $userId,
'createtime' => time(),
'expiretime' => time() + $days * 86400,
]);
return $token;
}
/**
* 签发刷新令牌(rf_ 前缀,40 天)
* 与访问令牌同表存储,但鉴权时会被拒绝(见 user()),只能用于 /auth/refresh 换新令牌
*
* 长度约束:fa_user_token.token 为 varchar(50),故取 20 字节随机(40 hex)+ 3 位前缀 = 43
*/
protected function issueRefreshToken($userId, $days = 40)
{
$refresh = 'rf_' . bin2hex(random_bytes(20));
Db::name('user_token')->insert([
'token' => $refresh,
'user_id' => $userId,
'createtime' => time(),
'expiretime' => time() + $days * 86400,
]);
return $refresh;
}
/**
* 简单频率限制(文件缓存计数)
*
* @param string $key 维度键(如 login:手机号、withdraw:用户ID)
* @param int $max 窗口内最大次数
* @param int $window 窗口秒数
* @return void
*/
protected function rateLimit($key, $max, $window)
{
$cacheKey = 'rl:' . md5($key);
$now = time();
$data = cache($cacheKey);
if (!is_array($data) || empty($data['reset']) || $data['reset'] <= $now) {
$data = ['count' => 0, 'reset' => $now + $window];
}
$data['count']++;
cache($cacheKey, $data, $window);
if ($data['count'] > $max) {
$this->fail('操作过于频繁,请稍后再试', 429, 429);
}
}
/* ==================== 入参 ==================== */
/**
* 读取单个入参(POST 优先,其次 GET)
*
* 注意:不能使用 $this->request->post($key) —— TP5.0 的 Request::input() 在参数名
* 不含 '/' 时会按字符串强转,若值本身是数组(如 media[]/benefits[]/icons)
* 会抛 InvalidArgumentException('variable type error:array')。
* 这里改用 post(false)/get(false) 取原始数组,天然支持数组与 JSON 请求体。
*/
protected function input($key, $default = null)
{
$raw = array_merge((array)$this->request->get(false), (array)$this->request->post(false));
if (!array_key_exists($key, $raw)) {
return $default;
}
$val = $raw[$key];
if ($val === '' || $val === null) {
return $default;
}
return $val;
}
/** 全部入参(数组安全) */
protected function inputs()
{
return array_merge((array)$this->request->get(false), (array)$this->request->post(false));
}
/** 必填校验,失败直接返回业务错误 */
protected function require2($fields, $data = null)
{
$data = $data === null ? $this->inputs() : $data;
foreach ((array)$fields as $f => $label) {
if (is_int($f)) {
$f = $label;
$label = $f;
}
if (!isset($data[$f]) || $data[$f] === '' || $data[$f] === null) {
$this->fail($label . '不能为空');
}
}
return true;
}
/* ==================== 分页 ==================== */
/** 解析分页参数 -> [page, size] */
protected function pager($defaultSize = 10, $maxSize = 50)
{
$page = max(1, (int)$this->input('page', 1));
$size = (int)$this->input('size', $defaultSize);
if ($size < 1) {
$size = $defaultSize;
}
if ($size > $maxSize) {
$size = $maxSize;
}
return [$page, $size];
}
/** 组装分页结构 {list,page,size,total,hasMore} */
protected function paginate($list, $total, $page, $size)
{
if (is_object($list) && method_exists($list, 'all')) {
$list = $list->all();
}
$list = array_values(is_array($list) ? $list : (array)$list);
return [
'list' => $list,
'page' => (int)$page,
'size' => (int)$size,
'total' => (int)$total,
'hasMore' => $page * $size < $total,
];
}
/** 对数组集合做内存分页(小数据集用) */
protected function paginateArray(array $items, $page, $size)
{
$total = count($items);
return $this->paginate(array_slice($items, ($page - 1) * $size, $size), $total, $page, $size);
}
/**
* 通用「数据库分页查询」(正确处理 TP5.0 的查询构造器复用陷阱)
*
* 注意:TP5.0 的 count() 内部会执行 $this->options = [],
* 因此**绝不能**复用同一个 Query 对象先 count() 再 select(),
* 否则 alias / join / where 全部丢失(表现为 SQL 报错或筛选失效)。
*
* 用法:
* list($rows, $total, $page, $size) = $this->pageQuery(function ($forCount) use ($x) {
* $q = Db::name('fc_xxx')->alias('t')->join('...')->where(...);
* return $forCount ? $q : $q->field('...')->order('...');
* });
*
* @param callable $builder function(bool $forCount): \think\db\Query
* @param int $defaultSize
* @return array [rows, total, page, size]
*/
protected function pageQuery(callable $builder, $defaultSize = 10)
{
list($page, $size) = $this->pager($defaultSize);
$total = (int)$builder(true)->count();
$rows = $builder(false)->page($page, $size)->select();
return [$rows, $total, $page, $size];
}
/**
* 读取路由 :id 参数(兼容 POST 体/查询串传入)
*
* @return int
*/
protected function routeId()
{
$id = (int)$this->input('id', 0);
if ($id <= 0) {
$id = (int)$this->request->param('id', 0);
}
return $id;
}
/**
* 提取请求中的经纬度入参(可选 float,gcj02)
* 未传 / 非数字 / 越界 / 为 0 时返回 null
* @param string $key lat|lng
* @param float $limit 绝对值上限(纬度 90 / 经度 180)
* @return float|null
*/
protected function coord($key, $limit)
{
$val = $this->input($key, null);
if ($val === null || $val === '' || !is_numeric($val)) {
return null;
}
$val = round((float)$val, 6);
if ($val == 0.0 || $val < -$limit || $val > $limit) {
return null;
}
return $val;
}
/* ==================== 业务配置(fa_fc_config) ==================== */
/**
* 读取配置,key 形如 fee.singlePost / ui.logo / base.appName
* 数组/对象值以 JSON 存取
*
* 缓存说明:使用类静态缓存(而非函数 static),以便 setCfg() 写入后同步失效,
* 保证「同一请求内写后即读」能拿到新值(如后台保存 UI 配置后立刻回显)。
*/
protected function cfg($key, $default = null)
{
if (self::$cfgCache === null) {
self::$cfgCache = [];
foreach (Db::name('fc_config')->select() as $row) {
self::$cfgCache[$row['key']] = $row['value'];
}
}
if (!array_key_exists($key, self::$cfgCache)) {
return $default;
}
$val = self::$cfgCache[$key];
$decoded = json_decode($val, true);
if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || $decoded === null)) {
return $decoded;
}
return $val;
}
/** 写入配置(并同步运行时缓存) */
protected function setCfg($key, $value)
{
$val = (is_scalar($value) || $value === null) ? (string)$value : json_encode($value, JSON_UNESCAPED_UNICODE);
if (Db::name('fc_config')->where('key', $key)->find()) {
Db::name('fc_config')->where('key', $key)->update(['value' => $val, 'updatetime' => time()]);
} else {
Db::name('fc_config')->insert([
'key' => $key, 'value' => $val, 'createtime' => time(), 'updatetime' => time(),
]);
}
if (self::$cfgCache === null) {
// 首次写入时先载入全量配置,避免后续读取其他 key 时命中空缓存而返回默认值
self::$cfgCache = [];
foreach (Db::name('fc_config')->select() as $row) {
self::$cfgCache[$row['key']] = $row['value'];
}
}
self::$cfgCache[$key] = $val;
return true;
}
/* ==================== 工具 ==================== */
/** 时间戳 -> ISO8601(前端 new Date() 可直接解析) */
protected function iso($timestamp)
{
if (!$timestamp) {
return null;
}
if (!is_numeric($timestamp)) {
$timestamp = strtotime($timestamp);
}
return date('c', (int)$timestamp);
}
/**
* 手机号与电话号码多格式安全脱敏
* 兼容标准11位手机号、带+86/0086国际前缀、带空格/中划线及带区号固定电话,防止未付费信息绕过泄露
*
* @param string $phone 原始号码
* @return string 脱敏后的号码字符串
*/
protected function maskPhone($phone)
{
$raw = trim((string)$phone);
if ($raw === "") {
return "";
}
// 统一提取数字部分进行标准化识别
$clean = preg_replace("/[^\d]/", "", $raw);
// 去除大陆前缀 86 或 0086
if (strpos($clean, "86") === 0 && strlen($clean) === 13) {
$clean = substr($clean, 2);
}
// 11位大陆标准手机号
if (preg_match("/^1[3-9]\d{9}$/", $clean)) {
return substr($clean, 0, 3) . "****" . substr($clean, 7);
}
// 10~12位座机固话(如 085188888888、01088888888)
if (preg_match("/^0\d{9,11}$/", $clean)) {
$prefixLen = (strpos($clean, "01") === 0 || strpos($clean, "02") === 0) ? 3 : 4;
return substr($clean, 0, $prefixLen) . "****" . substr($clean, -3);
}
// 其它>=7位的任意连续数字号码,统一对中段进行掩码
$len = strlen($clean);
if ($len >= 7) {
$start = max(1, (int)floor(($len - 4) / 2));
return substr($clean, 0, $start) . "****" . substr($clean, $start + 4);
}
return $raw;
}
/**
* 文本内容中的手机号与电话动态遮蔽
* 用于未付费解锁状态下对标题、详细描述中的手机号执行掩码保护,杜绝发帖人在正文直接贴电话白嫖
*
* @param string $content 原始富文本或正文内容
* @return string 动态掩码后的文本
*/
protected function maskTextPhones($content)
{
$text = (string)$content;
if ($text === "") {
return "";
}
// 匹配各类11位大陆手机号码,含可能夹带的空格或横杠(如 138 0000 0000 或 138-0000-0000)
$pattern = '/(?:(?:\+|00)86[\s-]?)?(1[3-9]\d)[\s-]?(\d{4})[\s-]?(\d{4})/u';
return preg_replace_callback($pattern, function ($matches) {
return $matches[1] . '****' . $matches[3];
}, $text);
}
/** 生成业务单号(前缀 + 时间 + 8 位随机十六进制,避免同秒并发碰撞) */
protected function orderNo($prefix)
{
return $prefix . date('YmdHis') . bin2hex(random_bytes(4));
}
/* ==================== 业务公共 ==================== */
/**
* 读取用户业务扩展(不存在则初始化)
* @return array
*/
protected function ext($userId)
{
$ext = Db::name('fc_user_ext')->where('user_id', $userId)->find();
if (!$ext) {
$inviteCode = strtoupper(substr(md5('fc' . $userId . mt_rand()), 0, 6));
Db::name('fc_user_ext')->insert([
'user_id' => $userId, 'openid' => '', 'user_type' => 1, 'realname_status' => 0,
'is_member' => 0, 'member_level' => '', 'member_expiretime' => null,
'invite_code' => $inviteCode, 'inviter_id' => 0,
'free_view_date' => date('Y-m-d'), 'free_view_used' => 0,
'createtime' => time(), 'updatetime' => time(),
]);
$ext = Db::name('fc_user_ext')->where('user_id', $userId)->find();
}
return $ext;
}
/**
* 组装用户信息(与前端 common/mock/data.js 的 datasets.user 字段一致)
*/
protected function userInfo($user = null, $phone = null)
{
$user = $user ?: $this->auth();
$ext = $this->ext($user['id']);
$isMember = (int)$ext['is_member'] === 1
&& !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > time();
// 查询商家认证状态与商铺资料
$merchant = Db::name('fc_merchant')->where('user_id', $user['id'])->find();
$isMerchant = $merchant && (int)$merchant['audit_status'] === 1 && $merchant['shop_status'] === 'normal';
$merchantStatus = $merchant ? (int)$merchant['audit_status'] : -1;
return [
'id' => (int)$user['id'],
'nickname' => (string)$user['nickname'],
'avatar' => (string)$user['avatar'],
'phone' => $phone === null ? (string)$user['mobile'] : $phone,
'userType' => $isMerchant ? 2 : (int)$ext['user_type'],
'isMember' => $isMember,
'memberLevel' => $isMember ? (string)$ext['member_level'] : '',
'memberExpireAt' => $ext['member_expiretime'] ? $this->iso($ext['member_expiretime']) : null,
'balance' => (float)$user['money'],
'realnameStatus' => (int)$ext['realname_status'],
'realname' => isset($ext['realname']) ? (string)$ext['realname'] : '',
'isMerchant' => (bool)$isMerchant,
'merchantStatus' => $merchantStatus,
'merchant' => $merchant ? [
'id' => (int)$merchant['id'],
'name' => (string)$merchant['name'],
'logo' => (string)$merchant['logo'],
'banner' => isset($merchant['banner']) ? (string)$merchant['banner'] : '',
'notice' => isset($merchant['notice']) ? (string)$merchant['notice'] : '',
'contact' => (string)$merchant['contact'],
'license' => (string)$merchant['license'],
'auditStatus' => (int)$merchant['audit_status'],
'auditReason' => isset($merchant['audit_reason']) ? (string)$merchant['audit_reason'] : '',
'shopStatus' => (string)$merchant['shop_status'],
] : null,
];
}
/** 解析 JSON 字段为数组 */
protected function jsonArr($val)
{
if (is_array($val)) {
return $val;
}
if (!$val) {
return [];
}
$decoded = json_decode($val, true);
return is_array($decoded) ? $decoded : [];
}
/**
* 记一条资金流水(含归属用户,供余额中心按用户查询)
* @param int $userId 归属用户 ID(0=平台/不可归属)
*/
protected function flow($type, $scene, $amount, $balance, $relatedId = 0, $remark = '', $userId = 0)
{
static $seq = 0;
$seq++;
Db::name('fc_finance_flow')->insert([
'user_id' => (int)$userId,
'flow_no' => 'F' . date('YmdHis') . bin2hex(random_bytes(4)) . $seq,
'type' => $type,
'scene' => $scene,
'amount' => $amount,
'balance' => $balance,
'related_id' => $relatedId,
'remark' => $remark,
'createtime' => time(),
]);
}
/**
* 记一条「用户钱包流水」(fc_wallet_flow)
* 仅在 fa_user.money 发生增减时调用——余额中心的「余额明细」数据源。
* 平台对账口径(微信收入等)继续走 flow() / fc_finance_flow。
* @param int $userId 用户 ID
* @param string $type income=入账 expense=支出(用户钱包口径)
* @param string $scene 场景:余额充值/后台充值/会员开通/发布付费/付费查看/订单退款(余额)/佣金提现
* @param float $amount 变动金额(正数)
* @param float $balance 变动后钱包余额
*/
protected function walletFlow($userId, $type, $scene, $amount, $balance, $relatedId = 0, $remark = '')
{
Db::name('fc_wallet_flow')->insert([
'user_id' => (int)$userId,
'flow_no' => 'W' . date('YmdHis') . bin2hex(random_bytes(4)),
'type' => $type,
'scene' => $scene,
'amount' => $amount,
'balance' => $balance,
'related_id' => (int)$relatedId,
'remark' => $remark,
'createtime' => time(),
]);
}
/**
* 余额支付前的支付密码校验(开关关闭时直接放行)
* 规则:后台开启「支付密码」后,所有余额支付必须携带正确的 payPassword;
* 未设置过密码时提示先设置(余额中心 → 支付密码)。
*/
protected function verifyPayPassword($user)
{
if (!(int)$this->cfg('pay.payPasswordEnabled', 0)) {
return;
}
$ext = $this->ext($user['id']);
$hash = (string)(isset($ext['pay_password']) ? $ext['pay_password'] : '');
if ($hash === '') {
$this->fail('请先在「余额中心」设置支付密码');
}
$pwd = trim((string)$this->input('payPassword', ''));
if ($pwd === '') {
$this->fail('请输入支付密码');
}
if (!preg_match('/^\d{6}$/', $pwd) || !password_verify($pwd, $hash)) {
$this->fail('支付密码错误');
}
}
/** 发一条站内消息 */
protected function notify($userId, $type, $title, $content)
{
Db::name('fc_message')->insert([
'user_id' => $userId, 'type' => $type, 'title' => $title,
'content' => $content, 'isread' => 0, 'readtime' => null, 'createtime' => time(),
]);
}
/** 管理端操作留痕 */
protected function adminLog($action, $remark = '')
{
try {
Db::name('admin_log')->insert([
'admin_id' => 1, 'username' => 'admin', 'url' => $this->request->url(),
'title' => $action, 'content' => $remark, 'ip' => $this->request->ip(),
'useragent' => (string)$this->request->header('user-agent'),
'createtime' => time(),
]);
} catch (\Exception $e) {
// 留痕失败不影响主流程
}
}
}