1. 新增会员权益配置控制器 Memberbenefit 和 Publish,拆分运营配置模块 2. 新增套餐表字段 free_view_quota、post_discount,支持按套餐配置免费看和发帖折扣 3. 优化前端页面,新增套餐编辑表单、区域选择器、发布配置交互 4. 新增调试支持,支持 mock openid、本地 IP 绕过权限,便于自动化测试 5. 新增数据库工具脚本,用于检查配置、修复数据、验证会员功能 6. 优化会员接口,返回套餐维度的权益信息,支持无限次免费看场景
707 行
25 KiB
PHP
707 行
25 KiB
PHP
<?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) {
|
||
// 留痕失败不影响主流程
|
||
}
|
||
}
|
||
}
|