,"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'], 'inviteCode' => isset($ext['invite_code']) ? (string)$ext['invite_code'] : '', '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) { // 留痕失败不影响主流程 } } }