where('enabled', 1) ->order('sort asc') ->select(); $list = []; foreach ($rows as $row) { $viewQuota = isset($row['view_quota']) ? max(0, (int)$row['view_quota']) : 20; $callQuota = isset($row['call_quota']) ? max(0, (int)$row['call_quota']) : 30; $discountNum = isset($row['post_discount']) ? \app\common\library\FcConfig::parseDiscountNum($row['post_discount']) : 8.0; $list[] = [ 'planId' => (string)$row['plan_code'], 'name' => (string)$row['name'], 'price' => (float)$row['price'], 'originPrice' => (float)$row['origin_price'], 'days' => (int)$row['days'], 'tag' => (string)$row['tag'], 'postQuota' => isset($row['post_quota']) ? (int)$row['post_quota'] : 0, 'viewQuota' => $viewQuota, 'viewUnlimited' => ($viewQuota === 0), 'callQuota' => $callQuota, 'callUnlimited' => ($callQuota === 0), 'postDiscountNum' => $discountNum, 'postDiscount' => \app\common\library\FcConfig::formatDiscountText($discountNum), 'benefits' => \app\common\library\FcConfig::buildPlanBenefits($row), ]; } $this->ok($list); } /** * M2 获取会员权益配置(以 fc_plan 套餐权益为唯一真源) * GET /api/v1/member/benefits * @return void */ public function benefits() { $me = $this->user(); $ext = $me ? $this->ext($me['id']) : null; $benefitCfg = \app\common\library\FcConfig::resolveMemberBenefit($ext); $viewQuota = (int)$benefitCfg['viewQuota']; $viewUnlimited = ($viewQuota === 0); $callQuota = (int)$benefitCfg['callQuota']; $callUnlimited = ($callQuota === 0); $postDiscountNum = (float)$benefitCfg['postDiscountNum']; $postDiscount = (string)$benefitCfg['postDiscount']; $benefits = (array)$benefitCfg['benefits']; $maxPlanQuota = (int)Db::name('fc_plan')->where('enabled', 1)->max('post_quota'); $postMode = (string)$this->cfg('post.mode', 'paid'); $singlePrice = round((float)$this->cfg('post.singlePrice', 5), 2); if ($singlePrice <= 0) { $postMode = 'free'; } $viewModeRaw = (string)$this->cfg('fee.viewMode', ''); $payViewPrice = round((float)$this->cfg('fee.payView', 2), 2); $viewMode = ($viewModeRaw === 'free' || $payViewPrice <= 0) ? 'free' : 'paid'; $isMember = false; $viewUsed = 0; $viewLeft = 0; $callUsed = 0; $callLeft = 0; $postQuotaLeft = 0; $memberExpireAt = null; if ($me && $ext) { $now = time(); $isMember = (int)$ext['is_member'] === 1 && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; $today = date('Y-m-d'); if ($isMember) { $viewUsed = (int)Db::name('fc_view_log') ->where('user_id', (int)$me['id']) ->where('view_date', $today) ->count(); $viewLeft = $viewUnlimited ? -1 : max(0, $viewQuota - $viewUsed); $callUsed = (int)Db::name('fc_call_log') ->where('user_id', (int)$me['id']) ->where('call_date', $today) ->count(); $callLeft = $callUnlimited ? -1 : max(0, $callQuota - $callUsed); } $postQuotaLeft = $this->quotaLeft($ext); $memberExpireAt = !empty($ext['member_expiretime']) ? $this->iso($ext['member_expiretime']) : null; } $this->ok([ 'viewQuota' => $viewQuota, 'viewUnlimited' => $viewUnlimited, 'callQuota' => $callQuota, 'callUnlimited' => $callUnlimited, 'postDiscountNum' => $postDiscountNum, 'postDiscount' => $postDiscount, 'maxPlanQuota' => $maxPlanQuota, 'postMode' => $postMode, 'viewMode' => $viewMode, 'benefits' => $benefits, 'isMember' => $isMember, 'viewUsed' => $viewUsed, 'viewLeft' => $viewLeft, 'callUsed' => $callUsed, 'callLeft' => $callLeft, 'postQuotaLeft' => $postQuotaLeft, 'memberExpireAt' => $memberExpireAt, ]); } /** * M3 开通会员 * POST /api/v1/member/open {planId,payType} * → {orderNo,status,order,expireAt,postQuota,quotaLeft,quotaExpireAt} * - balance:余额支付,下单即扣款并**立即发放会员**(status=paid,order=null) * - wxpay :订单 pending(order={orderNo,amount,payType,status:'pending'}), * 前端拿 orderNo 调 POST /pay/prepay 调起收银台,支付成功后由 /pay/notify 发放会员 * 规则 BR-07;套餐按 plan_code 查;金额取 fc_plan.price(服务端计算,忽略客户端传值) */ public function open() { $user = $this->auth(); $planId = trim((string)$this->input('planId', '')); if ($planId === '') { $this->fail('请选择会员套餐'); } $payType = (string)$this->input('payType', 'wxpay'); if ($payType === '') { $payType = 'wxpay'; } if (!in_array($payType, ['wxpay', 'balance'], true)) { $this->fail('支付方式不支持'); } $plan = Db::name('fc_plan')->where('plan_code', $planId)->find(); if (!$plan) { $this->fail('套餐不存在'); } $originAmount = round((float)$plan['price'], 2); $userCouponId = (int)$this->input('userCouponId', 0); // 优惠券:先试算(事务外只校验,真正锁定在事务内) $couponAmount = 0; $hasCoupon = false; if ($userCouponId > 0) { try { $couponInfo = \app\common\library\CouponService::preview($user['id'], $userCouponId, $originAmount); } catch (\Throwable $e) { $this->fail($e->getMessage()); } $couponAmount = $couponInfo['discount']; $hasCoupon = true; } $amount = round($originAmount - $couponAmount, 2); $now = time(); $isBalance = $payType === 'balance'; // 余额校验必须在落订单之前:否则余额不足时已插入一条 status=paid 的假订单 if ($isBalance) { if ((float)$user['money'] < $amount) { $this->fail('余额不足'); } // 后台开启支付密码时,余额支付需校验 $this->verifyPayPassword($user); } $orderNo = $this->orderNo('MO'); $grant = null; try { // 扣款 + 落订单 + 发放会员 同一事务:任一步失败整体回滚 Db::transaction(function () use (&$grant, $isBalance, $amount, $originAmount, $couponAmount, $hasCoupon, $userCouponId, $user, $plan, $payType, $orderNo, $now) { // 锁券(与订单同事务,失败则整体回滚) if ($hasCoupon) { \app\common\library\CouponService::lock($user['id'], $userCouponId, $originAmount, $orderNo); } if ($isBalance) { // 条件扣款:并发下不会扣成负数 $affected = Db::name('user') ->where('id', (int)$user['id']) ->where('money', '>=', $amount) ->setDec('money', $amount); if (!$affected) { throw new \RuntimeException('余额不足'); } } $orderId = Db::name('fc_order')->insertGetId([ 'order_no' => $orderNo, 'user_id' => $user['id'], 'type' => 'member', 'title' => (string)$plan['name'], 'amount' => $amount, 'origin_amount' => $originAmount, 'coupon_amount' => $couponAmount, 'coupon_id' => $hasCoupon ? $userCouponId : 0, 'pay_type' => $payType, 'status' => $isBalance ? 'paid' : 'pending', 'paytime' => $isBalance ? $now : null, // related_id 存套餐主键:支付回调 /pay/notify 据此复用 grantMembership(),回调可独立完成 'related_id' => (int)$plan['id'], 'createtime' => $now, 'updatetime' => $now, ]); if ($isBalance) { // 余额支付:立即发放会员(微信支付等待回调发放) $grant = $this->grantMembership(Db::name('fc_order')->where('id', $orderId)->find()); // 券核销(微信支付在 OrderFulfill 里核销) \app\common\library\CouponService::markUsed($orderNo); // 钱包流水(fa_user.money 减)→ 余额中心「余额明细」 $balance = round((float)Db::name('user')->where('id', (int)$user['id'])->value('money'), 2); $this->walletFlow($user['id'], 'expense', '会员开通', $amount, $balance, $orderId, (string)$plan['name']); } }); } catch (\Throwable $e) { $this->fail($e->getMessage() ?: '开通失败,请重试'); } $quota = isset($plan['post_quota']) ? max(0, (int)$plan['post_quota']) : 0; $this->ok([ 'orderNo' => $orderNo, 'status' => $isBalance ? 'paid' : 'pending', 'amount' => $amount, 'originAmount' => $originAmount, 'couponAmount' => $couponAmount, // 微信支付:前端拿到 orderNo 后调 /pay/prepay;余额支付无需再支付(order=null) 'order' => $isBalance ? null : [ 'orderNo' => $orderNo, 'amount' => $amount, 'originAmount' => $originAmount, 'couponAmount' => $couponAmount, 'payType' => 'wxpay', 'status' => 'pending', ], 'expireAt' => $grant ? $this->iso($grant['expireAt']) : null, 'postQuota' => $quota, 'quotaLeft' => $grant ? $grant['quotaLeft'] : $this->quotaLeft($this->ext($user['id'])), 'quotaExpireAt' => $grant ? $this->iso($grant['expireAt']) : null, ]); } /** * 会员发放(**可复用**:余额支付即时发放 / 微信支付回调 /pay/notify 发放) * 说明:以 fc_order.related_id(套餐主键)反查 fc_plan 取天数与赠送发布条数; * 老订单(related_id=0)按订单标题(套餐名)反查,仍失败则按默认 30 天、0 条额度发放; * 发放同时记「会员开通」资金流水并发站内信。 * @param array $order fc_order 行(需含 user_id / related_id / amount / id / title) * @return array{expireAt:int,postQuota:int,quotaLeft:int} */ public function grantMembership(array $order) { $userId = (int)$order['user_id']; $now = time(); $plan = null; $planId = (int)(isset($order['related_id']) ? $order['related_id'] : 0); if ($planId > 0) { $plan = Db::name('fc_plan')->where('id', $planId)->find(); } if (!$plan) { $title = isset($order['title']) ? (string)$order['title'] : ''; if ($title !== '') { $plan = Db::name('fc_plan')->where('name', $title)->find(); } } $days = $plan ? max(0, (int)$plan['days']) : 30; $quota = $plan && isset($plan['post_quota']) ? max(0, (int)$plan['post_quota']) : 0; $postDiscount = $plan && isset($plan['post_discount']) ? round((float)$plan['post_discount'], 1) : 8.0; $level = $plan ? (string)$plan['name'] : '会员'; $ext = $this->ext($userId); $base = (int)$ext['member_expiretime']; if ($base < $now) { $base = $now; } $expire = $base + $days * 86400; // 发布套餐额度:月/年卡赠送「含发布条数」,额度随会员有效期(过期后由发布接口自动清零) $left = (int)(isset($ext['post_quota_left']) ? $ext['post_quota_left'] : 0); $quotaExpire = (int)(isset($ext['post_quota_expire']) ? $ext['post_quota_expire'] : 0); if ($quotaExpire > 0 && $quotaExpire < $now) { $left = 0; // 上一周期额度已过期,不再累计 } $left += $quota; Db::name('fc_user_ext')->where('user_id', $userId)->update([ 'is_member' => 1, 'member_level' => $level, 'member_expiretime' => $expire, 'post_quota_left' => $left, 'post_quota_expire' => $expire, 'post_discount' => $postDiscount, 'updatetime' => $now, ]); $amount = round((float)(isset($order['amount']) ? $order['amount'] : 0), 2); $orderId = (int)(isset($order['id']) ? $order['id'] : 0); $this->flow('income', '会员开通', $amount, 0, $orderId, '', $userId); $this->notify( $userId, 'system', '会员开通成功', '您已开通「' . $level . '」,有效期至 ' . date('Y-m-d', $expire) . ',赠送发布条数 ' . $quota . ' 条。' ); // 触发分销分佣结算(开通会员产生消费) DistributionService::settleOrder($order); return ['expireAt' => $expire, 'postQuota' => $quota, 'quotaLeft' => $left]; } /** * 剩余发布条数(供未开通时回显) * @param array $ext * @return int */ protected function quotaLeft($ext) { $left = (int)(isset($ext['post_quota_left']) ? $ext['post_quota_left'] : 0); $expire = (int)(isset($ext['post_quota_expire']) ? $ext['post_quota_expire'] : 0); if ($left > 0 && $expire > 0 && $expire < time()) { return 0; } return $left; } /** * M4 我的订单 * GET /api/v1/member/orders {page,size} → {list,page,size,total,hasMore} * 规则:按 createtime desc */ public function orders() { $user = $this->auth(); list($page, $size) = $this->pager(); $total = Db::name('fc_order')->where('user_id', $user['id'])->count(); $rows = Db::name('fc_order') ->where('user_id', $user['id']) ->order('createtime desc') ->page($page, $size) ->select(); $list = []; foreach ($rows as $row) { // 惰性超时处理:若 pending 且已超时则自动关闭 if ((string)$row['status'] === 'pending') { $row = \app\common\library\OrderClose::checkAndCloseIfExpired($row); } $list[] = [ 'id' => (int)$row['id'], 'orderNo' => (string)$row['order_no'], 'type' => (string)$row['type'], 'title' => (string)$row['title'], 'amount' => (float)$row['amount'], 'payType' => (string)$row['pay_type'], 'status' => (string)$row['status'], 'createdAt' => $this->iso($row['createtime']), ]; } $this->ok($this->paginate($list, $total, $page, $size)); } /** * M5 我的资产 * GET /api/v1/member/assets * → {balance,coupons:[{id,couponId,name,type,amount,minAmount,status,expireAt}],couponCount,points} * 说明:coupons 来自 fc_user_coupon LEFT JOIN fc_coupon(真实数据); * status:unused 未使用 / used 已使用 / expired 已过期(有效期为 0 表示永久; * status=unused 但已过期时**动态判定**为 expired,无需定时任务)。 */ public function assets() { $user = $this->auth(); $now = time(); $rows = Db::name('fc_user_coupon')->alias('uc') ->join('fc_coupon c', 'c.id = uc.coupon_id', 'LEFT') ->where('uc.user_id', $user['id']) ->field('uc.id,uc.coupon_id,uc.status,uc.expiretime,uc.createtime,c.name,c.type,c.amount,c.min_amount') ->order('uc.id desc') ->select(); $coupons = []; foreach ($rows as $row) { $status = (string)$row['status']; $expire = !empty($row['expiretime']) ? (int)$row['expiretime'] : 0; if ($status === 'unused' && $expire > 0 && $expire < $now) { $status = 'expired'; } $coupons[] = [ 'id' => (int)$row['id'], 'couponId' => (int)$row['coupon_id'], 'name' => isset($row['name']) ? (string)$row['name'] : '', 'type' => isset($row['type']) ? (string)$row['type'] : '', 'amount' => isset($row['amount']) ? (float)$row['amount'] : 0, 'minAmount' => isset($row['min_amount']) ? (float)$row['min_amount'] : 0, 'status' => $status, 'expireAt' => $expire > 0 ? $this->iso($expire) : null, ]; } $ext = $this->ext($user['id']); $isMember = (int)$ext['is_member'] === 1 && !empty($ext['member_expiretime']) && (int)$ext['member_expiretime'] > $now; $this->ok([ 'balance' => (float)$user['money'], 'isMember' => $isMember, 'coupons' => $coupons, 'couponCount' => count($coupons), 'points' => (int)$user['score'], 'payPasswordEnabled' => (int)$this->cfg('pay.payPasswordEnabled', 0) === 1, 'hasPayPassword' => (string)$ext['pay_password'] !== '', ]); } /** * 余额明细(余额中心交易记录) * GET /api/v1/member/flows {page,size,type} → {list:[...],page,size,total,hasMore} * type 筛选:recharge=充值 / expense=消费 / refund=退款,缺省=全部 * 数据源:fc_wallet_flow(用户钱包流水,仅记录 fa_user.money 增减; * fc_finance_flow 是平台对账口径,含微信收入等非钱包变动,不在此展示) */ public function flows() { $user = $this->auth(); $type = (string)$this->input('type', ''); list($rows, $total, $page, $size) = $this->pageQuery(function ($forCount) use ($user, $type) { // TP5.0 禁止复用同一 Query 先 count 再 select,必须走 pageQuery 重建 $q = Db::name('fc_wallet_flow')->where('user_id', $user['id']); if ($type === 'recharge') { // 充值:微信充值入账与后台充值 $q->where('scene', 'in', ['余额充值', '后台充值']); } elseif ($type === 'expense') { // 消费:会员开通 / 发布付费 / 付费查看 / 充值退款扣回等支出 $q->where('type', 'expense'); } elseif ($type === 'refund') { // 退款:订单退款(余额原路退回) $q->where('scene', 'like', '%退款%'); } return $forCount ? $q : $q->order('id desc'); }, 15); $list = []; foreach ($rows as $row) { $list[] = [ 'flowNo' => (string)$row['flow_no'], 'type' => (string)$row['type'], 'scene' => (string)$row['scene'], 'amount' => round((float)$row['amount'], 2), 'balance' => round((float)$row['balance'], 2), 'remark' => (string)$row['remark'], 'createdAt' => !empty($row['createtime']) ? $this->iso((int)$row['createtime']) : null, ]; } $this->ok($this->paginate($list, $total, $page, $size)); } /** * 设置/修改支付密码(6 位数字) * POST /api/v1/member/pay-password/set {password, oldPassword?} * 首次设置无需 oldPassword;已设置时必须携带正确的 oldPassword(找回走人工/后续 SMS)。 */ public function setPayPassword() { $user = $this->auth(); $this->rateLimit('payswd:' . $user['id'], 10, 60); $password = trim((string)$this->input('password', '')); $oldPassword = trim((string)$this->input('oldPassword', '')); if (!preg_match('/^\d{6}$/', $password)) { $this->fail('支付密码必须是 6 位数字'); } $ext = $this->ext($user['id']); $hash = (string)(isset($ext['pay_password']) ? $ext['pay_password'] : ''); if ($hash !== '') { // 修改密码:必须验证旧密码 $old = trim((string)$oldPassword); if ($old === '') { $this->fail('请输入原支付密码'); } if (!preg_match('/^\d{6}$/', $old) || !password_verify($old, $hash)) { $this->fail('原支付密码错误'); } if ($old === $password) { $this->fail('新密码不能与原密码相同'); } } // 弱密码拦截:6 位连号/同号(如 123456 / 888888) if (preg_match('/^(\d)\1{5}$/', $password) || in_array($password, ['012345', '123456', '234567', '345678', '456789', '987654', '876543'], true)) { $this->fail('支付密码过于简单,请更换'); } Db::name('fc_user_ext')->where('user_id', $user['id'])->update([ 'pay_password' => password_hash($password, PASSWORD_DEFAULT), 'updatetime' => time(), ]); $this->ok(['hasPayPassword' => true]); } }