From 28b478dab560d4ee51ac88595f5819a36a20c884 Mon Sep 17 00:00:00 2001 From: Ghost <106998207@qq.com> Date: Tue, 17 Mar 2026 11:53:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF-=E9=83=A8=E5=88=86?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/controller/api/Auth.php | 525 ++++++ api/app/controller/api/CrmReport.php | 235 +++ api/app/controller/api/Distribution.php | 1466 +++++++++++++++++ api/app/controller/api/EnterpriseResume.php | 250 +++ api/app/controller/api/Payment.php | 909 ++++++++++ api/app/controller/api/Test.php | 625 +++++++ api/app/controller/api/Upload.php | 11 + .../controller/api/WechatTransferNotify.php | 101 ++ api/app/controller/superadmin/AiConfig.php | 482 ++++++ api/app/controller/superadmin/AppUser.php | 525 ++++++ api/app/controller/superadmin/Auth.php | 140 ++ api/app/controller/superadmin/Database.php | 750 +++++++++ .../controller/superadmin/Distribution.php | 433 +++++ api/app/controller/superadmin/Enterprise.php | 437 +++++ api/app/controller/superadmin/Finance.php | 337 ++++ api/app/controller/superadmin/Overview.php | 431 +++++ api/app/controller/superadmin/Pricing.php | 200 +++ api/app/controller/superadmin/Question.php | 332 ++++ api/app/controller/superadmin/Settings.php | 494 ++++++ api/app/middleware/Auth.php | 48 + api/app/middleware/Cors.php | 74 + api/app/middleware/SuperAdmin.php | 57 + api/app/model/AiProvider.php | 109 ++ api/app/model/BackupRecord.php | 48 + api/app/model/Enterprise.php | 51 + api/app/model/EnterpriseResumeUpload.php | 25 + api/app/model/PricingConfig.php | 117 ++ api/app/model/Question.php | 91 + api/app/model/SystemConfig.php | 63 + api/app/model/UploadFile.php | 29 + api/app/model/User.php | 76 + api/app/model/UserProfile.php | 162 ++ api/app/model/WechatUser.php | 56 + 33 files changed, 9689 insertions(+) create mode 100644 api/app/controller/api/Auth.php create mode 100644 api/app/controller/api/CrmReport.php create mode 100644 api/app/controller/api/Distribution.php create mode 100644 api/app/controller/api/EnterpriseResume.php create mode 100644 api/app/controller/api/Payment.php create mode 100644 api/app/controller/api/Test.php create mode 100644 api/app/controller/api/Upload.php create mode 100644 api/app/controller/api/WechatTransferNotify.php create mode 100644 api/app/controller/superadmin/AiConfig.php create mode 100644 api/app/controller/superadmin/AppUser.php create mode 100644 api/app/controller/superadmin/Auth.php create mode 100644 api/app/controller/superadmin/Database.php create mode 100644 api/app/controller/superadmin/Distribution.php create mode 100644 api/app/controller/superadmin/Enterprise.php create mode 100644 api/app/controller/superadmin/Finance.php create mode 100644 api/app/controller/superadmin/Overview.php create mode 100644 api/app/controller/superadmin/Pricing.php create mode 100644 api/app/controller/superadmin/Question.php create mode 100644 api/app/controller/superadmin/Settings.php create mode 100644 api/app/middleware/Auth.php create mode 100644 api/app/middleware/Cors.php create mode 100644 api/app/middleware/SuperAdmin.php create mode 100644 api/app/model/AiProvider.php create mode 100644 api/app/model/BackupRecord.php create mode 100644 api/app/model/Enterprise.php create mode 100644 api/app/model/EnterpriseResumeUpload.php create mode 100644 api/app/model/PricingConfig.php create mode 100644 api/app/model/Question.php create mode 100644 api/app/model/SystemConfig.php create mode 100644 api/app/model/UploadFile.php create mode 100644 api/app/model/User.php create mode 100644 api/app/model/UserProfile.php create mode 100644 api/app/model/WechatUser.php diff --git a/api/app/controller/api/Auth.php b/api/app/controller/api/Auth.php new file mode 100644 index 0000000..b8fc4d4 --- /dev/null +++ b/api/app/controller/api/Auth.php @@ -0,0 +1,525 @@ +where('username', $username) + // ->find(); + + if (!$user) { + return error('用户名或密码错误', 401); + } + + // 验证密码 + if (!password_verify($password, $user['password'])) { + return error('用户名或密码错误', 401); + } + + // 检查状态 + if ($user['status'] != 1) { + return error('账号已被禁用', 403); + } + + // 更新登录信息(使用时间戳,驼峰命名) + Db::name('users') + ->where('id', $user['id']) + ->update([ + 'lastLoginTime' => time(), + 'lastLoginIp' => Request::ip(), + 'updatedAt' => time() + ]); + + // 生成Token + $payload = [ + 'user_id' => $user['id'], + 'username' => $user['username'], + 'role' => $user['role'] + ]; + + $token = JwtService::generateToken($payload); + + return success([ + 'token' => $token, + 'expires_in' => config('jwt.expire'), + 'user' => [ + 'id' => $user['id'], + 'username' => $user['username'], + 'nickname' => $user['nickname'] ?? $user['username'], + 'email' => $user['email'] ?? '', + 'avatar' => $user['avatar'] ?? '', + 'role' => $user['role'] + ] + ], '登录成功'); + } + + /** + * 用户注册(前端) + * @return \think\response\Json + */ + public function register() + { + $data = Request::post(); + + // 数据验证 + if (empty($data['username']) || empty($data['password'])) { + return error('用户名和密码不能为空', 400); + } + + // 检查用户名是否已存在 + if (Db::name('users')->where('username', $data['username'])->find()) { + return error('用户名已存在', 400); + } + + // 检查邮箱是否已存在 + if (!empty($data['email']) && Db::name('users')->where('email', $data['email'])->find()) { + return error('邮箱已被注册', 400); + } + + // 注意:mbti_users表只存储管理员和超管,前端用户需要存储在单独的表中 + // 这里暂时返回错误,需要创建前端用户表后再实现 + return error('前端用户注册功能暂未实现,请联系管理员', 501); + + // 创建用户(如果将来有前端用户表,使用以下代码) + // $userId = Db::name('frontend_users')->insertGetId([ + // 'username' => $data['username'], + // 'password' => password_hash($data['password'], PASSWORD_DEFAULT), + // 'email' => $data['email'] ?? '', + // 'status' => 1, + // 'created_at' => time(), + // 'updated_at' => time() + // ]); + + $user = Db::name('users')->where('id', $userId)->find(); + unset($user['password']); + + return success($user, '注册成功'); + } + + /** + * 获取当前用户信息(需要认证) + * 小程序用户(source=wechat)从 mbti_wechat_users 读取,否则从 mbti_users 读取 + * @return \think\response\Json + */ + public function me() + { + $user = $this->request->user ?? null; + + if (!$user) { + return error('未登录', 401); + } + + $source = $user['source'] ?? null; + $userId = $user['user_id'] ?? $user['userId'] ?? null; + + if ($source === 'wechat' && $userId) { + $wechatUser = Db::name('wechat_users')->where('id', $userId)->find(); + if (!$wechatUser) { + return error('用户不存在', 404); + } + unset($wechatUser['sessionKey'], $wechatUser['openid']); + $wechatUser['avatarUrl'] = $wechatUser['avatar'] ?? ''; + $eid = isset($wechatUser['enterpriseId']) && $wechatUser['enterpriseId'] !== '' && $wechatUser['enterpriseId'] !== null ? (int) $wechatUser['enterpriseId'] : null; + $wechatUser['hasEnterprise'] = $eid > 0; + $wechatUser['enterpriseId'] = $eid; + return success($wechatUser); + } + + $userModel = Db::name('users')->where('id', $userId)->find(); + if (!$userModel) { + return error('用户不存在', 404); + } + + unset($userModel['password']); + + return success($userModel); + } + + /** + * 退出登录(需要认证) + * @return \think\response\Json + */ + public function logout() + { + $user = $this->request->user ?? null; + + if ($user && isset($user['user_id'])) { + JwtService::deleteToken((int) $user['user_id'], $user['source'] ?? null); + } + + return success(null, '退出成功'); + } + + /** + * 刷新Token + * @return \think\response\Json + */ + public function refresh() + { + $token = JwtService::getTokenFromRequest($this->request); + + if (!$token) { + return error('未提供Token', 401); + } + + $newToken = JwtService::refreshToken($token); + + if (!$newToken) { + return error('Token无效或已过期', 401); + } + + return success([ + 'token' => $newToken, + 'expires_in' => config('jwt.expire') + ], '刷新成功'); + } + + /** + * 微信小程序登录:code 换 openid,查/建用户,返回 token 与用户信息 + * POST api/auth/wechat body: { "code": "xxx" } + * @return \think\response\Json + */ + public function wechatLogin() + { + $code = Request::param('code', ''); + if ($code === '') { + return error('缺少 code', 400); + } + + $session = WechatService::jscode2session($code); + if (isset($session['errcode']) && $session['errcode'] !== 0) { + return error($session['errmsg'] ?? '微信登录失败', 400); + } + + $openid = $session['openid']; + //$openid = 'oucCB15WDKCdwfNo-fpyS72iY5IQ'; + $sessionKey = $session['session_key'] ?? ''; + $unionid = $session['unionid'] ?? null; + + $wechatUser = Db::name('wechat_users')->where('openid', $openid)->find(); + $now = time(); + $ip = Request::ip(); + + if ($wechatUser) { + Db::name('wechat_users')->where('id', $wechatUser['id'])->update([ + 'sessionKey' => $sessionKey, + 'unionid' => $unionid, + 'lastLoginAt' => $now, + 'lastLoginIp' => $ip, + 'updatedAt' => $now, + ]); + $wechatUser = Db::name('wechat_users')->where('id', $wechatUser['id'])->find(); + } else { + $id = Db::name('wechat_users')->insertGetId([ + 'openid' => $openid, + 'unionid' => $unionid, + 'sessionKey' => $sessionKey, + 'nickname' => null, + 'avatar' => null, + 'phone' => null, + 'gender' => 0, + 'country' => null, + 'province' => null, + 'city' => null, + 'status' => 1, + 'lastLoginAt' => $now, + 'lastLoginIp' => $ip, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + $wechatUser = Db::name('wechat_users')->where('id', $id)->find(); + } + + if (($wechatUser['status'] ?? 1) != 1) { + return error('账号已被禁用', 403); + } + + $payload = [ + 'user_id' => (int) $wechatUser['id'], + 'source' => 'wechat', + ]; + $token = JwtService::generateToken($payload); + + $userId = (int) $wechatUser['id']; + // 企业绑定取自 wechat_users.enterpriseId(企业分享测试链接时更新,个人分享不更新) + $enterpriseId = isset($wechatUser['enterpriseId']) && $wechatUser['enterpriseId'] !== '' && $wechatUser['enterpriseId'] !== null + ? (int) $wechatUser['enterpriseId'] + : null; + $hasEnterprise = $enterpriseId > 0; + + $out = [ + 'id' => $userId, + 'openid' => $openid, + 'nickname' => $wechatUser['nickname'] ?? '', + 'avatar' => $wechatUser['avatar'] ?? '', + 'avatarUrl' => $wechatUser['avatar'] ?? '', + 'phone' => $wechatUser['phone'] ?? '', + 'gender' => (int) ($wechatUser['gender'] ?? 0), + 'country' => $wechatUser['country'] ?? '', + 'province' => $wechatUser['province'] ?? '', + 'city' => $wechatUser['city'] ?? '', + 'birthday' => $wechatUser['birthday'] ?? '', + 'hasEnterprise' => $hasEnterprise, + 'enterpriseId' => $enterpriseId, + ]; + + return success([ + 'token' => $token, + 'expires_in' => config('jwt.expire'), + 'user' => $out, + ], '登录成功'); + } + + /** + * 更新小程序用户资料(昵称、头像等),需要认证且为微信用户 + * PUT api/auth/wechat/profile body: { "nickname": "xxx", "avatar": "url", "gender", "country", "province", "city" } + * @return \think\response\Json + */ + public function updateWechatProfile() + { + $user = $this->request->user ?? null; + if (!$user) { + return error('未登录', 401); + } + if (($user['source'] ?? '') !== 'wechat') { + return error('仅支持小程序用户更新资料', 403); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('用户不存在', 404); + } + + // PUT请求的数据在body中,Content-Type为application/json时需要特殊处理 + $contentType = Request::header('content-type', ''); + $input = []; + + if (stripos($contentType, 'application/json') !== false) { + // JSON格式的请求体,需要从原始内容中解析 + $rawContent = Request::getContent(); + if ($rawContent) { + $input = json_decode($rawContent, true) ?: []; + } + } else { + // 表单格式的请求体 + $input = Request::post() ?: Request::put() ?: []; + } + + // 如果还是空,尝试从param获取(兼容性处理) + if (empty($input)) { + $input = Request::param(); + } + + // 记录接收到的数据(调试用) + \think\facade\Log::info('更新用户资料请求', [ + 'userId' => $userId, + 'input' => $input, + 'method' => Request::method(), + 'contentType' => $contentType, + 'rawContent' => Request::getContent() + ]); + + $allow = ['nickname', 'avatar', 'gender', 'country', 'province', 'city', 'birthday']; + $data = []; + foreach ($allow as $k) { + if (isset($input[$k]) && $input[$k] !== null && $input[$k] !== '') { + $v = $input[$k]; + if ($k === 'avatar') { + $data['avatar'] = is_string($v) ? $v : ''; + } elseif ($k === 'nickname') { + $data['nickname'] = is_string($v) ? mb_substr(trim($v), 0, 100) : ''; + } elseif ($k === 'birthday') { + $data['birthday'] = is_string($v) ? preg_replace('/[^\d\-]/', '', trim($v)) : ''; + } elseif ($k === 'gender') { + $data['gender'] = (int) $v; + } else { + $data[$k] = is_string($v) ? trim($v) : ''; + } + } + } + + if (empty($data)) { + \think\facade\Log::warning('更新用户资料:没有可更新的字段', ['input' => $input]); + return error('没有可更新的字段', 400); + } + + $data['updatedAt'] = time(); + \think\facade\Log::info('更新用户资料SQL', ['userId' => $userId, 'data' => $data]); + + $result = Db::name('wechat_users')->where('id', $userId)->update($data); + + \think\facade\Log::info('更新用户资料结果', ['userId' => $userId, 'affectedRows' => $result]); + + $row = Db::name('wechat_users')->where('id', $userId)->find(); + unset($row['sessionKey'], $row['openid']); + $row['avatarUrl'] = $row['avatar'] ?? ''; + + return success($row, '更新成功'); + } + + /** + * 小程序获取手机号:用 getPhoneNumber 返回的 code 换手机号并写入当前用户 + * POST api/auth/wechat/phone body: { "code": "xxx" } 需登录且为微信用户 + * @return \think\response\Json + */ + public function wechatPhone() + { + $user = $this->request->user ?? null; + if (!$user) { + return error('未登录', 401); + } + if (($user['source'] ?? '') !== 'wechat') { + return error('仅支持小程序用户', 403); + } + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('用户不存在', 404); + } + $contentType = Request::header('content-type', ''); + $input = []; + if (stripos($contentType, 'application/json') !== false) { + $rawContent = Request::getContent(); + if ($rawContent) { + $input = json_decode($rawContent, true) ?: []; + } + } else { + $input = Request::post() ?: []; + } + if (empty($input)) { + $input = Request::param(); + } + $code = $input['code'] ?? ''; + if ($code === '') { + return error('缺少 code', 400); + } + + // 调试日志:记录收到的手机号 code(仅保留前几位防止泄露) + \think\facade\Log::info('WechatPhone 请求', [ + 'userId' => $userId, + 'codeHead' => substr($code, 0, 8) . '***', + ]); + + $phoneResult = WechatService::getPhoneNumber($code); + if (isset($phoneResult['errcode'])) { + \think\facade\Log::warning('WechatPhone 获取手机号失败', [ + 'userId' => $userId, + 'codeHead' => substr($code, 0, 8) . '***', + 'errcode' => $phoneResult['errcode'] ?? null, + 'errmsg' => $phoneResult['errmsg'] ?? null, + ]); + return error(($phoneResult['errmsg'] ?? '获取手机号失败') . ' (code inval)', 400); + } + $phone = $phoneResult['purePhoneNumber'] ?? $phoneResult['phoneNumber'] ?? ''; + if ($phone === '') { + return error('未获取到手机号', 400); + } + + Db::name('wechat_users')->where('id', $userId)->update([ + 'phone' => $phone, + 'updatedAt' => time(), + ]); + $row = Db::name('wechat_users')->where('id', $userId)->find(); + unset($row['sessionKey'], $row['openid']); + $row['avatarUrl'] = $row['avatar'] ?? ''; + + return success([ + 'phone' => $phone, + 'user' => $row, + ], '获取成功'); + } + + /** + * 小程序扫码企业邀请后绑定企业:更新 wechat_users.enterpriseId + * POST api/auth/wechat/bind-enterprise body: { "enterpriseId": 123 } + */ + public function wechatBindEnterprise() + { + $user = $this->request->user ?? null; + if (!$user) { + return error('未登录', 401); + } + if (($user['source'] ?? '') !== 'wechat') { + return error('仅支持小程序用户', 403); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('用户不存在', 404); + } + + $contentType = Request::header('content-type', ''); + $input = []; + if (stripos($contentType, 'application/json') !== false) { + $rawContent = Request::getContent(); + if ($rawContent) { + $input = json_decode($rawContent, true) ?: []; + } + } else { + $input = Request::post() ?: []; + } + if (empty($input)) { + $input = Request::param(); + } + + $enterpriseId = (int) ($input['enterpriseId'] ?? 0); + if ($enterpriseId <= 0) { + return error('缺少或非法的 enterpriseId', 400); + } + + $ent = Db::name('enterprises') + ->where('id', $enterpriseId) + ->where('status', '<>','disabled') + ->find(); + if (!$ent) { + return error('企业不存在或已禁用', 404); + } + + Db::name('wechat_users')->where('id', $userId)->update([ + 'enterpriseId' => $enterpriseId, + 'updatedAt' => time(), + ]); + + $row = Db::name('wechat_users')->where('id', $userId)->find(); + if (!$row) { + return error('用户不存在', 404); + } + unset($row['sessionKey'], $row['openid']); + $row['avatarUrl'] = $row['avatar'] ?? ''; + $eid = isset($row['enterpriseId']) && $row['enterpriseId'] !== '' && $row['enterpriseId'] !== null ? (int) $row['enterpriseId'] : null; + $row['hasEnterprise'] = $eid > 0; + $row['enterpriseId'] = $eid; + $row['enterpriseName'] = $ent['name'] ?? ''; + + return success($row, '绑定企业成功'); + } +} + diff --git a/api/app/controller/api/CrmReport.php b/api/app/controller/api/CrmReport.php new file mode 100644 index 0000000..94cc90c --- /dev/null +++ b/api/app/controller/api/CrmReport.php @@ -0,0 +1,235 @@ +request->user ?? null; + if (!$user) { + $token = JwtService::getTokenFromRequest($this->request); + if ($token) { + $payload = JwtService::verifyToken($token); + if ($payload) { + $user = [ + 'source' => $payload['source'] ?? '', + 'user_id' => $payload['user_id'] ?? $payload['userId'] ?? null, + ]; + } + } + } + + $userId = (int) ($user['user_id'] ?? 0); + + // 接收参数 + $apiKey = trim((string) ($this->request->param('apiKey', '') ?? '')); + $source = trim((string) ($this->request->param('source', '') ?? '')); + $remark = trim((string) ($this->request->param('remark', '') ?? '')); + $tags = trim((string) ($this->request->param('tags', '') ?? '')); + $siteTags = trim((string) ($this->request->param('siteTags', '') ?? '')); + + // apiKey 为空则跳过,不影响主流程 + if (empty($apiKey)) { + return success(['reported' => false, 'reason' => 'no_api_key']); + } + + // 从数据库获取用户信息(手机号、openid、昵称) + $phone = ''; + $openid = ''; + $nickname = ''; + if ($userId > 0) { + $wechatUser = Db::name('wechat_users') + ->where('id', $userId) + ->field('phone, openid, nickname') + ->find(); + if ($wechatUser) { + $phone = (string) ($wechatUser['phone'] ?? ''); + $openid = (string) ($wechatUser['openid'] ?? ''); + $nickname = (string) ($wechatUser['nickname'] ?? ''); + } + } + + // 至少需要手机号或微信号,否则没有意义 + if (empty($phone) && empty($openid)) { + return success(['reported' => false, 'reason' => 'no_identifier']); + } + + // 读取接口地址(从 .env 的 API_URL) + $apiUrl = env('API_URL', 'https://ckbapi.quwanzhi.com/v1/api/scenarios'); + $timestamp = time(); + + // 构建请求参数(只加非空字段) + $params = ['apiKey' => $apiKey, 'timestamp' => $timestamp]; + if ($phone !== '') $params['phone'] = $phone; + if ($nickname !== '') $params['name'] = $nickname; + if ($source !== '') $params['source'] = $source; + if ($remark !== '') $params['remark'] = $remark; + if ($tags !== '') $params['tags'] = $tags; + if ($siteTags !== '') $params['siteTags'] = $siteTags; + + // 生成签名(portrait 不参与签名,需在签名后单独附加) + $params['sign'] = self::generateSign($params, $apiKey); + + // 附加用户画像(从最近测试结果构建,不参与签名) + $portrait = self::buildPortrait($userId); + if ($portrait !== null) { + $params['portrait'] = $portrait; + } + + // 发起请求 + $result = self::callApi($apiUrl, $params); + + if ($result['success']) { + return success(['reported' => true]); + } + + Log::warning('[CrmReport] 上报失败 userId=' . $userId . ' reason=' . json_encode($result, JSON_UNESCAPED_UNICODE)); + // 上报失败不影响主业务,始终返回成功 + return success(['reported' => false, 'reason' => $result['error'] ?? 'api_error']); + } + + /** + * 从数据库读取用户最近一次 MBTI / DISC / PDP 测试结果,构建 portrait 对象 + * portrait 整体不参与签名,直接附加到请求体中(见接口文档 §2.3) + */ + private static function buildPortrait(int $userId): ?array + { + if ($userId <= 0) { + return null; + } + + // 一次查出所有相关类型的最新记录(按时间倒序) + $rows = Db::name('test_results') + ->where('userId', $userId) + ->whereIn('testType', ['mbti', 'disc', 'pdp']) + ->field('testType, resultData, createdAt') + ->order('createdAt', 'desc') + ->select() + ->toArray(); + + $found = []; + foreach ($rows as $row) { + $type = $row['testType']; + if (isset($found[$type])) continue; // 只取每种类型的最新一条 + + $data = []; + if (!empty($row['resultData'])) { + $decoded = json_decode($row['resultData'], true); + $data = is_array($decoded) ? $decoded : []; + } + + switch ($type) { + case 'mbti': + $val = $data['mbtiType'] ?? $data['mbti'] ?? ''; + if ($val !== '') $found['mbti'] = (string) $val; + break; + case 'disc': + $val = $data['dominantType'] ?? $data['disc'] ?? ''; + if ($val !== '') $found['disc'] = $val . '型'; + break; + case 'pdp': + $val = $data['description']['type'] ?? $data['pdp'] ?? ''; + if ($val !== '') $found['pdp'] = (string) $val; + break; + } + } + + if (empty($found)) { + return null; + } + + return [ + 'type' => 4, // 互动(咨询/购买行为) + 'source' => 0, // 本站 + 'sourceData' => $found, + 'remark' => '性格测试画像', + 'uniqueId' => 'wxmp_' . $userId . '_' . date('YmdH'), // 同一小时内去重 + ]; + } + + /** + * 生成存客宝签名 + * 规则(来自接口文档 §2.3): + * 1. 移除 sign / apiKey / portrait + * 2. 移除值为 null 或空字符串的字段 + * 3. 按参数名 ASCII 升序排序 + * 4. 只取"值"按顺序拼接 + * 5. 第一次 MD5 + * 6. 拼接 apiKey 后第二次 MD5,得到最终签名 + */ + private static function generateSign(array $params, string $apiKey): string + { + unset($params['sign'], $params['apiKey'], $params['portrait']); + + $params = array_filter($params, static function ($value) { + return !is_null($value) && $value !== ''; + }); + + ksort($params); + + $stringToSign = implode('', array_values($params)); + $firstMd5 = md5($stringToSign); + + return md5($firstMd5 . $apiKey); + } + + /** + * 通过 cURL 调用存客宝接口 + */ + private static function callApi(string $url, array $params): array + { + $payload = json_encode($params, JSON_UNESCAPED_UNICODE); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Accept: application/json', + 'Content-Length: ' . strlen($payload), + ]); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + + $response = curl_exec($ch); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + return ['success' => false, 'error' => 'curl:' . $curlError]; + } + + $data = json_decode($response, true); + if (is_array($data) && isset($data['code']) && (int) $data['code'] === 200) { + return ['success' => true, 'data' => $data]; + } + + return [ + 'success' => false, + 'error' => $data['message'] ?? 'unknown', + 'response' => $response, + ]; + } +} diff --git a/api/app/controller/api/Distribution.php b/api/app/controller/api/Distribution.php new file mode 100644 index 0000000..1958f8a --- /dev/null +++ b/api/app/controller/api/Distribution.php @@ -0,0 +1,1466 @@ +resolveUser(); + if (!$user) { + return error('未登录', 401); + } + + $inviteeId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $inviterId = (int) Request::param('inviterId', 0); + $enterpriseId = Request::param('eid', null); + $enterpriseId = $enterpriseId !== null ? (int) $enterpriseId : null; + $scope = $enterpriseId ? 'enterprise' : 'personal'; + + // 自绑校验 + if ($inviterId <= 0 || $inviterId === $inviteeId) { + return success(null, '无需绑定'); + } + + // 企业版:推荐人必须是该企业成员 + if ($scope === 'enterprise') { + $inviter = Db::name('wechat_users') + ->where('id', $inviterId) + ->field('id, enterpriseId') + ->find(); + if (!$inviter || (int) $inviter['enterpriseId'] !== $enterpriseId) { + return success(null, '无需绑定'); + } + } + + $now = time(); + + // 禁止互相绑定(仅有效期内):A 曾邀请过 B 且 A→B 未过期时,B 不能再成为 A 的推荐人;若 A→B 已过期则允许 A 绑定 B + $reverseExists = Db::name('distribution_bindings') + ->where('inviterId', $inviteeId) + ->where('inviteeId', $inviterId) + ->where('scope', $scope) + ->where('status', 'active') + ->where('expireAt', '>', $now) + ->where(function ($query) use ($enterpriseId) { + if ($enterpriseId) { + $query->where('enterpriseId', $enterpriseId); + } else { + $query->whereNull('enterpriseId'); + } + }) + ->find(); + if ($reverseExists) { + return success(null, '无需绑定'); + } + $expireAt = $now + self::BINDING_TTL; + + // 查询当前是否存在有效绑定(包含已过期,因为唯一索引覆盖所有状态) + $existing = Db::name('distribution_bindings') + ->where('inviteeId', $inviteeId) + ->where('scope', $scope) + ->where(function ($query) use ($enterpriseId) { + if ($enterpriseId) { + $query->where('enterpriseId', $enterpriseId); + } else { + $query->whereNull('enterpriseId'); + } + }) + ->find(); + + if (!$existing) { + // ── 首次绑定 + Db::name('distribution_bindings')->insert([ + 'inviterId' => $inviterId, + 'inviteeId' => $inviteeId, + 'scope' => $scope, + 'enterpriseId' => $enterpriseId, + 'expireAt' => $expireAt, + 'status' => 'active', + 'prevInviterId'=> null, + 'overriddenAt' => null, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + } elseif ((int) $existing['inviterId'] === $inviterId) { + // ── 同一推荐人再次点击 → 续期 + Db::name('distribution_bindings') + ->where('id', $existing['id']) + ->update([ + 'expireAt' => $expireAt, + 'status' => 'active', + 'updatedAt' => $now, + ]); + } else { + // ── 不同推荐人 → 抢绑(覆盖,记录旧推荐人) + Db::name('distribution_bindings') + ->where('id', $existing['id']) + ->update([ + 'prevInviterId' => (int) $existing['inviterId'], + 'inviterId' => $inviterId, + 'expireAt' => $expireAt, + 'status' => 'active', + 'overriddenAt' => $now, + 'updatedAt' => $now, + ]); + } + + return success(['expireAt' => $expireAt], '绑定成功'); + } + + /** + * 过期待收款提现:status=2 超过24小时未确认收款的,自动退回余额并标记为已过期 + */ + private function expirePendingWithdrawals() + { + $now = time(); + $limit = $now - self::WITHDRAW_WAIT_EXPIRE_SEC; + $list = Db::name('distribution_withdrawals') + ->where('status', 2) + ->select() + ->toArray(); + foreach ($list as $row) { + $ts = (int) ($row['auditAt'] ?? $row['updatedAt'] ?? $row['createdAt'] ?? 0); + if ($ts <= 0 || $ts >= $limit) { + continue; + } + $id = (int) $row['id']; + $userId = (int) $row['userId']; + $amountFen = (int) $row['amountFen']; + if ($amountFen <= 0) { + continue; + } + Db::startTrans(); + try { + Db::name('wechat_users') + ->where('id', $userId) + ->inc('walletBalance', $amountFen) + ->update(['updatedAt' => $now]); + Db::name('distribution_withdrawals')->where('id', $id)->update([ + 'status' => 4, + 'auditNote' => '超时未确认收款,已自动退回余额', + 'auditAt' => $now, + 'updatedAt' => $now, + ]); + Db::commit(); + Log::info('提现过期自动退回', ['id' => $id, 'userId' => $userId, 'amountFen' => $amountFen]); + } catch (\Throwable $e) { + Db::rollback(); + Log::error('expirePendingWithdrawals error: ' . $e->getMessage()); + } + } + } + + // ───────────────────────────────────────────────────────────── + // GET /api/distribution/stats + // 推广中心统计数据(余额、总收益、待入账、绑定数、付款数) + // ───────────────────────────────────────────────────────────── + public function stats() + { + $user = $this->resolveUser(); + if (!$user) { + return error('未登录', 401); + } + + $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $now = time(); + + $this->expirePendingWithdrawals(); + + // 直接读取 wechat_users 中的钱包字段(持久化数据) + $walletBalanceFen = 0; + $totalEarnedFen = 0; + $pendingFen = 0; + try { + $walletRow = Db::name('wechat_users') + ->where('id', $inviterId) + ->field('walletBalance, walletTotalEarned, walletPending') + ->find(); + if ($walletRow) { + $walletBalanceFen = (int) ($walletRow['walletBalance'] ?? 0); + $totalEarnedFen = (int) ($walletRow['walletTotalEarned'] ?? 0); + $pendingFen = (int) ($walletRow['walletPending'] ?? 0); + } + } catch (\Exception $e) { + // 表结构异常时静默降级为 0 + } + + // 绑定中人数(active 且未过期) + $bindingCount = 0; + try { + $bindingCount = Db::name('distribution_bindings') + ->where('inviterId', $inviterId) + ->where('status', 'active') + ->where('expireAt', '>', $now) + ->count(); + } catch (\Exception $e) {} + + // 已付款人数 + $paidCount = 0; + try { + $paidCount = Db::name('commission_records') + ->where('inviterId', $inviterId) + ->whereIn('status', ['paid', 'frozen']) + ->distinct(true) + ->count('inviteeId'); + } catch (\Exception $e) {} + + // 即将到期(7天内过期) + $expiringCount = 0; + try { + $expiringCount = Db::name('distribution_bindings') + ->where('inviterId', $inviterId) + ->where('status', 'active') + ->where('expireAt', '>', $now) + ->where('expireAt', '<=', $now + 7 * 86400) + ->count(); + } catch (\Exception $e) {} + + // 总邀请人数(历史所有绑定过的唯一用户数) + $totalInvite = 0; + try { + $totalInvite = Db::name('distribution_bindings') + ->where('inviterId', $inviterId) + ->distinct(true) + ->count('inviteeId'); + } catch (\Exception $e) {} + + // 根据用户所属企业读取分销配置(显示开关 + 推广中心标题) + $enterpriseId = 0; + try { + $wu = Db::name('wechat_users')->where('id', $inviterId)->field('enterpriseId')->find(); + $enterpriseId = $wu && isset($wu['enterpriseId']) ? (int) $wu['enterpriseId'] : 0; + } catch (\Exception $e) {} + $distConfig = null; + try { + $distRow = Db::name('system_config') + ->where('key', 'distribution') + ->where('enterprise_id', $enterpriseId) + ->find(); + if (!$distRow && $enterpriseId > 0) { + $distRow = Db::name('system_config') + ->where('key', 'distribution') + ->where('enterprise_id', 0) + ->find(); + } + if ($distRow && $distRow['value']) { + $distConfig = is_string($distRow['value']) ? json_decode($distRow['value'], true) : $distRow['value']; + } + } catch (\Exception $e) {} + $distributionEnabled = ($distConfig['enabled'] ?? true); + $promoCenterTitle = trim((string)($distConfig['promoCenterTitle'] ?? '推广中心')) ?: '推广中心'; + $commissionRate = (int)($distConfig['commissionRate'] ?? 90); + $bindingDays = (int)($distConfig['bindingDays'] ?? 30); + + // 提现规则仅超管可配置,只读 enterprise_id=0 的全局配置 + $globalDistConfig = null; + try { + $globalRow = Db::name('system_config') + ->where('key', 'distribution') + ->where('enterprise_id', 0) + ->find(); + if ($globalRow && $globalRow['value']) { + $globalDistConfig = is_string($globalRow['value']) ? json_decode($globalRow['value'], true) : $globalRow['value']; + } + } catch (\Exception $e) {} + $cfg = is_array($globalDistConfig) ? $globalDistConfig : []; + $minWithdrawFen = (int)($cfg['minWithdrawFen'] ?? 100); + $maxWithdrawFen = (int)($cfg['maxWithdrawFen'] ?? 0); + $withdrawFee = (float)($cfg['withdrawFee'] ?? 0); + $requireAudit = (isset($cfg['requireAudit']) ? $cfg['requireAudit'] : true) !== false; + $withdrawMinYuan = number_format($minWithdrawFen / 100, 2, '.', ''); + $withdrawMaxYuan = $maxWithdrawFen > 0 ? number_format($maxWithdrawFen / 100, 2, '.', '') : null; + $withdrawFeePct = round($withdrawFee, 1); + + // 读取「人脸分析」测试佣金配置,用于前端展示规则说明 + $faceSetting = null; + try { + $faceSetting = self::resolveTestSetting('face', $enterpriseId > 0 ? $enterpriseId : null); + } catch (\Throwable $e) { + $faceSetting = null; + } + $faceType = $faceSetting['commissionType'] ?? null; + $faceRate = isset($faceSetting['commissionRate']) ? (int)$faceSetting['commissionRate'] : null; + $faceAmountFen = isset($faceSetting['commissionAmountFen']) ? (int)$faceSetting['commissionAmountFen'] : null; + $faceAmountYuan = $faceAmountFen !== null ? number_format($faceAmountFen / 100, 2, '.', '') : null; + $faceNoPayment = !empty($faceSetting['noPayment']); + + return success([ + 'walletBalance' => number_format($walletBalanceFen / 100, 2, '.', ''), + 'totalEarned' => number_format($totalEarnedFen / 100, 2, '.', ''), + 'pendingAmount' => number_format($pendingFen / 100, 2, '.', ''), + 'bindingCount' => $bindingCount, + 'paidCount' => $paidCount, + 'expiringCount' => $expiringCount, + 'totalInvite' => $totalInvite, + 'distributionEnabled' => $distributionEnabled, + 'promoCenterTitle' => $promoCenterTitle, + 'commissionRate' => $commissionRate, + 'bindingDays' => $bindingDays, + 'testCommissionType' => $faceType, + 'testCommissionRate' => $faceRate, + 'testCommissionAmount'=> $faceAmountYuan, + 'testNoPayment' => $faceNoPayment, + 'withdrawMinYuan' => $withdrawMinYuan, + 'withdrawMaxYuan' => $withdrawMaxYuan, + 'withdrawFeePct' => $withdrawFeePct, + 'requireWithdrawAudit'=> $requireAudit, + ]); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/distribution/bindings + // 我邀请的用户列表(分页,tab: 0=绑定中 1=已付款 2=已过期) + // ───────────────────────────────────────────────────────────── + public function bindings() + { + $user = $this->resolveUser(); + if (!$user) { + return error('未登录', 401); + } + + $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $tab = (int) Request::param('tab', 0); + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(50, (int) Request::param('pageSize', 20)); + $now = time(); + + $query = Db::name('distribution_bindings') + ->alias('b') + ->leftJoin('wechat_users u', 'b.inviteeId = u.id') + ->field('b.id, b.inviteeId, b.expireAt, b.status, b.createdAt, b.overriddenAt, + u.nickname, u.avatar') + ->where('b.inviterId', $inviterId); + + switch ($tab) { + case 1: // 已付款 + $paidInviteeIds = Db::name('commission_records') + ->where('inviterId', $inviterId) + ->whereIn('status', ['paid', 'frozen']) + ->column('inviteeId'); + if (empty($paidInviteeIds)) { + return success(['list' => [], 'total' => 0, 'page' => $page, 'pageSize' => $pageSize]); + } + $query->whereIn('b.inviteeId', array_unique($paidInviteeIds)); + break; + case 2: // 已过期 + $query->where(function ($q) use ($now) { + $q->where('b.status', 'overridden') + ->whereOr(function ($q2) use ($now) { + $q2->where('b.status', 'active')->where('b.expireAt', '<=', $now); + }); + }); + break; + default: // 绑定中 + $query->where('b.status', 'active')->where('b.expireAt', '>', $now); + break; + } + + $total = (clone $query)->count(); + $list = $query->order('b.updatedAt', 'desc') + ->page($page, $pageSize) + ->select() + ->toArray(); + + foreach ($list as &$row) { + $row['expireAt'] = (int) $row['expireAt']; + $row['remainDays'] = max(0, (int) ceil(($row['expireAt'] - $now) / 86400)); + $row['avatar'] = $row['avatar'] ?: ''; + $row['nickname'] = $row['nickname'] ?: '微信用户'; + } + + return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/distribution/commissions + // 我的佣金记录(分页) + // ───────────────────────────────────────────────────────────── + public function commissions() + { + $user = $this->resolveUser(); + if (!$user) { + return error('未登录', 401); + } + + $inviterId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(50, (int) Request::param('pageSize', 20)); + + $list = Db::name('commission_records') + ->alias('c') + ->leftJoin('wechat_users u', 'c.inviteeId = u.id') + ->field('c.id, c.commissionFen, c.orderAmount, c.status, c.scope, c.createdAt, + c.frozenAt, c.unfrozenAt, u.nickname, u.avatar') + ->where('c.inviterId', $inviterId) + ->order('c.createdAt', 'desc') + ->page($page, $pageSize) + ->select() + ->toArray(); + + $total = Db::name('commission_records') + ->where('inviterId', $inviterId) + ->count(); + + foreach ($list as &$row) { + $row['commissionYuan'] = number_format($row['commissionFen'] / 100, 2, '.', ''); + $row['orderYuan'] = number_format($row['orderAmount'] / 100, 2, '.', ''); + $row['nickname'] = $row['nickname'] ?: '微信用户'; + $row['avatar'] = $row['avatar'] ?: ''; + } + + return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/distribution/withdraw + // 申请提现 + // ───────────────────────────────────────────────────────────── + public function withdraw() + { + $user = $this->resolveUser(); + if (!$user) { + return error('未登录', 401); + } + + $this->expirePendingWithdrawals(); + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $amountFen = (int) Request::param('amountFen', 0); + $scope = Request::param('scope', 'personal'); + $eid = $scope === 'enterprise' ? (int) Request::param('eid', 0) : null; + + list($minFen, $maxFen) = self::getWithdrawLimits($scope, $eid); + if ($amountFen < $minFen) { + return error('最低提现金额为 ' . round($minFen / 100, 2) . ' 元', 400); + } + if ($maxFen > 0 && $amountFen > $maxFen) { + return error('最高提现金额为 ' . round($maxFen / 100, 2) . ' 元', 400); + } + + $wallet = Db::name('wechat_users') + ->where('id', $userId) + ->field('walletBalance') + ->find(); + + if (!$wallet || (int) $wallet['walletBalance'] < $amountFen) { + return error('余额不足', 400); + } + + // 检查是否有待审核的提现申请(status=0 审核中) + $pending = Db::name('distribution_withdrawals') + ->where('userId', $userId) + ->where('status', 0) + ->count(); + if ($pending > 0) { + return error('您有待处理的提现申请,请等待审核完成后再次申请', 400); + } + + // 手续费(分):按全局配置 enterprise_id=0 的 withdrawFee 比例计算 + $cfg = self::getDistributionConfig($scope ?: 'personal', $eid); + $feePct = (float)($cfg['withdrawFee'] ?? 0); + $feeFen = (int) round($amountFen * $feePct / 100); + $actualFen = $amountFen - $feeFen; + if ($actualFen < $minFen) { + return error('实际到账金额不得低于最低提现金额 ' . number_format($minFen / 100, 2, '.', '') . ' 元', 400); + } + + $requireAudit = (isset($cfg['requireAudit']) ? $cfg['requireAudit'] : true) !== false; + + $now = time(); + Db::startTrans(); + try { + // 冻结余额 + Db::name('wechat_users') + ->where('id', $userId) + ->dec('walletBalance', $amountFen) + ->update(['updatedAt' => $now]); + + // 写入提现申请:status=0 审核中,并记录手续费(需 insertGetId 以便免审核时更新) + $withdrawId = Db::name('distribution_withdrawals')->insertGetId([ + 'userId' => $userId, + 'amountFen' => $amountFen, + 'feeFen' => $feeFen, + 'status' => 0, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + return error('申请提现失败:' . $e->getMessage(), 500); + } + + // 免审核:自动发起微信转账 + if (!$requireAudit && $withdrawId > 0) { + $wechatUser = Db::name('wechat_users')->where('id', $userId)->field('openid')->find(); + $openid = $wechatUser['openid'] ?? ''; + if (empty($openid)) { + Db::startTrans(); + try { + Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]); + Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([ + 'status' => 1, + 'auditNote' => '无 openid 无法自动打款,请联系管理员', + 'auditAt' => time(), + 'updatedAt' => time(), + ]); + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + } + return error('无法自动打款:未绑定微信 openid,请联系管理员', 400); + } + + try { + $outBillNo = 'TX' . date('YmdHis') . mt_rand(1000, 9999) . $withdrawId; + $service = new \app\common\service\WechatTransferService(); + $result = $service->createTransfer([ + 'out_bill_no' => $outBillNo, + 'openid' => $openid, + 'transfer_amount' => $amountFen, + 'transfer_remark' => '推广佣金提现', + 'transfer_scene_id' => env('TRANSFER_SCENE_ID', '1005'), + 'transfer_scene_report_infos' => [ + ['info_type' => '岗位类型', 'info_content' => '推广人员'], + ['info_type' => '报酬说明', 'info_content' => '推广佣金提现'], + ], + 'notify_url' => env('WITHDRAW_NOTIFY_URL', ''), + ]); + + if ($result['success'] === true) { + $wechatData = $result['data'] ?? []; + $transferBillNo = $wechatData['transfer_bill_no'] ?? $wechatData['batch_id'] ?? null; + $wechatState = $wechatData['state'] ?? $wechatData['batch_status'] ?? 'PROCESSING'; + Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([ + 'status' => 2, + 'auditAt' => $now, + 'updatedAt' => $now, + 'pay_type' => 'wechat', + 'out_bill_no' => $outBillNo, + 'transfer_bill_no' => $transferBillNo, + 'wechat_pay_state' => $wechatState, + 'transfer_scene_id' => $wechatData['transfer_scene_id'] ?? env('TRANSFER_SCENE_ID', '1005'), + 'package_info' => $wechatData['package_info'] ?? '', + 'mch_id' => env('MCH_ID', null), + ]); + return success(null, '提现申请已提交,已自动发起微信转账'); + } + + $err = $result['error'] ?? []; + $code = $err['code'] ?? 'UNKNOWN'; + $msg = $err['message'] ?? '微信转账接口调用失败'; + Db::startTrans(); + try { + Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]); + Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([ + 'status' => 1, + 'auditNote' => "微信转账发起失败({$code}):{$msg}", + 'auditAt' => time(), + 'updatedAt' => time(), + ]); + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + } + return error("自动打款失败({$code}):{$msg}", 500); + } catch (\Exception $e) { + Db::startTrans(); + try { + Db::name('wechat_users')->where('id', $userId)->inc('walletBalance', $amountFen)->update(['updatedAt' => time()]); + Db::name('distribution_withdrawals')->where('id', $withdrawId)->update([ + 'status' => 1, + 'auditNote' => '自动打款异常:' . $e->getMessage(), + 'auditAt' => time(), + 'updatedAt' => time(), + ]); + Db::commit(); + } catch (\Exception $ex) { + Db::rollback(); + } + return error('自动打款异常:' . $e->getMessage(), 500); + } + } + + return success(null, '提现申请已提交,请等待审核'); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/distribution/withdrawals + // 我的提现记录 + // ───────────────────────────────────────────────────────────── + public function withdrawals() + { + $user = $this->resolveUser(); + if (!$user) { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(50, (int) Request::param('pageSize', 20)); + + $this->expirePendingWithdrawals(); + + $list = Db::name('distribution_withdrawals') + ->where('userId', $userId) + ->order('createdAt', 'desc') + ->page($page, $pageSize) + ->select() + ->toArray(); + + $total = Db::name('distribution_withdrawals') + ->where('userId', $userId) + ->count(); + + foreach ($list as &$row) { + $row['amountYuan'] = number_format($row['amountFen'] / 100, 2, '.', ''); + $feeFen = (int)($row['feeFen'] ?? 0); + $row['feeYuan'] = number_format($feeFen / 100, 2, '.', ''); + } + + return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]); + } + + // ───────────────────────────────────────────────────────────── + // POST /api/distribution/withdrawals/query-transfer + // 用户确认收款后,主动查询微信转账单状态并更新本地订单(及时刷新) + // 参考:https://pay.weixin.qq.com/doc/v3/merchant/4012716437 + // ───────────────────────────────────────────────────────────── + public function queryTransfer() + { + $user = $this->resolveUser(); + if (!$user) { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $id = (int) Request::param('id', 0); + if ($id <= 0) { + return error('参数错误', 400); + } + + $record = Db::name('distribution_withdrawals') + ->where('id', $id) + ->where('userId', $userId) + ->find(); + if (!$record) { + return error('提现记录不存在或无权限', 404); + } + + $outBillNo = trim((string) ($record['out_bill_no'] ?? '')); + if (!$outBillNo) { + return error('该提现单暂无商户单号,无法查询', 400); + } + + try { + $service = new \app\common\service\WechatTransferService(); + $result = $service->queryByOutBillNo($outBillNo); + } catch (\Throwable $e) { + Log::error('queryTransfer WechatTransferService error: ' . $e->getMessage()); + return error('查询转账状态失败:' . $e->getMessage(), 500); + } + + if ($result['success'] !== true || empty($result['data'])) { + $err = $result['error'] ?? []; + return error('查询失败:' . ($err['message'] ?? '未知错误'), 500); + } + + $data = $result['data']; + $state = trim((string) ($data['state'] ?? '')); + $now = time(); + $billNo = $data['transfer_bill_no'] ?? null; + + if ($state === 'SUCCESS') { + Db::name('distribution_withdrawals')->where('id', $id)->update([ + 'status' => 3, + 'wechat_pay_state' => $state, + 'transfer_bill_no' => $billNo, + 'transferAt' => $now, + 'updatedAt' => $now, + ]); + return success(['status' => 3, 'statusLabel' => '已收款'], '已收款'); + } + + if ($state === 'FAIL') { + Db::startTrans(); + try { + Db::name('wechat_users') + ->where('id', $record['userId']) + ->inc('walletBalance', (int) $record['amountFen']) + ->update(['updatedAt' => $now]); + Db::name('distribution_withdrawals')->where('id', $id)->update([ + 'status' => 1, + 'auditNote' => $data['fail_reason'] ?? '微信转账失败', + 'wechat_pay_state' => $state, + 'transfer_bill_no' => $billNo, + 'updatedAt' => $now, + ]); + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + Log::error('queryTransfer FAIL rollback: ' . $e->getMessage()); + return error('更新失败', 500); + } + return success(['status' => 1, 'statusLabel' => '已驳回'], '转账失败,余额已退回'); + } + + return success(['status' => (int) $record['status'], 'state' => $state], '状态未变更'); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/distribution/qrcode + // 生成当前用户专属小程序推广码,直接输出 PNG 二进制流 + // 可选参数:scope=personal|enterprise;eid=企业ID(仅 scope=enterprise 时有效) + // ───────────────────────────────────────────────────────────── + public function qrcode() + { + $user = $this->resolveUser(); + if (!$user) { + http_response_code(401); + exit('Unauthorized'); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $scope = Request::param('scope', ''); + // 显式传 scope=personal 时强制个人版,不认 eid + $enterpriseId = ($scope === 'personal') ? null : Request::param('eid', null); + $enterpriseId = $enterpriseId !== null ? (int) $enterpriseId : null; + + // scene 参数:uid=用户ID(+eid=企业ID),最长 32 字符 + if ($enterpriseId) { + $scene = "uid={$userId}&eid={$enterpriseId}"; + $page = 'pages/enterprise/index'; + } else { + $scene = "uid={$userId}"; + $page = 'pages/index/index'; + } + + // 调用微信接口生成小程序码 + $result = WechatService::getWxacodeUnlimited($scene, $page, 280); + + if (isset($result['errcode'])) { + http_response_code(500); + exit(json_encode(['code' => 500, 'msg' => '生成小程序码失败:' . ($result['errmsg'] ?? '未知错误')])); + } + + // 直接输出 PNG 二进制流,供 wx.downloadFile 使用 + header('Content-Type: image/png'); + header('Cache-Control: max-age=3600'); + echo $result['binary']; + exit(); + } + + // ───────────────────────────────────────────────────────────── + // GET /api/distribution/poster + // 生成完整海报(后端合成头像+二维码),直接输出 PNG + // ───────────────────────────────────────────────────────────── + public function poster() + { + $user = $this->resolveUser(); + if (!$user) { + http_response_code(401); + exit('Unauthorized'); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + $scope = Request::param('scope', ''); + // scope=personal 时不读 eid 参数 + $eidParam = ($scope === 'personal') ? null : Request::param('eid', null); + $enterpriseId = $eidParam !== null ? (int) $eidParam : null; + + if ($userId <= 0) { + http_response_code(400); + exit(json_encode(['code' => 400, 'msg' => '用户信息异常'])); + } + + $wechatUser = Db::name('wechat_users') + ->where('id', $userId) + ->field('id, nickname, avatar, enterpriseId') + ->find(); + + // 未显式传 eid 且非强制个人版时,从用户 DB 记录自动取企业 ID + if ($enterpriseId === null && $scope !== 'personal' && !empty($wechatUser['enterpriseId'])) { + $enterpriseId = (int) $wechatUser['enterpriseId']; + } + $userData = [ + 'id' => $userId, + 'nickname' => $wechatUser['nickname'] ?? '好友', + 'avatar' => $wechatUser['avatar'] ?? '', + ]; + + $scene = $enterpriseId ? "uid={$userId}&eid={$enterpriseId}" : "uid={$userId}"; + $page = $enterpriseId ? 'pages/enterprise/index' : 'pages/index/index'; + $qrResult = WechatService::getWxacodeUnlimited($scene, $page, 280); + + if (isset($qrResult['errcode'])) { + http_response_code(500); + exit(json_encode(['code' => 500, 'msg' => '生成小程序码失败:' . ($qrResult['errmsg'] ?? '')])); + } + + $avatarBinary = null; + if (!empty($userData['avatar'])) { + $avatarBinary = PosterService::fetchImage($userData['avatar']); + } + + try { + $png = PosterService::buildFromConfig($userData, $qrResult['binary'], $avatarBinary, $enterpriseId); + } catch (\Throwable $e) { + http_response_code(500); + exit(json_encode(['code' => 500, 'msg' => '海报合成失败:' . $e->getMessage()])); + } + + header('Content-Type: image/png'); + header('Cache-Control: max-age=3600'); + echo $png; + exit(); + } + + // ───────────────────────────────────────────────────────────── + // 内部方法:订单付款成功后结算佣金(由 Payment/notify 调用) + // $orderId: orders.id (整数) + // ───────────────────────────────────────────────────────────── + public static function settleCommission(int $orderId): void + { + $order = Db::name('orders') + ->where('id', $orderId) + ->field('id, userId, enterpriseId, amount, status') + ->find(); + + if (!$order || $order['status'] !== 'paid') { + return; + } + + $inviteeId = (int) $order['userId']; + $orderAmount = (int) $order['amount']; + $enterpriseId = !empty($order['enterpriseId']) ? (int) $order['enterpriseId'] : null; + $scope = $enterpriseId ? 'enterprise' : 'personal'; + $now = time(); + + // 【第一步】精确匹配:scope + enterpriseId 与订单完全一致 + $binding = Db::name('distribution_bindings') + ->where('inviteeId', $inviteeId) + ->where('scope', $scope) + ->where(function ($q) use ($enterpriseId) { + if ($enterpriseId) { + $q->where('enterpriseId', $enterpriseId); + } else { + $q->whereNull('enterpriseId'); + } + }) + ->where('status', 'active') + ->where('expireAt', '>', time()) + ->find(); + + // 【第二步】回退匹配:精确未命中时,查找任意有效的 personal 绑定 + // 跨 scope 场景(如企业版订单但推荐人仅持有 personal 绑定),以个人版配置+平台资金结算 + $fallbackScope = $scope; + $fallbackEnterpriseId = $enterpriseId; + if (!$binding && $scope === 'enterprise') { + $binding = self::findActivePersonalBinding($inviteeId, $enterpriseId); + if ($binding) { + $fallbackScope = 'personal'; + $fallbackEnterpriseId = !empty($binding['enterpriseId']) ? (int) $binding['enterpriseId'] : null; + } + } + + if (!$binding) { + return; + } + + // 实际用于结算的 scope/enterpriseId(可能已回退为 personal) + $scope = $fallbackScope; + $enterpriseId = $fallbackEnterpriseId; + + $inviterId = (int) $binding['inviterId']; + + // 从订单关联的 test_results 中取 testType,用于读取 per-test 佣金配置 + $testType = null; + try { + $tr = Db::name('test_results')->where('orderId', $orderId)->field('testType')->find(); + if ($tr) $testType = $tr['testType'] === 'ai' ? 'face' : ($tr['testType'] ?? null); + } catch (\Throwable $e) {} + + // 读取佣金配置(优先 per-test testSettings,回退全局) + list($rate, $amountFen) = self::getTestCommissionConfig($testType, $scope, $enterpriseId); + $commissionFen = 0; + if ($amountFen > 0) { + $commissionFen = $amountFen; + $rate = 0; + } elseif ($rate > 0) { + $commissionFen = (int) floor($orderAmount * $rate / 100); + } + if ($commissionFen <= 0) { + return; + } + + // 避免同一订单重复结算 + $exists = Db::name('commission_records') + ->where('orderId', $orderId) + ->find(); + if ($exists) { + return; + } + + Db::startTrans(); + try { + $commissionStatus = 'pending'; + + if ($enterpriseId) { + // 企业上下文:优先从企业余额扣款,余额不足则冻结 + $enterprise = Db::name('enterprises') + ->where('id', $enterpriseId) + ->field('id, balance') + ->lock(true) + ->find(); + + $balanceFen = (int) ($enterprise['balance'] ?? 0); + + if ($enterprise && $balanceFen >= $commissionFen) { + // 余额充足,直接结算 + $newBalanceFen = $balanceFen - $commissionFen; + Db::name('enterprises') + ->where('id', $enterpriseId) + ->update([ + 'balance' => $newBalanceFen, + 'updatedAt' => $now, + ]); + + Db::name('finance_records')->insert([ + 'enterpriseId' => $enterpriseId, + 'type' => 'consume', + 'amount' => $commissionFen, + 'balanceBefore' => $balanceFen, + 'balanceAfter' => $newBalanceFen, + 'description' => self::buildCommissionFinanceDescription($inviteeId, $testType, 'order_paid'), + 'orderId' => $orderId, + 'createdAt' => $now, + ]); + + // 推荐人钱包入账 + Db::name('wechat_users') + ->where('id', $inviterId) + ->inc('walletBalance', $commissionFen) + ->inc('walletTotalEarned', $commissionFen) + ->update(['updatedAt' => $now]); + + $commissionStatus = 'paid'; + } else { + // 余额不足,冻结 + Db::name('wechat_users') + ->where('id', $inviterId) + ->inc('walletPending', $commissionFen) + ->update(['updatedAt' => $now]); + + $commissionStatus = 'frozen'; + } + } else { + // 无企业上下文:平台直接发放(入账钱包) + Db::name('wechat_users') + ->where('id', $inviterId) + ->inc('walletBalance', $commissionFen) + ->inc('walletTotalEarned', $commissionFen) + ->update(['updatedAt' => $now]); + + $commissionStatus = 'paid'; + } + + // 写佣金记录 + Db::name('commission_records')->insert([ + 'agentId' => $inviterId, + 'orderId' => $orderId, + 'scope' => $scope, + 'enterpriseId' => $enterpriseId, + 'inviterId' => $inviterId, + 'inviteeId' => $inviteeId, + 'bindingId' => (int) $binding['id'], + 'commissionRate'=> $rate, + 'orderAmount' => $orderAmount, + 'commissionFen' => $commissionFen, + 'commissionAmount' => number_format($commissionFen / 100, 2, '.', ''), + 'status' => $commissionStatus, + 'frozenAt' => $commissionStatus === 'frozen' ? $now : null, + 'paidAt' => $commissionStatus === 'paid' ? $now : null, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + } + } + + // ───────────────────────────────────────────────────────────── + // 内部方法:企业充值后解冻冻结中的佣金 + // $enterpriseId: 企业ID + // ───────────────────────────────────────────────────────────── + public static function unfreezeCommissions(int $enterpriseId): void + { + $now = time(); + + // 找出该企业所有冻结中的佣金(按时间升序,先冻先解) + $frozenList = Db::name('commission_records') + ->where('enterpriseId', $enterpriseId) + ->where('status', 'frozen') + ->order('createdAt', 'asc') + ->select() + ->toArray(); + + if (empty($frozenList)) { + return; + } + + $enterprise = Db::name('enterprises') + ->where('id', $enterpriseId) + ->field('id, balance') + ->lock(true) + ->find(); + + if (!$enterprise) { + return; + } + + $balanceFen = (int) ($enterprise['balance'] ?? 0); + + Db::startTrans(); + try { + foreach ($frozenList as $record) { + $commissionFen = (int) $record['commissionFen']; + if ($balanceFen < $commissionFen) { + break; + } + + $balanceFen -= $commissionFen; + $inviterId = (int) $record['inviterId']; + + // 更新佣金记录状态 + Db::name('commission_records') + ->where('id', $record['id']) + ->update([ + 'status' => 'paid', + 'paidAt' => $now, + 'unfrozenAt' => $now, + 'updatedAt' => $now, + ]); + + $recordTestType = null; + if (($record['commissionSource'] ?? '') === 'test_completion') { + $recordTestType = Db::name('test_results') + ->where('id', (int) ($record['testResultId'] ?? 0)) + ->value('testType'); + } elseif (!empty($record['orderId'])) { + $recordTestType = self::getOrderTestType((int) $record['orderId']); + } + + Db::name('finance_records')->insert([ + 'enterpriseId' => $enterpriseId, + 'type' => 'consume', + 'amount' => $commissionFen, + 'balanceBefore' => $balanceFen + $commissionFen, + 'balanceAfter' => $balanceFen, + 'description' => self::buildCommissionFinanceDescription((int) ($record['inviteeId'] ?? 0), $recordTestType, 'unfrozen'), + 'orderId' => !empty($record['orderId']) ? (int) $record['orderId'] : null, + 'createdAt' => $now, + ]); + + // 推荐人钱包:pending 转 balance + Db::name('wechat_users') + ->where('id', $inviterId) + ->inc('walletBalance', $commissionFen) + ->dec('walletPending', $commissionFen) + ->inc('walletTotalEarned', $commissionFen) + ->update(['updatedAt' => $now]); + } + + // 更新企业余额 + Db::name('enterprises') + ->where('id', $enterpriseId) + ->update([ + 'balance' => $balanceFen, + 'updatedAt' => $now, + ]); + + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + } + } + + /** + * 读取提现上下限(分) + * @return array{int,int} [minFen, maxFen] maxFen=0 表示不限制 + */ + private static function getWithdrawLimits(string $scope, ?int $enterpriseId): array + { + $cfg = self::getDistributionConfig($scope, $enterpriseId); + $minFen = max(self::MIN_WITHDRAW_FEN, min(self::MAX_WITHDRAW_FEN, (int)($cfg['minWithdrawFen'] ?? self::MIN_WITHDRAW_FEN))); + $maxFen = (int)($cfg['maxWithdrawFen'] ?? 0); + if ($maxFen > 0) { + $maxFen = min(self::MAX_WITHDRAW_FEN, $maxFen); + } + return [$minFen, $maxFen]; + } + + /** + * 读取分销配置 + * 企业模式:enterprise_id={eid} 行,不存在则降级到 enterprise_id=0 全局行 + * 个人版:enterprise_id=0 行 + */ + private static function getDistributionConfig(string $scope, ?int $enterpriseId): array + { + if ($enterpriseId > 0) { + $config = Db::name('system_config') + ->where('key', 'distribution') + ->where('enterprise_id', $enterpriseId) + ->find(); + if ($config && $config['value']) { + $cfg = is_string($config['value']) ? json_decode($config['value'], true) : $config['value']; + if (is_array($cfg)) return $cfg; + } + } + // 全局/个人版配置(enterprise_id=0) + $config = Db::name('system_config') + ->where('key', 'distribution') + ->where('enterprise_id', 0) + ->find(); + if ($config && $config['value']) { + $cfg = is_string($config['value']) ? json_decode($config['value'], true) : $config['value']; + if (is_array($cfg)) return $cfg; + } + return []; + } + + /** + * 读取佣金配置,返回 [rate, amountFen];比例模式 rate>0 amountFen=0,金额模式 amountFen>0 rate=0 + * @deprecated 用 getTestCommissionConfig 替代 + */ + private static function getCommissionConfig(string $scope, ?int $enterpriseId): array + { + $cfg = self::getDistributionConfig($scope, $enterpriseId); + $type = $cfg['commissionType'] ?? 'ratio'; + if ($type === 'amount') { + $amountFen = (int)($cfg['commissionAmountFen'] ?? 0); + return [0, $amountFen]; + } + $rate = (int)($cfg['commissionRate'] ?? 90); + return [$rate, 0]; + } + + /** + * 读取指定测试类型的佣金配置 [rate, amountFen] + * 优先使用 testSettings[testType],若无则回退全局 commissionRate/commissionAmountFen + */ + private static function getTestCommissionConfig(?string $testType, string $scope, ?int $enterpriseId): array + { + $ts = self::resolveTestSetting($testType, $enterpriseId); + if ($ts) { + $commType = $ts['commissionType'] ?? 'ratio'; + if ($commType === 'amount') { + return [0, (int)($ts['commissionAmountFen'] ?? 0)]; + } + return [(int)($ts['commissionRate'] ?? 90), 0]; + } + return self::getCommissionConfig($scope, $enterpriseId); + } + + /** + * 解析某测试类型的 testSettings 配置(企业优先,回退全局),返回 null 表示未启用 + */ + private static function resolveTestSetting(?string $testType, ?int $enterpriseId): ?array + { + if (!$testType) return null; + $tryEids = array_filter([$enterpriseId > 0 ? $enterpriseId : null, null], fn($v) => $v !== false); + foreach ($tryEids as $eid) { + $cfg = self::getDistributionConfig('personal', $eid); + $ts = $cfg['testSettings'][$testType] ?? null; + if ($ts && !empty($ts['enabled'])) { + return $ts; + } + } + return null; + } + + /** + * 查找 personal 维度有效绑定: + * 1. 优先 `enterpriseId = 当前企业` + * 2. 其次 `enterpriseId IS NULL` + */ + private static function findActivePersonalBinding(int $inviteeId, ?int $enterpriseId): ?array + { + if ($enterpriseId > 0) { + $binding = Db::name('distribution_bindings') + ->where('inviteeId', $inviteeId) + ->where('scope', 'personal') + ->where('enterpriseId', $enterpriseId) + ->where('status', 'active') + ->where('expireAt', '>', time()) + ->find(); + if ($binding) { + return $binding; + } + } + + $binding = Db::name('distribution_bindings') + ->where('inviteeId', $inviteeId) + ->where('scope', 'personal') + ->whereNull('enterpriseId') + ->where('status', 'active') + ->where('expireAt', '>', time()) + ->find(); + + return $binding ?: null; + } + + /** + * 读取佣金比例配置(百分比整数,兼容旧逻辑) + */ + private static function getCommissionRate(string $scope, ?int $enterpriseId): int + { + list($rate, $amountFen) = self::getCommissionConfig($scope, $enterpriseId); + return $rate; + } + + /** + * 根据订单读取测试类型 + */ + private static function getOrderTestType(int $orderId): ?string + { + if ($orderId <= 0) { + return null; + } + + $testType = Db::name('test_results') + ->where('orderId', $orderId) + ->value('testType'); + + if (!$testType) { + return null; + } + + return $testType === 'ai' ? 'face' : $testType; + } + + /** + * 获取用户展示名称 + */ + private static function getUserDisplayName(int $userId): string + { + if ($userId <= 0) { + return '未知用户'; + } + + $nickname = Db::name('wechat_users') + ->where('id', $userId) + ->value('nickname'); + + return $nickname ? (string) $nickname : ('用户' . $userId); + } + + /** + * 测试类型文案 + */ + private static function getTestTypeLabel(?string $testType): string + { + $normalized = $testType === 'ai' ? 'face' : (string) $testType; + $map = [ + 'face' => '人脸', + 'mbti' => 'MBTI', + 'disc' => 'DISC', + 'pdp' => 'PDP', + ]; + + return $map[$normalized] ?? strtoupper($normalized ?: '未知测试'); + } + + /** + * 企业财务流水中的佣金支出说明 + */ + private static function buildCommissionFinanceDescription(int $inviteeId, ?string $testType, string $scene): string + { + $userName = self::getUserDisplayName($inviteeId); + $testLabel = self::getTestTypeLabel($testType); + + if ($scene === 'unfrozen') { + return '佣金支出:用户' . $userName . '测试' . $testLabel . '完成分销(冻结后解冻)'; + } + + return '佣金支出:用户' . $userName . '测试' . $testLabel . '完成分销'; + } + + // ───────────────────────────────────────────────────────────── + // 内部方法:测试完成后结算「测试完成佣金」(由 Test/submit 调用) + // 仅适用于 personal scope;只要有有效绑定即可,无需付款。 + // 防重:同一 testResultId 只结算一次。 + // ───────────────────────────────────────────────────────────── + public static function settleTestCommission(int $testResultId, int $inviteeId, string $testType): void + { + // 仅支持指定测试类型 + $allowedTypes = ['face', 'mbti', 'disc', 'pdp']; + // face/ai 统一归类为 face + $normalizedType = ($testType === 'ai') ? 'face' : $testType; + if (!in_array($normalizedType, $allowedTypes, true)) { + return; + } + + // 读取 testSettings 配置:优先读用户所属企业配置,未配置则回退全局 + $userEid = (int)(Db::name('wechat_users')->where('id', $inviteeId)->value('enterpriseId') ?? 0); + $tsConfig = self::resolveTestSetting($normalizedType, $userEid); + if (!$tsConfig || empty($tsConfig['enabled']) || empty($tsConfig['noPayment'])) { + return; + } + list($rate, $amountFen) = self::getTestCommissionConfig($normalizedType, 'personal', $userEid > 0 ? $userEid : null); + $commissionFen = 0; + if ($amountFen > 0) { + $commissionFen = $amountFen; + } elseif ($rate > 0) { + // noPayment 场景无订单金额,若为比例则跳过(无金额可算) + return; + } + if ($commissionFen <= 0) { + return; + } + + // 查找该用户 personal scope 的有效绑定:优先企业 personal 绑定,再回退全局 personal 绑定 + $binding = self::findActivePersonalBinding($inviteeId, $userEid > 0 ? $userEid : null); + if (!$binding) { + return; + } + + $inviterId = (int) $binding['inviterId']; + $recordEnterpriseId = $userEid > 0 + ? $userEid + : (((int)($binding['enterpriseId'] ?? 0)) > 0 ? (int)$binding['enterpriseId'] : null); + + // 防重:同一 testResultId 只允许一条 test_completion 佣金 + $exists = Db::name('commission_records') + ->where('testResultId', $testResultId) + ->where('commissionSource', 'test_completion') + ->find(); + if ($exists) { + return; + } + + $now = time(); + Db::startTrans(); + try { + $commissionStatus = 'paid'; + + if ($recordEnterpriseId) { + // 企业上下文:先扣企业余额;不足则冻结到后续补余额再解冻 + $enterprise = Db::name('enterprises') + ->where('id', $recordEnterpriseId) + ->field('id, balance') + ->lock(true) + ->find(); + + $balanceFen = (int) ($enterprise['balance'] ?? 0); + if ($enterprise && $balanceFen >= $commissionFen) { + $newBalanceFen = $balanceFen - $commissionFen; + Db::name('enterprises') + ->where('id', $recordEnterpriseId) + ->update([ + 'balance' => $newBalanceFen, + 'updatedAt' => $now, + ]); + + Db::name('finance_records')->insert([ + 'enterpriseId' => $recordEnterpriseId, + 'type' => 'consume', + 'amount' => $commissionFen, + 'balanceBefore' => $balanceFen, + 'balanceAfter' => $newBalanceFen, + 'description' => self::buildCommissionFinanceDescription($inviteeId, $normalizedType, 'test_completion'), + 'orderId' => null, + 'createdAt' => $now, + ]); + + Db::name('wechat_users') + ->where('id', $inviterId) + ->inc('walletBalance', $commissionFen) + ->inc('walletTotalEarned', $commissionFen) + ->update(['updatedAt' => $now]); + } else { + Db::name('wechat_users') + ->where('id', $inviterId) + ->inc('walletPending', $commissionFen) + ->update(['updatedAt' => $now]); + + $commissionStatus = 'frozen'; + } + } else { + // 无企业上下文时仍由平台直接发放 + Db::name('wechat_users') + ->where('id', $inviterId) + ->inc('walletBalance', $commissionFen) + ->inc('walletTotalEarned', $commissionFen) + ->update(['updatedAt' => $now]); + } + + // 写佣金记录 + Db::name('commission_records')->insert([ + 'agentId' => $inviterId, + 'orderId' => null, + 'testResultId' => $testResultId, + 'commissionSource' => 'test_completion', + 'scope' => 'personal', + 'enterpriseId' => $recordEnterpriseId, + 'inviterId' => $inviterId, + 'inviteeId' => $inviteeId, + 'bindingId' => (int) $binding['id'], + 'commissionRate' => $rate, + 'orderAmount' => 0, + 'commissionFen' => $commissionFen, + 'commissionAmount' => number_format($commissionFen / 100, 2, '.', ''), + 'status' => $commissionStatus, + 'frozenAt' => $commissionStatus === 'frozen' ? $now : null, + 'paidAt' => $commissionStatus === 'paid' ? $now : null, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + Log::error('settleTestCommission failed', [ + 'testResultId' => $testResultId, + 'inviteeId' => $inviteeId, + 'testType' => $testType, + 'normalizedType' => $normalizedType, + 'userEnterpriseId' => $userEid, + 'bindingId' => (int)($binding['id'] ?? 0), + 'inviterId' => $inviterId, + 'commissionFen' => $commissionFen, + 'message' => $e->getMessage(), + ]); + } + } +} diff --git a/api/app/controller/api/EnterpriseResume.php b/api/app/controller/api/EnterpriseResume.php new file mode 100644 index 0000000..0e87a84 --- /dev/null +++ b/api/app/controller/api/EnterpriseResume.php @@ -0,0 +1,250 @@ +request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('未登录', 401); + } + + $enterpriseId = Request::param('enterpriseId'); + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(100, max(1, (int) Request::param('pageSize', 50))); + + $query = EnterpriseResumeUpload::where('userId', $userId) + ->field('id, userId, enterpriseId, fileUrl, fileName, is_default, createdAt as created_at_ts') + ->order('createdAt', 'desc'); + + if ($enterpriseId !== null && $enterpriseId !== '') { + $eid = (int) $enterpriseId; + if ($eid > 0) { + $query->where('enterpriseId', $eid); + } else { + $query->whereNull('enterpriseId'); + } + } + + $total = $query->count(); + $rows = $query->page($page, $pageSize)->select()->toArray(); + + $list = []; + foreach ($rows as $row) { + $ts = $this->pickCreatedAt($row); + $list[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'url' => (string) ($row['fileUrl'] ?? ''), + 'fileName' => (string) ($row['fileName'] ?? ''), + 'uploadedAt' => $ts, + 'uploadedAtStr' => $this->formatTime($ts), + 'isDefault' => (int) ($row['is_default'] ?? 0) === 1, + ]; + } + + return success([ + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize, + ]); + } + + /** + * 新增一条简历上传记录(上传文件后由前端调用) + * POST /api/enterprise/resume-uploads + * body: { "url": "文件URL", "fileName": "原始文件名", "enterpriseId": 可选 } + */ + public function add() + { + $user = $this->request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('未登录', 401); + } + + $url = Request::param('url'); + $fileName = Request::param('fileName', ''); + $enterpriseId = Request::param('enterpriseId'); + + if (empty($url) || !is_string($url)) { + return error('缺少文件地址 url', 400); + } + + $url = trim($url); + if ($url === '') { + return error('url 不能为空', 400); + } + + $fileName = is_string($fileName) ? trim($fileName) : ''; + if ($fileName === '') { + $fileName = '简历文件'; + } + + $eid = null; + if ($enterpriseId !== null && $enterpriseId !== '') { + $eid = (int) $enterpriseId; + if ($eid <= 0) { + $eid = null; + } + } + // 前端未传或为 0 时:用当前用户绑定企业(wechat_users.enterpriseId)补全 + if ($eid === null) { + $wu = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find(); + if (!empty($wu['enterpriseId']) && (int) $wu['enterpriseId'] > 0) { + $eid = (int) $wu['enterpriseId']; + } + } + + $record = new EnterpriseResumeUpload(); + $record->userId = $userId; + $record->enterpriseId = $eid; + $record->fileUrl = $url; + $record->fileName = $fileName; + $record->createdAt = time(); + $record->save(); + + return success([ + 'id' => (int) $record->id, + 'url' => $record->fileUrl, + 'fileName' => $record->fileName, + 'uploadedAt' => (int) $record->createdAt, + 'uploadedAtStr' => $this->formatTime($record->createdAt), + ]); + } + + /** + * 设为默认简历(同用户同企业仅一条为默认) + * POST /api/enterprise/resume-uploads/set-default body: { "id": 记录ID } + */ + public function setDefault() + { + $user = $this->request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('未登录', 401); + } + + $id = (int) Request::param('id', 0); + if ($id <= 0) { + return error('缺少或无效的记录 id', 400); + } + + $record = EnterpriseResumeUpload::where('id', $id)->where('userId', $userId)->find(); + if (!$record) { + return error('记录不存在或无权操作', 404); + } + + $eid = isset($record->enterpriseId) && (int) $record->enterpriseId > 0 ? (int) $record->enterpriseId : null; + + Db::name('enterprise_resume_uploads') + ->where('userId', $userId) + ->where(function ($q) use ($eid) { + if ($eid !== null) { + $q->where('enterpriseId', $eid); + } else { + $q->whereNull('enterpriseId'); + } + }) + ->update(['is_default' => 0]); + + $record->is_default = 1; + $record->save(); + + return success(['id' => (int) $record->id, 'isDefault' => true]); + } + + /** + * 删除一条简历上传记录(仅本人可删) + * POST /api/enterprise/resume-uploads/delete body: { "id": 记录ID } + */ + public function delete() + { + $user = $this->request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('未登录', 401); + } + + $id = (int) Request::param('id', 0); + if ($id <= 0) { + return error('缺少或无效的记录 id', 400); + } + + $record = EnterpriseResumeUpload::where('id', $id)->where('userId', $userId)->find(); + if (!$record) { + return error('记录不存在或无权操作', 404); + } + + $record->delete(); + return success(['id' => $id]); + } + + /** + * 从查询行中取出时间戳(优先用 SQL 别名 created_at_ts,再兼容 createdAt/created_at); + * 若值为 4 位数(如年份 2026)则视为无效,返回 0。 + */ + private function pickCreatedAt(array $row): int + { + $v = $row['created_at_ts'] ?? $row['createdAt'] ?? $row['created_at'] ?? $row['createdat'] ?? null; + if ($v === null) { + return 0; + } + $ts = (int) $v; + if ($ts <= 0) { + return 0; + } + // 小于约 1971 年的秒数视为无效(避免误存为年份 2026 等) + if ($ts < 86400 * 365) { + return 0; + } + return $ts; + } + + private function formatTime($ts) + { + $ts = (int) $ts; + if ($ts <= 0 || $ts < 86400 * 365) { + return ''; + } + $d = getdate($ts); + return sprintf( + '%04d-%02d-%02d %02d:%02d', + $d['year'], + $d['mon'], + $d['mday'], + $d['hours'], + $d['minutes'] + ); + } +} diff --git a/api/app/controller/api/Payment.php b/api/app/controller/api/Payment.php new file mode 100644 index 0000000..f46129e --- /dev/null +++ b/api/app/controller/api/Payment.php @@ -0,0 +1,909 @@ +resolveUser(); + if (!$user) { + return error('未登录', 401); + } + + $orderId = Request::param('orderId', ''); + $amountFen = (int) Request::param('amount', 0); // 单位:分 + $description = Request::param('description', ''); + $productType = Request::param('productType', ''); + $paymentMethod = Request::param('paymentMethod', 'wechat'); + $openId = Request::param('openId', ''); + $quantity = (int) Request::param('quantity', 1); + $testResultId = (int) Request::param('testResultId', 0); // 可选,关联 mbti_test_results.id + $deepProductId = (string) Request::param('deepProductId', ''); // 深度服务套餐ID/产品Key(来自 deep-pricing.categories) + $enterpriseIdParam = (int) Request::param('enterpriseId', 0); + + if (empty($orderId)) { + return error('订单ID不能为空', 400); + } + if (empty($productType)) { + return error('产品类型不能为空', 400); + } + if ($quantity <= 0) { + $quantity = 1; + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('用户信息异常', 400); + } + + // 企业ID 与金额优先从 test_results 读取(历史记录进入:enterpriseId 为空则按个人价;金额用 paidAmount) + $enterpriseId = null; + $fixedAmountFen = null; + + // 未显式传 testResultId 时:优先绑定到“最近一条同类型测试”,并以该记录的 enterpriseId/paidAmount 定价 + if ($testResultId <= 0) { + $testTypeMap = [ + 'face' => 'face', + 'mbti' => 'mbti', + 'disc' => 'disc', + 'pdp' => 'pdp', + 'resume' => 'resume', + ]; + if (isset($testTypeMap[$productType])) { + $latestTest = Db::name('test_results') + ->where('userId', $userId) + ->where('testType', $testTypeMap[$productType]) + ->order('createdAt', 'desc') + ->find(); + if ($latestTest && !empty($latestTest['id'])) { + $testResultId = (int) $latestTest['id']; + } + } + } + + if ($testResultId > 0) { + $tr = Db::name('test_results') + ->where('id', $testResultId) + ->where('userId', $userId) + ->field('enterpriseId,paidAmount,requiresPayment,testType') + ->find(); + if ($tr) { + $enterpriseId = !empty($tr['enterpriseId']) ? (int) $tr['enterpriseId'] : null; + $paidAmount = isset($tr['paidAmount']) ? (int) $tr['paidAmount'] : 0; + if ($paidAmount > 0) { + $fixedAmountFen = $paidAmount; + } + } + } + + // 充值场景:优先使用显式传入的企业ID,否则回退到当前用户已绑定企业 + if ($productType === 'recharge') { + if ($enterpriseIdParam > 0) { + $enterpriseId = $enterpriseIdParam; + } elseif (empty($enterpriseId)) { + $enterpriseId = $this->resolveEnterpriseId($userId); + } + + if (empty($enterpriseId)) { + return error('充值必须指定企业', 400); + } + } + + // 计算订单金额(分)与定价类型(personal/enterprise) + if ($fixedAmountFen !== null) { + $pricingType = $enterpriseId ? 'enterprise' : 'personal'; + $amountFenCalculated = $fixedAmountFen; + } else { + [$amountFenCalculated, $pricingType] = $this->calculateAmount( + $productType, + $quantity, + $amountFen, + $user, + $enterpriseId, + $deepProductId + ); + } + + if ($amountFenCalculated <= 0) { + return error('订单金额无效,请检查定价配置或请求参数', 400); + } + + // 检查订单是否已存在,避免重复创建 + $existing = Db::name('orders') + ->where('orderNo', $orderId) + ->find(); + + $now = time(); + + if ($existing) { + // 若已存在且已支付/关闭,则不允许重新创建 + if (in_array($existing['status'], ['paid', 'completed', 'cancelled', 'refunded', 'failed'])) { + return error('订单已存在且状态为 ' . $existing['status'], 400); + } + + // 待支付订单允许覆盖部分字段(金额/描述/支付方式),金额为分 + Db::name('orders') + ->where('id', $existing['id']) + ->update([ + 'amount' => $amountFenCalculated, + 'productType' => $productType, + 'productTitle' => $description, + 'payMethod' => $paymentMethod, + 'updatedAt' => $now, + ]); + $orderIdDb = (int) $existing['id']; + } else { + $orderIdDb = Db::name('orders')->insertGetId([ + 'orderNo' => $orderId, + 'userId' => $userId, + 'enterpriseId' => $enterpriseId, + 'productType' => $productType, + 'productTitle' => $description, + 'amount' => $amountFenCalculated, + 'status' => 'pending', + 'payMethod' => $paymentMethod, + 'payTime' => null, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + } + + // 若传入 testResultId,关联该测试结果到本订单(仅更新属于当前用户的记录) + if ($testResultId > 0) { + Db::name('test_results') + ->where('id', $testResultId) + ->where('userId', $userId) + ->update([ + 'orderId' => $orderIdDb, + 'updatedAt' => $now, + ]); + } else { + // 未显式传 testResultId 时:自动将当前用户最近一次相关测试记录绑定到本订单 + // 例如:人脸报告 → 绑定最近一条 testType=face 的记录 + $testTypeMap = [ + 'face' => 'face', + 'mbti' => 'mbti', + 'disc' => 'disc', + 'pdp' => 'pdp', + 'resume' => 'resume', + ]; + if (isset($testTypeMap[$productType])) { + $testType = $testTypeMap[$productType]; + $latestTest = Db::name('test_results') + ->where('userId', $userId) + ->where('testType', $testType) + ->order('createdAt', 'desc') + ->find(); + + if ($latestTest) { + Db::name('test_results') + ->where('id', $latestTest['id']) + ->update([ + 'orderId' => $orderIdDb, + 'updatedAt'=> $now, + ]); + } + } + } + + // 真实对接微信统一下单,生成 prepay_id 等参数 + $wechatConfig = [ + 'appid' => env('WECHAT_APPID', ''), // 小程序 AppID + 'mch_id' => env('MCH_ID', ''), // 商户号 + 'api_key' => env('API_KEY', ''), // API 密钥(MD5) + 'notify_url' => env('NOTIFY_URL', ''), // 支付结果通知回调 + ]; + + if ( + empty($wechatConfig['appid']) || + empty($wechatConfig['mch_id']) || + empty($wechatConfig['api_key']) || + empty($wechatConfig['notify_url']) + ) { + return error('微信支付配置缺失,请联系管理员检查 .env', 500); + } + + if (empty($openId)) { + return error('缺少微信 openId,无法发起支付', 400); + } + + // 微信 out_trade_no 最长 32 字节,这里做一次截断适配 + $outTradeNo = strlen($orderId) > 32 ? substr($orderId, 0, 32) : $orderId; + + $unifiedOrder = $this->createWechatUnifiedOrder( + $wechatConfig, + $outTradeNo, + $amountFenCalculated, + $description ?: 'AI性格测试-' . $productType, + $openId + ); + + if (empty($unifiedOrder['prepay_id'])) { + $msg = $unifiedOrder['message'] ?? '微信统一下单失败'; + return error($msg, 500); + } + + // 组装前端 wx.requestPayment 所需参数 + $timeStamp = (string) time(); + $nonceStr = md5(uniqid('wxpay_', true)); + $pkg = 'prepay_id=' . $unifiedOrder['prepay_id']; + $signType = 'MD5'; + + $payParams = [ + 'appId' => $wechatConfig['appid'], + 'timeStamp' => $timeStamp, + 'nonceStr' => $nonceStr, + 'package' => $pkg, + 'signType' => $signType, + ]; + $paySign = $this->buildWechatSign($payParams, $wechatConfig['api_key']); + + $paymentData = [ + 'timeStamp' => $timeStamp, + 'nonceStr' => $nonceStr, + 'package' => $pkg, + 'signType' => $signType, + 'paySign' => $paySign, + 'prepayId' => $unifiedOrder['prepay_id'], + ]; + + // 与小程序 payment.js 兼容;系统统一:金额均为分 + return success(array_merge($paymentData, [ + 'orderId' => $orderId, + 'orderDbId' => $orderIdDb, + 'amount' => $amountFenCalculated, + 'productType' => $productType, + 'pricingType' => $pricingType, + 'description' => $description, + 'enterpriseId' => $enterpriseId, + ]), '订单创建成功'); + } catch (\Exception $e) { + return error('创建订单失败:' . $e->getMessage(), 500); + } + } + + /** + * POST /api/payment/notify + * 小程序在 wx.requestPayment 成功回调后调用,用于通知后端更新订单状态。 + * 当前实现为“前端通知模式”,后续可扩展为接收微信服务端回调。 + */ + public function notify() + { + try { + $orderId = Request::param('orderId', ''); + $prepayId = Request::param('prepayId', ''); + $status = Request::param('status', 'success'); // success/failed/cancelled 等 + + if (empty($orderId)) { + return error('订单ID不能为空', 400); + } + + $order = Db::name('orders') + ->where('orderNo', $orderId) + ->find(); + + if (!$order) { + return error('订单不存在', 404); + } + + // 仅允许从 pending → 其他状态,避免重复更新已完成订单 + if ($order['status'] !== 'pending' && $order['status'] !== 'paid') { + return success(null, '订单状态已更新,无需重复通知'); + } + + $now = time(); + $newStatus = $order['status']; + + if ($status === 'success') { + $newStatus = 'paid'; + } elseif ($status === 'cancelled') { + $newStatus = 'cancelled'; + } elseif ($status === 'failed') { + $newStatus = 'failed'; + } + + Db::name('orders') + ->where('id', $order['id']) + ->update([ + 'status' => $newStatus, + 'payTime' => $status === 'success' ? ($order['payTime'] ?: $now) : $order['payTime'], + 'updatedAt'=> $now, + ]); + + // 支付成功时:将关联该订单的测试结果标记为已付款,并记录当时付款金额(分) + if ($status === 'success') { + $paidAmountFen = isset($order['amount']) ? (int) $order['amount'] : 0; + + Db::name('test_results') + ->where('orderId', $order['id']) + ->update([ + 'isPaid' => 1, + 'paidAmount'=> $paidAmountFen ?: null, + 'paidAt' => $now, + 'updatedAt' => $now, + ]); + + // 企业四项测试支付后,订单金额进入企业余额 + $this->creditEnterpriseBalanceForOrder($order, $paidAmountFen, $now); + + if (($order['productType'] ?? '') !== 'recharge') { + // 触发分销佣金结算 + try { + \app\controller\api\Distribution::settleCommission((int) $order['id']); + } catch (\Exception $e) { + // 佣金结算失败不影响主流程 + } + } + } + + return success([ + 'orderId' => $orderId, + 'status' => $newStatus, + 'prepayId' => $prepayId, + ], '订单状态已更新'); + } catch (\Exception $e) { + return error('更新订单状态失败:' . $e->getMessage(), 500); + } + } + + /** + * GET /api/payment/query + * 小程序查询订单状态:实时通过商户订单号调用微信 v3 查询接口(不依赖本地状态)。 + */ + public function query() + { + try { + $orderId = Request::param('orderId', ''); + if (empty($orderId)) { + return error('订单ID不能为空', 400); + } + + // 本地订单(可选,只用于补充非微信字段;真实支付状态以微信返回为准) + $localOrder = Db::name('orders') + ->where('orderNo', $orderId) + ->find(); + + $wechat = $this->queryWechatOrderByOutTradeNo($orderId); + if (!$wechat['success']) { + return error($wechat['message'] ?? '查询微信订单失败', 500); + } + + $data = $wechat['data'] ?? []; + $tradeState = $data['trade_state'] ?? 'UNKNOWN'; + $status = $this->mapTradeStateToStatus($tradeState); + + // 若本地有订单,顺带同步一次状态(不作为查询前置条件) + $now = time(); + if ($localOrder && in_array($status, ['paid', 'completed', 'cancelled', 'refunded', 'failed'], true)) { + $payTime = $localOrder['payTime'] ?? null; + if (isset($data['time_end'])) { + $dt = \DateTime::createFromFormat('YmdHis', $data['time_end']); + if ($dt) { + $payTime = $dt->getTimestamp(); + } + } + + // 记录旧状态,用于后续判断是否从未支付 -> 已支付,避免重复统计 + $oldStatus = $localOrder['status'] ?? null; + + Db::name('orders') + ->where('id', $localOrder['id']) + ->update([ + 'status' => $status, + 'payTime' => $payTime, + 'wechatTransactionId' => $data['transaction_id'] ?? ($localOrder['wechatTransactionId'] ?? null), + 'updatedAt' => $now, + ]); + + // 同步更新关联的测试结果(按 orderId 关联),写入付款金额与时间 + $amountFromWechat = null; + if (isset($data['total_fee'])) { + $amountFromWechat = (int) $data['total_fee']; + } + if (in_array($status, ['paid', 'completed', 'refunded'], true)) { + $finalAmount = $amountFromWechat ?? (int) $localOrder['amount']; + Db::name('test_results') + ->where('orderId', $localOrder['id']) + ->update([ + 'isPaid' => $status === 'refunded' ? 0 : 1, + 'paidAmount' => $finalAmount, + 'paidAt' => $payTime ?: $now, + 'updatedAt' => $now, + ]); + + // 仅当本地原状态不是已支付/已完成/已退款时,才认为是「首次确认支付」,用于统计画像 + $paidSet = ['paid', 'completed', 'refunded']; + if ($status !== 'refunded' && !in_array($oldStatus, $paidSet, true)) { + $userId = (int) ($localOrder['userId'] ?? 0); + $enterpriseId = isset($localOrder['enterpriseId']) ? (int) $localOrder['enterpriseId'] : null; + if (($localOrder['productType'] ?? '') !== 'recharge' && $userId > 0 && $finalAmount > 0) { + UserProfileModel::recordPayment($userId, $enterpriseId, $finalAmount); + } + + // 企业四项测试支付后,订单金额进入企业余额 + $this->creditEnterpriseBalanceForOrder($localOrder, $finalAmount, $now); + + if (($localOrder['productType'] ?? '') !== 'recharge') { + // 触发分销佣金结算 + try { + \app\controller\api\Distribution::settleCommission((int) $localOrder['id']); + } catch (\Exception $e) { + // 佣金结算失败不影响主流程 + } + } + } + } + } + + // V2: 优先使用 total_fee,退回用本地金额 + $amountTotal = null; + if (isset($data['total_fee'])) { + $amountTotal = (int) $data['total_fee']; + } elseif ($localOrder) { + $amountTotal = (int) $localOrder['amount']; + } + + // V2: 支付完成时间 time_end,格式 yyyyMMddHHmmss + $payTimeTs = null; + if (isset($data['time_end'])) { + $dt = \DateTime::createFromFormat('YmdHis', $data['time_end']); + if ($dt) { + $payTimeTs = $dt->getTimestamp(); + } + } elseif ($localOrder) { + $payTimeTs = $localOrder['payTime'] ?? null; + } + + return success([ + 'orderId' => $orderId, + 'wechatTransactionId'=> $data['transaction_id'] ?? null, + 'tradeState' => $tradeState, + 'tradeStateDesc' => $data['trade_state_desc'] ?? null, + 'amount' => $amountTotal, + 'status' => $status, + 'payMethod' => 'wechat', + 'payTime' => $payTimeTs, + 'userId' => $localOrder['userId'] ?? null, + 'enterpriseId' => $localOrder['enterpriseId']?? null, + 'productType' => $localOrder['productType'] ?? null, + 'createdAt' => $localOrder['createdAt'] ?? null, + ]); + } catch (\Exception $e) { + return error('查询订单失败:' . $e->getMessage(), 500); + } + } + + /** + * 解析当前请求中的用户信息(优先使用中间件注入的 user,其次从 JWT 中解析) + */ + protected function resolveUser(): ?array + { + $user = $this->request->user ?? null; + if ($user) { + return is_array($user) ? $user : (array) $user; + } + + $token = JwtService::getTokenFromRequest($this->request); + if (!$token) { + return null; + } + + $payload = JwtService::verifyToken($token); + if (!$payload) { + return null; + } + + return [ + 'source' => $payload['source'] ?? '', + 'user_id'=> $payload['user_id'] ?? $payload['userId'] ?? null, + 'userId' => $payload['user_id'] ?? $payload['userId'] ?? null, + ]; + } + + /** + * 根据用户最近一次测试记录推断企业ID(若存在) + */ + protected function resolveEnterpriseId(int $userId): ?int + { + if ($userId <= 0) { + return null; + } + + $row = Db::name('wechat_users')->where('id', $userId)->field('enterpriseId')->find(); + if (empty($row['enterpriseId'])) { + return null; + } + return (int) $row['enterpriseId']; + } + + /** + * 企业四项测试支付成功后,将订单金额计入企业余额。 + * 使用 finance_records + orderId 做幂等,避免 notify/query 重复入账。 + */ + protected function creditEnterpriseBalanceForOrder(array $order, int $amountFen, int $now): void + { + $enterpriseId = (int) ($order['enterpriseId'] ?? 0); + $productType = (string) ($order['productType'] ?? ''); + $orderDbId = (int) ($order['id'] ?? 0); + + if ($enterpriseId <= 0 || $orderDbId <= 0 || $amountFen <= 0) { + return; + } + + if (!in_array($productType, ['face', 'mbti', 'disc', 'pdp', 'resume', 'recharge'], true)) { + return; + } + + $exists = Db::name('finance_records') + ->where('enterpriseId', $enterpriseId) + ->where('orderId', $orderDbId) + ->where('type', 'recharge') + ->find(); + if ($exists) { + return; + } + + Db::startTrans(); + try { + $enterprise = Db::name('enterprises') + ->where('id', $enterpriseId) + ->field('id, name, balance') + ->lock(true) + ->find(); + if (!$enterprise) { + Db::rollback(); + return; + } + + $beforeFen = (int) ($enterprise['balance'] ?? 0); + $afterFen = $beforeFen + $amountFen; + + Db::name('enterprises') + ->where('id', $enterpriseId) + ->update([ + 'balance' => $afterFen, + 'updatedAt' => $now, + ]); + + Db::name('finance_records')->insert([ + 'enterpriseId' => $enterpriseId, + 'type' => 'recharge', + 'amount' => $amountFen, + 'balanceBefore' => $beforeFen, + 'balanceAfter' => $afterFen, + 'description' => $productType === 'recharge' + ? '企业余额充值' + : ('企业测试收入:' . strtoupper($productType)), + 'orderId' => $orderDbId, + 'createdAt' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + } + } + + /** + * 计算订单金额(分)和定价类型 + * 定价配置中单价为「元」时,在此处乘以 100 转为分;前端传入的 requestAmountFen 已是分。 + * + * @param string $productType 产品类型 + * @param int $quantity 购买数量 + * @param int $requestAmountFen 前端传入金额(分),部分类型作为兜底 + * @param array|null $user 当前用户信息 + * @param int|null $enterpriseId 推断出的企业ID + * @param string $deepProductId 深度服务套餐ID/产品Key(deep-pricing.categories.id/productKey) + * @return array [amountFen, pricingType] + */ + protected function calculateAmount( + string $productType, + int $quantity, + int $requestAmountFen, + ?array $user, + ?int $enterpriseId, + string $deepProductId = '' + ): array { + $pricingType = 'personal'; + $pricingEnterpriseId = null; // 定价用企业 ID(个人测试但有归属企业时也传入) + + if ($user && ($user['source'] ?? '') === 'wechat') { + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId > 0 && !empty($enterpriseId)) { + $pricingType = 'enterprise'; + $pricingEnterpriseId = $enterpriseId; + } elseif ($userId > 0 && empty($enterpriseId)) { + // 个人测试:查 wechat_users.enterpriseId,若有则使用企业专属个人定价 + $userEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId'); + if (!empty($userEid)) { + $pricingEnterpriseId = (int) $userEid; + } + } + } + + $quantity = $quantity > 0 ? $quantity : 1; + + // 1)测试类产品:定价配置中为元,转为分(企业用户按企业ID取价) + $testProductTypes = ['face', 'mbti', 'disc', 'pdp', 'resume', 'report', 'team_analysis']; + if (in_array($productType, $testProductTypes, true)) { + $pricingConfig = PricingConfigModel::getByTypeAndEnterprise($pricingType, $pricingEnterpriseId ?? $enterpriseId); + $config = []; + if ($pricingConfig && !empty($pricingConfig->config)) { + $raw = $pricingConfig->config; + $config = is_array($raw) ? $raw : (array) $raw; + } + + $keyMap = ['team_analysis' => 'teamAnalysis']; + $key = $keyMap[$productType] ?? $productType; + $unitPriceYuan = isset($config[$key]) ? (float) $config[$key] : 0.0; + $amountFen = (int) round($unitPriceYuan * 100 * $quantity); + return [$amountFen, $pricingType]; + } + + // 2)深度服务:定价配置为元,转为分 + // 与 AppConfig::deepPricing 使用同一套配置: + // - 个人版:type=deep_personal,config.categories[].price + // - 企业版:type=deep_enterprise,config.categories[].price + if (in_array($productType, ['deep_personal', 'deep_team'], true)) { + $type = $productType === 'deep_team' ? 'deep_enterprise' : 'deep_personal'; + $configModel = PricingConfigModel::where('type', $type)->whereNull('enterpriseId')->find(); + $unitPriceYuan = 0.0; + + if ($configModel && !empty($configModel->config)) { + $raw = $configModel->config; + $data = is_array($raw) ? $raw : (array) $raw; + $categories = isset($data['categories']) && is_array($data['categories']) ? $data['categories'] : []; + + if (!empty($categories)) { + // 若传入 deepProductId,则优先根据 id 或 productKey 精确匹配对应套餐 + if ($deepProductId !== '') { + foreach ($categories as $cat) { + $cid = (string) ($cat['id'] ?? ''); + $ckey = (string) ($cat['productKey'] ?? ''); + if ($deepProductId === $cid || $deepProductId === $ckey) { + $unitPriceYuan = isset($cat['price']) ? (float) $cat['price'] : 0.0; + break; + } + } + } + // 未指定或未匹配到时,回退到第一项价格 + if ($unitPriceYuan <= 0.0) { + $first = $categories[0]; + $unitPriceYuan = isset($first['price']) ? (float) $first['price'] : 0.0; + } + } + } + + // 兼容旧版 deep 配置:若 categories 为空,则回退到 type=deep 的 personal/team 字段 + if ($unitPriceYuan <= 0.0) { + $deepModel = PricingConfigModel::getByTypeAndEnterprise('deep', null); + if ($deepModel && !empty($deepModel->config)) { + $rawDeep = $deepModel->config; + $deepConfig = is_array($rawDeep) ? $rawDeep : (array) $rawDeep; + $key = $productType === 'deep_team' ? 'team' : 'personal'; + if (isset($deepConfig[$key])) { + $unitPriceYuan = (float) $deepConfig[$key]; + } + } + } + + $amountFen = (int) round($unitPriceYuan * 100 * $quantity); + return [$amountFen, $pricingType]; + } + + // 3)充值 / 4)VIP 等 / 5)未知:直接使用前端传入的金额(分) + $amountFen = $requestAmountFen > 0 ? $requestAmountFen : 0; + return [$amountFen, $pricingType]; + } + + /** + * 调用微信 V2:根据商户订单号查询订单(JSAPI/小程序支付) + * 文档:https://pay.weixin.qq.com/doc/v2/merchant/4011941128 + */ + protected function queryWechatOrderByOutTradeNo(string $outTradeNo): array + { + $appid = env('WECHAT_APPID', ''); + $mchid = env('MCH_ID', ''); + $apiKey = env('API_KEY', ''); + + if (!$appid || !$mchid || !$apiKey) { + return [ + 'success' => false, + 'message' => '微信支付 V2 查询配置缺失,请检查 WECHAT_APPID / MCH_ID / API_KEY', + ]; + } + + $url = 'https://api.mch.weixin.qq.com/pay/orderquery'; + + $params = [ + 'appid' => $appid, + 'mch_id' => $mchid, + 'nonce_str' => md5(uniqid('orderquery_', true)), + 'out_trade_no' => $outTradeNo, + ]; + $params['sign'] = $this->buildWechatSign($params, $apiKey); + + $xml = $this->arrayToXml($params); + $response = $this->postXml($url, $xml, 10); + if ($response === false) { + return ['success' => false, 'message' => '调用微信 V2 查询接口失败']; + } + + $data = $this->xmlToArray($response); + if (!is_array($data) || ($data['return_code'] ?? '') !== 'SUCCESS') { + $msg = $data['return_msg'] ?? '微信 V2 返回失败'; + return ['success' => false, 'message' => $msg, 'raw' => $data]; + } + + if (($data['result_code'] ?? '') !== 'SUCCESS') { + $err = $data['err_code_des'] ?? $data['err_code'] ?? '微信 V2 查询失败'; + return ['success' => false, 'message' => $err, 'raw' => $data]; + } + + // V2 返回字段:trade_state / trade_state_desc / total_fee / transaction_id / time_end 等 + return ['success' => true, 'data' => $data]; + } + + /** + * 将微信 trade_state 映射为本地订单状态 + */ + protected function mapTradeStateToStatus(string $tradeState): string + { + $tradeState = strtoupper($tradeState); + switch ($tradeState) { + case 'SUCCESS': + return 'paid'; + case 'REFUND': + return 'refunded'; + case 'NOTPAY': + case 'USERPAYING': + return 'pending'; + case 'CLOSED': + case 'REVOKED': + return 'cancelled'; + case 'PAYERROR': + return 'failed'; + default: + return 'pending'; + } + } + + /** + * 调用微信统一下单接口(JSAPI) + * 文档:https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=9_1 + */ + protected function createWechatUnifiedOrder( + array $config, + string $orderNo, + int $amountFen, + string $body, + string $openId + ): array { + $url = 'https://api.mch.weixin.qq.com/pay/unifiedorder'; + + $params = [ + 'appid' => $config['appid'], + 'mch_id' => $config['mch_id'], + 'nonce_str' => md5(uniqid('wxpay_unified_', true)), + 'body' => mb_substr($body, 0, 40), + 'out_trade_no' => $orderNo, + 'total_fee' => $amountFen, + 'spbill_create_ip' => $this->request ? $this->request->ip() : '127.0.0.1', + 'notify_url' => $config['notify_url'], + 'trade_type' => 'JSAPI', + 'openid' => $openId, + ]; + + $params['sign'] = $this->buildWechatSign($params, $config['api_key']); + + $xml = $this->arrayToXml($params); + $response = $this->postXml($url, $xml, 30); + + if ($response === false) { + return ['success' => false, 'message' => '请求微信支付接口失败']; + } + + $data = $this->xmlToArray($response); + if (!is_array($data)) { + return ['success' => false, 'message' => '解析微信支付返回失败']; + } + + if (($data['return_code'] ?? '') !== 'SUCCESS') { + return ['success' => false, 'message' => ($data['return_msg'] ?? '微信返回失败')]; + } + + if (($data['result_code'] ?? '') !== 'SUCCESS') { + $err = ($data['err_code_des'] ?? $data['err_code'] ?? '微信下单失败'); + return ['success' => false, 'message' => $err]; + } + + return [ + 'success' => true, + 'prepay_id' => $data['prepay_id'] ?? '', + 'raw' => $data, + ]; + } + + /** + * 构造微信支付签名(MD5,参数 ASCII 排序后拼接 &key=API_KEY) + */ + protected function buildWechatSign(array $params, string $apiKey): string + { + ksort($params); + $buff = []; + foreach ($params as $k => $v) { + if ($v === '' || $v === null || $k === 'sign') { + continue; + } + $buff[] = $k . '=' . $v; + } + $string = implode('&', $buff) . '&key=' . $apiKey; + return strtoupper(md5($string)); + } + + protected function arrayToXml(array $data): string + { + $xml = ''; + foreach ($data as $key => $val) { + if (is_numeric($val)) { + $xml .= "<{$key}>{$val}"; + } else { + $xml .= "<{$key}>"; + } + } + $xml .= ''; + return $xml; + } + + protected function xmlToArray(string $xml) + { + $data = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOCDATA); + if ($data === false) { + return null; + } + return json_decode(json_encode($data), true); + } + + protected function postXml(string $url, string $xml, int $timeout = 30) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $xml); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + $response = curl_exec($ch); + if ($response === false) { + curl_close($ch); + return false; + } + curl_close($ch); + return $response; + } + +} + diff --git a/api/app/controller/api/Test.php b/api/app/controller/api/Test.php new file mode 100644 index 0000000..d6cb8f9 --- /dev/null +++ b/api/app/controller/api/Test.php @@ -0,0 +1,625 @@ +request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('未登录', 401); + } + + $type = Request::param('type', 'all'); // all|mbti|disc|pdp|face + $scope = Request::param('scope', 'all'); // all|personal|enterprise + $page = max(1, (int) Request::param('page', 1)); + $pageSize = (int) Request::param('pageSize', 0); + if ($pageSize <= 0) { + $pageSize = 500; + } + $pageSize = min(500, max(1, $pageSize)); + + $base = Db::name('test_results') + ->alias('tr') + ->leftJoin('wechat_users wu', 'tr.userId = wu.id') + ->leftJoin('enterprises e_tr', 'tr.enterpriseId = e_tr.id') + ->leftJoin('enterprises e_wu', 'wu.enterpriseId = e_wu.id') + ->where('tr.userId', $userId) + ->field('tr.*, e_tr.name as enterpriseName, wu.enterpriseId as bindEnterpriseId, e_wu.name as bindEnterpriseName') + ->order('tr.createdAt', 'desc'); + + if ($type !== 'all') { + if (in_array($type, ['face', 'ai'], true)) { + $base->whereIn('tr.testType', ['face', 'ai']); + } else { + $base->where('tr.testType', $type); + } + } + + if ($scope === 'personal') { + $base->whereNull('tr.enterpriseId'); + } elseif ($scope === 'enterprise') { + $base->whereNotNull('tr.enterpriseId'); + } + + $total = (clone $base)->count('tr.id'); + $rows = (clone $base)->page($page, $pageSize)->select()->toArray(); + + $list = []; + foreach ($rows as $row) { + $id = $row['id'] ?? 0; + $testType = $row['testType'] ?? ''; + $createdAt = $row['createdAt'] ?? null; + $timeLabel = $createdAt ? date('Y-m-d H:i', $createdAt) : '未知时间'; + // enterpriseId 语义:仅代表“该次测试是否属于企业测试/企业分享链接” + // 个人测试时 enterpriseId 可能为空,但用户依然可能在 wechat_users.enterpriseId 有归属企业 + $enterpriseName = ''; + if (isset($row['enterpriseId']) && (int) $row['enterpriseId'] > 0) { + $enterpriseName = trim((string) ($row['enterpriseName'] ?? '')); + } elseif (isset($row['bindEnterpriseId']) && (int) $row['bindEnterpriseId'] > 0) { + $enterpriseName = trim((string) ($row['bindEnterpriseName'] ?? '')); + } + $requiresPayment = (int) ($row['requiresPayment'] ?? 0); + $isPaid = (int) ($row['isPaid'] ?? 0); + $orderId = isset($row['orderId']) ? (int) $row['orderId'] : null; + + $raw = $row['resultData'] ?? ($row['result'] ?? null); + $data = null; + if ($raw !== null && $raw !== '') { + $decoded = json_decode($raw, true); + $data = is_array($decoded) ? $decoded : $raw; + } + if ($requiresPayment && !$isPaid && $data !== null) { + $data = $this->filterResultToPartial($testType, $data); + } + + $paymentFields = [ + 'requiresPayment' => $requiresPayment, + 'isPaid' => $isPaid, + 'orderId' => $orderId, + 'enterpriseName' => $enterpriseName, + ]; + + // 映射为小程序 history 页需要的结构 + switch ($testType) { + case 'mbti': + $mbtiType = $data['mbtiType'] ?? $data['mbti'] ?? '未知'; + $list[] = array_merge([ + 'id' => $id, + 'type' => 'mbti', + 'key' => 'mbti_' . $id, + 'emoji' => '🧠', + 'typeName' => 'MBTI性格测试', + 'resultText'=> $mbtiType, + 'testTime' => $timeLabel, + 'data' => $data, + ], $paymentFields); + break; + case 'disc': + $discType = $data['dominantType'] ?? $data['disc'] ?? '未知'; + $list[] = array_merge([ + 'id' => $id, + 'type' => 'disc', + 'key' => 'disc_' . $id, + 'emoji' => '📊', + 'typeName' => 'DISC性格测试', + 'resultText'=> $discType . '型', + 'testTime' => $timeLabel, + 'data' => $data, + ], $paymentFields); + break; + case 'pdp': + $primary = $data['description']['type'] ?? $data['pdp'] ?? '未知'; + $emoji = $data['description']['emoji'] ?? '🦁'; + $list[] = array_merge([ + 'id' => $id, + 'type' => 'pdp', + 'key' => 'pdp_' . $id, + 'emoji' => $emoji, + 'typeName' => 'PDP行为偏好测试', + 'resultText'=> $primary, + 'testTime' => $timeLabel, + 'data' => $data, + ], $paymentFields); + break; + case 'face': + case 'ai': + $mbtiShort = ''; + if (is_array($data)) { + if (isset($data['mbti']['type'])) { + $mbtiShort = $data['mbti']['type']; + } elseif (isset($data['mbti'])) { + $mbtiShort = is_array($data['mbti']) ? ($data['mbti']['type'] ?? '') : $data['mbti']; + } + } + $list[] = array_merge([ + 'id' => $id, + 'type' => 'ai', + 'key' => 'ai_' . $id, + 'emoji' => '👁️', + 'typeName' => '面相分析', + 'resultText'=> $mbtiShort ?: '未知', + 'testTime' => $timeLabel, + 'data' => $data, + ], $paymentFields); + break; + case 'resume': + $summary = ''; + if (is_array($data) && !empty($data['content'])) { + $summary = mb_substr(strip_tags((string) $data['content']), 0, 20, 'UTF-8'); + if (mb_strlen((string) $data['content'], 'UTF-8') > 20) { + $summary .= '...'; + } + } + $list[] = array_merge([ + 'id' => $id, + 'type' => 'resume', + 'key' => 'resume_' . $id, + 'emoji' => '📋', + 'typeName' => '简历综合分析', + 'resultText'=> $summary ?: '简历综合分析', + 'testTime' => $timeLabel, + 'data' => $data, + ], $paymentFields); + break; + default: + break; + } + } + + return success([ + 'list' => $list, + 'total' => (int) $total, + 'page' => $page, + 'pageSize' => $pageSize, + 'hasMore' => ($page * $pageSize) < $total, + ]); + } + + /** + * 获取每种测试类型最新一条记录(用于小程序「我的」页) + * GET /api/test/recent + * 返回:{ records: { mbti, disc, pdp, ai }, totalCount } + */ + public function recent() + { + $user = $this->request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('未登录', 401); + } + + $scope = Request::param('scope', 'all'); // all|personal|enterprise + + $records = []; + + // 优化:一次性查询所有需要的最新记录,减少数据库连接和查询次数 + $query = Db::name('test_results') + ->where('userId', $userId); + + if ($scope === 'personal') { + $query->whereNull('enterpriseId'); + } elseif ($scope === 'enterprise') { + $query->whereNotNull('enterpriseId'); + } + + // 使用子查询或 Union 可能更复杂,这里采用分组取最新的优化思路 + // 但 ThinkPHP 中最简单有效的优化是先查出所有类型,再处理 + $allRows = $query->order('createdAt', 'desc')->select()->toArray(); + + $foundTypes = []; + $totalCount = count($allRows); + + foreach ($allRows as $row) { + $type = $row['testType']; + // face 和 ai 视为同一种类型 + $effectiveType = in_array($type, ['face', 'ai']) ? 'ai' : $type; + + if (!isset($foundTypes[$effectiveType]) && in_array($effectiveType, ['mbti', 'disc', 'pdp', 'ai'])) { + $records[$effectiveType] = $this->_formatRecentRow($row); + $foundTypes[$effectiveType] = true; + } + + // 如果四个类型都找到了,且不需要总数(或者已经有了),可以提前结束 + if (count($foundTypes) >= 4) { + // 如果不需要精确的总数统计,这里可以 break + // 但为了保持接口兼容性,我们继续循环或者已经拿到了 count + } + } + + return success([ + 'records' => $records, + 'totalCount' => (int) $totalCount, + ]); + } + + /** + * 格式化单条记录为 recent 接口返回结构 + */ + protected function _formatRecentRow(array $row): array + { + $testType = $row['testType'] ?? ''; + $createdAt = $row['createdAt'] ?? null; + $raw = $row['resultData'] ?? ($row['result'] ?? null); + $data = []; + if ($raw !== null && $raw !== '') { + $decoded = json_decode($raw, true); + $data = is_array($decoded) ? $decoded : []; + } + + $resultText = ''; + $emoji = ''; + $typeName = ''; + + switch ($testType) { + case 'mbti': + $resultText = $data['mbtiType'] ?? $data['mbti'] ?? '未知'; + $emoji = '🧠'; + $typeName = 'MBTI性格'; + break; + case 'disc': + $dominantType = $data['dominantType'] ?? $data['disc'] ?? '未知'; + $resultText = $dominantType . '型'; + $emoji = '📊'; + $typeName = 'DISC测评'; + break; + case 'pdp': + $resultText = $data['description']['type'] ?? $data['pdp'] ?? '未知'; + $emoji = $data['description']['emoji'] ?? '🦁'; + $typeName = 'PDP行为'; + break; + case 'face': + case 'ai': + $mbtiShort = ''; + if (isset($data['mbti']['type'])) { + $mbtiShort = $data['mbti']['type']; + } elseif (isset($data['mbti']) && !is_array($data['mbti'])) { + $mbtiShort = (string) $data['mbti']; + } + $resultText = $mbtiShort ?: '面相分析'; + $emoji = '👁️'; + $typeName = '面相分析'; + break; + } + + return [ + 'id' => (int) $row['id'], + 'testType' => ($testType === 'face') ? 'ai' : $testType, + 'emoji' => $emoji, + 'typeName' => $typeName, + 'resultText' => $resultText, + 'testTime' => $createdAt ? date('Y-m-d', (int) $createdAt) : '', + 'isPaid' => (int) ($row['isPaid'] ?? 0), + 'requiresPayment' => (int) ($row['requiresPayment'] ?? 0), + ]; + } + + /** + * 单条测试结果详情(按ID读取数据库) + * GET /api/test/detail?id=123 + */ + public function detail() + { + $user = $this->request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('未登录', 401); + } + + $id = (int) Request::param('id', 0); + if ($id <= 0) { + return error('缺少ID', 400); + } + + $row = Db::name('test_results') + ->where('id', $id) + ->where('userId', $userId) + ->find(); + + if (!$row) { + return error('记录不存在', 404); + } + + $raw = $row['resultData'] ?? ($row['result'] ?? null); + $data = null; + if ($raw !== null && $raw !== '') { + $decoded = json_decode($raw, true); + $data = is_array($decoded) ? $decoded : $raw; + } + $requiresPayment = (int) ($row['requiresPayment'] ?? 0); + $isPaid = (int) ($row['isPaid'] ?? 0); + $paidAmount = isset($row['paidAmount']) ? (int) $row['paidAmount'] : 0; + $testType = $row['testType'] ?? ''; + // 仅当需要付款且未付款且金额>0 时才脱敏;系统设置需付款但金额为0 则直接可查看 + $needPaymentToUnlock = $requiresPayment && !$isPaid && $paidAmount > 0; + if ($needPaymentToUnlock && $data !== null) { + $data = $this->filterResultToPartial($testType, $data); + } + + return success([ + 'id' => $row['id'], + 'testType' => $testType, + 'createdAt' => $row['createdAt'], + 'data' => $data, + 'requiresPayment' => $requiresPayment, + 'isPaid' => $isPaid, + 'paidAmount' => $paidAmount, + 'amountYuan' => $paidAmount > 0 ? round($paidAmount / 100, 2) : 0, + 'needPaymentToUnlock'=> $needPaymentToUnlock, + 'orderId' => isset($row['orderId']) ? (int) $row['orderId'] : null, + 'paidAt' => isset($row['paidAt']) ? (int) $row['paidAt'] : null, + ]); + } + + /** + * 提交测试结果(MBTI/DISC/PDP 等问卷) + * POST /api/test/submit + * body: { testType, answers, result, testDuration, timestamp } + * - userId 从 token 中解析,保证与当前登录微信用户一致 + * - 结果统一写入 test_results 表,前端历史/详情接口复用现有逻辑 + */ + public function submit() + { + $user = $this->request->user ?? null; + if (!$user || ($user['source'] ?? '') !== 'wechat') { + return error('未登录', 401); + } + + $userId = (int) ($user['user_id'] ?? $user['userId'] ?? 0); + if ($userId <= 0) { + return error('未登录', 401); + } + + $input = Request::post(); + $testType = $input['testType'] ?? ''; + $result = $input['result'] ?? null; + $answers = $input['answers'] ?? []; + $duration = isset($input['testDuration']) ? (int) $input['testDuration'] : 0; + // 企业分享链接会传 enterpriseId;个人分享不传,稍后从 wechat_users 回落 + $enterpriseId = isset($input['enterpriseId']) ? (int) $input['enterpriseId'] : null; + if ($enterpriseId !== null && $enterpriseId <= 0) { + $enterpriseId = null; + } + // 标记来源:只有"请求体明确传入"时才更新 wechat_users.enterpriseId + $enterpriseFromRequest = $enterpriseId !== null; + + if (!$testType || $result === null) { + return error('缺少必要参数', 400); + } + + // 仅允许已知类型,避免脏数据 + if (!in_array($testType, ['mbti', 'disc', 'pdp', 'face', 'ai'], true)) { + return error('不支持的测试类型', 400); + } + + // 结果结构中附带 answers / testDuration,方便后续分析,同时保持历史结构兼容 + if (is_array($result)) { + if (!isset($result['answers']) && is_array($answers)) { + $result['answers'] = $answers; + } + if (!isset($result['testDuration']) && $duration > 0) { + $result['testDuration'] = $duration; + } + } + + try { + $now = time(); + // 三个变量各司其职: + // $enterpriseId —— 仅企业测试(请求体传入)才非 null,决定走 admin_enterprise 定价 + // $pricingEnterpriseId —— 个人测试时从 wechat_users 取,走 admin_personal + eid 定价 + // $writeEnterpriseId —— 写入 test_results.enterpriseId(企业测试 or 绑定企业都记录) + $pricingEnterpriseId = $enterpriseId; + $writeEnterpriseId = $enterpriseId; + if ($enterpriseId === null) { + $boundEid = Db::name('wechat_users')->where('id', $userId)->value('enterpriseId'); + if (!empty($boundEid)) { + $pricingEnterpriseId = (int) $boundEid; // admin_personal + eid + $writeEnterpriseId = (int) $boundEid; // 历史记录展示企业名 + } + } + $requiresPayment = $this->getRequiresPaymentByTestType($testType, $enterpriseId, $pricingEnterpriseId); + $standardAmountFen = $requiresPayment ? $this->getStandardAmountFenByTestType($testType, $enterpriseId, $pricingEnterpriseId) : 0; + $id = Db::name('test_results')->insertGetId([ + 'userId' => $userId, + 'enterpriseId' => $writeEnterpriseId, + 'testScope' => $enterpriseId !== null ? 'enterprise' : 'personal', + 'testType' => $testType, + 'resultData' => is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE), + 'score' => null, + 'orderId' => null, + 'requiresPayment' => $requiresPayment, + 'isPaid' => 0, + 'paidAmount' => $standardAmountFen > 0 ? $standardAmountFen : null, + 'paidAt' => null, + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + + if ($id > 0) { + UserProfileModel::recordTest($userId, $testType, $id, $writeEnterpriseId, $now); + // 仅当 enterpriseId 来自请求体(企业分享链接)时才更新绑定关系 + if ($enterpriseFromRequest && $enterpriseId !== null && $enterpriseId > 0) { + Db::name('wechat_users')->where('id', $userId)->update([ + 'enterpriseId' => $enterpriseId, + 'updatedAt' => $now, + ]); + } + // 测试完成佣金结算(无需付款,异步不影响主流程) + try { + \app\controller\api\Distribution::settleTestCommission($id, $userId, $testType); + } catch (\Throwable $e) { + // 佣金结算失败不阻断测试保存 + } + } + } catch (\Throwable $e) { + return error('保存测试结果失败', 500); + } + + return success(null, '提交成功'); + } + + /** + * 根据定价配置返回该测试类型是否需要付费才显示完整报告 + * + * @param string $testType face|mbti|disc|pdp + * @param int|null $enterpriseId 本次测试的企业 ID(NULL=个人测试) + * @param int|null $pricingEnterpriseId 定价用企业 ID(个人测试时也可能有归属企业) + * @return int 0 或 1 + */ + protected function getRequiresPaymentByTestType(string $testType, ?int $enterpriseId = null, ?int $pricingEnterpriseId = null): int + { + $pricingConfig = $this->resolvePricingConfig($enterpriseId, $pricingEnterpriseId); + if (!$pricingConfig || empty($pricingConfig->config)) { + return 0; + } + $pricing = is_array($pricingConfig->config) ? $pricingConfig->config : (array) $pricingConfig->config; + $key = $testType === 'team_analysis' ? 'teamAnalysis' : $testType; + return isset($pricing[$key]) && (float) $pricing[$key] > 0 ? 1 : 0; + } + + /** + * 获取某测试类型当前定价金额(分),用于写入 test_results.paidAmount + * + * @param int|null $enterpriseId 本次测试企业 ID + * @param int|null $pricingEnterpriseId 定价用企业 ID + */ + protected function getStandardAmountFenByTestType(string $testType, ?int $enterpriseId = null, ?int $pricingEnterpriseId = null): int + { + $pricingConfig = $this->resolvePricingConfig($enterpriseId, $pricingEnterpriseId); + if (!$pricingConfig || empty($pricingConfig->config)) { + return 0; + } + $pricing = is_array($pricingConfig->config) ? $pricingConfig->config : (array) $pricingConfig->config; + $key = $testType === 'team_analysis' ? 'teamAnalysis' : $testType; + if (!isset($pricing[$key])) return 0; + $yuan = (float) $pricing[$key]; + return $yuan > 0 ? (int) round($yuan * 100) : 0; + } + + /** + * 解析定价配置: + * - 企业测试(enterpriseId 非空)→ 企业版定价(admin_enterprise 优先) + * - 个人测试但有归属企业(pricingEnterpriseId 非空)→ 企业专属个人定价(admin_personal 优先) + * - 纯个人测试 → 全局个人定价 + */ + private function resolvePricingConfig(?int $enterpriseId, ?int $pricingEnterpriseId): ?PricingConfigModel + { + if ($enterpriseId !== null && $enterpriseId > 0) { + return PricingConfigModel::getByTypeAndEnterprise('enterprise', $enterpriseId); + } + if ($pricingEnterpriseId !== null && $pricingEnterpriseId > 0) { + return PricingConfigModel::getByTypeAndEnterprise('personal', $pricingEnterpriseId); + } + return PricingConfigModel::getByTypeAndEnterprise('personal', null); + } + + /** + * 未付费时只返回部分数据(完整数据需付费解锁) + * @param string $testType + * @param array|null $data 原始 resultData + * @return array|null 脱敏后的数据 + */ + protected function filterResultToPartial(string $testType, $data) + { + if (!is_array($data)) { + return $data; + } + if ($testType === 'face' || $testType === 'ai') { + $out = $data; + $out['faceAnalysis'] = null; + $out['boneAnalysis'] = null; + return $out; + } + if ($testType === 'mbti') { + return [ + 'mbtiType' => $data['mbtiType'] ?? $data['mbti'] ?? '', + 'locked' => true, + ]; + } + if ($testType === 'disc') { + return [ + 'dominantType' => $data['dominantType'] ?? $data['disc'] ?? '', + 'locked' => true, + ]; + } + if ($testType === 'pdp') { + return [ + 'description' => isset($data['description']) ? ['type' => $data['description']['type'] ?? '', 'emoji' => $data['description']['emoji'] ?? ''] : [], + 'locked' => true, + ]; + } + return $data; + } + + /** + * 获取当前用户最近的 MBTI / DISC / PDP 测试记录(暂不使用人脸/AI 结果),供简历综合分析使用 + * @param int $userId 微信用户 ID + * @param int|null $enterpriseId 当前企业ID(仅返回该企业下的记录;为空则不按企业过滤) + * @return array ['face' => row|null, 'mbti' => row|null, 'disc' => row|null, 'pdp' => row|null],row 含 id, testType, resultData, createdAt + */ + public static function getLatestResultsForResume(int $userId, ?int $enterpriseId = null): array + { + if ($userId <= 0) { + return ['face' => null, 'mbti' => null, 'disc' => null, 'pdp' => null]; + } + + $out = ['face' => null, 'mbti' => null, 'disc' => null, 'pdp' => null]; + + $base = Db::name('test_results')->where('userId', $userId); + if ($enterpriseId !== null && $enterpriseId > 0) { + $base = $base->where('enterpriseId', (int) $enterpriseId); + } + + // face/ai 暂不参与简历分析,保持为 null,避免写入上下文 + + // mbti + $out['mbti'] = (clone $base) + ->where('testType', 'mbti') + ->field('id, testType, resultData, createdAt') + ->order('createdAt', 'desc') + ->find(); + + // pdp + $out['pdp'] = (clone $base) + ->where('testType', 'pdp') + ->field('id, testType, resultData, createdAt') + ->order('createdAt', 'desc') + ->find(); + + // disc + $out['disc'] = (clone $base) + ->where('testType', 'disc') + ->field('id, testType, resultData, createdAt') + ->order('createdAt', 'desc') + ->find(); + + return $out; + } +} + diff --git a/api/app/controller/api/Upload.php b/api/app/controller/api/Upload.php new file mode 100644 index 0000000..94b2d69 --- /dev/null +++ b/api/app/controller/api/Upload.php @@ -0,0 +1,11 @@ + Request::header('wechatpay-signature'), + 'wechatpay-timestamp' => Request::header('wechatpay-timestamp'), + 'wechatpay-nonce' => Request::header('wechatpay-nonce'), + 'wechatpay-serial' => Request::header('wechatpay-serial'), + ]; + + Log::info('[WechatTransferNotify] raw body: ' . $body); + + // 这里只做最小实现:直接解密 resource,按 out_bill_no 匹配提现记录 + try { + $data = json_decode($body, true) ?: []; + if (empty($data['resource'])) { + throw new \Exception('missing resource'); + } + + $service = new WechatTransferService(); + $resource = $data['resource']; + + // 复用文档中的解密逻辑 + $decrypted = $service->decryptCallbackResource($resource); + + $outBillNo = $decrypted['out_bill_no'] ?? ''; + $state = $decrypted['state'] ?? ''; + $transferBillNo = $decrypted['transfer_bill_no'] ?? null; + + if (!preg_match('/^TX(\d+)$/', (string) $outBillNo, $m)) { + throw new \Exception('invalid out_bill_no: ' . $outBillNo); + } + $withdrawId = (int) $m[1]; + + $now = time(); + if ($state === 'SUCCESS') { + // 微信转账成功:仅允许从「待收款 status=2」更新为「已收款 status=3」 + Db::name('distribution_withdrawals') + ->where('id', $withdrawId) + ->where('status', 2) + ->update([ + 'status' => 3, + 'wechat_pay_state' => $state, + 'transfer_bill_no' => $transferBillNo, + 'transferAt' => $now, + 'updatedAt' => $now, + ]); + } elseif ($state === 'FAIL') { + // 转账失败:退回余额 + $record = Db::name('distribution_withdrawals')->where('id', $withdrawId)->find(); + if ($record && (int)$record['status'] !== 3) { + Db::startTrans(); + try { + Db::name('wechat_users') + ->where('id', $record['userId']) + ->inc('walletBalance', (int) $record['amountFen']) + ->update(['updatedAt' => $now]); + + Db::name('distribution_withdrawals') + ->where('id', $withdrawId) + ->update([ + // 1=已驳回 + 'status' => 1, + 'auditNote' => '微信转账失败自动退回', + 'wechat_pay_state' => $state, + 'transfer_bill_no' => $transferBillNo, + 'updatedAt' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + Log::error('[WechatTransferNotify] fail rollback error: ' . $e->getMessage()); + } + } + } + + return json(['code' => 'SUCCESS']); + } catch (\Throwable $e) { + Log::error('[WechatTransferNotify] error: ' . $e->getMessage()); + return json(['code' => 'FAIL', 'message' => '处理失败'])->code(500); + } + } +} + diff --git a/api/app/controller/superadmin/AiConfig.php b/api/app/controller/superadmin/AiConfig.php new file mode 100644 index 0000000..9eb0f09 --- /dev/null +++ b/api/app/controller/superadmin/AiConfig.php @@ -0,0 +1,482 @@ +request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + // 列表只返回“显示”的配置(visible=1 或未设);隐藏的由数据库 visible=0 控制,不在此列表展示 + $providers = AiProviderModel::order('id', 'asc') + ->whereRaw('(visible IS NULL OR visible = 1)') + ->select() + ->toArray(); + + // 处理返回数据 + $result = []; + foreach ($providers as $provider) { + $result[] = [ + 'id' => $provider['providerId'], + 'name' => $provider['name'], + 'enabled' => $provider['enabled'] == 1, + 'visible' => isset($provider['visible']) ? ($provider['visible'] == 1) : true, + 'apiKey' => $provider['apiKey'] ?? '', // 脱敏后的密钥 + 'apiEndpoint' => $provider['apiEndpoint'] ?? '', + 'model' => $provider['model'] ?? '', + 'organizationId' => $provider['organizationId'] ?? '', + 'maxTokens' => $provider['maxTokens'] ?? 4096, + 'balanceAlertEnabled' => $provider['balanceAlertEnabled'] == 1, + 'balanceAlertThreshold' => floatval($provider['balanceAlertThreshold'] ?? 10), + 'notes' => $provider['notes'] ?? '', + 'docUrl' => $provider['docUrl'] ?? '', + 'isFree' => $provider['isFree'] == 1, + 'supportsBalance' => $provider['supportsBalance'] == 1, + '_hasKey' => !empty($provider['apiKey']), + 'lastBalance' => $provider['lastBalance'] ? floatval($provider['lastBalance']) : null, + 'lastBalanceCurrency' => $provider['lastBalanceCurrency'] ?? null, + 'lastBalanceCheckedAt' => $provider['lastBalanceCheckedAt'] ? date('Y-m-d H:i:s', $provider['lastBalanceCheckedAt']) : null, + 'extraConfig' => is_array($provider['extraConfig'] ?? null) ? $provider['extraConfig'] : (isset($provider['extraConfig']) && is_string($provider['extraConfig']) ? (json_decode($provider['extraConfig'], true) ?: []) : []) + ]; + } + + return success($result); + } + + /** + * 更新AI服务商配置 + * @return \think\response\Json + */ + public function update() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $providerId = Request::param('providerId', ''); + $data = Request::only([ + 'name', 'enabled', 'visible', 'apiKey', 'apiEndpoint', 'model', 'organizationId', + 'maxTokens', 'balanceAlertEnabled', 'balanceAlertThreshold', 'notes', + 'extraConfig' + ]); + + if (empty($providerId)) { + return error('服务商ID不能为空', 400); + } + + // 查找服务商配置 + $provider = AiProviderModel::where('providerId', $providerId)->find(); + + if (!$provider) { + return error('服务商配置不存在', 404); + } + + // 处理 enabled 字段(前端传的是布尔值) + if (isset($data['enabled'])) { + $data['enabled'] = $data['enabled'] ? 1 : 0; + } + + // 处理 balanceAlertEnabled 字段 + if (isset($data['balanceAlertEnabled'])) { + $data['balanceAlertEnabled'] = $data['balanceAlertEnabled'] ? 1 : 0; + } + + // 处理 visible 字段(显示/隐藏,数据库直接控制) + if (isset($data['visible'])) { + $data['visible'] = $data['visible'] ? 1 : 0; + } + + // extraConfig 可为数组或 JSON 字符串,模型 type=json 会处理 + if (isset($data['extraConfig']) && is_string($data['extraConfig'])) { + $decoded = json_decode($data['extraConfig'], true); + $data['extraConfig'] = is_array($decoded) ? $decoded : []; + } + + // 如果API Key为空或包含脱敏标记(****),不更新(保持原值) + if (isset($data['apiKey'])) { + if (empty($data['apiKey']) || strpos($data['apiKey'], '****') !== false) { + unset($data['apiKey']); + } + } + + // 更新配置 + $provider->save($data); + + // 返回更新后的数据(脱敏) + $result = [ + 'id' => $provider->providerId, + 'name' => $provider->name, + 'enabled' => $provider->enabled == 1, + 'visible' => isset($provider->visible) ? ($provider->visible == 1) : true, + 'apiKey' => $provider->apiKey ?? '', + 'apiEndpoint' => $provider->apiEndpoint ?? '', + 'model' => $provider->model ?? '', + 'organizationId' => $provider->organizationId ?? '', + 'maxTokens' => $provider->maxTokens ?? 4096, + 'balanceAlertEnabled' => $provider->balanceAlertEnabled == 1, + 'balanceAlertThreshold' => floatval($provider->balanceAlertThreshold ?? 10), + 'notes' => $provider->notes ?? '', + 'isFree' => $provider->isFree == 1, + 'supportsBalance' => $provider->supportsBalance == 1, + '_hasKey' => !empty($provider->apiKey), + 'extraConfig' => $provider->extraConfig ?? [] + ]; + + return success($result, '保存成功'); + } + + /** + * 批量更新AI服务商配置 + * @return \think\response\Json + */ + public function batchUpdate() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $providers = Request::param('providers', []); + + if (empty($providers) || !is_array($providers)) { + return error('配置数据不能为空', 400); + } + + $successCount = 0; + $errors = []; + + Db::startTrans(); + try { + foreach ($providers as $providerData) { + $providerId = $providerData['id'] ?? $providerData['providerId'] ?? ''; + + if (empty($providerId)) { + $errors[] = '服务商ID不能为空'; + continue; + } + + $provider = AiProviderModel::where('providerId', $providerId)->find(); + + if (!$provider) { + $errors[] = "服务商 {$providerId} 不存在"; + continue; + } + + // 准备更新数据 + $updateData = []; + if (isset($providerData['enabled'])) { + $updateData['enabled'] = $providerData['enabled'] ? 1 : 0; + } + if (isset($providerData['apiKey']) && !empty($providerData['apiKey'])) { + $updateData['apiKey'] = $providerData['apiKey']; + } + if (isset($providerData['apiEndpoint'])) { + $updateData['apiEndpoint'] = $providerData['apiEndpoint']; + } + if (isset($providerData['model'])) { + $updateData['model'] = $providerData['model']; + } + if (isset($providerData['organizationId'])) { + $updateData['organizationId'] = $providerData['organizationId']; + } + if (isset($providerData['maxTokens'])) { + $updateData['maxTokens'] = intval($providerData['maxTokens']); + } + if (isset($providerData['balanceAlertEnabled'])) { + $updateData['balanceAlertEnabled'] = $providerData['balanceAlertEnabled'] ? 1 : 0; + } + if (isset($providerData['balanceAlertThreshold'])) { + $updateData['balanceAlertThreshold'] = floatval($providerData['balanceAlertThreshold']); + } + if (isset($providerData['notes'])) { + $updateData['notes'] = $providerData['notes']; + } + if (isset($providerData['visible'])) { + $updateData['visible'] = $providerData['visible'] ? 1 : 0; + } + if (isset($providerData['extraConfig'])) { + $updateData['extraConfig'] = is_array($providerData['extraConfig']) + ? $providerData['extraConfig'] + : (is_string($providerData['extraConfig']) ? json_decode($providerData['extraConfig'], true) : []); + if (!is_array($updateData['extraConfig'])) { + $updateData['extraConfig'] = []; + } + } + + $provider->save($updateData); + $successCount++; + } + + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + return error('批量保存失败:' . $e->getMessage(), 500); + } + + if (!empty($errors)) { + return error('部分配置保存失败:' . implode(';', $errors), 400); + } + + return success(null, "成功保存 {$successCount} 个配置"); + } + + /** + * 查询余额(单个服务商) + * @return \think\response\Json + */ + public function queryBalance() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $providerId = Request::param('providerId', ''); + + if (empty($providerId)) { + return error('服务商ID不能为空', 400); + } + + $provider = AiProviderModel::where('providerId', $providerId)->find(); + + if (!$provider) { + return error('服务商配置不存在', 404); + } + + if (empty($provider->apiKey)) { + return error('请先配置 API Key', 400); + } + + if (!$provider->supportsBalance) { + return error('该服务商暂不支持余额查询', 400); + } + + // 调用余额查询服务 + $balanceResult = $this->queryProviderBalance($provider); + + // 更新最后查询的余额 + if ($balanceResult['status'] === 'success' && isset($balanceResult['balance'])) { + $provider->lastBalance = $balanceResult['balance']; + $provider->lastBalanceCurrency = $balanceResult['currency'] ?? 'CNY'; + $provider->lastBalanceCheckedAt = time(); + $provider->save(); + } + + return success($balanceResult); + } + + /** + * 批量查询余额 + * @return \think\response\Json + */ + public function queryAllBalances() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $providerIds = Request::param('providerIds', []); + + // 如果没有指定,查询所有已启用且已配置密钥的服务商 + if (empty($providerIds)) { + $providers = AiProviderModel::where('enabled', 1) + ->where('apiKey', '<>', '') + ->where('apiKey', '<>', null) + ->select(); + } else { + $providers = AiProviderModel::where('providerId', 'in', $providerIds) + ->where('apiKey', '<>', '') + ->where('apiKey', '<>', null) + ->select(); + } + + $results = []; + foreach ($providers as $provider) { + if (!$provider->supportsBalance) { + continue; + } + + $balanceResult = $this->queryProviderBalance($provider); + + // 更新最后查询的余额 + if ($balanceResult['status'] === 'success' && isset($balanceResult['balance'])) { + $provider->lastBalance = $balanceResult['balance']; + $provider->lastBalanceCurrency = $balanceResult['currency'] ?? 'CNY'; + $provider->lastBalanceCheckedAt = time(); + $provider->save(); + } + + $results[] = $balanceResult; + } + + return success($results); + } + + /** + * 查询服务商余额(内部方法) + * @param AiProviderModel $provider + * @return array + */ + private function queryProviderBalance($provider) + { + // 这里需要实现各服务商的余额查询逻辑 + // 由于各服务商的API不同,这里提供一个基础框架 + + $providerId = $provider->providerId; + $apiKey = $provider->getRawApiKey(); // 获取原始密钥用于API调用 + + // TODO: 实现各服务商的余额查询API调用 + // 目前返回模拟数据,实际需要调用各服务商的API + + try { + switch ($providerId) { + case 'openai': + // OpenAI余额查询逻辑 + return $this->queryOpenAIBalance($apiKey); + + case 'deepseek': + // DeepSeek余额查询逻辑 + return $this->queryDeepSeekBalance($apiKey); + + case 'moonshot': + // Moonshot余额查询逻辑 + return $this->queryMoonshotBalance($apiKey); + + default: + return [ + 'providerId' => $providerId, + 'providerName' => $provider->name, + 'status' => 'unsupported', + 'message' => '该服务商暂不支持余额查询', + 'balance' => null, + 'currency' => null, + 'checkedAt' => date('Y-m-d H:i:s') + ]; + } + } catch (\Exception $e) { + return [ + 'providerId' => $providerId, + 'providerName' => $provider->name, + 'status' => 'error', + 'message' => '查询失败:' . $e->getMessage(), + 'balance' => null, + 'currency' => null, + 'checkedAt' => date('Y-m-d H:i:s') + ]; + } + } + + /** + * 查询OpenAI余额 + * @param string $apiKey + * @return array + */ + private function queryOpenAIBalance($apiKey) + { + // TODO: 实现OpenAI余额查询 + // OpenAI没有直接的余额查询API,需要通过使用情况估算 + return [ + 'providerId' => 'openai', + 'providerName' => 'OpenAI (GPT)', + 'status' => 'success', + 'message' => '余额查询成功:$100.00', + 'balance' => 100.00, + 'currency' => 'USD', + 'checkedAt' => date('Y-m-d H:i:s') + ]; + } + + /** + * 查询DeepSeek余额 + * @param string $apiKey + * @return array + */ + private function queryDeepSeekBalance($apiKey) + { + // TODO: 实现DeepSeek余额查询 + try { + // 示例:调用DeepSeek API查询余额 + // $response = file_get_contents('https://api.deepseek.com/v1/balance', [ + // 'http' => [ + // 'method' => 'GET', + // 'header' => "Authorization: Bearer {$apiKey}\r\n" + // ] + // ]); + + return [ + 'providerId' => 'deepseek', + 'providerName' => 'DeepSeek', + 'status' => 'success', + 'message' => '余额查询成功:¥500.00', + 'balance' => 500.00, + 'currency' => 'CNY', + 'checkedAt' => date('Y-m-d H:i:s') + ]; + } catch (\Exception $e) { + return [ + 'providerId' => 'deepseek', + 'providerName' => 'DeepSeek', + 'status' => 'error', + 'message' => '查询失败:' . $e->getMessage(), + 'balance' => null, + 'currency' => null, + 'checkedAt' => date('Y-m-d H:i:s') + ]; + } + } + + /** + * 查询Moonshot余额 + * @param string $apiKey + * @return array + */ + private function queryMoonshotBalance($apiKey) + { + // TODO: 实现Moonshot余额查询 + try { + // 示例:调用Moonshot API查询余额 + return [ + 'providerId' => 'moonshot', + 'providerName' => 'Moonshot (Kimi)', + 'status' => 'success', + 'message' => '余额查询成功:¥200.00', + 'balance' => 200.00, + 'currency' => 'CNY', + 'checkedAt' => date('Y-m-d H:i:s') + ]; + } catch (\Exception $e) { + return [ + 'providerId' => 'moonshot', + 'providerName' => 'Moonshot (Kimi)', + 'status' => 'error', + 'message' => '查询失败:' . $e->getMessage(), + 'balance' => null, + 'currency' => null, + 'checkedAt' => date('Y-m-d H:i:s') + ]; + } + } +} + diff --git a/api/app/controller/superadmin/AppUser.php b/api/app/controller/superadmin/AppUser.php new file mode 100644 index 0000000..2a983e8 --- /dev/null +++ b/api/app/controller/superadmin/AppUser.php @@ -0,0 +1,525 @@ +request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + // 用户数按 openid 去重 + try { + $totalUsers = (int) Db::name('wechat_users')->count('openid', true); + } catch (\Throwable $e) { + $totalUsers = (int) Db::name('wechat_users')->count(); + } + $last30d = time() - 30 * 86400; + + // 全部池:去重后的测试用户 & 近 30 天活跃用户(按 userId 去重) + // 这里使用逻辑表名 test_results,底层会自动加前缀生成 mbti_test_results + $testedUserIds = Db::name('test_results')->distinct(true)->column('userId'); + $testedUsers = count(array_filter($testedUserIds)); + + $activeUserIds = Db::name('test_results') + ->where('createdAt', '>=', $last30d) + ->distinct(true) + ->column('userId'); + $activeUsers = count(array_filter($activeUserIds)); + + $userCards = [ + [ + 'type' => 'all', + 'name' => '全部用户', + 'total' => $totalUsers, + 'active' => $activeUsers, + 'tested' => $testedUsers + ] + ]; + + try { + // 个人池:enterpriseId 为空的测试用户,按 userId 去重 + $individualIds = Db::name('test_results') + ->where(function ($q) { + $q->whereNull('enterpriseId')->whereOr('enterpriseId', ''); + }) + ->distinct(true) + ->column('userId'); + $individualTotal = count(array_filter($individualIds)); + + $individualActiveIds = Db::name('test_results') + ->where('createdAt', '>=', $last30d) + ->where(function ($q) { + $q->whereNull('enterpriseId')->whereOr('enterpriseId', ''); + }) + ->distinct(true) + ->column('userId'); + $individualActive = count(array_filter($individualActiveIds)); + $userCards[] = [ + 'type' => 'individual', + 'name' => '个人用户(无企业)', + 'total' => $individualTotal, + 'active' => $individualActive, + 'tested' => $individualTotal + ]; + } catch (\Throwable $e) { + $userCards[] = [ + 'type' => 'individual', + 'name' => '个人用户(无企业)', + 'total' => 0, + 'active' => 0, + 'tested' => 0 + ]; + } + + $enterprises = Db::name('enterprises')->field('id,name')->select()->toArray(); + foreach ($enterprises as $e) { + $eid = $e['id']; + try { + $ids = Db::name('test_results') + ->where('enterpriseId', $eid) + ->distinct(true) + ->column('userId'); + $total = count(array_filter($ids)); + + $activeIds = Db::name('test_results') + ->where('enterpriseId', $eid) + ->where('createdAt', '>=', $last30d) + ->distinct(true) + ->column('userId'); + $active = count(array_filter($activeIds)); + } catch (\Throwable $ex) { + $total = 0; + $active = 0; + } + $userCards[] = [ + 'type' => 'enterprise', + 'enterpriseId' => $eid, + 'name' => $e['name'] ?? ('企业' . $eid), + 'total' => $total, + 'active' => $active, + 'tested' => $total + ]; + } + + // MBTI 类型分布:按用户去重,每人只计其最新一次 MBTI 结果 + $mbtiTypes = []; + try { + $rows = Db::name('test_results') + ->where('testType', 'mbti') + ->field('userId, resultData, createdAt') + ->order('createdAt', 'desc') + ->select() + ->toArray(); + $seenUserIds = []; + foreach ($rows as $r) { + $uid = (int) ($r['userId'] ?? 0); + if ($uid <= 0 || isset($seenUserIds[$uid])) { + continue; + } + $raw = $r['resultData'] ?? ''; + $dec = is_string($raw) ? json_decode($raw, true) : (is_array($raw) ? $raw : null); + if (!is_array($dec)) { + $seenUserIds[$uid] = true; + continue; + } + $type = ''; + if (isset($dec['mbtiType'])) { + $type = $dec['mbtiType']; + } elseif (isset($dec['mbti']['type'])) { + $type = $dec['mbti']['type']; + } elseif (isset($dec['type'])) { + $type = $dec['type']; + } + $type = strtoupper(trim((string) $type)); + $seenUserIds[$uid] = true; + if ($type === '') { + continue; + } + $mbtiTypes[$type] = ($mbtiTypes[$type] ?? 0) + 1; + } + } catch (\Throwable $e) { + // ignore + } + $mbtiDistribution = []; + foreach ($mbtiTypes as $type => $count) { + $mbtiDistribution[] = ['type' => $type, 'count' => $count]; + } + + return success([ + 'totalUsers' => $totalUsers, + 'testedUsers' => $testedUsers, + 'activeUsers' => $activeUsers, + 'userCards' => $userCards, + 'mbtiDistribution' => $mbtiDistribution + ]); + } + + /** + * 测试用户列表:分页、关键词、池筛选、MBTI 筛选 + * GET /api/v1/superadmin/app-users?page=1&pageSize=20&keyword=&pool=all|individual|enterprise&enterpriseId=&mbti= + */ + public function index() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + $page = (int) Request::param('page', 1); + $pageSize = (int) Request::param('pageSize', 20); + $pageSize = min(max($pageSize, 1), 100); + $keyword = trim(Request::param('keyword', '')); + $pool = Request::param('pool', 'all'); + $enterpriseId = Request::param('enterpriseId', ''); + $mbti = trim(Request::param('mbti', '')); + + $where = []; + if ($keyword !== '') { + $where[] = ['nickname|phone|city|province', 'like', '%' . $keyword . '%']; + } + + $wechatIds = null; + if ($pool === 'individual' || ($pool === 'enterprise' && $enterpriseId !== '')) { + try { + $trQuery = Db::name('test_results'); + if ($pool === 'individual') { + $trQuery->where(function ($q) { + $q->whereNull('enterpriseId')->whereOr('enterpriseId', ''); + }); + } else { + $trQuery->where('enterpriseId', $enterpriseId); + } + $wechatIds = $trQuery->distinct(true)->column('userId'); + $wechatIds = array_values(array_unique(array_filter($wechatIds))); + } catch (\Throwable $e) { + $wechatIds = null; + } + } + if ($mbti !== '') { + $mbtiUserIds = Db::name('test_results')->where('testType', 'mbti')->distinct(true)->column('userId'); + $mbtiUserIds = array_values(array_unique(array_filter($mbtiUserIds))); + if ($wechatIds !== null) { + $wechatIds = array_values(array_intersect($wechatIds, $mbtiUserIds)); + } else { + $wechatIds = $mbtiUserIds; + } + } + + // 按 openid 去重:每个 openid 只保留 id 最大的一条;失败则不去重 + try { + $dedupIds = Db::name('wechat_users')->field('openid, MAX(id) as mid')->group('openid')->column('mid'); + $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : []; + } catch (\Throwable $e) { + $dedupIds = Db::name('wechat_users')->column('id'); + $dedupIds = $dedupIds ? array_values(array_filter($dedupIds)) : []; + } + if (empty($dedupIds)) { + return paginate_response([], 0, $page, $pageSize); + } + + $baseQuery = Db::name('wechat_users')->where('id', 'in', $dedupIds); + if ($where) { + $baseQuery->where($where); + } + if ($wechatIds !== null && !empty($wechatIds)) { + $baseQuery->where('id', 'in', array_intersect($dedupIds, $wechatIds)); + } elseif ($wechatIds !== null && empty($wechatIds)) { + return paginate_response([], 0, $page, $pageSize); + } + + $total = $baseQuery->count(); + $list = (clone $baseQuery) + ->field('id,openid,nickname,avatar,phone,gender,country,province,city,status,lastLoginAt,createdAt') + ->order('createdAt', 'desc') + ->page($page, $pageSize) + ->select() + ->toArray(); + + $ids = array_column($list, 'id'); + $testCounts = []; + $lastTestAt = []; + $testTypes = []; + $userEnterprise = []; + $payStats = []; + if (!empty($ids)) { + $counts = Db::name('test_results')->where('userId', 'in', $ids)->group('userId')->column('COUNT(*) as cnt', 'userId'); + $testCounts = $counts ?: []; + $lastRows = Db::name('test_results') + ->where('userId', 'in', $ids) + ->field('id, userId, testType, resultData, createdAt') + ->order('createdAt', 'desc') + ->select(); + foreach ($lastRows as $row) { + $uid = $row['userId']; + if (!isset($lastTestAt[$uid])) { + $lastTestAt[$uid] = $row['createdAt']; + } + if (!isset($testTypes[$uid])) { + $testTypes[$uid] = []; + } + $testTypes[$uid][] = [ + 'testType' => $row['testType'], + 'result' => is_string($row['resultData'] ?? '') ? ($row['resultData'] ?? '') : json_encode($row['resultData'] ?? '', JSON_UNESCAPED_UNICODE), + 'createdAt' => $row['createdAt'], + ]; + } + try { + $trWithE = Db::name('test_results') + ->where('userId', 'in', $ids) + ->where('enterpriseId', '<>', null) + ->where('enterpriseId', '<>', '') + ->field('userId, enterpriseId') + ->select(); + $eids = array_unique(array_filter(array_column($trWithE, 'enterpriseId'))); + $enterpriseNames = []; + if (!empty($eids)) { + $enterpriseNames = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id'); + } + foreach ($trWithE as $r) { + if (!isset($userEnterprise[$r['userId']])) { + $userEnterprise[$r['userId']] = $enterpriseNames[$r['enterpriseId']] ?? ('企业' . $r['enterpriseId']); + } + } + } catch (\Throwable $e) { + // test_results 可能无 enterpriseId 列 + } + foreach ($ids as $uid) { + if (!isset($userEnterprise[$uid])) { + $userEnterprise[$uid] = '个人用户(无企业)'; + } + } + + // 从用户画像表汇总支付统计(付款次数与总金额) + try { + $profiles = Db::name('user_profile') + ->where('userId', 'in', $ids) + ->field('userId, SUM(paidOrders) AS paidOrders, SUM(totalPaidAmount) AS totalPaidAmount') + ->group('userId') + ->select() + ->toArray(); + foreach ($profiles as $p) { + $uid = (int) ($p['userId'] ?? 0); + if ($uid <= 0) { + continue; + } + $payStats[$uid] = [ + 'paidOrders' => (int) ($p['paidOrders'] ?? 0), + 'totalPaidAmount' => (int) ($p['totalPaidAmount'] ?? 0), + ]; + } + } catch (\Throwable $e) { + $payStats = []; + } + } + + foreach ($list as &$row) { + $id = $row['id']; + $testsForUser = $testTypes[$id] ?? []; + $row['username'] = $row['nickname'] ?? ('用户' . $id); + $row['testCount'] = (int) ($testCounts[$id] ?? 0); + $row['lastTestAt'] = $lastTestAt[$id] ?? null; + $row['tests'] = $testsForUser; + $row['mbtiType'] = $this->extractResultType($testsForUser, 'mbti'); + $row['pdpType'] = $this->extractResultType($testsForUser, 'pdp'); + $row['discType'] = $this->extractResultType($testsForUser, 'disc'); + $row['faceType'] = $this->extractResultType($testsForUser, 'face'); + $row['faceMbtiType'] = $this->extractFaceSubType($testsForUser, 'mbti'); + $row['faceDiscType'] = $this->extractFaceSubType($testsForUser, 'disc'); + $row['facePdpType'] = $this->extractFaceSubType($testsForUser, 'pdp'); + $row['enterprise'] = $userEnterprise[$id] ?? '个人用户(无企业)'; + + $pay = $payStats[$id] ?? null; + $totalPaidFen = $pay ? (int) ($pay['totalPaidAmount'] ?? 0) : 0; + $row['paidOrders'] = $pay ? (int) ($pay['paidOrders'] ?? 0) : 0; + $row['totalPaidAmount'] = $totalPaidFen; + $row['totalPaidAmountYuan'] = $totalPaidFen > 0 ? round($totalPaidFen / 100, 2) : 0; + } + + return paginate_response($list, $total, $page, $pageSize); + } + + /** + * 测试用户详情 + * GET /api/v1/superadmin/app-users/:id + */ + public function detail($id) + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + $row = Db::name('wechat_users')->where('id', $id)->find(); + if (!$row) { + return error('用户不存在', 404); + } + + $data = [ + 'id' => (int) $row['id'], + 'username' => $row['nickname'] ?? ('用户' . $row['id']), + 'nickname' => $row['nickname'] ?? '', + 'avatar' => $row['avatar'] ?? '', + 'phone' => $row['phone'] ?? '', + 'email' => '', + 'gender' => (int) ($row['gender'] ?? 0), + 'country' => $row['country'] ?? '', + 'province' => $row['province'] ?? '', + 'city' => $row['city'] ?? '', + 'status' => (int) ($row['status'] ?? 1), + 'lastLoginAt' => isset($row['lastLoginAt']) ? (int) $row['lastLoginAt'] : null, + 'createdAt' => isset($row['createdAt']) ? (int) $row['createdAt'] : null, + 'updatedAt' => isset($row['updatedAt']) ? (int) $row['updatedAt'] : null, + ]; + + $tests = Db::name('test_results') + ->where('userId', $id) + ->field('id, testType, resultData, createdAt, requiresPayment, isPaid, paidAmount, paidAt, orderId') + ->order('createdAt', 'desc') + ->select() + ->toArray(); + foreach ($tests as &$t) { + $raw = $t['resultData'] ?? ''; + $t['result'] = is_string($raw) ? $raw : json_encode($raw, JSON_UNESCAPED_UNICODE); + } + + $data['testCount'] = count($tests); + $data['testList'] = $tests; + $data['mbtiType'] = $this->extractResultType($tests, 'mbti'); + $data['pdpType'] = $this->extractResultType($tests, 'pdp'); + $data['discType'] = $this->extractResultType($tests, 'disc'); + $data['faceType'] = $this->extractResultType($tests, 'face'); + $data['faceMbtiType'] = $this->extractFaceSubType($tests, 'mbti'); + $data['faceDiscType'] = $this->extractFaceSubType($tests, 'disc'); + $data['facePdpType'] = $this->extractFaceSubType($tests, 'pdp'); + + return success($data); + } + + private function parseMbtiFromResult($result): string + { + if (!is_string($result)) return ''; + $dec = json_decode($result, true); + if (is_array($dec)) { + return (string) ($dec['type'] ?? $dec['result'] ?? $dec['mbtiType'] ?? ''); + } + return trim($result); + } + + private function extractResultType(array $tests, string $type): string + { + $targetType = strtolower($type); + foreach ($tests as $t) { + if (strtolower($t['testType'] ?? '') !== $targetType) { + continue; + } + $result = $t['result'] ?? ''; + if (!is_string($result)) { + continue; + } + $dec = json_decode($result, true); + if (!is_array($dec)) { + // 无法解析 JSON 时,直接返回原始字符串 + return $targetType === 'face' ? '人脸分析' : trim($result); + } + + // 人脸分析:有记录就返回固定标签 + if ($targetType === 'face') { + return '人脸分析'; + } + + // MBTI:直接读 mbtiType/type + if ($targetType === 'mbti') { + return (string) ($dec['mbtiType'] ?? $dec['type'] ?? $dec['result'] ?? ''); + } + + // DISC:优先 description.type,然后 dominantType + if ($targetType === 'disc') { + $desc = $dec['description']['type'] ?? null; + if (is_string($desc) && $desc !== '') { + return $desc; + } + if (!empty($dec['dominantType'])) { + return (string) $dec['dominantType']; + } + return (string) ($dec['disc'] ?? ''); + } + + // PDP:优先 description.type,然后 dominantType + if ($targetType === 'pdp') { + $desc = $dec['description']['type'] ?? null; + if (is_string($desc) && $desc !== '') { + return $desc; + } + if (!empty($dec['dominantType'])) { + return (string) $dec['dominantType']; + } + return (string) ($dec['pdp'] ?? ''); + } + + // 兜底:尝试常见字段 + return (string) ($dec['type'] ?? $dec['result'] ?? ''); + } + return ''; + } + + /** + * 从人脸分析结果中提取对应的 MBTI / DISC / PDP 文本 + */ + private function extractFaceSubType(array $tests, string $subType): string + { + $target = strtolower($subType); + foreach ($tests as $t) { + if (strtolower($t['testType'] ?? '') !== 'face') { + continue; + } + $result = $t['result'] ?? ''; + if (!is_string($result)) { + continue; + } + $dec = json_decode($result, true); + if (!is_array($dec)) { + continue; + } + + if ($target === 'mbti') { + if (!empty($dec['mbti']['type'])) { + return (string) $dec['mbti']['type']; + } + if (!empty($dec['mbtiType'])) { + return (string) $dec['mbtiType']; + } + } elseif ($target === 'disc') { + if (!empty($dec['disc']['primary'])) { + return (string) $dec['disc']['primary']; + } + if (!empty($dec['disc'])) { + return (string) $dec['disc']; + } + } elseif ($target === 'pdp') { + if (!empty($dec['pdp']['primary'])) { + return (string) $dec['pdp']['primary']; + } + if (!empty($dec['pdp'])) { + return (string) $dec['pdp']; + } + } + } + return ''; + } + +} diff --git a/api/app/controller/superadmin/Auth.php b/api/app/controller/superadmin/Auth.php new file mode 100644 index 0000000..69c56bc --- /dev/null +++ b/api/app/controller/superadmin/Auth.php @@ -0,0 +1,140 @@ +where('username', $username) + ->where('role', 'superadmin') + ->find(); + + if (!$user) { + return error('用户名或密码错误', 401); + } + + // 验证密码 + if (!password_verify($password, $user['password'])) { + return error('用户名或密码错误', 401); + } + + // 检查账号状态 + if ($user['status'] != 1) { + return error('账号已被禁用', 403); + } + + // 更新登录信息 + Db::name('users') + ->where('id', $user['id']) + ->update([ + 'lastLoginTime' => time(), + 'lastLoginIp' => Request::ip(), + 'updatedAt' => time() + ]); + + // 生成Token + $payload = [ + 'userId' => $user['id'], + 'username' => $user['username'], + 'role' => $user['role'] + ]; + + $token = JwtService::generateToken($payload); + + unset($user['password']); + + return success([ + 'token' => $token, + 'expiresIn' => config('jwt.expire'), + 'user' => $user + ], '登录成功'); + } + + /** + * 获取当前登录超级管理员信息(需要认证) + * @return \think\response\Json + */ + public function me() + { + $user = $this->request->user ?? null; + + if (!$user) { + return error('未登录', 401); + } + + // 验证是否为超级管理员 + if ($user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $userModel = Db::name('users')->where('id', $user['userId'])->find(); + if (!$userModel) { + return error('用户不存在', 404); + } + + unset($userModel['password']); + + return success($userModel); + } + + /** + * 退出登录(需要认证) + * @return \think\response\Json + */ + public function logout() + { + $user = $this->request->user ?? null; + + if ($user && isset($user['userId'])) { + JwtService::deleteToken($user['userId']); + } + + return success(null, '退出成功'); + } + + /** + * 刷新Token + * @return \think\response\Json + */ + public function refresh() + { + $token = JwtService::getTokenFromRequest($this->request); + + if (!$token) { + return error('未提供Token', 401); + } + + $newToken = JwtService::refreshToken($token); + + if (!$newToken) { + return error('Token无效或已过期', 401); + } + + return success([ + 'token' => $newToken, + 'expiresIn' => config('jwt.expire') + ], '刷新成功'); + } +} + diff --git a/api/app/controller/superadmin/Database.php b/api/app/controller/superadmin/Database.php new file mode 100644 index 0000000..7a51eea --- /dev/null +++ b/api/app/controller/superadmin/Database.php @@ -0,0 +1,750 @@ +request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + // 获取数据库配置 + $config = Config::get('database.connections.mysql'); + $database = $config['database'] ?? ''; + + // 获取数据库大小 + $dbSize = $this->getDatabaseSize($database); + + // 获取表数量 + $tableCount = $this->getTableCount($database); + + // 获取连接状态 + try { + Db::query('SELECT 1'); + $connected = true; + } catch (\Exception $e) { + $connected = false; + } + + return success([ + 'databaseType' => 'MySQL', + 'databaseName' => $database, + 'connected' => $connected, + 'databaseSize' => $dbSize, + 'tableCount' => $tableCount + ]); + } catch (\Exception $e) { + return error('获取数据库信息失败:' . $e->getMessage(), 500); + } + } + + /** + * 获取表列表 + * @return \think\response\Json + */ + public function tables() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $config = Config::get('database.connections.mysql'); + $database = $config['database'] ?? ''; + $prefix = $config['prefix'] ?? 'mbti_'; + + // 获取所有表 + $tables = Db::query("SHOW TABLE STATUS FROM `{$database}`"); + + $result = []; + foreach ($tables as $table) { + $tableName = $table['Name']; + + // 只显示带前缀的表(或者所有表) + if (empty($prefix) || strpos($tableName, $prefix) === 0) { + // 获取记录数 + $rowCount = Db::query("SELECT COUNT(*) as count FROM `{$tableName}`")[0]['count'] ?? 0; + + // 获取索引数 + $indexes = Db::query("SHOW INDEX FROM `{$tableName}`"); + $indexCount = count(array_unique(array_column($indexes, 'Key_name'))); + + $result[] = [ + 'name' => $tableName, + 'docCount' => intval($rowCount), + 'size' => intval($table['Data_length'] + $table['Index_length']), + 'indexCount' => $indexCount, + 'engine' => $table['Engine'] ?? '', + 'collation' => $table['Collation'] ?? '' + ]; + } + } + + return success($result); + } catch (\Exception $e) { + return error('获取表列表失败:' . $e->getMessage(), 500); + } + } + + /** + * 查看表数据 + * @return \think\response\Json + */ + public function viewTable() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $tableName = Request::param('table', ''); + $page = Request::param('page', 1); + $pageSize = Request::param('pageSize', 20); + + if (empty($tableName)) { + return error('表名不能为空', 400); + } + + try { + // 验证表是否存在 + $config = Config::get('database.connections.mysql'); + $database = $config['database'] ?? ''; + $tables = Db::query("SHOW TABLES FROM `{$database}` LIKE '{$tableName}'"); + + if (empty($tables)) { + return error('表不存在', 404); + } + + // 获取表结构 + $columns = Db::query("SHOW COLUMNS FROM `{$tableName}`"); + + // 获取数据 + $total = Db::name(str_replace($config['prefix'] ?? 'mbti_', '', $tableName))->count(); + $list = Db::name(str_replace($config['prefix'] ?? 'mbti_', '', $tableName)) + ->page($page, $pageSize) + ->select() + ->toArray(); + + return success([ + 'columns' => $columns, + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize + ]); + } catch (\Exception $e) { + return error('查看表数据失败:' . $e->getMessage(), 500); + } + } + + /** + * 导出表数据 + * @return \think\response\Json + */ + public function exportTable() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $tableName = Request::param('table', ''); + + if (empty($tableName)) { + return error('表名不能为空', 400); + } + + try { + // 生成SQL导出文件 + $backupDir = root_path() . 'runtime/backup/'; + if (!is_dir($backupDir)) { + mkdir($backupDir, 0755, true); + } + + $filename = $tableName . '_' . date('YmdHis') . '.sql'; + $filepath = $backupDir . $filename; + + $this->exportTableToSql($tableName, $filepath); + + return success([ + 'filename' => $filename, + 'filepath' => $filepath, + 'downloadUrl' => '/api/v1/superadmin/database/download?file=' . urlencode($filename) + ], '导出成功'); + } catch (\Exception $e) { + return error('导出表数据失败:' . $e->getMessage(), 500); + } + } + + /** + * 清空表数据 + * @return \think\response\Json + */ + public function clearTable() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $tableName = Request::param('table', ''); + + if (empty($tableName)) { + return error('表名不能为空', 400); + } + + try { + // 验证表是否存在 + $config = Config::get('database.connections.mysql'); + $database = $config['database'] ?? ''; + $tables = Db::query("SHOW TABLES FROM `{$database}` LIKE '{$tableName}'"); + + if (empty($tables)) { + return error('表不存在', 404); + } + + // 清空表 + Db::execute("TRUNCATE TABLE `{$tableName}`"); + + return success(null, '表数据已清空'); + } catch (\Exception $e) { + return error('清空表数据失败:' . $e->getMessage(), 500); + } + } + + /** + * 备份数据库 + * @return \think\response\Json + */ + public function backup() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $config = Config::get('database.connections.mysql'); + $host = $config['hostname'] ?? 'localhost'; + $port = $config['hostport'] ?? 3306; + $database = $config['database'] ?? ''; + $username = $config['username'] ?? ''; + $password = $config['password'] ?? ''; + + // 创建备份目录 + $backupDir = root_path() . 'runtime/backup/'; + if (!is_dir($backupDir)) { + mkdir($backupDir, 0755, true); + } + + $filename = 'backup_' . $database . '_' . date('YmdHis') . '.sql'; + $filepath = $backupDir . $filename; + + // 优先使用PHP方式备份(更可靠) + $this->backupDatabase($database, $filepath); + + // 如果文件不存在或为空,尝试使用mysqldump + if (!file_exists($filepath) || filesize($filepath) == 0) { + $mysqldumpPath = $this->findMysqldump(); + + if ($mysqldumpPath) { + // 使用mysqldump命令 + $command = sprintf( + '"%s" -h%s -P%s -u%s -p%s %s > "%s" 2>&1', + $mysqldumpPath, + escapeshellarg($host), + escapeshellarg($port), + escapeshellarg($username), + escapeshellarg($password), + escapeshellarg($database), + escapeshellarg($filepath) + ); + + exec($command, $output, $returnVar); + + if ($returnVar !== 0) { + throw new \Exception('mysqldump执行失败: ' . implode("\n", $output)); + } + } + } + + // 获取文件大小 + $fileSize = filesize($filepath); + + // 上传到OSS + $ossUrl = null; + $ossPath = null; + try { + $ossResult = $this->uploadBackupToOss($filepath, $filename); + if ($ossResult) { + $ossUrl = $ossResult['url']; + $ossPath = $ossResult['path']; + } + } catch (\Exception $e) { + // OSS上传失败不影响备份成功,只记录错误 + Log::error('备份文件上传OSS失败:' . $e->getMessage()); + } + + // 记录备份信息 + $this->saveBackupRecord($filename, $filepath, $fileSize, $ossUrl, $ossPath); + + return success([ + 'filename' => $filename, + 'filepath' => $filepath, + 'size' => $fileSize, + 'time' => date('Y-m-d H:i:s'), + 'ossUrl' => $ossUrl, + 'ossPath' => $ossPath, + 'downloadUrl' => '/api/v1/superadmin/database/download?file=' . urlencode($filename) + ], '备份成功' . ($ossUrl ? ',已上传到OSS' : '')); + } catch (\Exception $e) { + return error('备份失败:' . $e->getMessage(), 500); + } + } + + /** + * 获取备份记录列表 + * @return \think\response\Json + */ + public function backups() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + // 从数据库读取备份记录 + $records = BackupRecordModel::order('createdAt', 'desc')->select()->toArray(); + + $backups = []; + foreach ($records as $record) { + $backups[] = [ + 'id' => $record['id'], + 'filename' => $record['filename'], + 'time' => date('Y-m-d\TH:i:s', $record['createdAt']), + 'size' => intval($record['fileSize']), + 'status' => $record['status'] ?? 'success', + 'ossUrl' => $record['ossUrl'] ?? null, + 'ossPath' => $record['ossPath'] ?? null, + 'filepath' => $record['filepath'] ?? null + ]; + } + + return success($backups); + } catch (\Exception $e) { + return error('获取备份记录失败:' . $e->getMessage(), 500); + } + } + + /** + * 删除备份记录(软删除) + * @return \think\response\Json + */ + public function delete() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + // 支持从路由参数或请求参数获取ID + $id = Request::param('id', 0) ?: Request::route('id', 0); + + if (empty($id)) { + return error('记录ID不能为空', 400); + } + + try { + $record = BackupRecordModel::find($id); + + if (!$record) { + return error('备份记录不存在', 404); + } + + // 软删除(ThinkPHP的SoftDelete会自动设置deletedAt) + $record->delete(); + + return success(null, '备份记录已删除'); + } catch (\Exception $e) { + return error('删除失败:' . $e->getMessage(), 500); + } + } + + /** + * 下载备份文件 + */ + public function download() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $filename = Request::param('file', ''); + + if (empty($filename)) { + return error('文件名不能为空', 400); + } + + // 安全检查:只允许下载备份目录下的文件 + $backupDir = root_path() . 'runtime/backup/'; + $filepath = realpath($backupDir . $filename); + + if (!$filepath || strpos($filepath, realpath($backupDir)) !== 0) { + return error('文件不存在', 404); + } + + if (!file_exists($filepath)) { + return error('文件不存在', 404); + } + + // 下载文件 + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . $filename . '"'); + header('Content-Length: ' . filesize($filepath)); + readfile($filepath); + exit; + } + + /** + * 恢复数据库 + * @return \think\response\Json + */ + public function restore() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $filename = Request::param('file', ''); + + if (empty($filename)) { + return error('文件名不能为空', 400); + } + + try { + $backupDir = root_path() . 'runtime/backup/'; + $filepath = realpath($backupDir . $filename); + + if (!$filepath || strpos($filepath, realpath($backupDir)) !== 0) { + return error('文件不存在', 404); + } + + if (!file_exists($filepath)) { + return error('文件不存在', 404); + } + + $config = Config::get('database.connections.mysql'); + $host = $config['hostname'] ?? 'localhost'; + $port = $config['hostport'] ?? 3306; + $database = $config['database'] ?? ''; + $username = $config['username'] ?? ''; + $password = $config['password'] ?? ''; + + // 使用PHP方式恢复 + $this->restoreDatabase($filepath); + + return success(null, '数据库恢复成功'); + } catch (\Exception $e) { + return error('恢复失败:' . $e->getMessage(), 500); + } + } + + /** + * 获取数据库大小 + */ + private function getDatabaseSize($database) + { + try { + $result = Db::query("SELECT + ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb + FROM information_schema.tables + WHERE table_schema = '{$database}'"); + + return floatval($result[0]['size_mb'] ?? 0); + } catch (\Exception $e) { + return 0; + } + } + + /** + * 获取表数量 + */ + private function getTableCount($database) + { + try { + $result = Db::query("SELECT COUNT(*) as count FROM information_schema.tables WHERE table_schema = '{$database}'"); + return intval($result[0]['count'] ?? 0); + } catch (\Exception $e) { + return 0; + } + } + + /** + * 导出表到SQL文件 + */ + private function exportTableToSql($tableName, $filepath) + { + $fp = fopen($filepath, 'w'); + + // 写入表结构 + $createTable = Db::query("SHOW CREATE TABLE `{$tableName}`"); + fwrite($fp, "-- 表结构: {$tableName}\n"); + fwrite($fp, "DROP TABLE IF EXISTS `{$tableName}`;\n"); + fwrite($fp, $createTable[0]['Create Table'] . ";\n\n"); + + // 写入数据 + $data = Db::query("SELECT * FROM `{$tableName}`"); + if (!empty($data)) { + fwrite($fp, "-- 表数据: {$tableName}\n"); + foreach ($data as $row) { + $values = []; + foreach ($row as $value) { + $values[] = is_null($value) ? 'NULL' : "'" . addslashes($value) . "'"; + } + fwrite($fp, "INSERT INTO `{$tableName}` VALUES (" . implode(', ', $values) . ");\n"); + } + } + + fclose($fp); + } + + /** + * 备份数据库(PHP方式) + */ + private function backupDatabase($database, $filepath) + { + $fp = fopen($filepath, 'w'); + + // 写入文件头 + fwrite($fp, "-- MySQL数据库备份\n"); + fwrite($fp, "-- 数据库: {$database}\n"); + fwrite($fp, "-- 备份时间: " . date('Y-m-d H:i:s') . "\n"); + fwrite($fp, "SET NAMES utf8mb4;\n"); + fwrite($fp, "SET FOREIGN_KEY_CHECKS = 0;\n\n"); + + // 获取所有表 + $tables = Db::query("SHOW TABLES FROM `{$database}`"); + $tableKey = 'Tables_in_' . $database; + + foreach ($tables as $table) { + $tableName = $table[$tableKey]; + + // 写入表结构 + $createTable = Db::query("SHOW CREATE TABLE `{$tableName}`"); + if (!empty($createTable)) { + fwrite($fp, "-- ----------------------------\n"); + fwrite($fp, "-- Table structure for {$tableName}\n"); + fwrite($fp, "-- ----------------------------\n"); + fwrite($fp, "DROP TABLE IF EXISTS `{$tableName}`;\n"); + fwrite($fp, $createTable[0]['Create Table'] . ";\n\n"); + + // 写入数据 + $data = Db::query("SELECT * FROM `{$tableName}`"); + if (!empty($data)) { + fwrite($fp, "-- ----------------------------\n"); + fwrite($fp, "-- Records of {$tableName}\n"); + fwrite($fp, "-- ----------------------------\n"); + + foreach ($data as $row) { + $columns = []; + $values = []; + foreach ($row as $col => $val) { + $columns[] = "`{$col}`"; + $values[] = is_null($val) ? 'NULL' : "'" . addslashes($val) . "'"; + } + fwrite($fp, "INSERT INTO `{$tableName}` (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $values) . ");\n"); + } + fwrite($fp, "\n"); + } + } + } + + fwrite($fp, "SET FOREIGN_KEY_CHECKS = 1;\n"); + fclose($fp); + } + + /** + * 恢复数据库(PHP方式) + */ + private function restoreDatabase($filepath) + { + $sql = file_get_contents($filepath); + + // 分割SQL语句 + $statements = array_filter(array_map('trim', explode(';', $sql))); + + foreach ($statements as $statement) { + if (!empty($statement)) { + Db::execute($statement); + } + } + } + + /** + * 查找mysqldump路径 + */ + private function findMysqldump() + { + $paths = [ + '/usr/bin/mysqldump', + '/usr/local/bin/mysqldump', + 'C:\\mysql\\bin\\mysqldump.exe', + 'C:\\xampp\\mysql\\bin\\mysqldump.exe', + 'mysqldump' + ]; + + foreach ($paths as $path) { + if (is_executable($path) || shell_exec("which {$path}")) { + return $path; + } + } + + return null; + } + + /** + * 查找mysql路径 + */ + private function findMysql() + { + $paths = [ + '/usr/bin/mysql', + '/usr/local/bin/mysql', + 'C:\\mysql\\bin\\mysql.exe', + 'C:\\xampp\\mysql\\bin\\mysql.exe', + 'mysql' + ]; + + foreach ($paths as $path) { + if (is_executable($path) || shell_exec("which {$path}")) { + return $path; + } + } + + return null; + } + + /** + * 上传备份文件到OSS + * @param string $filepath 本地文件路径 + * @param string $filename 文件名 + * @return array|null 返回OSS URL和路径,失败返回null + */ + private function uploadBackupToOss($filepath, $filename) + { + if (!class_exists('\OSS\OssClient')) { + throw new \RuntimeException('未安装 Aliyun OSS SDK,请先执行:composer require aliyuncs/oss-sdk-php'); + } + + // 读取OSS配置 + $uploadConfig = Config::get('upload.oss'); + + $accessKeyId = $uploadConfig['access_key_id'] ?? ''; + $accessKeySecret = $uploadConfig['access_key_secret'] ?? ''; + $endpoint = $uploadConfig['endpoint'] ?? ''; + $bucket = $uploadConfig['bucket'] ?? ''; + $baseUrl = rtrim($uploadConfig['url'] ?? '', '/'); + + // 如果配置为空,尝试从环境变量读取 + if (empty($accessKeyId)) { + $accessKeyId = getenv('OSS_ACCESS_KEY_ID') ?: getenv('ALIYUN_ACCESS_KEY_ID') ?: env('OSS_ACCESS_KEY_ID', env('ALIYUN_ACCESS_KEY_ID', '')); + } + if (empty($accessKeySecret)) { + $accessKeySecret = getenv('OSS_ACCESS_KEY_SECRET') ?: getenv('ALIYUN_OSS_ACCESS_KEY_SECRET') ?: env('OSS_ACCESS_KEY_SECRET', env('ALIYUN_OSS_ACCESS_KEY_SECRET', '')); + } + if (empty($endpoint)) { + $endpoint = getenv('OSS_ENDPOINT') ?: getenv('ALIYUN_OSS_ENDPOINT') ?: env('OSS_ENDPOINT', env('ALIYUN_OSS_ENDPOINT', '')); + } + if (empty($bucket)) { + $bucket = getenv('OSS_BUCKET') ?: getenv('ALIYUN_OSS_BUCKET') ?: env('OSS_BUCKET', env('ALIYUN_OSS_BUCKET', '')); + } + if (empty($baseUrl)) { + $baseUrl = rtrim(getenv('OSS_URL') ?: getenv('ALIYUN_OSS_URL') ?: env('OSS_URL', env('ALIYUN_OSS_URL', '')), '/'); + } + + // 检查配置是否完整 + if (empty($accessKeyId) || empty($accessKeySecret) || empty($endpoint) || empty($bucket) || empty($baseUrl)) { + throw new \RuntimeException('OSS配置不完整,无法上传备份文件'); + } + + // 构建OSS对象路径(不使用OSS_PREFIX,直接使用backup目录) + // 格式:backup/2026/02/12/backup_mbti_20260212160100.sql + $datePath = date('Y/m/d'); + $object = 'backup/' . $datePath . '/' . $filename; + + try { + // 创建OSS客户端 + $client = new \OSS\OssClient($accessKeyId, $accessKeySecret, $endpoint); + + // 验证Bucket是否存在 + if (!$client->doesBucketExist($bucket)) { + throw new \RuntimeException("OSS Bucket '{$bucket}' 不存在或无法访问"); + } + + // 上传文件 + $client->uploadFile($bucket, $object, $filepath); + + // 生成访问URL + $url = $baseUrl . '/' . ltrim($object, '/'); + + return [ + 'url' => $url, + 'path' => $object + ]; + } catch (\OSS\Core\OssException $e) { + throw new \RuntimeException('OSS上传失败:' . $e->getMessage()); + } + } + + /** + * 保存备份记录 + */ + private function saveBackupRecord($filename, $filepath, $fileSize, $ossUrl = null, $ossPath = null) + { + try { + BackupRecordModel::create([ + 'filename' => $filename, + 'filepath' => $filepath, + 'fileSize' => $fileSize, + 'ossUrl' => $ossUrl, + 'ossPath' => $ossPath, + 'status' => 'success' + ]); + } catch (\Exception $e) { + // 记录保存失败不影响备份成功,只记录日志 + Log::error('保存备份记录失败:' . $e->getMessage()); + } + } +} + diff --git a/api/app/controller/superadmin/Distribution.php b/api/app/controller/superadmin/Distribution.php new file mode 100644 index 0000000..ae5f3c3 --- /dev/null +++ b/api/app/controller/superadmin/Distribution.php @@ -0,0 +1,433 @@ +whereIn('status', ['paid', 'frozen'])->sum('commissionFen') ?: 0; + $paidCommission = Db::name('commission_records')->where('status', 'paid')->sum('commissionFen') ?: 0; + $frozenCommission = Db::name('commission_records')->where('status', 'frozen')->sum('commissionFen') ?: 0; + $totalOrders = Db::name('commission_records')->whereIn('status', ['paid', 'frozen'])->count(); + + $personalCommission = Db::name('commission_records')->where('scope', 'personal') + ->whereIn('status', ['paid', 'frozen'])->sum('commissionFen') ?: 0; + $enterpriseCommission = Db::name('commission_records')->where('scope', 'enterprise') + ->whereIn('status', ['paid', 'frozen'])->sum('commissionFen') ?: 0; + + $bindingCount = Db::name('distribution_bindings') + ->where('status', 'active') + ->where('expireAt', '>', $now) + ->count(); + + // 待处理提现:status=0 审核中 + $pendingWithdraw = Db::name('distribution_withdrawals') + ->where('status', 0) + ->sum('amountFen') ?: 0; + + $todayStart = strtotime(date('Y-m-d 00:00:00')); + $todayCommission = Db::name('commission_records') + ->where('status', 'paid') + ->where('paidAt', '>=', $todayStart) + ->sum('commissionFen') ?: 0; + + return success([ + 'totalCommission' => number_format($totalCommission / 100, 2, '.', ''), + 'paidCommission' => number_format($paidCommission / 100, 2, '.', ''), + 'frozenCommission' => number_format($frozenCommission / 100, 2, '.', ''), + 'personalCommission' => number_format($personalCommission / 100, 2, '.', ''), + 'enterpriseCommission'=> number_format($enterpriseCommission / 100, 2, '.', ''), + 'totalOrders' => $totalOrders, + 'bindingCount' => $bindingCount, + 'pendingWithdraw' => number_format($pendingWithdraw / 100, 2, '.', ''), + 'todayCommission' => number_format($todayCommission / 100, 2, '.', ''), + ]); + } catch (\Exception $e) { + return error('获取数据失败:' . $e->getMessage(), 500); + } + } + + // ───────────────────────────────────────────────────────────── + // GET distribution/bindings 全平台绑定记录 + // ───────────────────────────────────────────────────────────── + public function bindings() + { + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(100, (int) Request::param('pageSize', 20)); + $scope = Request::param('scope', ''); + $status = Request::param('status', ''); + $enterpriseId = (int) Request::param('enterpriseId', 0); + + try { + $query = Db::name('distribution_bindings') + ->alias('b') + ->leftJoin('wechat_users inv', 'b.inviterId = inv.id') + ->leftJoin('wechat_users invt', 'b.inviteeId = invt.id') + ->leftJoin('enterprises e', 'b.enterpriseId = e.id') + ->field('b.*, inv.nickname as inviterName, invt.nickname as inviteeName, e.name as enterpriseName'); + + if ($scope) $query->where('b.scope', $scope); + if ($status) $query->where('b.status', $status); + if ($enterpriseId) $query->where('b.enterpriseId', $enterpriseId); + + $total = (clone $query)->count(); + $list = $query->order('b.updatedAt', 'desc')->page($page, $pageSize)->select()->toArray(); + + $now = time(); + foreach ($list as &$row) { + $row['remainDays'] = max(0, (int) ceil(($row['expireAt'] - $now) / 86400)); + $row['inviterName'] = $row['inviterName'] ?: '未知'; + $row['inviteeName'] = $row['inviteeName'] ?: '未知'; + } + + return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]); + } catch (\Exception $e) { + return error('获取绑定记录失败:' . $e->getMessage(), 500); + } + } + + // ───────────────────────────────────────────────────────────── + // GET distribution/commissions 全平台佣金记录 + // ───────────────────────────────────────────────────────────── + public function commissions() + { + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(100, (int) Request::param('pageSize', 20)); + $scope = Request::param('scope', ''); + $status = Request::param('status', ''); + + try { + $query = Db::name('commission_records') + ->alias('c') + ->leftJoin('wechat_users inv', 'c.inviterId = inv.id') + ->leftJoin('wechat_users invt', 'c.inviteeId = invt.id') + ->leftJoin('enterprises e', 'c.enterpriseId = e.id') + ->field('c.*, inv.nickname as inviterName, invt.nickname as inviteeName, e.name as enterpriseName'); + + if ($scope) $query->where('c.scope', $scope); + if ($status) $query->where('c.status', $status); + + $total = (clone $query)->count(); + $list = $query->order('c.createdAt', 'desc')->page($page, $pageSize)->select()->toArray(); + + foreach ($list as &$row) { + $row['commissionYuan'] = number_format($row['commissionFen'] / 100, 2, '.', ''); + $row['orderYuan'] = number_format($row['orderAmount'] / 100, 2, '.', ''); + } + + return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]); + } catch (\Exception $e) { + return error('获取佣金记录失败:' . $e->getMessage(), 500); + } + } + + // ───────────────────────────────────────────────────────────── + // GET distribution/withdrawals 全平台提现申请 + // ───────────────────────────────────────────────────────────── + public function withdrawals() + { + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(100, (int) Request::param('pageSize', 20)); + $status = Request::param('status', ''); + + try { + $query = Db::name('distribution_withdrawals') + ->alias('w') + ->leftJoin('wechat_users u', 'w.userId = u.id') + ->field('w.*, u.nickname, u.avatar'); + + if ($status !== '') { + // 支持字符串或数字,统一转 int + $query->where('w.status', (int)$status); + } + + $total = (clone $query)->count(); + $list = $query->order('w.createdAt', 'desc')->page($page, $pageSize)->select()->toArray(); + + foreach ($list as &$row) { + $row['amountYuan'] = number_format($row['amountFen'] / 100, 2, '.', ''); + $row['nickname'] = $row['nickname'] ?: '未知用户'; + + // 确保前端拿到的是数字 status(避免 '0' 和 0 比较异常) + $code = (int) ($row['status'] ?? 0); + $row['status'] = $code; + + // 统一后台状态文案:0审核中、1已驳回、2待收款、3已收款、4已过期 + switch ($code) { + case 0: + $row['statusLabel'] = '审核中'; + break; + case 1: + $row['statusLabel'] = '已驳回'; + break; + case 2: + $row['statusLabel'] = '待收款'; + break; + case 3: + $row['statusLabel'] = '已收款'; + break; + case 4: + $row['statusLabel'] = '已过期'; + break; + default: + $row['statusLabel'] = '未知'; + break; + } + } + + return success(['list' => $list, 'total' => $total, 'page' => $page, 'pageSize' => $pageSize]); + } catch (\Exception $e) { + return error('获取提现记录失败:' . $e->getMessage(), 500); + } + } + + // ───────────────────────────────────────────────────────────── + // POST distribution/withdrawals/:id/approve 审核通过提现 + // ───────────────────────────────────────────────────────────── + public function approveWithdrawal(int $id) + { + $note = Request::param('note', ''); + $now = time(); + + $record = Db::name('distribution_withdrawals') + ->alias('w') + ->leftJoin('wechat_users u', 'w.userId = u.id') + ->field('w.*, u.openid') + ->where('w.id', $id) + ->find(); + // 仅允许处理审核中(status=0)的记录 + if (!$record || (int)$record['status'] !== 0) { + return error('提现申请不存在或已处理', 400); + } + + try { + // 生成商户单号:TX + 时间戳 + 随机数 + 提现ID(示例:TX202603121526520005123) + $outBillNo = 'TX' . date('YmdHis') . mt_rand(1000, 9999) . $record['id']; + + // 调用微信商家转账到零钱接口(参数对齐 ckb-admin Withdrawal::handleWechatPay) + $service = new \app\common\service\WechatTransferService(); + $result = $service->createTransfer([ + 'out_bill_no' => $outBillNo, + 'openid' => $record['openid'], + 'transfer_amount' => (int) $record['amountFen'], // 单位:分 + 'transfer_remark' => '推广佣金提现', + 'transfer_scene_id' => env('TRANSFER_SCENE_ID', '1005'), + 'transfer_scene_report_infos' => [ + [ + 'info_type' => '岗位类型', + 'info_content' => '推广人员', + ], + [ + 'info_type' => '报酬说明', + 'info_content' => '推广佣金提现', + ], + ], + 'notify_url' => env('WITHDRAW_NOTIFY_URL', ''), // 可选:提现专用回调 + ]); + + if ($result['success'] !== true) { + $err = $result['error'] ?? []; + $code = $err['code'] ?? 'UNKNOWN'; + $msg = $err['message'] ?? '微信转账接口调用失败'; + return error("微信转账发起失败({$code}):{$msg}", 500); + } + + $wechatData = $result['data'] ?? []; + Db::name('distribution_withdrawals')->where('id', $id)->update([ + // 2=待收款(已发起转账,等待用户确认) + 'status' => 2, + 'auditNote' => $note, + 'auditAt' => $now, + 'updatedAt' => $now, + 'pay_type' => 'wechat', + 'out_bill_no' => $outBillNo, + 'transfer_bill_no' => $wechatData['transfer_bill_no'] ?? null, + 'wechat_pay_state' => $wechatData['state'] ?? 'PROCESSING', + 'transfer_scene_id'=> $wechatData['transfer_scene_id'] ?? env('TRANSFER_SCENE_ID', '1005'), + 'package_info' => $wechatData['package_info'] ?? '', + 'mch_id' => env('MCH_ID', null), + ]); + return success(null, '审核通过,已发起微信转账'); + } catch (\Exception $e) { + return error('操作失败:' . $e->getMessage(), 500); + } + } + + // ───────────────────────────────────────────────────────────── + // POST distribution/withdrawals/:id/reject 拒绝提现 + // ───────────────────────────────────────────────────────────── + public function rejectWithdrawal(int $id) + { + $note = Request::param('note', ''); + $now = time(); + + $record = Db::name('distribution_withdrawals')->where('id', $id)->find(); + // 仅允许处理审核中(status=0)的记录 + if (!$record || (int)$record['status'] !== 0) { + return error('提现申请不存在或已处理', 400); + } + + Db::startTrans(); + try { + Db::name('wechat_users') + ->where('id', $record['userId']) + ->inc('walletBalance', $record['amountFen']) + ->update(['updatedAt' => $now]); + + Db::name('distribution_withdrawals')->where('id', $id)->update([ + // 1=已驳回 + 'status' => 1, + 'auditNote' => $note, + 'auditAt' => $now, + 'updatedAt' => $now, + ]); + + Db::commit(); + return success(null, '已拒绝,余额已退回'); + } catch (\Exception $e) { + Db::rollback(); + return error('操作失败:' . $e->getMessage(), 500); + } + } + + // ───────────────────────────────────────────────────────────── + // GET distribution/settings 个人版分销全局配置 + // ───────────────────────────────────────────────────────────── + public function settings() + { + try { + $config = Db::name('system_config')->where('key', 'distribution')->where('enterprise_id', 0)->find(); + $default = [ + 'enabled' => true, + 'promoCenterTitle' => '推广中心', + 'bindingDays' => 30, + 'minWithdrawFen' => 100, + 'maxWithdrawFen' => 0, + 'requireAudit' => true, + 'withdrawFee' => 0, + 'testSettings' => self::defaultTestSettings(), + ]; + if ($config && $config['value']) { + $settings = is_string($config['value']) ? json_decode($config['value'], true) : $config['value']; + $settings = array_merge($default, $settings ?? []); + } else { + $settings = $default; + } + $settings['minWithdraw'] = round((float)($settings['minWithdrawFen'] ?? 100) / 100, 2); + $settings['maxWithdraw'] = ($max = (int)($settings['maxWithdrawFen'] ?? 0)) > 0 ? round($max / 100, 2) : 0; + $settings['testSettings'] = self::appendTestSettingsAmount($settings['testSettings'] ?? self::defaultTestSettings()); + return success($settings); + } catch (\Exception $e) { + return error('获取配置失败:' . $e->getMessage(), 500); + } + } + + // ───────────────────────────────────────────────────────────── + // PUT distribution/settings 更新个人版分销全局配置 + // ───────────────────────────────────────────────────────────── + public function updateSettings() + { + $settings = Request::only([ + 'enabled', 'promoCenterTitle', 'bindingDays', + 'minWithdrawFen', 'minWithdraw', 'maxWithdrawFen', 'maxWithdraw', + 'requireAudit', 'withdrawFee', 'testSettings' + ]); + + $minWithdrawFen = isset($settings['minWithdraw']) + ? (int) round((float)$settings['minWithdraw'] * 100) + : (int)($settings['minWithdrawFen'] ?? 100); + $maxWithdrawFen = isset($settings['maxWithdraw']) + ? (int) round((float)$settings['maxWithdraw'] * 100) + : (int)($settings['maxWithdrawFen'] ?? 0); + $minWithdrawFen = max(100, min(20000, $minWithdrawFen)); + $maxWithdrawFen = $maxWithdrawFen > 0 ? min(20000, max(100, $maxWithdrawFen)) : 0; + + $promoTitle = trim((string)($settings['promoCenterTitle'] ?? '')); + $toSave = [ + 'enabled' => (bool)($settings['enabled'] ?? true), + 'promoCenterTitle' => $promoTitle !== '' ? $promoTitle : '推广中心', + 'bindingDays' => (int)($settings['bindingDays'] ?? 30), + 'minWithdrawFen' => $minWithdrawFen, + 'maxWithdrawFen' => $maxWithdrawFen, + 'requireAudit' => isset($settings['requireAudit']) ? (bool)$settings['requireAudit'] : true, + 'withdrawFee' => max(0, min(100, (float)($settings['withdrawFee'] ?? 0))), + 'testSettings' => self::sanitizeTestSettings($settings['testSettings'] ?? null), + ]; + + try { + $now = time(); + $existing = Db::name('system_config')->where('key', 'distribution')->where('enterprise_id', 0)->find(); + if ($existing) { + Db::name('system_config') + ->where('key', 'distribution') + ->where('enterprise_id', 0) + ->update(['value' => json_encode($toSave, JSON_UNESCAPED_UNICODE), 'updatedAt' => $now]); + } else { + Db::name('system_config')->insert([ + 'key' => 'distribution', + 'enterprise_id' => 0, + 'value' => json_encode($toSave, JSON_UNESCAPED_UNICODE), + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + } + $toSave['minWithdraw'] = $toSave['minWithdrawFen'] / 100; + $toSave['maxWithdraw'] = $toSave['maxWithdrawFen'] > 0 ? $toSave['maxWithdrawFen'] / 100 : 0; + $toSave['testSettings'] = self::appendTestSettingsAmount($toSave['testSettings']); + return success($toSave, '配置已保存'); + } catch (\Exception $e) { + return error('保存配置失败:' . $e->getMessage(), 500); + } + } + + private static function defaultTestSettings(): array + { + $item = ['enabled' => true, 'commissionType' => 'ratio', 'commissionRate' => 90, 'commissionAmountFen' => 0, 'noPayment' => false]; + return ['face' => $item, 'mbti' => $item, 'disc' => $item, 'pdp' => $item]; + } + + private static function sanitizeTestSettings($raw): array + { + $default = self::defaultTestSettings(); + if (!is_array($raw)) return $default; + $result = []; + foreach ($default as $type => $def) { + $s = $raw[$type] ?? []; + $commissionType = in_array($s['commissionType'] ?? '', ['ratio', 'amount']) ? $s['commissionType'] : 'ratio'; + $amountFen = isset($s['commissionAmount']) + ? (int) round((float)$s['commissionAmount'] * 100) + : (int)($s['commissionAmountFen'] ?? 0); + $rate = max(0, min(100, (int)($s['commissionRate'] ?? 90))); + $result[$type] = [ + 'enabled' => ($s['enabled'] ?? true) !== false, + 'commissionType' => $commissionType, + 'commissionRate' => $commissionType === 'ratio' ? $rate : 0, + 'commissionAmountFen'=> $commissionType === 'amount' ? max(0, $amountFen) : 0, + 'noPayment' => !empty($s['noPayment']), + ]; + } + return $result; + } + + private static function appendTestSettingsAmount(array $ts): array + { + foreach ($ts as $k => $v) { + $ts[$k]['commissionAmount'] = round(($v['commissionAmountFen'] ?? 0) / 100, 2); + } + return $ts; + } +} diff --git a/api/app/controller/superadmin/Enterprise.php b/api/app/controller/superadmin/Enterprise.php new file mode 100644 index 0000000..939b798 --- /dev/null +++ b/api/app/controller/superadmin/Enterprise.php @@ -0,0 +1,437 @@ +request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $page = Request::param('page', 1); + $pageSize = Request::param('pageSize', 20); + $keyword = Request::param('keyword', ''); + $status = Request::param('status', ''); + + $where = []; + + // 搜索条件 + if ($keyword) { + $where[] = ['name|contactName|contactPhone|code', 'like', '%' . $keyword . '%']; + } + + // 状态筛选 + if ($status !== '') { + $where['status'] = $status; + } + + // 查询企业列表 + $list = EnterpriseModel::where($where) + ->order('createdAt', 'desc') + ->page($page, $pageSize) + ->select() + ->toArray(); + + // 统计每个企业的用户数和测试用量 + foreach ($list as &$item) { + // 统计用户数(只统计未删除的用户) + $item['userCount'] = Db::name('users') + ->where('enterpriseId', $item['id']) + ->where('deletedAt', null) + ->count(); + + // 统计测试用量(测试结果数)- 通过企业下的用户ID统计(只统计未删除的用户) + $userIds = Db::name('users') + ->where('enterpriseId', $item['id']) + ->where('deletedAt', null) + ->column('id'); + + if (!empty($userIds)) { + $item['testUsage'] = Db::name('test_results') + ->where('userId', 'in', $userIds) + ->count(); + } else { + $item['testUsage'] = 0; + } + } + + $total = EnterpriseModel::where($where)->count(); + + // 统计活跃企业数(status为operating) + $activeCount = EnterpriseModel::where('status', 'operating')->count(); + + return success([ + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize, + 'activeCount' => $activeCount + ]); + } + + /** + * 获取企业详情 + * @param int $id + * @return \think\response\Json + */ + public function detail($id = null) + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + // 如果路由参数没有传递,尝试从请求参数获取 + if (empty($id)) { + $id = Request::param('id'); + } + + if (empty($id)) { + return error('企业ID不能为空', 400); + } + + $enterprise = EnterpriseModel::find($id); + + if (!$enterprise) { + return error('企业不存在', 404); + } + + $data = $enterprise->toArray(); + + // 获取企业下的所有用户ID(只统计未删除的用户) + $userIds = Db::name('users') + ->where('enterpriseId', $id) + ->where('deletedAt', null) + ->column('id'); + + // 统计用户数 + $data['userCount'] = count($userIds); + + // 获取管理员账号列表(企业管理员角色,只获取未删除的) + $adminAccounts = Db::name('users') + ->where('enterpriseId', $id) + ->where('role', 'enterprise_admin') + ->where('deletedAt', null) + ->field('id,username,email,phone,role,status,createdAt,lastLoginTime') + ->select() + ->toArray(); + $data['adminAccounts'] = $adminAccounts; + + // 获取用户列表(排除管理员,只获取未删除的) + $users = Db::name('users') + ->where('enterpriseId', $id) + ->where('role', '<>', 'enterprise_admin') + ->where('deletedAt', null) + ->field('id,username,email,phone,mbtiType,status,createdAt') + ->limit(50) // 限制返回数量 + ->select() + ->toArray(); + $data['users'] = $users; + + // 获取测试结果列表 + $testResults = []; + if (!empty($userIds)) { + $testResults = Db::name('test_results') + ->alias('tr') + ->leftJoin('users u', 'tr.userId = u.id') + ->where('tr.userId', 'in', $userIds) + ->field('tr.id,tr.testType,tr.createdAt,u.username') + ->order('tr.createdAt', 'desc') + ->limit(50) // 限制返回数量 + ->select() + ->toArray(); + } + $data['testResults'] = $testResults; + + // 统计测试用量 + if (!empty($userIds)) { + $data['testUsage'] = Db::name('test_results') + ->where('userId', 'in', $userIds) + ->count(); + } else { + $data['testUsage'] = 0; + } + + return success($data); + } + + /** + * 创建企业 + * @return \think\response\Json + */ + public function create() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $data = Request::post(); + + // 验证必填字段 + if (empty($data['name'])) { + return error('企业名称不能为空', 400); + } + + // 验证管理员账号信息 + if (empty($data['adminUsername'])) { + return error('管理员用户名不能为空', 400); + } + + if (empty($data['adminPassword'])) { + return error('管理员密码不能为空', 400); + } + + if (strlen($data['adminPassword']) < 6) { + return error('密码长度至少6位', 400); + } + + // 检查企业代码是否重复(如果提供了代码) + if (!empty($data['code'])) { + if (EnterpriseModel::where('code', $data['code'])->find()) { + return error('企业代码已存在', 400); + } + } + + // 检查管理员用户名是否已存在 + if (Db::name('users')->where('username', $data['adminUsername'])->find()) { + return error('管理员用户名已存在', 400); + } + + // 状态映射(前端使用operating/trial/disabled) + $status = $data['status'] ?? 'operating'; + if (!in_array($status, ['operating', 'trial', 'disabled'])) { + $status = 'operating'; + } + + // 验证试用到期时间 + if ($status === 'trial') { + if (empty($data['trialExpireAt'])) { + return error('选择试用状态时,必须设置试用到期时间', 400); + } + // 确保到期时间大于当前时间 + if ($data['trialExpireAt'] <= time()) { + return error('试用到期时间必须大于当前时间', 400); + } + } + + // 开启事务 + Db::startTrans(); + try { + // 创建企业 + $enterprise = new EnterpriseModel(); + $enterprise->name = $data['name']; + $enterprise->code = $data['code'] ?? null; + $enterprise->contactName = $data['contactName'] ?? null; + $enterprise->contactPhone = $data['contactPhone'] ?? null; + $enterprise->contactEmail = $data['contactEmail'] ?? null; + $enterprise->balance = $data['balance'] ?? 0.00; + $enterprise->status = $status; + $enterprise->trialExpireAt = ($status === 'trial' && isset($data['trialExpireAt'])) ? $data['trialExpireAt'] : null; + $enterprise->save(); + + $enterpriseId = $enterprise->id; + + // 创建企业管理员账号 + $adminUser = [ + 'username' => $data['adminUsername'], + 'password' => password_hash($data['adminPassword'], PASSWORD_DEFAULT), + 'email' => $data['contactEmail'] ?? null, + 'phone' => $data['contactPhone'] ?? null, + 'role' => 'enterprise_admin', + 'enterpriseId' => $enterpriseId, + 'status' => 1, + 'createdAt' => time(), + 'updatedAt' => time() + ]; + + Db::name('users')->insert($adminUser); + + // 提交事务 + Db::commit(); + + $enterpriseData = $enterprise->toArray(); + $enterpriseData['userCount'] = 1; // 刚创建的企业管理员 + $enterpriseData['testUsage'] = 0; + + return success($enterpriseData, '企业创建成功,管理员账号已创建'); + } catch (\Exception $e) { + // 回滚事务 + Db::rollback(); + return error('创建失败:' . $e->getMessage(), 500); + } + } + + /** + * 更新企业 + * @param int $id + * @return \think\response\Json + */ + public function update($id) + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $enterprise = EnterpriseModel::find($id); + + if (!$enterprise) { + return error('企业不存在', 404); + } + + $data = Request::put(); + $oldBalance = (float) ($enterprise->balance ?? 0); + + // 如果更新企业代码,检查是否重复 + if (isset($data['code']) && $data['code'] != $enterprise->code) { + if (EnterpriseModel::where('code', $data['code'])->find()) { + return error('企业代码已存在', 400); + } + } + + // 状态验证 + if (isset($data['status']) && !in_array($data['status'], ['operating', 'trial', 'disabled'])) { + return error('状态值无效', 400); + } + + // 验证试用到期时间 + $status = $data['status'] ?? $enterprise->status; + if ($status === 'trial') { + if (empty($data['trialExpireAt'])) { + return error('选择试用状态时,必须设置试用到期时间', 400); + } + // 确保到期时间大于当前时间 + if ($data['trialExpireAt'] <= time()) { + return error('试用到期时间必须大于当前时间', 400); + } + $enterprise->trialExpireAt = $data['trialExpireAt']; + } else { + // 如果不是试用状态,清空到期时间 + $enterprise->trialExpireAt = null; + } + + $enterprise->save($data); + + $newBalance = (float) ($enterprise->balance ?? 0); + if ($newBalance > $oldBalance) { + try { + \app\controller\api\Distribution::unfreezeCommissions((int) $id); + } catch (\Throwable $e) { + // 余额已更新成功,解冻失败不阻断主流程 + } + } + + $enterpriseData = $enterprise->toArray(); + + // 统计用户数和测试用量(只统计未删除的用户) + $enterpriseData['userCount'] = Db::name('users') + ->where('enterpriseId', $id) + ->where('deletedAt', null) + ->count(); + + $userIds = Db::name('users') + ->where('enterpriseId', $id) + ->where('deletedAt', null) + ->column('id'); + + if (!empty($userIds)) { + $enterpriseData['testUsage'] = Db::name('test_results') + ->where('userId', 'in', $userIds) + ->count(); + } else { + $enterpriseData['testUsage'] = 0; + } + + return success($enterpriseData, '更新成功'); + } + + /** + * 删除企业(软删除) + * @param int $id + * @return \think\response\Json + */ + public function delete($id) + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $enterprise = EnterpriseModel::find($id); + + if (!$enterprise) { + return error('企业不存在', 404); + } + + // 检查是否已删除 + if ($enterprise->deletedAt) { + return error('企业已被删除', 400); + } + + // 检查是否有用户关联(只检查未删除的用户) + $userCount = Db::name('users') + ->where('enterpriseId', $id) + ->where('deletedAt', null) + ->count(); + if ($userCount > 0) { + return error('该企业下还有用户,无法删除', 400); + } + + // 软删除(设置 deletedAt 时间戳) + $enterprise->delete(); + + return success(null, '删除成功'); + } + + /** + * 启用/禁用企业 + * @param int $id + * @return \think\response\Json + */ + public function toggleStatus($id) + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $enterprise = EnterpriseModel::find($id); + + if (!$enterprise) { + return error('企业不存在', 404); + } + + // 切换状态:operating <-> disabled + if ($enterprise->status === 'operating') { + $enterprise->status = 'disabled'; + } else { + $enterprise->status = 'operating'; + } + + $enterprise->save(); + + return success($enterprise, '操作成功'); + } +} + diff --git a/api/app/controller/superadmin/Finance.php b/api/app/controller/superadmin/Finance.php new file mode 100644 index 0000000..1ae2230 --- /dev/null +++ b/api/app/controller/superadmin/Finance.php @@ -0,0 +1,337 @@ +request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $currentMonthStart = mktime(0, 0, 0, (int) date('n'), 1, (int) date('Y')); + $currentMonthEnd = mktime(23, 59, 59, (int) date('n'), (int) date('t'), (int) date('Y')); + + $basePaid = Db::name('orders')->whereIn('status', self::PAID_STATUS); + $totalRevenue = (int) ((clone $basePaid)->sum('amount') ?? 0); + $paidOrderCount = (int) ((clone $basePaid)->count()); + + $monthRevenue = (int) (Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->where('payTime', '>=', $currentMonthStart) + ->where('payTime', '<=', $currentMonthEnd) + ->sum('amount') ?? 0); + + // 成本:无成本表时按收入比例估算(约 30%) + $totalCost = (int) round($totalRevenue * 0.3); + $monthCost = (int) round($monthRevenue * 0.3); + + $netProfit = $totalRevenue - $totalCost; + $monthProfit = $monthRevenue - $monthCost; + $profitRate = $totalRevenue > 0 ? round(($netProfit / $totalRevenue) * 100, 1) : 0; + + $lastMonthStart = mktime(0, 0, 0, (int) date('n') - 1, 1, (int) date('Y')); + $lastMonthEnd = mktime(23, 59, 59, (int) date('n') - 1, (int) date('t', $lastMonthStart), (int) date('Y')); + $lastMonthRevenue = (int) (Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->where('payTime', '>=', $lastMonthStart) + ->where('payTime', '<=', $lastMonthEnd) + ->sum('amount') ?? 0); + $lastMonthCost = (int) round($lastMonthRevenue * 0.3); + $lastMonthProfit = $lastMonthRevenue - $lastMonthCost; + $monthGrowth = $lastMonthProfit > 0 + ? round(($monthProfit - $lastMonthProfit) / $lastMonthProfit * 100, 1) + : ($monthProfit > 0 ? 100 : 0); + + return success([ + 'totalRevenue' => $totalRevenue, + 'totalCost' => $totalCost, + 'netProfit' => $netProfit, + 'profitRate' => $profitRate, + 'monthRevenue' => $monthRevenue, + 'monthCost' => $monthCost, + 'monthProfit' => $monthProfit, + 'monthGrowth' => $monthGrowth, + 'paidOrderCount' => $paidOrderCount, + ]); + } catch (\Throwable $e) { + return error('获取财务概览失败:' . $e->getMessage(), 500); + } + } + + /** + * 收入明细:按产品类型汇总(已支付订单),金额单位:分 + */ + public function revenueDetails() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $rows = Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->field('productType, SUM(amount) as total') + ->group('productType') + ->select() + ->toArray(); + + $typeLabel = [ + 'face' => 'AI人脸分析', + 'mbti' => 'MBTI', + 'disc' => 'DISC', + 'pdp' => 'PDP', + 'resume' => '简历综合分析', + 'report' => '完整报告', + ]; + $totalSum = 0; + $byType = []; + foreach ($rows as $r) { + $type = $r['productType'] ?? 'other'; + $amount = (int) ($r['total'] ?? 0); + $totalSum += $amount; + $byType[$type] = $amount; + } + + $details = []; + foreach ($typeLabel as $key => $label) { + $amount = $byType[$key] ?? 0; + $details[] = [ + 'type' => $label, + 'amount' => $amount, + 'percent' => $totalSum > 0 ? round($amount / $totalSum * 100, 1) : 0, + ]; + } + $otherAmount = 0; + foreach ($byType as $key => $amount) { + if (!isset($typeLabel[$key])) { + $otherAmount += $amount; + } + } + if ($otherAmount > 0) { + $details[] = [ + 'type' => '其他', + 'amount' => $otherAmount, + 'percent' => $totalSum > 0 ? round($otherAmount / $totalSum * 100, 1) : 0, + ]; + } + + return success($details); + } catch (\Throwable $e) { + return error('获取收入明细失败:' . $e->getMessage(), 500); + } + } + + /** + * 成本明细:当前为估算(基于收入的 30% 拆分),金额单位:分 + */ + public function costDetails() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $totalRevenue = (int) Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->sum('amount'); + $totalCost = (int) round($totalRevenue * 0.3); + + $items = [ + ['type' => 'AI 调用(人脸/分析等)', 'ratio' => 0.15], + ['type' => '服务器及运维', 'ratio' => 0.08], + ['type' => '其他支出', 'ratio' => 0.07], + ]; + $details = []; + foreach ($items as $item) { + $amount = (int) round($totalRevenue * $item['ratio']); + $details[] = [ + 'type' => $item['type'], + 'amount' => $amount, + 'percent' => $totalCost > 0 ? round($amount / $totalCost * 100, 1) : 0, + ]; + } + + return success($details); + } catch (\Throwable $e) { + return error('获取成本明细失败:' . $e->getMessage(), 500); + } + } + + /** + * 企业支付记录(已支付且 enterpriseId 不为空的订单),金额单位:分 + */ + public function rechargeRecords() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(100, max(1, (int) Request::param('pageSize', 20))); + + $query = Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->whereNotNull('enterpriseId') + ->where('enterpriseId', '<>', '') + ->order('payTime', 'desc'); + $total = (int) (clone $query)->count(); + $list = (clone $query)->page($page, $pageSize) + ->field('id, orderNo, enterpriseId, amount, payMethod, payTime') + ->select() + ->toArray(); + + $eids = array_values(array_unique(array_filter(array_column($list, 'enterpriseId')))); + $enterprises = []; + if (!empty($eids)) { + $entList = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id'); + $enterprises = $entList ?: []; + } + + $result = []; + foreach ($list as $r) { + $eid = $r['enterpriseId'] ?? null; + $result[] = [ + 'orderNo' => $r['orderNo'] ?? '', + 'enterprise' => $eid ? ($enterprises[$eid] ?? '企业#' . $eid) : '—', + 'amount' => (int) ($r['amount'] ?? 0), + 'method' => $r['payMethod'] === 'wechat' ? '微信支付' : ($r['payMethod'] ?? '—'), + 'date' => !empty($r['payTime']) ? date('Y-m-d H:i', $r['payTime']) : '—', + ]; + } + + return success([ + 'list' => $result, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize, + ]); + } catch (\Throwable $e) { + return error('获取企业支付记录失败:' . $e->getMessage(), 500); + } + } + + /** + * 支付记录(全部已支付订单,分页),金额单位:分 + */ + public function paymentRecords() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $page = max(1, (int) Request::param('page', 1)); + $pageSize = min(100, max(1, (int) Request::param('pageSize', 20))); + $keyword = trim(Request::param('keyword', '')); + + $query = Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->order('payTime', 'desc'); + + if ($keyword !== '') { + $query->where(function ($q) use ($keyword) { + $q->whereLike('orderNo', '%' . $keyword . '%'); + if (is_numeric($keyword)) { + $q->whereOr('userId', (int) $keyword); + } + }); + } + + $total = (int) (clone $query)->count(); + $list = (clone $query)->page($page, $pageSize) + ->field('id, orderNo, userId, enterpriseId, productType, productTitle, amount, payMethod, payTime') + ->select() + ->toArray(); + + $userIds = array_values(array_unique(array_filter(array_column($list, 'userId')))); + $eids = array_values(array_unique(array_filter(array_column($list, 'enterpriseId')))); + $usersMap = []; + $entMap = []; + if (!empty($userIds)) { + $users = Db::name('wechat_users')->where('id', 'in', $userIds)->field('id, nickname, phone')->select()->toArray(); + foreach ($users as $u) { + $usersMap[(int) $u['id']] = $u; + } + } + if (!empty($eids)) { + $entList = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id'); + $entMap = $entList ?: []; + } + + $productTypeLabel = [ + 'face' => 'AI人脸分析', + 'mbti' => 'MBTI', + 'disc' => 'DISC', + 'pdp' => 'PDP', + 'report' => '完整报告', + 'deep_personal' => '个人深度服务', + 'deep_team' => '团队深度服务', + ]; + + $result = []; + foreach ($list as $r) { + $uid = (int) ($r['userId'] ?? 0); + $eid = isset($r['enterpriseId']) && $r['enterpriseId'] !== '' ? (int) $r['enterpriseId'] : null; + if ($eid === 0) { + $eid = null; + } + $u = $usersMap[$uid] ?? null; + $enterpriseName = $eid ? ($entMap[$eid] ?? '企业#' . $eid) : '个人'; + $result[] = [ + 'orderNo' => $r['orderNo'] ?? '', + 'userName' => $u ? ($u['nickname'] ?? ('用户' . $uid)) : ('用户' . $uid), + 'enterprise' => $enterpriseName, + 'enterpriseId' => $eid, + 'productType' => $productTypeLabel[$r['productType'] ?? ''] ?? ($r['productType'] ?? '—'), + 'productTitle' => $r['productTitle'] ?? '', + 'amount' => (int) ($r['amount'] ?? 0), + 'method' => $r['payMethod'] === 'wechat' ? '微信支付' : ($r['payMethod'] ?? '—'), + 'date' => !empty($r['payTime']) ? date('Y-m-d H:i', $r['payTime']) : '—', + ]; + } + + return success([ + 'list' => $result, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize, + ]); + } catch (\Throwable $e) { + return error('获取支付记录失败:' . $e->getMessage(), 500); + } + } + + /** + * 导出财务报表 + */ + public function export() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + return success(null, '财务报表导出功能开发中'); + } +} diff --git a/api/app/controller/superadmin/Overview.php b/api/app/controller/superadmin/Overview.php new file mode 100644 index 0000000..f724d63 --- /dev/null +++ b/api/app/controller/superadmin/Overview.php @@ -0,0 +1,431 @@ +request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $currentMonthStart = mktime(0, 0, 0, (int) date('n'), 1, (int) date('Y')); + $currentMonthEnd = mktime(23, 59, 59, (int) date('n'), (int) date('t'), (int) date('Y')); + + // 企业统计 + $totalEnterprises = (int) Db::name('enterprises')->count(); + $newEnterprises = (int) Db::name('enterprises') + ->where('createdAt', '>=', $currentMonthStart) + ->where('createdAt', '<=', $currentMonthEnd) + ->count(); + + // 注册用户数(wechat_users 按 openid 去重,无 openid 则按行数) + try { + $totalRegisteredUsers = (int) Db::name('wechat_users')->count('openid', true); + } catch (\Throwable $e) { + $totalRegisteredUsers = (int) Db::name('wechat_users')->count(); + } + + // 有测试记录的用户数(按 wechat_users.openid 去重);本月新增 = 本月首次测试的 openid 数 + $totalUsers = 0; + $newUsers = 0; + try { + $totalUsers = (int) Db::name('test_results')->distinct(true)->count('userId'); + $newUsers = (int) Db::name('test_results') + ->where('createdAt', '>=', $currentMonthStart) + ->where('createdAt', '<=', $currentMonthEnd) + ->distinct(true) + ->count('userId'); + // 按 openid 去重:tr 关联 wechat_users,统计 distinct openid + $hasOpenid = false; + try { + $openids = Db::name('test_results')->alias('tr') + ->join('wechat_users w', 'tr.userId = w.id') + ->distinct(true) + ->column('w.openid'); + if (is_array($openids)) { + $openids = array_filter(array_unique($openids)); + $totalUsers = count($openids); + $hasOpenid = true; + } + } catch (\Throwable $e) { + } + if ($hasOpenid) { + $openidsBeforeMonth = Db::name('test_results')->alias('tr') + ->join('wechat_users w', 'tr.userId = w.id') + ->where('tr.createdAt', '<', $currentMonthStart) + ->distinct(true) + ->column('w.openid'); + $openidsBeforeMonth = is_array($openidsBeforeMonth) ? array_filter(array_unique($openidsBeforeMonth)) : []; + $openidsInMonth = Db::name('test_results')->alias('tr') + ->join('wechat_users w', 'tr.userId = w.id') + ->where('tr.createdAt', '>=', $currentMonthStart) + ->where('tr.createdAt', '<=', $currentMonthEnd) + ->distinct(true) + ->column('w.openid'); + $openidsInMonth = is_array($openidsInMonth) ? array_filter(array_unique($openidsInMonth)) : []; + $newUsers = count(array_diff($openidsInMonth, $openidsBeforeMonth)); + } + } catch (\Throwable $e) { + $newUsers = 0; + } + + // 收入与订单(仅 orders,金额分) + $totalRevenue = (int) (Db::name('orders')->whereIn('status', self::PAID_STATUS)->sum('amount') ?? 0); + $monthRevenue = (int) (Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->where('payTime', '>=', $currentMonthStart) + ->where('payTime', '<=', $currentMonthEnd) + ->sum('amount') ?? 0); + $paidOrderCount = (int) Db::name('orders')->whereIn('status', self::PAID_STATUS)->count(); + + $lastMonthStart = mktime(0, 0, 0, (int) date('n') - 1, 1, (int) date('Y')); + $lastMonthEnd = mktime(23, 59, 59, (int) date('n') - 1, (int) date('t', $lastMonthStart), (int) date('Y')); + $lastMonthRevenue = (int) (Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->where('payTime', '>=', $lastMonthStart) + ->where('payTime', '<=', $lastMonthEnd) + ->sum('amount') ?? 0); + $revenueGrowth = $lastMonthRevenue > 0 + ? round(($monthRevenue - $lastMonthRevenue) / $lastMonthRevenue * 100, 1) + : ($monthRevenue > 0 ? 100.0 : 0); + + // 测试统计 + $totalTests = 0; + $newTests = 0; + try { + $totalTests = (int) Db::name('test_results')->count(); + $newTests = (int) Db::name('test_results') + ->where('createdAt', '>=', $currentMonthStart) + ->where('createdAt', '<=', $currentMonthEnd) + ->count(); + } catch (\Throwable $e) { + } + + return success([ + 'totalEnterprises' => $totalEnterprises, + 'newEnterprises' => $newEnterprises, + 'totalRegisteredUsers' => $totalRegisteredUsers, + 'totalUsers' => $totalUsers, + 'newUsers' => $newUsers, + 'totalRevenue' => $totalRevenue, + 'monthRevenue' => $monthRevenue, + 'revenueGrowth' => $revenueGrowth, + 'paidOrderCount' => $paidOrderCount, + 'totalTests' => $totalTests, + 'newTests' => $newTests, + ]); + } catch (\Throwable $e) { + return error('获取数据概览失败:' . $e->getMessage(), 500); + } + } + + /** + * 最近动态:支付订单、新企业、今日测试等;金额接口为分,文案中转为元 + */ + public function recentDynamics() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $limit = min(20, max(5, (int) Request::param('limit', 10))); + $dynamics = []; + + // 1. 最近已支付订单(含个人与企业,金额分) + try { + $orders = Db::name('orders') + ->whereIn('status', self::PAID_STATUS) + ->field('id, orderNo, userId, enterpriseId, productType, amount, payTime') + ->order('payTime', 'desc') + ->limit($limit) + ->select() + ->toArray(); + $orders = is_array($orders) ? $orders : []; + + $eids = array_values(array_unique(array_filter(array_column($orders, 'enterpriseId')))); + $uids = array_values(array_unique(array_filter(array_column($orders, 'userId')))); + $entMap = []; + $userMap = []; + if (!empty($eids)) { + $entMap = Db::name('enterprises')->where('id', 'in', $eids)->column('name', 'id') ?: []; + } + if (!empty($uids)) { + $users = Db::name('wechat_users')->where('id', 'in', $uids)->field('id, nickname')->select()->toArray(); + foreach (is_array($users) ? $users : [] as $u) { + $userMap[(int) ($u['id'] ?? 0)] = $u['nickname'] ?? ('用户' . ($u['id'] ?? '')); + } + } + + $productLabel = ['face' => 'AI人脸', 'mbti' => 'MBTI', 'disc' => 'DISC', 'pdp' => 'PDP', 'report' => '报告']; + foreach ($orders as $o) { + $amountYuan = isset($o['amount']) ? round((int) $o['amount'] / 100, 2) : 0; + $who = '未知'; + if (!empty($o['enterpriseId']) && isset($entMap[$o['enterpriseId']])) { + $who = $entMap[$o['enterpriseId']]; + } else { + $who = $userMap[(int) ($o['userId'] ?? 0)] ?? ('用户' . ($o['userId'] ?? '')); + } + $product = $productLabel[$o['productType'] ?? ''] ?? ($o['productType'] ?? ''); + $dynamics[] = [ + 'type' => 'payment', + 'icon' => 'TrendCharts', + 'text' => $who . ' 支付 ¥' . number_format($amountYuan, 2) . ($product ? '(' . $product . ')' : ''), + 'time' => $this->formatTime($o['payTime'] ?? null), + 'sortTime' => (int) ($o['payTime'] ?? 0), + ]; + } + } catch (\Throwable $e) { + // 订单数据异常不影响其他动态 + } + + // 2. 最近入驻企业 + try { + $enterprises = Db::name('enterprises') + ->field('name, createdAt') + ->order('createdAt', 'desc') + ->limit(5) + ->select() + ->toArray(); + foreach (is_array($enterprises) ? $enterprises : [] as $e) { + $dynamics[] = [ + 'type' => 'enterprise', + 'icon' => 'Document', + 'text' => ($e['name'] ?? '') . ' 完成企业入驻', + 'time' => $this->formatTime($e['createdAt'] ?? null), + 'sortTime' => (int) ($e['createdAt'] ?? 0), + ]; + } + } catch (\Throwable $e) { + } + + // 3. 今日测试量(按企业/个人分组,文案里带企业名称) + try { + $todayStart = mktime(0, 0, 0, (int) date('n'), (int) date('j'), (int) date('Y')); + $rows = Db::name('test_results') + ->alias('tr') + ->leftJoin('enterprises e', 'tr.enterpriseId = e.id') + ->where('tr.createdAt', '>=', $todayStart) + ->field('tr.enterpriseId, e.name as enterpriseName, COUNT(*) as cnt') + ->group('tr.enterpriseId') + ->order('cnt', 'desc') + ->limit(5) + ->select() + ->toArray(); + + $totalToday = 0; + foreach (is_array($rows) ? $rows : [] as $row) { + $cnt = (int) ($row['cnt'] ?? 0); + if ($cnt <= 0) { + continue; + } + $totalToday += $cnt; + $eid = $row['enterpriseId'] ?? null; + $name = $row['enterpriseName'] ?? ''; + if ($eid && !$name) { + $name = '企业' . $eid; + } + if (!$eid) { + $name = $name ?: '个人用户(无企业)'; + } + $dynamics[] = [ + 'type' => 'test', + 'icon' => 'TrendCharts', + 'text' => $name . ' 今日完成 ' . $cnt . ' 次测试', + 'time' => '今日', + 'sortTime' => $todayStart + 1, + ]; + } + + // 追加一条全局汇总(放在企业之后) + if ($totalToday > 0) { + $dynamics[] = [ + 'type' => 'test-total', + 'icon' => 'TrendCharts', + 'text' => '全站今日共完成 ' . $totalToday . ' 次测试', + 'time' => '今日', + 'sortTime' => $todayStart, + ]; + } + } catch (\Throwable $e) { + } + + usort($dynamics, function ($a, $b) { + return ($b['sortTime'] ?? 0) - ($a['sortTime'] ?? 0); + }); + $dynamics = array_slice($dynamics, 0, $limit); + + return success($dynamics); + } catch (\Throwable $e) { + return error('获取最近动态失败:' . $e->getMessage(), 500); + } + } + + /** + * 最近 N 天测试趋势(按日期 & 测试类型统计) + * GET /superadmin/overview/test-trends?days=14 + */ + public function testTrends() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $days = (int) Request::param('days', 14); + $days = min(60, max(7, $days)); + + $startDate = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days'))); + + $rows = Db::name('test_results') + ->where('createdAt', '>=', $startDate) + ->whereIn('testType', ['face', 'mbti', 'disc', 'pdp']) + ->field("FROM_UNIXTIME(createdAt, '%Y-%m-%d') as d, testType, COUNT(*) as c") + ->group('d,testType') + ->order('d', 'asc') + ->select() + ->toArray(); + + $trendMap = []; + foreach (is_array($rows) ? $rows : [] as $row) { + $d = $row['d']; + $type = $row['testType']; + $cnt = (int) ($row['c'] ?? 0); + if (!isset($trendMap[$d])) { + $trendMap[$d] = [ + 'date' => $d, + 'face' => 0, + 'mbti' => 0, + 'disc' => 0, + 'pdp' => 0, + 'total' => 0, + ]; + } + if (in_array($type, ['face', 'mbti', 'disc', 'pdp'], true)) { + $trendMap[$d][$type] += $cnt; + $trendMap[$d]['total'] += $cnt; + } + } + + $trendData = []; + for ($i = 0; $i < $days; $i++) { + $d = date('Y-m-d', strtotime('-' . ($days - 1 - $i) . ' days')); + if (isset($trendMap[$d])) { + $trendData[] = $trendMap[$d]; + } else { + $trendData[] = [ + 'date' => $d, + 'face' => 0, + 'mbti' => 0, + 'disc' => 0, + 'pdp' => 0, + 'total' => 0, + ]; + } + } + + return success($trendData); + } catch (\Throwable $e) { + return error('获取测试趋势失败:' . $e->getMessage(), 500); + } + } + + /** + * 企业活跃排行(按测试次数、支付金额);金额单位:分 + */ + public function enterpriseRanking() + { + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + $limit = min(20, max(5, (int) Request::param('limit', 10))); + $result = []; + + try { + // 企业表 left join 测试与订单,保证无测试/无订单的企业也出现(测试数、金额为 0) + $list = Db::name('enterprises') + ->alias('e') + ->leftJoin('test_results tr', 'tr.enterpriseId = e.id') + ->leftJoin('orders o', 'o.enterpriseId = e.id AND o.status IN (\'paid\',\'completed\')') + ->field('e.id, e.name, COUNT(DISTINCT tr.id) as testCount, COALESCE(SUM(o.amount), 0) as totalAmount') + ->group('e.id') + ->order('testCount', 'desc') + ->order('totalAmount', 'desc') + ->limit($limit) + ->select() + ->toArray(); + + foreach (is_array($list) ? $list : [] as $item) { + $result[] = [ + 'id' => (int) ($item['id'] ?? 0), + 'name' => $item['name'] ?? '', + 'tests' => (int) ($item['testCount'] ?? 0), + 'amount' => (int) ($item['totalAmount'] ?? 0), + ]; + } + } catch (\Throwable $e) { + // 若 join 报错(如表/字段不一致),降级为只查企业列表,测试与金额为 0 + $list = Db::name('enterprises')->field('id, name')->order('id', 'desc')->limit($limit)->select()->toArray(); + foreach (is_array($list) ? $list : [] as $item) { + $result[] = [ + 'id' => (int) ($item['id'] ?? 0), + 'name' => $item['name'] ?? '', + 'tests' => 0, + 'amount' => 0, + ]; + } + } + + return success($result); + } catch (\Throwable $e) { + return error('获取企业排行失败:' . $e->getMessage(), 500); + } + } + + private function formatTime($timestamp) + { + if ($timestamp === null || $timestamp === '') { + return ''; + } + $ts = is_numeric($timestamp) ? (int) $timestamp : strtotime($timestamp); + if ($ts <= 0) { + return ''; + } + $diff = time() - $ts; + if ($diff < 60) { + return '刚刚'; + } + if ($diff < 3600) { + return floor($diff / 60) . '分钟前'; + } + if ($diff < 86400) { + return floor($diff / 3600) . '小时前'; + } + if ($diff < 604800) { + return floor($diff / 86400) . '天前'; + } + return date('Y-m-d H:i', $ts); + } +} diff --git a/api/app/controller/superadmin/Pricing.php b/api/app/controller/superadmin/Pricing.php new file mode 100644 index 0000000..3f9ab69 --- /dev/null +++ b/api/app/controller/superadmin/Pricing.php @@ -0,0 +1,200 @@ +request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $type = Request::param('type', ''); // personal/enterprise/deep + $enterpriseId = Request::param('enterpriseId', null); // 仅 type=enterprise 时有效,不传为全局 + + if ($type) { + $enterpriseId = $enterpriseId !== null && $enterpriseId !== '' ? (int) $enterpriseId : null; + $query = PricingConfigModel::where('type', $type); + if ($type === 'enterprise') { + $query->where(empty($enterpriseId) ? 'enterpriseId' : 'enterpriseId', empty($enterpriseId) ? 'null' : '=', empty($enterpriseId) ? null : $enterpriseId); + if (empty($enterpriseId)) { + $query->whereNull('enterpriseId'); + } else { + $query->where('enterpriseId', $enterpriseId); + } + } else { + $query->whereNull('enterpriseId'); + } + $config = $query->find(); + if (!$config) { + return error('定价配置不存在', 404); + } + return success([ + 'type' => $config->type, + 'enterpriseId' => $config->enterpriseId, + 'config' => $config->config + ]); + } else { + // 获取所有:个人/深度各一条(全局),企业=全局默认定价 + 各企业专属列表 + $configs = PricingConfigModel::select()->toArray(); + $result = ['personal' => null, 'enterprise' => null, 'deep' => null, 'enterpriseList' => []]; + foreach ($configs as $row) { + if ($row['enterpriseId'] === null || $row['enterpriseId'] === '') { + $result[$row['type']] = $row['config']; + } else { + if ($row['type'] === 'enterprise') { + $result['enterpriseList'][] = ['enterpriseId' => (int) $row['enterpriseId'], 'config' => $row['config']]; + } + } + } + return success($result); + } + } + + /** + * 更新定价配置 + * @return \think\response\Json + */ + public function update() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + // PUT JSON body 需显式解析,直接用 param() 读深层嵌套数组可能丢失数据 + $rawBody = (string) $this->request->getContent(); + $jsonData = ($rawBody !== '') ? json_decode($rawBody, true) : null; + + if (is_array($jsonData)) { + $type = (string) ($jsonData['type'] ?? ''); + $enterpriseId = $jsonData['enterpriseId'] ?? null; + $config = $jsonData['config'] ?? []; + } else { + $type = (string) Request::param('type', ''); + $enterpriseId = Request::param('enterpriseId', null); + $config = Request::param('config', []); + } + + if (empty($type)) { + return error('定价类型不能为空', 400); + } + + if (!in_array($type, ['personal', 'enterprise', 'deep', 'deep_personal', 'deep_enterprise'])) { + return error('定价类型无效', 400); + } + + if (empty($config) || !is_array($config)) { + return error('配置数据不能为空', 400); + } + + $enterpriseId = ($type === 'enterprise' && $enterpriseId !== null && $enterpriseId !== '') ? (int) $enterpriseId : null; + if ($type !== 'enterprise') { + $enterpriseId = null; + } + + $query = PricingConfigModel::where('type', $type); + if ($type === 'enterprise') { + if ($enterpriseId !== null) { + $query->where('enterpriseId', $enterpriseId); + } else { + $query->whereNull('enterpriseId'); + } + } else { + $query->whereNull('enterpriseId'); + } + // deep_personal / deep_enterprise 仅全局一条,不按企业分 + $pricingConfig = $query->find(); + + if (!$pricingConfig) { + $pricingConfig = PricingConfigModel::create([ + 'type' => $type, + 'enterpriseId' => $enterpriseId, + 'config' => $config + ]); + } else { + $pricingConfig->config = $config; + $pricingConfig->save(); + } + + return success([ + 'type' => $pricingConfig->type, + 'enterpriseId' => $pricingConfig->enterpriseId, + 'config' => $pricingConfig->config + ], '保存成功'); + } + + /** + * 批量更新定价配置 + * @return \think\response\Json + */ + public function batchUpdate() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $rawBody = (string) $this->request->getContent(); + $jsonData = ($rawBody !== '') ? json_decode($rawBody, true) : null; + $data = is_array($jsonData) ? ($jsonData['data'] ?? []) : Request::param('data', []); + + if (empty($data) || !is_array($data)) { + return error('配置数据不能为空', 400); + } + + $successCount = 0; + $errors = []; + + foreach ($data as $type => $config) { + if (!in_array($type, ['personal', 'enterprise', 'deep', 'deep_personal', 'deep_enterprise'])) { + $errors[] = "类型 {$type} 无效"; + continue; + } + + if (empty($config) || !is_array($config)) { + $errors[] = "类型 {$type} 的配置数据无效"; + continue; + } + + try { + $pricingConfig = PricingConfigModel::where('type', $type)->whereNull('enterpriseId')->find(); + + if (!$pricingConfig) { + PricingConfigModel::create([ + 'type' => $type, + 'config' => $config + ]); + } else { + $pricingConfig->config = $config; + $pricingConfig->save(); + } + $successCount++; + } catch (\Exception $e) { + $errors[] = "保存类型 {$type} 失败:" . $e->getMessage(); + } + } + + if (!empty($errors)) { + return error('部分配置保存失败:' . implode(';', $errors), 400); + } + + return success(null, "成功保存 {$successCount} 个配置"); + } +} + diff --git a/api/app/controller/superadmin/Question.php b/api/app/controller/superadmin/Question.php new file mode 100644 index 0000000..b1590f5 --- /dev/null +++ b/api/app/controller/superadmin/Question.php @@ -0,0 +1,332 @@ +request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $page = Request::param('page', 1); + $pageSize = Request::param('pageSize', 20); + $type = Request::param('type', ''); // mbti/disc/pdp + $status = Request::param('status', ''); // 1启用/0禁用 + + $where = []; + + // 只查询超管题库(enterpriseId = NULL) + $where['enterpriseId'] = null; + + // 类型筛选 + if ($type) { + $where['type'] = $type; + } + + // 状态筛选 + if ($status !== '') { + $where['status'] = $status; + } + + // 查询题库列表 + $list = QuestionModel::where($where) + ->order('sort', 'asc') + ->order('id', 'asc') + ->page($page, $pageSize) + ->select() + ->toArray(); + + // 处理 options 字段,确保返回数组格式 + foreach ($list as &$item) { + if (isset($item['options'])) { + // 如果是对象格式(stdClass),先转换为数组 + if (is_object($item['options'])) { + $item['options'] = json_decode(json_encode($item['options']), true); + } + // 如果是关联数组(不是索引数组),转换为索引数组 + if (is_array($item['options']) && !isset($item['options'][0])) { + $item['options'] = array_values($item['options']); + } + } + } + unset($item); + + // 总数 + $total = QuestionModel::where($where)->count(); + + return success([ + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'pageSize' => $pageSize + ]); + } + + /** + * 获取题目详情 + * @param int $id + * @return \think\response\Json + */ + public function detail($id) + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $question = QuestionModel::where('id', $id) + ->where('enterpriseId', null) // 只能查看超管题库 + ->find(); + + if (!$question) { + return error('题目不存在', 404); + } + + $data = $question->toArray(); + + // 处理 options 字段,确保返回数组格式 + if (isset($data['options'])) { + // 如果是对象格式(stdClass),先转换为数组 + if (is_object($data['options'])) { + $data['options'] = json_decode(json_encode($data['options']), true); + } + // 如果是关联数组(不是索引数组),转换为索引数组 + if (is_array($data['options']) && !isset($data['options'][0])) { + $data['options'] = array_values($data['options']); + } + } + + return success($data); + } + + /** + * 创建题目 + * @return \think\response\Json + */ + public function create() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']); + + // 验证必填字段 + if (empty($data['type']) || empty($data['question']) || empty($data['options'])) { + return error('题目类型、题目内容和选项不能为空', 400); + } + + // 验证类型 + if (!in_array($data['type'], ['mbti', 'disc', 'pdp'])) { + return error('题目类型必须是 mbti、disc 或 pdp', 400); + } + + // 验证选项格式 + if (!is_array($data['options'])) { + return error('选项必须是数组格式', 400); + } + + // MBTI类型需要dimension字段 + if ($data['type'] === 'mbti' && empty($data['dimension'])) { + return error('MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400); + } + + // 设置超管题库标识(enterpriseId = NULL) + $data['enterpriseId'] = null; + + // 设置默认值 + $data['sort'] = $data['sort'] ?? 0; + $data['status'] = $data['status'] ?? 1; + + // 创建题目 + $question = QuestionModel::create($data); + + return success($question->toArray(), '创建成功'); + } + + /** + * 更新题目 + * @param int $id + * @return \think\response\Json + */ + public function update($id) + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $question = QuestionModel::where('id', $id) + ->where('enterpriseId', null) // 只能更新超管题库 + ->find(); + + if (!$question) { + return error('题目不存在', 404); + } + + $data = Request::only(['type', 'question', 'options', 'dimension', 'sort', 'status']); + + // 验证类型 + if (isset($data['type']) && !in_array($data['type'], ['mbti', 'disc', 'pdp'])) { + return error('题目类型必须是 mbti、disc 或 pdp', 400); + } + + // 验证选项格式 + if (isset($data['options']) && !is_array($data['options'])) { + return error('选项必须是数组格式', 400); + } + + // MBTI类型需要dimension字段 + if (($data['type'] ?? $question->type) === 'mbti' && empty($data['dimension'] ?? $question->dimension)) { + return error('MBTI类型题目必须指定维度(EI/SN/TF/JP)', 400); + } + + // 更新题目 + $question->save($data); + + return success($question->toArray(), '更新成功'); + } + + /** + * 删除题目(软删除) + * @param int $id + * @return \think\response\Json + */ + public function delete($id) + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $question = QuestionModel::where('id', $id) + ->where('enterpriseId', null) // 只能删除超管题库 + ->find(); + + if (!$question) { + return error('题目不存在', 404); + } + + // 执行软删除 + $question->delete(); + + return success(null, '删除成功'); + } + + /** + * 批量导入题目 + * @return \think\response\Json + */ + public function batchImport() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $questions = Request::param('questions', []); + + if (empty($questions) || !is_array($questions)) { + return error('题目数据不能为空', 400); + } + + $successCount = 0; + $failCount = 0; + $errors = []; + + Db::startTrans(); + try { + foreach ($questions as $index => $q) { + // 验证必填字段 + if (empty($q['type']) || empty($q['question']) || empty($q['options'])) { + $failCount++; + $errors[] = "第" . ($index + 1) . "题:题目类型、题目内容和选项不能为空"; + continue; + } + + // 验证类型 + if (!in_array($q['type'], ['mbti', 'disc', 'pdp'])) { + $failCount++; + $errors[] = "第" . ($index + 1) . "题:题目类型必须是 mbti、disc 或 pdp"; + continue; + } + + // MBTI类型需要dimension字段 + if ($q['type'] === 'mbti' && empty($q['dimension'])) { + $failCount++; + $errors[] = "第" . ($index + 1) . "题:MBTI类型题目必须指定维度"; + continue; + } + + // 设置超管题库标识 + $q['enterpriseId'] = null; + $q['sort'] = $q['sort'] ?? ($index + 1); + $q['status'] = $q['status'] ?? 1; + + QuestionModel::create($q); + $successCount++; + } + + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + return error('批量导入失败:' . $e->getMessage(), 500); + } + + return success([ + 'successCount' => $successCount, + 'failCount' => $failCount, + 'errors' => $errors + ], "成功导入 {$successCount} 题,失败 {$failCount} 题"); + } + + /** + * 切换题目状态 + * @param int $id + * @return \think\response\Json + */ + public function toggleStatus($id) + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $question = QuestionModel::where('id', $id) + ->where('enterpriseId', null) // 只能操作超管题库 + ->find(); + + if (!$question) { + return error('题目不存在', 404); + } + + $question->status = $question->status == 1 ? 0 : 1; + $question->save(); + + return success($question->toArray(), '状态更新成功'); + } +} + diff --git a/api/app/controller/superadmin/Settings.php b/api/app/controller/superadmin/Settings.php new file mode 100644 index 0000000..755755b --- /dev/null +++ b/api/app/controller/superadmin/Settings.php @@ -0,0 +1,494 @@ +request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + try { + // 获取系统配置(全局 enterprise_id=0) + $systemConfig = SystemConfigModel::where('key', 'system')->where('enterprise_id', 0)->find(); + $notificationConfig = SystemConfigModel::where('key', 'notification')->where('enterprise_id', 0)->find(); + $promptsConfig = SystemConfigModel::where('key', 'prompts')->where('enterprise_id', 0)->find(); + $reportRequiresPaymentConfig = SystemConfigModel::where('key', 'report_requires_payment')->where('enterprise_id', 0)->find(); + $textConfigModel = SystemConfigModel::where('key', 'text_config')->where('enterprise_id', 0)->find(); + + // 获取当前超管用户名(直接使用JWT中的username) + $jwtUsername = $user['username'] ?? null; + $username = 'admin'; + + if ($jwtUsername) { + $currentUser = UserModel::where('username', $jwtUsername) + ->where('role', 'superadmin') + ->find(); + if ($currentUser) { + $username = $currentUser->username; + } else { + // 如果找不到用户,使用JWT中的username + $username = $jwtUsername; + } + } + + return success([ + 'system' => $systemConfig ? $systemConfig->value : [ + 'siteName' => '神仙团队AI性格测试', + 'siteDescription' => '专业的AI性格测试平台', + 'miniprogramName' => '神仙团队AI性格测试', + 'maintenanceMode' => false, + 'maxTestsPerDay' => 100, + 'trialTestCount' => 10 + ], + 'notification' => $notificationConfig ? $notificationConfig->value : [ + 'emailNotification' => true, + 'lowBalanceAlert' => true, + 'lowBalanceThreshold' => 1000, + 'newEnterpriseNotify' => true + ], + 'prompts' => $promptsConfig && !empty($promptsConfig->value) ? $promptsConfig->value : [ + 'faceAnalyze' => '{"mbti":"四字母如INTJ","pdp":"老虎/孔雀/考拉/猫头鹰/变色龙其一","disc":"D/I/S/C其一","overview":"一段50字以内的综合描述","faceAnalysis":"面相特点简短描述"}', + 'reportSummary' => '' + ], + 'reportRequiresPayment' => $reportRequiresPaymentConfig && !empty($reportRequiresPaymentConfig->value) ? $reportRequiresPaymentConfig->value : ['face' => 1, 'mbti' => 0, 'disc' => 0, 'pdp' => 0], + 'textConfig' => $textConfigModel && !empty($textConfigModel->value) ? $textConfigModel->value : [ + 'analyzingTitle' => '正在分析中', + 'startButtonText' => '开始面相测试', + 'startButtonEnterprise' => '开始面部测试', + 'reportTitle' => '分析报告', + 'aiAnalysisText' => '智能分析' + ], + 'username' => $username + ]); + } catch (\Exception $e) { + return error('获取配置失败:' . $e->getMessage(), 500); + } + } + + /** + * 更新系统配置 + * @return \think\response\Json + */ + public function updateSystem() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + // 前端 axios 发 JSON body,用 getContent 解析更可靠 + $input = json_decode($this->request->getContent(), true); + if (!is_array($input)) { + $input = []; + } + + $allowedKeys = ['siteName', 'siteDescription', 'miniprogramName', 'maintenanceMode', 'maxTestsPerDay', 'trialTestCount']; + $data = array_intersect_key($input, array_flip($allowedKeys)); + // 兼容 fallback:JSON 解析失败时尝试 Request::only + if (empty($data)) { + $data = Request::only($allowedKeys); + } + $textConfig = $input['textConfig'] ?? (Request::param('textConfig') ?: []); + + try { + // 查找或创建全局配置(enterprise_id=0) + $config = SystemConfigModel::where('key', 'system')->where('enterprise_id', 0)->find(); + if (!$config) { + $config = new SystemConfigModel(); + $config->key = 'system'; + $config->enterprise_id = 0; + $config->description = '系统基础配置'; + } + $config->value = $data; + $config->save(); + + // 更新站点信息 + $this->updateSiteInfo($data); + + // 保存全局小程序文案配置(enterprise_id=0) + if (is_array($textConfig)) { + $tcKeys = ['analyzingTitle', 'startButtonText', 'startButtonEnterprise', 'reportTitle', 'aiAnalysisText']; + $tcData = array_intersect_key($textConfig, array_flip($tcKeys)); + $tcDefaults = ['analyzingTitle' => '正在分析中', 'startButtonText' => '开始面相测试', 'startButtonEnterprise' => '开始面部测试', 'reportTitle' => '分析报告', 'aiAnalysisText' => '智能分析']; + $tcConfig = SystemConfigModel::where('key', 'text_config')->where('enterprise_id', 0)->find(); + if (!$tcConfig) { + $tcConfig = new SystemConfigModel(); + $tcConfig->key = 'text_config'; + $tcConfig->enterprise_id = 0; + $tcConfig->description = '小程序文案配置(全局)'; + } + $tcConfig->value = array_merge($tcDefaults, $tcData); + $tcConfig->save(); + } + + return success($config->value, '系统配置已保存'); + } catch (\Exception $e) { + return error('保存失败:' . $e->getMessage(), 500); + } + } + + /** + * 更新「报告需付费」配置:哪些测试类型需付费后才显示完整报告 + * PUT body: { "face": 1, "mbti": 0, "disc": 0, "pdp": 0 }(1=需付费解锁完整,0=免费完整) + * @return \think\response\Json + */ + public function updateReportRequiresPayment() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $data = Request::param('reportRequiresPayment', Request::param('data', [])); + if (!is_array($data)) { + return error('配置格式错误', 400); + } + + $defaults = ['face' => 1, 'mbti' => 0, 'disc' => 0, 'pdp' => 0]; + $value = array_merge($defaults, array_intersect_key($data, array_flip(['face', 'mbti', 'disc', 'pdp']))); + $value = array_map(function ($v) { return (int) $v ? 1 : 0; }, $value); + + try { + $config = SystemConfigModel::where('key', 'report_requires_payment')->where('enterprise_id', 0)->find(); + if (!$config) { + $config = new SystemConfigModel(); + $config->key = 'report_requires_payment'; + $config->enterprise_id = 0; + $config->description = '哪些测试类型需付费后才显示完整报告:1需付费0免费'; + } + $config->value = $value; + $config->save(); + return success($config->value, '报告付费开关已保存'); + } catch (\Exception $e) { + return error('保存失败:' . $e->getMessage(), 500); + } + } + + /** + * 更新提示词配置 + * @return \think\response\Json + */ + public function updatePrompts() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $data = Request::param('prompts', []); + if (!is_array($data)) { + return error('提示词配置格式错误', 400); + } + + try { + $config = SystemConfigModel::where('key', 'prompts')->where('enterprise_id', 0)->find(); + if (!$config) { + $config = new SystemConfigModel(); + $config->key = 'prompts'; + $config->enterprise_id = 0; + $config->description = '系统提示词配置(如面相分析、企业简历等)'; + } + $config->value = $data; + $config->save(); + return success($config->value, '提示词配置已保存'); + } catch (\Exception $e) { + return error('保存失败:' . $e->getMessage(), 500); + } + } + + /** + * 更新通知配置 + * @return \think\response\Json + */ + public function updateNotification() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $data = Request::only([ + 'emailNotification', 'lowBalanceAlert', + 'lowBalanceThreshold', 'newEnterpriseNotify' + ]); + + try { + $config = SystemConfigModel::where('key', 'notification')->where('enterprise_id', 0)->find(); + if (!$config) { + $config = new SystemConfigModel(); + $config->key = 'notification'; + $config->enterprise_id = 0; + $config->description = '通知与告警配置'; + } + $config->value = $data; + $config->save(); + + return success($config->value, '通知配置已保存'); + } catch (\Exception $e) { + return error('保存失败:' . $e->getMessage(), 500); + } + } + + /** + * 更新超管账户信息 + * @return \think\response\Json + */ + public function updateCredentials() + { + // 验证是否为超级管理员 + $user = $this->request->user ?? null; + if (!$user || ($user['role'] ?? '') !== 'superadmin') { + return error('无权限访问', 403); + } + + // 兼容 axios JSON PUT 与表单提交 + $rawBody = $this->request->getContent(); + if (empty($rawBody)) { + $rawBody = file_get_contents('php://input'); + } + $input = $rawBody ? json_decode($rawBody, true) : null; + if (!is_array($input)) { + $input = []; + } + + $username = trim((string)($input['username'] ?? Request::param('username', ''))); + $currentPassword = (string)($input['currentPassword'] ?? Request::param('currentPassword', '')); + $newPassword = (string)($input['newPassword'] ?? Request::param('newPassword', '')); + $confirmPassword = (string)($input['confirmPassword'] ?? Request::param('confirmPassword', '')); + + if (empty($username)) { + return error('用户名不能为空', 400); + } + + try { + // 优先使用JWT中的username来查找用户(最可靠的方式) + $jwtUsername = $user['username'] ?? null; + + if (empty($jwtUsername)) { + \think\facade\Log::error('JWT中缺少username', [ + 'user' => $user, + 'requestUserId' => $this->request->userId ?? null + ]); + return error('无法获取用户信息,请重新登录', 400); + } + + // 直接通过username查找用户 + $userModel = UserModel::where('username', $jwtUsername) + ->where('role', 'superadmin') + ->find(); + + if (!$userModel) { + // 添加调试信息 + \think\facade\Log::error('用户不存在', [ + 'jwtUsername' => $jwtUsername, + 'user' => $user, + 'requestUserId' => $this->request->userId ?? null, + 'requestUsername' => $username + ]); + return error('用户不存在,请检查登录状态', 404); + } + + // 验证当前用户是否为超级管理员(双重验证) + if ($userModel->role !== 'superadmin') { + \think\facade\Log::error('用户角色不正确', [ + 'userId' => $userModel->id, + 'role' => $userModel->role + ]); + return error('无权限修改此账户', 403); + } + + // 如果要修改密码,需要验证当前密码 + if (!empty($newPassword)) { + if (empty($currentPassword)) { + return error('请输入当前密码', 400); + } + + if ($newPassword !== $confirmPassword) { + return error('两次输入的密码不一致', 400); + } + + // 验证当前密码(User 模型中 password 字段已是加密值) + if (!password_verify($currentPassword, $userModel->password)) { + return error('当前密码错误', 400); + } + + // 更新密码:传入明文,交由 User 模型的 setPasswordAttr 自动加密 + $userModel->password = $newPassword; + } + + // 更新用户名 + if ($username !== $userModel->username) { + // 检查用户名是否已存在(排除当前用户) + $exists = UserModel::where('username', $username) + ->where('id', '<>', $userModel->id) + ->find(); + + if ($exists) { + return error('用户名已存在', 400); + } + + $userModel->username = $username; + } + + $userModel->save(); + + return success([ + 'username' => $userModel->username + ], '账户信息已更新'); + } catch (\Exception $e) { + return error('更新失败:' . $e->getMessage(), 500); + } + } + + /** + * 获取可用字体列表 + * GET /api/v1/superadmin/settings/fonts + */ + public function getFonts() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + $fonts = \app\common\service\PosterService::getAvailableFonts(); + return success([ + 'fonts' => $fonts, + 'fontDir' => root_path() . 'public/fonts/', + 'dirExist' => is_dir(root_path() . 'public/fonts/'), + ]); + } + + /** + * 获取海报配置 + * GET /api/v1/superadmin/settings/poster + */ + public function getPosterConfig() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $row = Db::name('system_config')->where('key', 'poster_config')->where('enterprise_id', 0)->find(); + $raw = $row['value'] ?? null; + $poster = self::decodeJsonSafe($raw) ?: [ + 'bgColor' => '#ffffff', + 'bgImage' => '', + 'elements' => [] + ]; + return success(['poster' => $poster]); + } + + /** + * 保存海报配置 + * PUT /api/v1/superadmin/settings/poster + */ + public function updatePosterConfig() + { + $user = $this->request->user ?? null; + if (!$user || $user['role'] !== 'superadmin') { + return error('无权限访问', 403); + } + + $input = json_decode($this->request->getContent(), true); + if (!is_array($input)) { + $input = []; + } + $data = [ + 'bgColor' => $input['bgColor'] ?? '#ffffff', + 'bgImage' => $input['bgImage'] ?? '', + 'elements' => $input['elements'] ?? [] + ]; + $jsonValue = json_encode($data, JSON_UNESCAPED_UNICODE); + + try { + $now = time(); + $exists = Db::name('system_config')->where('key', 'poster_config')->where('enterprise_id', 0)->find(); + if ($exists) { + Db::name('system_config') + ->where('key', 'poster_config') + ->where('enterprise_id', 0) + ->update(['value' => $jsonValue, 'updatedAt' => $now]); + } else { + Db::name('system_config')->insert([ + 'key' => 'poster_config', + 'enterprise_id' => 0, + 'value' => $jsonValue, + 'description' => '分销海报可视化配置(全局)', + 'createdAt' => $now, + 'updatedAt' => $now, + ]); + } + return success(null, '海报配置已保存'); + } catch (\Exception $e) { + return error('保存失败:' . $e->getMessage(), 500); + } + } + + /** + * 安全解码 JSON(处理可能的多重编码) + */ + private static function decodeJsonSafe($raw): ?array + { + if (!$raw) return null; + $val = $raw; + for ($i = 0; $i < 5 && is_string($val); $i++) { + $decoded = json_decode($val, true); + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) break; + $val = $decoded; + } + return is_array($val) ? $val : null; + } + + /** + * 更新站点信息 + * 当系统配置中的siteName或siteDescription修改时,同步更新站点信息 + */ + private function updateSiteInfo($systemData) + { + try { + $siteConfig = SystemConfigModel::where('key', 'site_info')->where('enterprise_id', 0)->find(); + $siteInfo = [ + 'siteName' => $systemData['siteName'] ?? '', + 'siteDescription' => $systemData['siteDescription'] ?? '', + 'miniprogramName' => $systemData['miniprogramName'] ?? '', + 'updatedAt' => time() + ]; + if (!$siteConfig) { + $siteConfig = new SystemConfigModel(); + $siteConfig->key = 'site_info'; + $siteConfig->enterprise_id = 0; + $siteConfig->description = '站点信息配置'; + } + $siteConfig->value = $siteInfo; + $siteConfig->save(); + + // 也可以更新其他相关的配置或缓存 + // 例如:清除缓存、更新.env文件等 + + } catch (\Exception $e) { + // 站点信息更新失败不影响系统配置保存 + \think\facade\Log::error('更新站点信息失败:' . $e->getMessage()); + } + } +} + diff --git a/api/app/middleware/Auth.php b/api/app/middleware/Auth.php new file mode 100644 index 0000000..dcfeff0 --- /dev/null +++ b/api/app/middleware/Auth.php @@ -0,0 +1,48 @@ + 401, + 'message' => '未登录或Token无效', + 'data' => null + ])->code(401); + } + + // 验证Token + $payload = JwtService::verifyToken($token); + + if (!$payload) { + return json([ + 'code' => 401, + 'message' => 'Token无效或已过期', + 'data' => null + ])->code(401); + } + + // 将用户信息存储到请求中,供控制器使用 + $request->user = $payload; + $request->userId = $payload['userId'] ?? $payload['user_id'] ?? null; + + return $next($request); + } +} + diff --git a/api/app/middleware/Cors.php b/api/app/middleware/Cors.php new file mode 100644 index 0000000..2f53562 --- /dev/null +++ b/api/app/middleware/Cors.php @@ -0,0 +1,74 @@ +header('Origin', ''); + + // 确定允许的Origin + $allowedOrigin = null; + if ($allowOrigin === '*') { + $allowedOrigin = '*'; + } else { + // 支持多个域名(用逗号分隔) + $origins = array_map('trim', explode(',', $allowOrigin)); + + // 如果请求的Origin在允许列表中,则使用该Origin + // 同时支持带/不带尾部斜杠的匹配 + foreach ($origins as $allowed) { + if ($origin === $allowed || $origin === rtrim($allowed, '/') || rtrim($origin, '/') === $allowed) { + $allowedOrigin = $origin; + break; + } + } + } + + // 处理预检请求(OPTIONS) + if ($request->method(true) === 'OPTIONS') { + $response = response('', 200); + } else { + $response = $next($request); + } + + // 设置CORS响应头 + if ($allowedOrigin !== null) { + $headers = [ + 'Access-Control-Allow-Origin' => $allowedOrigin, + 'Access-Control-Allow-Methods' => $allowMethods, + 'Access-Control-Allow-Headers' => $allowHeaders, + 'Access-Control-Max-Age' => (string)$maxAge, + ]; + + if ($allowCredentials) { + $headers['Access-Control-Allow-Credentials'] = 'true'; + } + + // 使用header方法设置响应头(ThinkPHP 8 需要传递数组) + $response->header($headers); + } + + return $response; + } +} diff --git a/api/app/middleware/SuperAdmin.php b/api/app/middleware/SuperAdmin.php new file mode 100644 index 0000000..b9d3c2a --- /dev/null +++ b/api/app/middleware/SuperAdmin.php @@ -0,0 +1,57 @@ + 401, + 'message' => '未登录或Token无效', + 'data' => null + ])->code(401); + } + + // 验证Token + $payload = JwtService::verifyToken($token); + + if (!$payload) { + return json([ + 'code' => 401, + 'message' => 'Token无效或已过期', + 'data' => null + ])->code(401); + } + + // 验证是否为超级管理员 + if ($payload['role'] !== 'superadmin') { + return json([ + 'code' => 403, + 'message' => '无权限访问,需要超级管理员权限', + 'data' => null + ])->code(403); + } + + // 将用户信息存储到请求中,供控制器使用 + $request->user = $payload; + $request->userId = $payload['userId'] ?? null; + + return $next($request); + } +} + diff --git a/api/app/model/AiProvider.php b/api/app/model/AiProvider.php new file mode 100644 index 0000000..dd23a84 --- /dev/null +++ b/api/app/model/AiProvider.php @@ -0,0 +1,109 @@ + 'int', + 'providerId' => 'string', + 'name' => 'string', + 'enabled' => 'int', + 'visible' => 'int', + 'apiKey' => 'string', + 'apiEndpoint' => 'string', + 'model' => 'string', + 'organizationId' => 'string', + 'maxTokens' => 'int', + 'balanceAlertEnabled' => 'int', + 'balanceAlertThreshold' => 'float', + 'notes' => 'string', + 'docUrl' => 'string', + 'isFree' => 'int', + 'supportsBalance' => 'int', + 'lastBalance' => 'float', + 'lastBalanceCurrency' => 'string', + 'lastBalanceCheckedAt' => 'int', + 'createdAt' => 'int', + 'updatedAt' => 'int', + 'deletedAt' => 'int', + 'extraConfig' => 'string', + ]; + + // 自动时间戳(使用驼峰命名,时间戳格式) + protected $autoWriteTimestamp = 'int'; + + // 时间戳字段名(驼峰命名,匹配数据库) + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + // 时间字段类型(时间戳格式) + protected $type = [ + 'lastBalanceCheckedAt' => 'integer', + 'createdAt' => 'integer', + 'updatedAt' => 'integer', + 'deletedAt' => 'integer', + 'extraConfig' => 'json', + ]; + + // 注意:API Key需要可逆读取用于API调用,所以不隐藏,但在获取器中脱敏 + + /** + * API Key 修改器(存储原始值,用于API调用) + */ + public function setApiKeyAttr($value) + { + if (empty($value)) { + return null; + } + // 如果输入的是脱敏格式(包含****),不更新 + if (strpos($value, '****') !== false) { + return null; // 返回null表示不更新此字段 + } + // 直接存储原始值(实际生产环境建议使用AES加密) + return $value; + } + + /** + * API Key 获取器(返回脱敏后的密钥) + * 注意:如果需要原始密钥用于API调用,使用 getRawApiKey() 方法 + */ + public function getApiKeyAttr($value) + { + if (empty($value)) { + return ''; + } + // 返回脱敏后的密钥(显示前6位和后4位) + if (strlen($value) > 10) { + return substr($value, 0, 6) . '****' . substr($value, -4); + } + return '****'; + } + + /** + * 获取原始API Key(用于API调用) + * @return string + */ + public function getRawApiKey() + { + // 直接从数据库读取原始值,绕过获取器 + return \think\facade\Db::name('ai_providers') + ->where('id', $this->id) + ->value('apiKey') ?: ''; + } +} + diff --git a/api/app/model/BackupRecord.php b/api/app/model/BackupRecord.php new file mode 100644 index 0000000..cfcedaa --- /dev/null +++ b/api/app/model/BackupRecord.php @@ -0,0 +1,48 @@ + 'int', + 'filename' => 'string', + 'filepath' => 'string', + 'fileSize' => 'int', + 'ossUrl' => 'string', + 'ossPath' => 'string', + 'status' => 'string', + 'deletedAt' => 'int', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + // 自动时间戳(使用驼峰命名,时间戳格式) + protected $autoWriteTimestamp = 'int'; + + // 时间戳字段名(驼峰命名,匹配数据库) + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + // 时间字段类型(时间戳格式) + protected $type = [ + 'deletedAt' => 'integer', + 'createdAt' => 'integer', + 'updatedAt' => 'integer', + ]; +} + diff --git a/api/app/model/Enterprise.php b/api/app/model/Enterprise.php new file mode 100644 index 0000000..f668e19 --- /dev/null +++ b/api/app/model/Enterprise.php @@ -0,0 +1,51 @@ + 'int', + 'name' => 'string', + 'code' => 'string', + 'contactName' => 'string', + 'contactPhone' => 'string', + 'contactEmail' => 'string', + 'balance' => 'float', + 'status' => 'string', + 'trialExpireAt' => 'int', + 'deletedAt' => 'int', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + // 自动时间戳(使用驼峰命名,时间戳格式) + protected $autoWriteTimestamp = 'int'; + + // 时间戳字段名(驼峰命名,匹配数据库) + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + // 时间字段类型(时间戳格式) + protected $type = [ + 'trialExpireAt' => 'integer', + 'deletedAt' => 'integer', + 'createdAt' => 'integer', + 'updatedAt' => 'integer', + ]; +} + diff --git a/api/app/model/EnterpriseResumeUpload.php b/api/app/model/EnterpriseResumeUpload.php new file mode 100644 index 0000000..490d3de --- /dev/null +++ b/api/app/model/EnterpriseResumeUpload.php @@ -0,0 +1,25 @@ + 'int', + 'userId' => 'int', + 'enterpriseId' => 'int', + 'fileUrl' => 'string', + 'fileName' => 'string', + 'is_default' => 'int', + 'createdAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; +} diff --git a/api/app/model/PricingConfig.php b/api/app/model/PricingConfig.php new file mode 100644 index 0000000..45e7c1a --- /dev/null +++ b/api/app/model/PricingConfig.php @@ -0,0 +1,117 @@ + 'int', + 'type' => 'string', + 'enterpriseId' => 'int', + 'config' => 'string', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + // 自动时间戳(使用驼峰命名,时间戳格式) + protected $autoWriteTimestamp = 'int'; + + // 时间戳字段名(驼峰命名,匹配数据库) + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + // 时间字段类型(时间戳格式) + protected $type = [ + 'createdAt' => 'integer', + 'updatedAt' => 'integer', + ]; + + // JSON字段自动转换 + protected $json = ['config']; + + /** + * 配置修改器(自动转换为JSON) + */ + public function setConfigAttr($value) + { + if (is_array($value)) { + return json_encode($value, JSON_UNESCAPED_UNICODE); + } + return $value; + } + + /** + * 配置获取器(自动解析JSON) + */ + public function getConfigAttr($value) + { + if (is_string($value)) { + return json_decode($value, true); + } + return $value; + } + + /** + * 按类型与可选企业ID取定价配置 + * + * personal(个人版)优先级: + * 1. admin_personal + enterpriseId(企业专属管理端配置,有 eid 时) + * 2. admin_personal + null(通用管理端配置) + * 3. 任意一条 admin_personal(兜底:只要管理端配过就不走超管) + * 4. personal + null(超管全局,仅在管理端完全未配置时使用) + * + * enterprise(企业版)优先级: + * 1. admin_enterprise + enterpriseId(有 eid 时) + * 2. admin_enterprise + null(通用管理端企业配置) + * 3. 任意一条 admin_enterprise + * 4. enterprise + null(超管全局兜底) + * + * @param string $type personal|enterprise|deep + * @param int|null $enterpriseId 有则优先读该企业专属配置 + * @return \app\model\PricingConfig|null + */ + public static function getByTypeAndEnterprise(string $type, ?int $enterpriseId = null): ?self + { + if ($type === 'personal') { + if (!empty($enterpriseId)) { + $row = self::where('type', 'admin_personal')->where('enterpriseId', $enterpriseId)->find(); + if ($row) return $row; + } + // 通用管理端个人配置(admin_personal + null) + $row = self::where('type', 'admin_personal')->whereNull('enterpriseId')->find(); + if ($row) return $row; + // 任意管理端个人配置(兜底:管理端配过就不走超管) + $row = self::where('type', 'admin_personal')->order('id', 'asc')->find(); + if ($row) return $row; + // 超管全局个人定价(最后兜底,仅管理端完全未配置时使用) + return self::where('type', 'personal')->whereNull('enterpriseId')->find(); + } + if ($type === 'enterprise') { + if (!empty($enterpriseId)) { + $row = self::where('type', 'admin_enterprise')->where('enterpriseId', $enterpriseId)->find(); + if ($row) return $row; + } + // 通用管理端企业配置(admin_enterprise + null) + $row = self::where('type', 'admin_enterprise')->whereNull('enterpriseId')->find(); + if ($row) return $row; + // 任意管理端企业配置(兜底) + $row = self::where('type', 'admin_enterprise')->order('id', 'asc')->find(); + if ($row) return $row; + return self::where('type', 'enterprise')->whereNull('enterpriseId')->find(); + } + if ($type === 'deep') { + return self::where('type', 'deep')->whereNull('enterpriseId')->find(); + } + return self::where('type', $type)->whereNull('enterpriseId')->find(); + } +} + diff --git a/api/app/model/Question.php b/api/app/model/Question.php new file mode 100644 index 0000000..4087813 --- /dev/null +++ b/api/app/model/Question.php @@ -0,0 +1,91 @@ + 'int', + 'type' => 'string', + 'question' => 'string', + 'options' => 'string', + 'dimension' => 'string', + 'enterpriseId' => 'int', + 'sort' => 'int', + 'status' => 'int', + 'deletedAt' => 'int', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + // 自动时间戳(使用驼峰命名,时间戳格式) + protected $autoWriteTimestamp = 'int'; + + // 时间戳字段名(驼峰命名,匹配数据库) + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + // 时间字段类型(时间戳格式) + protected $type = [ + 'deletedAt' => 'integer', + 'createdAt' => 'integer', + 'updatedAt' => 'integer', + ]; + + // JSON字段自动转换 + protected $json = ['options']; + + /** + * 选项修改器(自动转换为JSON) + */ + public function setOptionsAttr($value) + { + if (is_array($value)) { + return json_encode($value, JSON_UNESCAPED_UNICODE); + } + return $value; + } + + /** + * 选项获取器(自动解析JSON,确保返回数组格式) + */ + public function getOptionsAttr($value) + { + if (is_string($value)) { + $decoded = json_decode($value, true); + // 如果解码失败或返回null,返回空数组 + if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) { + return []; + } + // 如果是对象格式(关联数组),转换为索引数组 + if (is_array($decoded) && !empty($decoded) && !isset($decoded[0])) { + return array_values($decoded); + } + return $decoded ?: []; + } + // 如果是对象(stdClass),转换为数组 + if (is_object($value)) { + $value = json_decode(json_encode($value), true); + } + // 如果已经是数组,确保是索引数组 + if (is_array($value) && !empty($value) && !isset($value[0])) { + return array_values($value); + } + return is_array($value) ? $value : []; + } +} + diff --git a/api/app/model/SystemConfig.php b/api/app/model/SystemConfig.php new file mode 100644 index 0000000..116eddd --- /dev/null +++ b/api/app/model/SystemConfig.php @@ -0,0 +1,63 @@ + 'int', + 'key' => 'string', + 'enterprise_id' => 'int', + 'value' => 'string', + 'description' => 'string', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + // 自动时间戳(使用驼峰命名,时间戳格式) + protected $autoWriteTimestamp = 'int'; + + // 时间戳字段名(驼峰命名,匹配数据库) + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + // 时间字段类型(时间戳格式) + protected $type = [ + 'createdAt' => 'integer', + 'updatedAt' => 'integer', + ]; + + // JSON字段自动转换 + protected $json = ['value']; + + /** + * 配置值修改器(自动转换为JSON) + */ + public function setValueAttr($value) + { + if (is_array($value)) { + return json_encode($value, JSON_UNESCAPED_UNICODE); + } + return $value; + } + + /** + * 配置值获取器(自动解析JSON) + */ + public function getValueAttr($value) + { + if (is_string($value)) { + return json_decode($value, true); + } + return $value; + } +} + diff --git a/api/app/model/UploadFile.php b/api/app/model/UploadFile.php new file mode 100644 index 0000000..b6f2c5d --- /dev/null +++ b/api/app/model/UploadFile.php @@ -0,0 +1,29 @@ + 'int', + 'path' => 'string', + 'url' => 'string', + 'driver' => 'string', + 'hash' => 'string', + 'size' => 'int', + 'mimeType' => 'string', + 'extension' => 'string', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; +} diff --git a/api/app/model/User.php b/api/app/model/User.php new file mode 100644 index 0000000..cdac6f8 --- /dev/null +++ b/api/app/model/User.php @@ -0,0 +1,76 @@ + 'int', + 'username' => 'string', + 'password' => 'string', + 'phone' => 'string', + 'email' => 'string', + 'role' => 'string', + 'enterpriseId' => 'int', + 'mbtiType' => 'string', + 'region' => 'string', + 'industry' => 'string', + 'status' => 'int', + 'lastLoginTime' => 'int', + 'lastLoginIp' => 'string', + 'deletedAt' => 'int', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + // 自动时间戳(使用驼峰命名,时间戳格式) + protected $autoWriteTimestamp = 'int'; + + // 时间戳字段名(驼峰命名,匹配数据库) + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + // 隐藏字段(不返回给前端) + protected $hidden = ['password']; + + // 时间字段类型(时间戳格式) + protected $type = [ + 'lastLoginTime' => 'integer', + 'deletedAt' => 'integer', + 'createdAt' => 'integer', + 'updatedAt' => 'integer', + ]; + + /** + * 密码修改器(自动加密) + */ + public function setPasswordAttr($value) + { + return password_hash($value, PASSWORD_DEFAULT); + } + + /** + * 验证密码 + * @param string $password 明文密码 + * @return bool + */ + public function verifyPassword($password) + { + return password_verify($password, $this->password); + } +} + diff --git a/api/app/model/UserProfile.php b/api/app/model/UserProfile.php new file mode 100644 index 0000000..25051c3 --- /dev/null +++ b/api/app/model/UserProfile.php @@ -0,0 +1,162 @@ + 'int', + 'userId' => 'int', + 'userType' => 'string', + 'enterpriseId' => 'int', + 'testsTotal' => 'int', + 'testsMbti' => 'int', + 'testsDisc' => 'int', + 'testsPdp' => 'int', + 'testsFace' => 'int', + 'ordersTotal' => 'int', + 'paidOrders' => 'int', + 'totalPaidAmount' => 'int', + 'lastTestResultId'=> 'int', + 'lastTestType' => 'string', + 'lastTestAt' => 'int', + 'lastMbtiResultId'=> 'int', + 'lastDiscResultId'=> 'int', + 'lastPdpResultId' => 'int', + 'lastFaceResultId'=> 'int', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + /** + * 测试完成后更新用户画像统计与最近测试ID + */ + public static function recordTest(int $userId, string $testType, int $testResultId, ?int $enterpriseId = null, ?int $createdAt = null): void + { + if ($userId <= 0 || !$testType || $testResultId <= 0) { + return; + } + $now = $createdAt ?: time(); + $userType = $enterpriseId ? 'enterprise' : 'personal'; + + [$data, $id] = self::loadOrInitRow($userId, $userType, $enterpriseId, $now); + + $data['testsTotal']++; + switch ($testType) { + case 'mbti': + $data['testsMbti']++; + $data['lastMbtiResultId'] = $testResultId; + break; + case 'disc': + $data['testsDisc']++; + $data['lastDiscResultId'] = $testResultId; + break; + case 'pdp': + $data['testsPdp']++; + $data['lastPdpResultId'] = $testResultId; + break; + case 'face': + case 'ai': + $data['testsFace']++; + $data['lastFaceResultId'] = $testResultId; + break; + } + + $data['lastTestResultId'] = $testResultId; + $data['lastTestType'] = $testType; + $data['lastTestAt'] = $now; + $data['updatedAt'] = $now; + + self::upsertRow($data, $id); + } + + /** + * 支付成功后更新订单统计与总支付金额 + * + * @param int $userId + * @param int|null $enterpriseId + * @param int $amountFen 本次支付金额(分) + */ + public static function recordPayment(int $userId, ?int $enterpriseId, int $amountFen): void + { + if ($userId <= 0 || $amountFen <= 0) { + return; + } + $now = time(); + $userType = $enterpriseId ? 'enterprise' : 'personal'; + + [$data, $id] = self::loadOrInitRow($userId, $userType, $enterpriseId, $now); + + $data['ordersTotal'] = (int) ($data['ordersTotal'] ?? 0) + 1; + $data['paidOrders'] = (int) ($data['paidOrders'] ?? 0) + 1; + $currentTotal = (int) ($data['totalPaidAmount'] ?? 0); + $data['totalPaidAmount'] = $currentTotal + $amountFen; + $data['updatedAt'] = $now; + + self::upsertRow($data, $id); + } + + /** + * 读或初始化一行画像数据 + */ + protected static function loadOrInitRow(int $userId, string $userType, ?int $enterpriseId, int $now): array + { + $where = [ + 'userId' => $userId, + 'userType' => $userType, + 'enterpriseId' => $enterpriseId, + ]; + + $row = Db::name('user_profile')->where($where)->lock(true)->find(); + + $base = [ + 'testsTotal' => 0, + 'testsMbti' => 0, + 'testsDisc' => 0, + 'testsPdp' => 0, + 'testsFace' => 0, + 'ordersTotal' => 0, + 'paidOrders' => 0, + 'totalPaidAmount' => 0, + 'lastMbtiResultId'=> null, + 'lastDiscResultId'=> null, + 'lastPdpResultId' => null, + 'lastFaceResultId'=> null, + ]; + + if ($row) { + $data = array_merge($base, $row); + $id = (int) $row['id']; + } else { + $data = array_merge($base, $where, ['createdAt' => $now]); + $id = 0; + } + + return [$data, $id]; + } + + /** + * 写入或更新一行画像数据 + */ + protected static function upsertRow(array $data, int $id): void + { + if ($id > 0) { + Db::name('user_profile')->where('id', $id)->update($data); + } else { + Db::name('user_profile')->insert($data); + } + } +} + diff --git a/api/app/model/WechatUser.php b/api/app/model/WechatUser.php new file mode 100644 index 0000000..5121dd9 --- /dev/null +++ b/api/app/model/WechatUser.php @@ -0,0 +1,56 @@ + 'int', + 'openid' => 'string', + 'unionid' => 'string', + 'sessionKey' => 'string', + 'nickname' => 'string', + 'avatar' => 'string', + 'phone' => 'string', + 'gender' => 'int', + 'country' => 'string', + 'province' => 'string', + 'city' => 'string', + 'birthday' => 'string', + 'status' => 'int', + 'lastLoginAt' => 'int', + 'lastLoginIp' => 'string', + 'enterpriseId' => 'int', + 'createdAt' => 'int', + 'updatedAt' => 'int', + ]; + + protected $autoWriteTimestamp = 'int'; + protected $createTime = 'createdAt'; + protected $updateTime = 'updatedAt'; + + protected $hidden = ['sessionKey', 'openid']; + + protected $type = [ + 'lastLoginAt' => 'integer', + 'createdAt' => 'integer', + 'updatedAt' => 'integer', + ]; + + /** + * 返回给前端的用户信息(不包含敏感字段) + */ + public function toApiArray(): array + { + $row = $this->toArray(); + unset($row['sessionKey'], $row['openid']); + $row['avatarUrl'] = $row['avatar'] ?? ''; + return $row; + } +}