服务端-部分代码
This commit is contained in:
525
api/app/controller/api/Auth.php
Normal file
525
api/app/controller/api/Auth.php
Normal file
@@ -0,0 +1,525 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\User as UserModel;
|
||||
use app\model\WechatUser;
|
||||
use app\common\service\JwtService;
|
||||
use app\common\service\WechatService;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 前端用户认证API控制器
|
||||
*/
|
||||
class Auth extends BaseController
|
||||
{
|
||||
/**
|
||||
* 用户登录(前端)
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$username = Request::param('username', '');
|
||||
$password = Request::param('password', '');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
return error('用户名和密码不能为空', 400);
|
||||
}
|
||||
|
||||
// 注意:mbti_users表只存储管理员和超管,前端用户需要存储在单独的表中
|
||||
// 这里暂时返回错误,需要创建前端用户表后再实现
|
||||
return error('前端用户登录功能暂未实现,请联系管理员', 501);
|
||||
|
||||
// 查找用户(如果将来有前端用户表,使用以下代码)
|
||||
// $user = Db::name('frontend_users')
|
||||
// ->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, '绑定企业成功');
|
||||
}
|
||||
}
|
||||
|
||||
235
api/app/controller/api/CrmReport.php
Normal file
235
api/app/controller/api/CrmReport.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\JwtService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 存客宝获客线索上报
|
||||
* 将小程序用户行为(申请咨询/完成付款)上报给存客宝系统
|
||||
*/
|
||||
class CrmReport extends BaseController
|
||||
{
|
||||
/**
|
||||
* POST api/crm/report
|
||||
* 接收前端上报请求,向存客宝发送线索数据
|
||||
*
|
||||
* @param string apiKey 类目配置中的存客宝KEY(consultWechat字段)
|
||||
* @param string source 线索来源描述,如"个人深度服务-1v1深度解读"
|
||||
* @param string remark 备注,如"申请咨询"/"完成付款"
|
||||
* @param string tags 可选,逗号分隔的微信标签
|
||||
* @param string siteTags 可选,逗号分隔的站内标签
|
||||
*/
|
||||
public function report()
|
||||
{
|
||||
// 获取当前用户(支持中间件注入和手动解析两种方式)
|
||||
$user = $this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
1466
api/app/controller/api/Distribution.php
Normal file
1466
api/app/controller/api/Distribution.php
Normal file
File diff suppressed because it is too large
Load Diff
250
api/app/controller/api/EnterpriseResume.php
Normal file
250
api/app/controller/api/EnterpriseResume.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\EnterpriseResumeUpload;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 企业版简历上传记录 API(仅记录与列表,支持预览用 URL)
|
||||
*/
|
||||
class EnterpriseResume extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取当前用户的简历上传记录列表
|
||||
* GET /api/enterprise/resume-uploads
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
$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']
|
||||
);
|
||||
}
|
||||
}
|
||||
909
api/app/controller/api/Payment.php
Normal file
909
api/app/controller/api/Payment.php
Normal file
@@ -0,0 +1,909 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use app\common\service\JwtService;
|
||||
use think\facade\Request;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 支付与订单控制器(小程序/前端)
|
||||
*
|
||||
* 目标:
|
||||
* - 统一创建本地订单(mbti_orders),记录用户、企业、产品类型与金额
|
||||
* - 对接小程序 payment.js 的 create/notify/query 三个接口
|
||||
* - 支持人脸/MBTI/DISC/PDP/完整报告/团队分析/充值/深度服务等多种产品类型
|
||||
* - 系统所有金额均以「分」为单位:入参、落库、出参均为分。
|
||||
*/
|
||||
class Payment extends BaseController
|
||||
{
|
||||
/**
|
||||
* POST /api/payment/create
|
||||
* 小程序发起支付前调用:创建本地订单并返回调起微信支付所需参数
|
||||
* 入参 amount 为分;订单表 amount 存分;返回 amount 为分。
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
try {
|
||||
$user = $this->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 = '<xml>';
|
||||
foreach ($data as $key => $val) {
|
||||
if (is_numeric($val)) {
|
||||
$xml .= "<{$key}>{$val}</{$key}>";
|
||||
} else {
|
||||
$xml .= "<{$key}><![CDATA[{$val}]]></{$key}>";
|
||||
}
|
||||
}
|
||||
$xml .= '</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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
625
api/app/controller/api/Test.php
Normal file
625
api/app/controller/api/Test.php
Normal file
@@ -0,0 +1,625 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\model\PricingConfig as PricingConfigModel;
|
||||
use app\model\UserProfile as UserProfileModel;
|
||||
use think\facade\Db;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 前端测试记录相关 API
|
||||
*/
|
||||
class Test extends BaseController
|
||||
{
|
||||
/**
|
||||
* 获取当前微信用户的测试历史记录(用于小程序「测试历史」页)
|
||||
* GET /api/test/history
|
||||
*/
|
||||
public function history()
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
11
api/app/controller/api/Upload.php
Normal file
11
api/app/controller/api/Upload.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\controller\admin\Upload as AdminUpload;
|
||||
|
||||
/**
|
||||
* 小程序用户上传(头像等),复用管理端上传逻辑,需 JWT 认证
|
||||
*/
|
||||
class Upload extends AdminUpload
|
||||
{
|
||||
}
|
||||
101
api/app/controller/api/WechatTransferNotify.php
Normal file
101
api/app/controller/api/WechatTransferNotify.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
namespace app\controller\api;
|
||||
|
||||
use app\BaseController;
|
||||
use app\common\service\WechatTransferService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\Request;
|
||||
|
||||
/**
|
||||
* 微信商家转账结果回调
|
||||
*
|
||||
* 回调地址示例:/api/wechat/transfer/notify
|
||||
*/
|
||||
class WechatTransferNotify extends BaseController
|
||||
{
|
||||
public function notify()
|
||||
{
|
||||
$body = file_get_contents('php://input');
|
||||
$headers = [
|
||||
'wechatpay-signature' => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user