feat: add smt module
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<html>
|
||||
<head><title>404 Not Found</title></head>
|
||||
<body>
|
||||
<center><h1>404 Not Found</h1></center>
|
||||
<hr><center>nginx</center>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,137 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\common;
|
||||
|
||||
/**
|
||||
* JWT 工具类
|
||||
* 用于生成和验证 JWT Token
|
||||
*/
|
||||
class Jwt
|
||||
{
|
||||
/**
|
||||
* 生成 Token
|
||||
* @param array $payload 载荷数据
|
||||
* @return string
|
||||
*/
|
||||
public static function encode(array $payload): string
|
||||
{
|
||||
$config = config('jwt');
|
||||
|
||||
// 添加标准声明
|
||||
$payload['iat'] = time();
|
||||
$payload['iss'] = $config['issuer'] ?? 'dyai-api';
|
||||
$payload['exp'] = time() + ($config['expire'] ?? 604800);
|
||||
|
||||
// 编码
|
||||
$header = self::base64UrlEncode(json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
|
||||
$body = self::base64UrlEncode(json_encode($payload));
|
||||
|
||||
// 签名
|
||||
$signature = self::signature("$header.$body", $config['secret'] ?? 'default_secret');
|
||||
|
||||
return "$header.$body.$signature";
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Token
|
||||
* @param string $token
|
||||
* @return array|null
|
||||
*/
|
||||
public static function decode(string $token): ?array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$header, $body, $signature] = $parts;
|
||||
|
||||
// 验证签名
|
||||
$config = config('jwt');
|
||||
$expectedSignature = self::signature("$header.$body", $config['secret'] ?? 'default_secret');
|
||||
|
||||
if (!hash_equals($expectedSignature, $signature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解码载荷
|
||||
$payload = json_decode(self::base64UrlDecode($body), true);
|
||||
if (!$payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 验证过期时间
|
||||
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成刷新 Token
|
||||
* @param int $userid
|
||||
* @return string
|
||||
*/
|
||||
public static function refreshToken(int $userid): string
|
||||
{
|
||||
$config = config('jwt');
|
||||
|
||||
$payload = [
|
||||
'userid' => $userid,
|
||||
'type' => 'refresh',
|
||||
'iat' => time(),
|
||||
'exp' => time() + ($config['refresh_expire'] ?? 2592000),
|
||||
];
|
||||
|
||||
return self::encode($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从请求头获取 Token
|
||||
* @return string|null
|
||||
*/
|
||||
public static function getTokenFromRequest(): ?string
|
||||
{
|
||||
$request = request();
|
||||
|
||||
// 从 Authorization 头获取
|
||||
$authorization = $request->header('Authorization', '');
|
||||
if (preg_match('/Bearer\s+(.+)/', $authorization, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// 从参数获取
|
||||
$token = $request->param('token');
|
||||
if ($token) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL安全的 Base64 编码
|
||||
*/
|
||||
private static function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* URL安全的 Base64 解码
|
||||
*/
|
||||
private static function base64UrlDecode(string $data): string
|
||||
{
|
||||
return base64_decode(strtr($data, '-_', '+/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名
|
||||
*/
|
||||
private static function signature(string $data, string $secret): string
|
||||
{
|
||||
return self::base64UrlEncode(hash_hmac('sha256', $data, $secret, true));
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace app\api\common;
|
||||
|
||||
/**
|
||||
* 统一响应类
|
||||
*/
|
||||
class Response
|
||||
{
|
||||
/**
|
||||
* 成功响应
|
||||
* @param mixed $data 返回数据
|
||||
* @param string $message 提示信息
|
||||
* @param int $code 状态码
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public static function success($data = [], string $message = 'success', int $code = 200)
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'msg' => $message,
|
||||
'data' => $data,
|
||||
'time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应
|
||||
* @param string $message 提示信息
|
||||
* @param int $code 状态码
|
||||
* @param mixed $data 返回数据
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public static function error(string $message = 'error', int $code = 400, $data = [])
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'msg' => $message,
|
||||
'data' => $data,
|
||||
'time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页数据响应
|
||||
* @param mixed $list 数据列表
|
||||
* @param int $total 总数
|
||||
* @param int $page 当前页
|
||||
* @param int $pageSize 每页数量
|
||||
* @param string $message 提示信息
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public static function paginate($list, int $total, int $page, int $pageSize, string $message = 'success')
|
||||
{
|
||||
return json([
|
||||
'code' => 200,
|
||||
'msg' => $message,
|
||||
'data' => [
|
||||
'list' => $list,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
],
|
||||
'time' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace app\api\controller;
|
||||
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
use think\Validate;
|
||||
|
||||
/**
|
||||
* API 基础控制器
|
||||
*/
|
||||
abstract class BaseController
|
||||
{
|
||||
/**
|
||||
* Request实例
|
||||
* @var \think\Request
|
||||
*/
|
||||
protected $request;
|
||||
|
||||
/**
|
||||
* 应用实例
|
||||
* @var \think\App
|
||||
*/
|
||||
protected $app;
|
||||
|
||||
/**
|
||||
* 构造方法
|
||||
* @access public
|
||||
* @param App $app 应用对象
|
||||
*/
|
||||
public function __construct(App $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
$this->request = $this->app->request;
|
||||
|
||||
// 控制器初始化
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
protected function initialize()
|
||||
{}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户载荷。
|
||||
*
|
||||
* 说明:
|
||||
* - 登录态由路由中间件 `\app\api\middleware\Auth` 统一校验
|
||||
* - 控制器只负责读取中间件已经注入的用户信息
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function getLoginPayload(): array
|
||||
{
|
||||
$payload = $this->request->middleware('payload', []);
|
||||
|
||||
if (empty($payload['userid'])) {
|
||||
throw new \RuntimeException('未登录', 401);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户 ID。
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function getLoginUserId(): int
|
||||
{
|
||||
return (int) $this->getLoginPayload()['userid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应
|
||||
* @param mixed $data 返回数据
|
||||
* @param string $message 提示信息
|
||||
* @param int $code 状态码
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
protected function success($data = [], string $message = 'success', int $code = 200)
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'msg' => $message,
|
||||
'data' => $data,
|
||||
'time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应
|
||||
* @param string $message 提示信息
|
||||
* @param int $code 状态码
|
||||
* @param mixed $data 返回数据
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
protected function error(string $message = 'error', int $code = 400, $data = [])
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'msg' => $message,
|
||||
'data' => $data,
|
||||
'time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据
|
||||
* @access protected
|
||||
* @param array $data 数据
|
||||
* @param string|array $validate 验证器名或者验证规则数组
|
||||
* @param array $message 提示信息
|
||||
* @param bool $batch 是否批量验证
|
||||
* @return array|string|true
|
||||
* @throws ValidateException
|
||||
*/
|
||||
protected function validate(array $data, $validate, array $message = [], bool $batch = false)
|
||||
{
|
||||
if (is_array($validate)) {
|
||||
$v = new Validate();
|
||||
$v->rule($validate);
|
||||
} else {
|
||||
if (strpos($validate, '.')) {
|
||||
// 支持场景
|
||||
[$validate, $scene] = explode('.', $validate);
|
||||
}
|
||||
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
|
||||
$v = new $class();
|
||||
if (!empty($scene)) {
|
||||
$v->scene($scene);
|
||||
}
|
||||
}
|
||||
|
||||
$v->message($message);
|
||||
|
||||
// 是否批量验证
|
||||
if ($batch || $this->request->isBatchValidate()) {
|
||||
$v->batch(true);
|
||||
}
|
||||
|
||||
return $v->failException(true)->check($data);
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\api\controller\BaseController;
|
||||
use app\api\service\AuthService;
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
|
||||
/**
|
||||
* v1 认证控制器
|
||||
*/
|
||||
class Auth extends BaseController
|
||||
{
|
||||
protected AuthService $authService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->authService = new AuthService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* POST /api/v1/auth/login
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
try {
|
||||
$data = $this->request->post();
|
||||
|
||||
validate([
|
||||
'username' => 'require',
|
||||
'password' => 'require',
|
||||
], [
|
||||
'username.require' => '用户名不能为空',
|
||||
'password.require' => '密码不能为空',
|
||||
])->check($data);
|
||||
|
||||
$result = $this->authService->login(
|
||||
$data['username'],
|
||||
$data['password']
|
||||
);
|
||||
|
||||
return Response::success($result, '登录成功');
|
||||
} catch (ValidateException $e) {
|
||||
return Response::error($e->getMessage(), 400);
|
||||
} catch (\Exception $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* POST /api/v1/auth/register
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
try {
|
||||
$data = $this->request->post();
|
||||
|
||||
validate([
|
||||
'username' => 'require|length:3,20|alphaNum',
|
||||
'password' => 'require|length:6,20',
|
||||
'email' => 'email',
|
||||
], [
|
||||
'username.require' => '用户名不能为空',
|
||||
'username.length' => '用户名长度3-20位',
|
||||
'username.alphaNum' => '用户名只能包含字母和数字',
|
||||
'password.require' => '密码不能为空',
|
||||
'password.length' => '密码长度6-20位',
|
||||
'email.email' => '邮箱格式不正确',
|
||||
])->check($data);
|
||||
|
||||
$result = $this->authService->register(
|
||||
$data['username'],
|
||||
$data['password'],
|
||||
$data['email'] ?? null,
|
||||
$data['formtypeid'] ?? null
|
||||
);
|
||||
|
||||
return Response::success($result, '注册成功');
|
||||
} catch (ValidateException $e) {
|
||||
return Response::error($e->getMessage(), 400);
|
||||
} catch (\Exception $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
* POST /api/v1/auth/refresh
|
||||
*/
|
||||
public function refresh()
|
||||
{
|
||||
try {
|
||||
$data = $this->request->post();
|
||||
|
||||
if (empty($data['refresh_token'])) {
|
||||
return Response::error('刷新令牌不能为空', 400);
|
||||
}
|
||||
|
||||
$result = $this->authService->refreshToken($data['refresh_token']);
|
||||
|
||||
return Response::success($result, '刷新成功');
|
||||
} catch (\Exception $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* GET /api/v1/auth/me
|
||||
*/
|
||||
public function me()
|
||||
{
|
||||
try {
|
||||
$userid = $this->getLoginUserId();
|
||||
|
||||
$result = $this->authService->getUserInfo($userid);
|
||||
|
||||
return Response::success($result);
|
||||
} catch (\Exception $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* POST /api/v1/auth/logout
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
return Response::success([], '退出成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
* POST /api/v1/auth/password
|
||||
*/
|
||||
public function password()
|
||||
{
|
||||
try {
|
||||
$userid = $this->getLoginUserId();
|
||||
|
||||
$data = $this->request->post();
|
||||
|
||||
validate([
|
||||
'old_password' => 'require',
|
||||
'new_password' => 'require|length:6,20|confirm:confirm_password',
|
||||
], [
|
||||
'old_password.require' => '原密码不能为空',
|
||||
'new_password.require' => '新密码不能为空',
|
||||
'new_password.length' => '新密码长度6-20位',
|
||||
'new_password.confirm' => '两次密码输入不一致',
|
||||
])->check($data);
|
||||
|
||||
$this->authService->changePassword(
|
||||
$userid,
|
||||
$data['old_password'],
|
||||
$data['new_password']
|
||||
);
|
||||
|
||||
return Response::success([], '密码修改成功');
|
||||
} catch (ValidateException $e) {
|
||||
return Response::error($e->getMessage(), 400);
|
||||
} catch (\Exception $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\api\controller\BaseController;
|
||||
use app\api\service\PlatformService;
|
||||
use think\App;
|
||||
|
||||
/**
|
||||
* 平台账号管理控制器。
|
||||
*
|
||||
* 本控制器仅提供小程序“平台账号管理”所需的最基础只读接口,
|
||||
* 关键字段和状态含义按 acgpmw `platform.php` 列表逻辑对齐。
|
||||
*/
|
||||
class Platform extends BaseController
|
||||
{
|
||||
protected PlatformService $platformService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->platformService = new PlatformService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台账号列表。
|
||||
*
|
||||
* GET /api/v1/platform/accounts
|
||||
*
|
||||
* 请求参数:
|
||||
* - `platform`:平台编号,可选;为空时返回全部平台
|
||||
*
|
||||
* 返回结构:
|
||||
* - `filters`:当前平台筛选项
|
||||
* - `summary`:当前筛选结果统计
|
||||
* - `list`:账号列表,含账号授权、数据授权、异常状态
|
||||
*/
|
||||
public function accounts()
|
||||
{
|
||||
try {
|
||||
$userid = $this->getLoginUserId();
|
||||
|
||||
$platformInput = $this->request->get('platform');
|
||||
$platform = null;
|
||||
|
||||
if ($platformInput !== null && $platformInput !== '' && $platformInput !== 'all') {
|
||||
if (!is_numeric((string) $platformInput)) {
|
||||
return Response::error('平台参数格式错误', 400);
|
||||
}
|
||||
|
||||
$platform = (int) $platformInput;
|
||||
}
|
||||
|
||||
$result = $this->platformService->getAccountList($userid, $platform);
|
||||
|
||||
return Response::success($result);
|
||||
} catch (\Exception $exception) {
|
||||
return Response::error($exception->getMessage(), $exception->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\api\controller\BaseController;
|
||||
use app\api\service\PublishPlanService;
|
||||
use think\App;
|
||||
|
||||
/**
|
||||
* 发布计划控制器。
|
||||
*
|
||||
* 本控制器用于给小程序“发布计划”页面提供基础能力:
|
||||
* 1. 计划列表查询
|
||||
* 2. 按状态筛选
|
||||
* 3. 按 acgpmw `cron.php` 对齐的 start / stop 最小动作
|
||||
*/
|
||||
class PublishPlan extends BaseController
|
||||
{
|
||||
protected PublishPlanService $publishPlanService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->publishPlanService = new PublishPlanService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布计划列表。
|
||||
*
|
||||
* GET /api/v1/publish-plan/list
|
||||
*
|
||||
* 请求参数:
|
||||
* - `status`:筛选值,支持 `all` / `running` / `stopped` / `finished`
|
||||
* - `page`:页码,可选
|
||||
* - `page_size`:每页数量,可选
|
||||
*
|
||||
* 返回结构:
|
||||
* - `filters`:状态筛选项
|
||||
* - `summary`:顶部统计卡片
|
||||
* - `pagination`:分页信息
|
||||
* - `list`:计划卡片数据
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
$userid = $this->getLoginUserId();
|
||||
|
||||
$result = $this->publishPlanService->getPlanList($userid, [
|
||||
'status' => (string) $this->request->get('status', 'all'),
|
||||
'page' => (int) $this->request->get('page', 1),
|
||||
'page_size' => (int) $this->request->get('page_size', 20),
|
||||
]);
|
||||
|
||||
return Response::success($result);
|
||||
} catch (\Exception $exception) {
|
||||
return Response::error($exception->getMessage(), $exception->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启发布计划。
|
||||
*
|
||||
* 这里按 acgpmw `cron::fabu()` 逻辑对齐,只更新 `jrstop=0`。
|
||||
* 已完成计划不提供恢复操作,避免小程序在未完整对齐复杂编辑流程时误操作。
|
||||
*
|
||||
* @param int $id 计划ID
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function start(int $id)
|
||||
{
|
||||
try {
|
||||
$userid = $this->getLoginUserId();
|
||||
|
||||
$result = $this->publishPlanService->startPlan($userid, $id);
|
||||
|
||||
return Response::success($result, '设置成功');
|
||||
} catch (\Exception $exception) {
|
||||
return Response::error($exception->getMessage(), $exception->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停发布计划。
|
||||
*
|
||||
* 这里按 acgpmw `cron::stop()` 逻辑对齐:
|
||||
* 1. 更新 `jrstop=1`
|
||||
* 2. 尝试把 `dy_cron_account` 中待执行状态改为 5
|
||||
*
|
||||
* @param int $id 计划ID
|
||||
* @return \think\response\Json
|
||||
*/
|
||||
public function stop(int $id)
|
||||
{
|
||||
try {
|
||||
$userid = $this->getLoginUserId();
|
||||
|
||||
$result = $this->publishPlanService->stopPlan($userid, $id);
|
||||
|
||||
return Response::success($result, '设置成功');
|
||||
} catch (\Exception $exception) {
|
||||
return Response::error($exception->getMessage(), $exception->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\api\controller\BaseController;
|
||||
use app\api\service\VideoWorkService;
|
||||
use think\App;
|
||||
|
||||
/**
|
||||
* 视频作品控制器。
|
||||
*
|
||||
* 本控制器用于给小程序“视频作品”页面提供只读数据:
|
||||
* 1. 平台筛选
|
||||
* 2. 账号筛选
|
||||
* 3. 作品列表与分页
|
||||
*/
|
||||
class VideoWork extends BaseController
|
||||
{
|
||||
protected VideoWorkService $videoWorkService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->videoWorkService = new VideoWorkService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频作品列表。
|
||||
*
|
||||
* GET /api/v1/video-work/list
|
||||
*
|
||||
* 请求参数:
|
||||
* - `platform`:平台编号,传 `all` 时不过滤
|
||||
* - `vuid`:账号ID,传 `all` 时不过滤
|
||||
* - `page`:页码
|
||||
* - `page_size`:每页数量
|
||||
*
|
||||
* 返回结构:
|
||||
* - `filters`:平台与账号筛选项
|
||||
* - `summary`:顶部统计卡片
|
||||
* - `pagination`:分页信息
|
||||
* - `list`:作品卡片列表
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
$userid = $this->getLoginUserId();
|
||||
|
||||
$result = $this->videoWorkService->getVideoList($userid, [
|
||||
'platform' => $this->request->get('platform', 'all'),
|
||||
'vuid' => $this->request->get('vuid', 'all'),
|
||||
'page' => (int) $this->request->get('page', 1),
|
||||
'page_size' => (int) $this->request->get('page_size', 12),
|
||||
]);
|
||||
|
||||
return Response::success($result);
|
||||
} catch (\Exception $exception) {
|
||||
return Response::error($exception->getMessage(), $exception->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
// API 应用中间件配置
|
||||
|
||||
return [
|
||||
// 全局中间件
|
||||
\app\api\middleware\CrossDomain::class,
|
||||
];
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\middleware;
|
||||
|
||||
use app\api\common\Jwt;
|
||||
use app\api\common\Response;
|
||||
|
||||
/**
|
||||
* JWT 认证中间件
|
||||
*/
|
||||
class Auth
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
* @param \think\Request $request
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
// 获取 Token
|
||||
$token = Jwt::getTokenFromRequest();
|
||||
|
||||
if (!$token) {
|
||||
return Response::error('未提供认证令牌', 401);
|
||||
}
|
||||
|
||||
// 验证 Token
|
||||
$payload = Jwt::decode($token);
|
||||
|
||||
if (!$payload) {
|
||||
return Response::error('令牌无效或已过期', 401);
|
||||
}
|
||||
|
||||
// 将用户信息写入请求中间件上下文,供控制器统一读取
|
||||
$request->withMiddleware([
|
||||
'payload' => $payload,
|
||||
'userid' => (int) ($payload['userid'] ?? 0),
|
||||
]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
declare (strict_types = 1);
|
||||
|
||||
namespace app\api\middleware;
|
||||
|
||||
use think\Response;
|
||||
|
||||
/**
|
||||
* API 跨域中间件
|
||||
*/
|
||||
class CrossDomain
|
||||
{
|
||||
/**
|
||||
* 处理请求
|
||||
* @param \think\Request $request
|
||||
* @param \Closure $next
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
// OPTIONS 请求直接返回
|
||||
if ($request->method() == 'OPTIONS') {
|
||||
return Response::create('', 'html', 204)
|
||||
->header([
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Origin, Content-Type, Accept, Authorization, X-Request-With, token',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// 设置跨域响应头
|
||||
$response->header([
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Origin, Content-Type, Accept, Authorization, X-Request-With, token',
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 用户特殊绑定额度模型(对应 dy_special_binding 表)
|
||||
*
|
||||
* 该表用于补充用户级绑定上限、公众号绑定数等特殊能力。
|
||||
*/
|
||||
class DySpecialBinding extends Model
|
||||
{
|
||||
// 与 acgpmw 保持一致,特殊绑定配置存放在 member 库。
|
||||
protected $connection = 'dbmember';
|
||||
|
||||
protected $name = 'dy_special_binding';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 获取当前用户启用中的特殊绑定配置。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findActiveByUserid(int $userid): ?self
|
||||
{
|
||||
return self::where('userid', $userid)
|
||||
->where('disabled', 0)
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 用户单独平台权限模型(对应 dy_special_platforms 表)
|
||||
*
|
||||
* 该表用于覆盖套餐默认平台集合,并补充少量用户级能力开关。
|
||||
*/
|
||||
class DySpecialPlatforms extends Model
|
||||
{
|
||||
// 与 acgpmw 保持一致,特殊平台权限存放在 member 库。
|
||||
protected $connection = 'dbmember';
|
||||
|
||||
protected $name = 'dy_special_platforms';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 获取当前用户启用中的特殊平台配置。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findActiveByUserid(int $userid): ?self
|
||||
{
|
||||
return self::where('userid', $userid)
|
||||
->where('disabled', 0)
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
use think\db\Query;
|
||||
|
||||
/**
|
||||
* 发布任务模型(对应 dy_video_cron 表)
|
||||
*
|
||||
* 首页“发布任务”统计与 acgpmw 一致,只排除 status=3 的记录。
|
||||
*/
|
||||
class DyVideoCron extends Model
|
||||
{
|
||||
protected $connection = 'dbmember';
|
||||
|
||||
protected $name = 'dy_video_cron';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 统计当前用户的有效发布任务数量。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return int
|
||||
*/
|
||||
public static function countActiveByUserId(int $userid): int
|
||||
{
|
||||
return (int) self::where('userid', $userid)
|
||||
->where('status', '<>', 3)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建“发布计划模块”基础查询。
|
||||
*
|
||||
* 这里直接对齐 acgpmw 以下两个列表控制器的取数范围:
|
||||
* 1. `cron::cron_list()`:`project_id = 0 and status not in (9,10)`
|
||||
* 2. `ai_project_cron::cron_list()`:`project_id > 0`
|
||||
*
|
||||
* 小程序发布计划主页面只聚合这两类数据,避免把 `dy_video_cron`
|
||||
* 中其他业务模块(如 AI 文案等)的记录误当成发布计划展示出来。
|
||||
*
|
||||
* @param int $userid 当前登录用户ID
|
||||
* @return Query
|
||||
*/
|
||||
public static function buildPublishPlanQuery(int $userid): Query
|
||||
{
|
||||
return self::where('userid', $userid)
|
||||
->where(function (Query $query) {
|
||||
$query->where(function (Query $innerQuery) {
|
||||
$innerQuery->where('project_id', 0)
|
||||
->whereNotIn('status', [9, 10]);
|
||||
})->whereOr(function (Query $innerQuery) {
|
||||
$innerQuery->where('project_id', '>', 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
use think\Collection;
|
||||
|
||||
/**
|
||||
* 授权账号模型(对应 dy_video_user 表)
|
||||
*
|
||||
* 首页“授权账号”卡片按 acgpmw 首页逻辑统计未禁用账号数。
|
||||
*/
|
||||
class DyVideoUser extends Model
|
||||
{
|
||||
protected $connection = 'dbmember';
|
||||
|
||||
protected $name = 'dy_video_user';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 统计当前用户可用的授权账号数量。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return int
|
||||
*/
|
||||
public static function countActiveByUserId(int $userid): int
|
||||
{
|
||||
return (int) self::where('userid', $userid)
|
||||
->where('disabled', 0)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的平台账号列表。
|
||||
*
|
||||
* 这里按 acgpmw `platform::index()` 和 `platform::get_zhanghu_list()` 的查询条件对齐:
|
||||
* - 仅返回当前用户数据
|
||||
* - 仅返回 `disabled=0` 的有效账号
|
||||
* - 排序保持 `is_endauth desc, id desc`
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param int|null $platform 指定平台;为空时返回全部平台
|
||||
* @return Collection
|
||||
*/
|
||||
public static function getPlatformAccountsByUserId(int $userid, ?int $platform = null): Collection
|
||||
{
|
||||
$query = self::where('userid', $userid)
|
||||
->where('disabled', 0)
|
||||
->order(['is_endauth' => 'desc', 'id' => 'desc'])
|
||||
->field([
|
||||
'id',
|
||||
'userid',
|
||||
'platform',
|
||||
'is_lanv',
|
||||
'is_endauth',
|
||||
'aa_endauth',
|
||||
'browser',
|
||||
'is_qyh',
|
||||
'proviceid',
|
||||
'info_shouji',
|
||||
'dy_avatar',
|
||||
'dy_nickname',
|
||||
'dy_intro',
|
||||
'dy_account',
|
||||
'dy_openid',
|
||||
'dy_unique_id',
|
||||
'addtime',
|
||||
'updatetime',
|
||||
]);
|
||||
|
||||
if ($platform !== null) {
|
||||
$query->where('platform', $platform);
|
||||
}
|
||||
|
||||
return $query->select();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频作品页筛选用的账号列表。
|
||||
*
|
||||
* 对齐 acgpmw `video_info::video_list()`:
|
||||
* - 仅返回当前用户启用中的账号
|
||||
* - 返回平台与昵称,供前端拼出“平台(昵称)#Pid”样式
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return Collection
|
||||
*/
|
||||
public static function getVideoFilterAccountsByUserId(int $userid): Collection
|
||||
{
|
||||
return self::where('userid', $userid)
|
||||
->where('disabled', 0)
|
||||
->order(['id' => 'desc'])
|
||||
->field([
|
||||
'id',
|
||||
'platform',
|
||||
'dy_nickname',
|
||||
'dy_avatar',
|
||||
])
|
||||
->select();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* TikTok 账号附表模型(对应 dy_video_user_titk 表)
|
||||
*
|
||||
* 该表在 acgpmw 平台账号管理页用于补充 TikTok 账号的国家信息。
|
||||
*/
|
||||
class DyVideoUserTitk extends Model
|
||||
{
|
||||
protected $connection = 'dbmember';
|
||||
|
||||
protected $name = 'dy_video_user_titk';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 根据授权账号 ID 获取 TikTok 附加信息。
|
||||
*
|
||||
* @param int $vuid 授权账号主表 ID
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findByVuid(int $vuid): ?self
|
||||
{
|
||||
return self::where('vuid', $vuid)->find();
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
use think\db\Query;
|
||||
|
||||
/**
|
||||
* 抖音发布日志模型(对应分表 dys_video_log_{userid % 1000})
|
||||
*
|
||||
* 该表在 douying 库中按用户取模分表,首页“发布作品”统计需要按原系统规则动态定位表名。
|
||||
*/
|
||||
class DysVideoLog extends Model
|
||||
{
|
||||
protected $connection = 'dbdouying';
|
||||
|
||||
// 仅作为默认占位,真正查询时会按用户ID动态切换分表名。
|
||||
protected $name = 'dys_video_log_0';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 统计当前用户已发布作品数量。
|
||||
*
|
||||
* 此处按 acgpmw 首页逻辑对齐,仅统计 status<=1 的发布记录。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return int
|
||||
*/
|
||||
public static function countPublishedByUserId(int $userid): int
|
||||
{
|
||||
$tableName = self::resolveTableName($userid);
|
||||
|
||||
return (int) (new self())
|
||||
->db()
|
||||
->name($tableName)
|
||||
->where('userid', $userid)
|
||||
->where('status', '<=', 1)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建当前用户的视频作品基础查询。
|
||||
*
|
||||
* 作品日志按 `dys_video_log_{userid % 1000}` 分表存储,
|
||||
* 这里统一返回已定位分表且已限定用户范围的查询对象。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return Query
|
||||
*/
|
||||
public static function buildUserQuery(int $userid): Query
|
||||
{
|
||||
$tableName = self::resolveTableName($userid);
|
||||
|
||||
return (new self())
|
||||
->db()
|
||||
->name($tableName)
|
||||
->where('userid', $userid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID计算分表名。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @return string
|
||||
*/
|
||||
private static function resolveTableName(int $userid): string
|
||||
{
|
||||
return sprintf('dys_video_log_%d', $userid % 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 用户模型 (对应原系统 member 表)
|
||||
* 数据库连接: dbmember (member库)
|
||||
*/
|
||||
class Member extends Model
|
||||
{
|
||||
// 设置连接名 (对应 config/database.php 中的 connections)
|
||||
protected $connection = 'dbmember';
|
||||
|
||||
// 表名
|
||||
protected $name = 'member';
|
||||
|
||||
// 主键
|
||||
protected $pk = 'userid';
|
||||
|
||||
// 自动时间戳
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
// 隐藏字段
|
||||
protected $hidden = ['password'];
|
||||
|
||||
/**
|
||||
* 根据用户名查找用户
|
||||
* @param string $username
|
||||
* @return Member|null
|
||||
*/
|
||||
public static function findByUsername(string $username): ?Member
|
||||
{
|
||||
return self::where('username', $username)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID查找用户
|
||||
* @param int $userid
|
||||
* @return Member|null
|
||||
*/
|
||||
public static function findByUserid(int $userid): ?Member
|
||||
{
|
||||
return self::where('userid', $userid)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
* 当前项目统一使用双重 MD5。
|
||||
*
|
||||
* @param string $password 明文密码
|
||||
* @return bool
|
||||
*/
|
||||
public function verifyPassword(string $password): bool
|
||||
{
|
||||
return self::makePassword($password) === $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成系统使用的密码摘要
|
||||
* @param string $password 明文密码
|
||||
* @return string
|
||||
*/
|
||||
public static function makePassword(string $password): string
|
||||
{
|
||||
return md5(md5($password));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否被禁用
|
||||
* @return bool
|
||||
*/
|
||||
public function isDisabled(): bool
|
||||
{
|
||||
return $this->disabled == 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号是否过期
|
||||
* @return bool
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if (empty($this->endtime)) {
|
||||
return false;
|
||||
}
|
||||
return $this->endtime < time();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户套餐信息
|
||||
* @return array|null
|
||||
*/
|
||||
public function getProductInfo(): ?array
|
||||
{
|
||||
// 套餐配置改由模型统一封装,避免业务层散落裸表查询。
|
||||
$product = ProductList::findByVType((int) $this->v_type);
|
||||
|
||||
if (!$product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->buildEffectiveProductInfo($product->toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取代理商信息
|
||||
* @return array|null
|
||||
*/
|
||||
public function getAgentInfo(): ?array
|
||||
{
|
||||
if (empty($this->formtypeid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::where('userid', $this->formtypeid)->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录登录日志
|
||||
* @param bool $success
|
||||
* @param string $loginType
|
||||
* @return void
|
||||
*/
|
||||
public function logLogin(bool $success, string $loginType = 'password'): void
|
||||
{
|
||||
try {
|
||||
MemberLoginLog::recordLogin([
|
||||
'userid' => $this->userid,
|
||||
'ip' => request()->ip(),
|
||||
'time' => time(),
|
||||
'succeed' => $success ? 1 : 0,
|
||||
'diqu' => '',
|
||||
'login_type' => $loginType,
|
||||
'adminid' => 0,
|
||||
'v_type' => $this->v_type ?? 0,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// 日志记录失败不影响登录
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建与 acgpmw `MemberController` 对齐后的套餐信息。
|
||||
*
|
||||
* 这里只补齐当前 tp 已实际用到的套餐二次加工逻辑:
|
||||
* 1. 处理特定版本新增平台
|
||||
* 2. 处理用户单独平台覆盖
|
||||
* 3. 处理公众号/微信绑定等特殊额度
|
||||
* 4. 处理内部账号临时补平台
|
||||
*
|
||||
* @param array $productInfo 原始套餐信息
|
||||
* @return array
|
||||
*/
|
||||
private function buildEffectiveProductInfo(array $productInfo): array
|
||||
{
|
||||
$productInfo['platforms'] = (string) ($productInfo['platforms'] ?? '');
|
||||
|
||||
// 对齐 acgpmw:金钻/钻石新注册账号额外补充 B站 和 B家号。
|
||||
if ((int) $this->regtime >= 1773158400
|
||||
&& in_array((int) $this->v_type, [123, 124], true)
|
||||
&& (int) ($productInfo['video_num'] ?? 0) > 0
|
||||
) {
|
||||
$productInfo['platforms'] = self::appendPlatform($productInfo['platforms'], 2);
|
||||
$productInfo['platforms'] = self::appendPlatform($productInfo['platforms'], 5);
|
||||
}
|
||||
|
||||
// 对齐 acgpmw:用户单独平台权限可直接覆盖套餐平台集合。
|
||||
$specialPlatforms = DySpecialPlatforms::findActiveByUserid((int) $this->userid);
|
||||
$productInfo['sora_turntable_type'] = (int) ($specialPlatforms->sora_turntable_type ?? 0);
|
||||
if ($specialPlatforms) {
|
||||
$specialPlatformsData = $specialPlatforms->toArray();
|
||||
if (!empty($specialPlatformsData['platforms'])) {
|
||||
$productInfo['platforms'] = (string) $specialPlatformsData['platforms'];
|
||||
}
|
||||
if (!empty($specialPlatformsData['kword_num'])) {
|
||||
$productInfo['kword_num'] = $specialPlatformsData['kword_num'];
|
||||
}
|
||||
if (!empty($specialPlatformsData['is_video_num']) && (int) ($productInfo['video_num'] ?? 0) < 0) {
|
||||
$productInfo['video_num'] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 对齐 acgpmw:用户单独绑定额度可覆盖微信/公众号绑定数量。
|
||||
$specialBinding = DySpecialBinding::findActiveByUserid((int) $this->userid);
|
||||
if ($specialBinding) {
|
||||
$specialBindingData = $specialBinding->toArray();
|
||||
if ((int) ($specialBindingData['wx_num'] ?? 0) > 0) {
|
||||
$productInfo['wx_num'] = (int) $specialBindingData['wx_num'];
|
||||
}
|
||||
if ((int) ($specialBindingData['wxgzh_num'] ?? 0) > 0) {
|
||||
$productInfo['wxgzh_num'] = (int) $specialBindingData['wxgzh_num'];
|
||||
}
|
||||
}
|
||||
|
||||
$productInfo['soft_groups_auth'] = $this->resolveSoftGroupsAuth();
|
||||
|
||||
// 对齐 acgpmw:内部号在部分版本下临时追加 B家号、B站、公众号。
|
||||
if ((int) $this->staff_type === 1
|
||||
&& in_array((int) $productInfo['soft_groups_auth'], [0, 2], true)
|
||||
&& !$this->hasTitkAuth()
|
||||
) {
|
||||
$productInfo['platforms'] = self::appendPlatform($productInfo['platforms'], 2);
|
||||
$productInfo['platforms'] = self::appendPlatform($productInfo['platforms'], 5);
|
||||
$productInfo['platforms'] = self::appendPlatform($productInfo['platforms'], 6);
|
||||
}
|
||||
|
||||
return $productInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算软件账号等级标识。
|
||||
*
|
||||
* 该规则直接按 acgpmw `MemberController` 中的 `soft_groups_auth` 判定对齐。
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function resolveSoftGroupsAuth(): int
|
||||
{
|
||||
$vType = (int) $this->v_type;
|
||||
$softGroupsAuth = 0;
|
||||
$softGroupVTypes = [144, 145, 146, 147, 156];
|
||||
|
||||
if (in_array($vType, $softGroupVTypes, true)) {
|
||||
$softGroupsAuth = 1;
|
||||
}
|
||||
if (in_array($vType, [149, 152, 157], true)) {
|
||||
$softGroupsAuth = 2;
|
||||
}
|
||||
if (in_array($vType, [161], true)) {
|
||||
$softGroupsAuth = 3;
|
||||
}
|
||||
if (in_array($vType, [162], true)) {
|
||||
$softGroupsAuth = 4;
|
||||
}
|
||||
if (in_array($vType, [169], true)) {
|
||||
$softGroupsAuth = 6;
|
||||
}
|
||||
if (in_array($vType, [170], true)) {
|
||||
$softGroupsAuth = 7;
|
||||
}
|
||||
if (in_array($vType, [171, 172, 173], true)) {
|
||||
$softGroupsAuth = 8;
|
||||
}
|
||||
if (in_array($vType, [176, 177, 178, 179], true)) {
|
||||
$softGroupsAuth = 9;
|
||||
}
|
||||
if (in_array($vType, [180], true)) {
|
||||
$softGroupsAuth = 10;
|
||||
}
|
||||
|
||||
return $softGroupsAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否属于 TikTok 特殊账号版本。
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function hasTitkAuth(): bool
|
||||
{
|
||||
return in_array((int) $this->v_type, [158, 159, 160, 163, 164, 166], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加平台到逗号包裹的平台字符串中。
|
||||
*
|
||||
* @param string $platforms 平台字符串,如 `,0,1,6,`
|
||||
* @param int $platform 平台编号
|
||||
* @return string
|
||||
*/
|
||||
private static function appendPlatform(string $platforms, int $platform): string
|
||||
{
|
||||
if (strpos($platforms, ',' . $platform . ',') !== false) {
|
||||
return $platforms;
|
||||
}
|
||||
|
||||
$platformList = [];
|
||||
$platformText = trim($platforms, ',');
|
||||
if ($platformText !== '') {
|
||||
foreach (explode(',', $platformText) as $item) {
|
||||
if ($item === '') {
|
||||
continue;
|
||||
}
|
||||
$platformList[] = (int) $item;
|
||||
}
|
||||
}
|
||||
|
||||
$platformList[] = $platform;
|
||||
$platformList = array_values(array_unique($platformList));
|
||||
sort($platformList);
|
||||
|
||||
return ',' . implode(',', $platformList) . ',';
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 会员登录日志模型(对应 member_login_log 表)
|
||||
*
|
||||
* 这里封装登录日志写入,避免业务层直接拼接表名。
|
||||
*/
|
||||
class MemberLoginLog extends Model
|
||||
{
|
||||
protected $connection = 'dbmember';
|
||||
|
||||
protected $name = 'member_login_log';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 记录一次登录结果。
|
||||
*
|
||||
* @param array $payload 登录日志字段
|
||||
* @return void
|
||||
*/
|
||||
public static function recordLogin(array $payload): void
|
||||
{
|
||||
$model = new self();
|
||||
$model->save($payload);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 套餐模型(对应业务库 product_list 表)
|
||||
*
|
||||
* 该模型用于读取会员套餐配置,供登录态与首页工作台展示使用。
|
||||
*/
|
||||
class ProductList extends Model
|
||||
{
|
||||
// 套餐配置存放在业务库。
|
||||
protected $connection = 'dbbiz';
|
||||
|
||||
// 对齐原系统 product_list 表。
|
||||
protected $name = 'product_list';
|
||||
|
||||
// 当前项目仅做读取,不依赖自动时间戳。
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 按套餐类型获取套餐信息。
|
||||
*
|
||||
* @param int $vType 套餐类型
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findByVType(int $vType): ?self
|
||||
{
|
||||
return self::where('v_type', $vType)->find();
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use think\facade\Route;
|
||||
use app\api\controller\v1\Auth;
|
||||
use app\api\controller\v1\Platform;
|
||||
use app\api\controller\v1\PublishPlan;
|
||||
use app\api\controller\v1\VideoWork;
|
||||
|
||||
/**
|
||||
* API 应用路由
|
||||
*/
|
||||
|
||||
// v1 认证接口(公开)
|
||||
Route::post('v1/auth/login', [Auth::class, 'login']);
|
||||
Route::post('v1/auth/register', [Auth::class, 'register']);
|
||||
Route::post('v1/auth/refresh', [Auth::class, 'refresh']);
|
||||
|
||||
// v1 认证接口(需登录)
|
||||
Route::group('v1/auth', function () {
|
||||
Route::get('me', [Auth::class, 'me']);
|
||||
Route::post('logout', [Auth::class, 'logout']);
|
||||
Route::post('password', [Auth::class, 'password']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
|
||||
// v1 平台账号管理接口(需登录)
|
||||
Route::group('v1/platform', function () {
|
||||
Route::get('accounts', [Platform::class, 'accounts']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
|
||||
// v1 发布计划接口(需登录)
|
||||
Route::group('v1/publish-plan', function () {
|
||||
Route::get('list', [PublishPlan::class, 'index']);
|
||||
Route::post('start/:id', [PublishPlan::class, 'start']);
|
||||
Route::post('stop/:id', [PublishPlan::class, 'stop']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
|
||||
// v1 视频作品接口(需登录)
|
||||
Route::group('v1/video-work', function () {
|
||||
Route::get('list', [VideoWork::class, 'index']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
@@ -1,252 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\service;
|
||||
|
||||
use app\api\common\Jwt;
|
||||
use app\api\model\DyVideoCron;
|
||||
use app\api\model\DyVideoUser;
|
||||
use app\api\model\DysVideoLog;
|
||||
use app\api\model\Member;
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理用户登录、注册、Token 管理等
|
||||
*/
|
||||
class AuthService
|
||||
{
|
||||
/**
|
||||
* 用户登录
|
||||
* @param string $username 用户名
|
||||
* @param string $password 密码
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function login(string $username, string $password): array
|
||||
{
|
||||
// 查找用户
|
||||
$member = Member::findByUsername($username);
|
||||
if (!$member) {
|
||||
throw new \Exception('用户名或密码错误', 4001);
|
||||
}
|
||||
|
||||
// 检查是否被禁用
|
||||
if ($member->isDisabled()) {
|
||||
$member->logLogin(false, 'password');
|
||||
throw new \Exception('账号已被禁用', 4002);
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (!$member->verifyPassword($password)) {
|
||||
$member->logLogin(false, 'password');
|
||||
throw new \Exception('用户名或密码错误', 4001);
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if ($member->isExpired()) {
|
||||
$member->logLogin(false, 'password');
|
||||
throw new \Exception('账号已过期,请联系客服续费', 4003);
|
||||
}
|
||||
|
||||
// 记录登录日志
|
||||
$member->logLogin(true, 'password');
|
||||
|
||||
// 生成 Token
|
||||
$token = Jwt::encode([
|
||||
'userid' => $member->userid,
|
||||
'username' => $member->username,
|
||||
'v_type' => $member->v_type,
|
||||
]);
|
||||
|
||||
$refreshToken = Jwt::refreshToken($member->userid);
|
||||
|
||||
// 返回用户信息
|
||||
return [
|
||||
'token' => $token,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_in' => config('jwt.expire', 604800),
|
||||
'user' => [
|
||||
'userid' => $member->userid,
|
||||
'username' => $member->username,
|
||||
'v_type' => $member->v_type,
|
||||
'endtime' => $member->endtime,
|
||||
'formtypeid' => $member->formtypeid,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* @param string $username 用户名
|
||||
* @param string $password 密码
|
||||
* @param string|null $email 邮箱
|
||||
* @param int|null $formtypeid 代理商ID
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function register(string $username, string $password, ?string $email = null, ?int $formtypeid = null): array
|
||||
{
|
||||
// 检查用户名是否已存在
|
||||
$exists = Member::findByUsername($username);
|
||||
if ($exists) {
|
||||
throw new \Exception('用户名已存在', 4004);
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
$member = new Member();
|
||||
$member->username = $username;
|
||||
$member->password = Member::makePassword($password);
|
||||
$member->email = $email;
|
||||
$member->formtypeid = $formtypeid ?? 0;
|
||||
$member->v_type = 0; // 默认套餐
|
||||
$member->disabled = 0;
|
||||
$member->endtime = 0;
|
||||
$member->regtime = time();
|
||||
$member->regip = request()->ip();
|
||||
|
||||
if (!$member->save()) {
|
||||
throw new \Exception('注册失败,请稍后重试', 5001);
|
||||
}
|
||||
|
||||
// 自动登录
|
||||
return $this->login($username, $password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
* @param string $refreshToken
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function refreshToken(string $refreshToken): array
|
||||
{
|
||||
$payload = Jwt::decode($refreshToken);
|
||||
if (!$payload || ($payload['type'] ?? '') !== 'refresh') {
|
||||
throw new \Exception('无效的刷新令牌', 4005);
|
||||
}
|
||||
|
||||
$member = Member::findByUserid($payload['userid']);
|
||||
if (!$member || $member->isDisabled()) {
|
||||
throw new \Exception('用户不存在或已被禁用', 4002);
|
||||
}
|
||||
|
||||
// 生成新 Token
|
||||
$token = Jwt::encode([
|
||||
'userid' => $member->userid,
|
||||
'username' => $member->username,
|
||||
'v_type' => $member->v_type,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'expires_in' => config('jwt.expire', 604800),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
* @param int $userid
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getUserInfo(int $userid): array
|
||||
{
|
||||
$member = Member::findByUserid($userid);
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 4006);
|
||||
}
|
||||
|
||||
// 获取套餐信息
|
||||
$productInfo = $member->getProductInfo();
|
||||
$dashboardStats = $this->getDashboardStats($member, $productInfo);
|
||||
|
||||
return [
|
||||
'userid' => $member->userid,
|
||||
'username' => $member->username,
|
||||
'v_type' => $member->v_type,
|
||||
'endtime' => $member->endtime,
|
||||
'formtypeid' => $member->formtypeid,
|
||||
'disabled' => $member->disabled,
|
||||
'video_num' => $member->video_num,
|
||||
'sp_num' => $member->sp_num,
|
||||
'product' => $productInfo ? [
|
||||
'v_type' => $productInfo['v_type'] ?? null,
|
||||
'nameu' => $productInfo['nameu'] ?? '',
|
||||
'video_num' => $productInfo['video_num'] ?? 0,
|
||||
'account_num' => $productInfo['account_num'] ?? 0,
|
||||
] : null,
|
||||
'stats' => $dashboardStats,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取首页统计数据。
|
||||
*
|
||||
* 此处按 acgpmw `dyai/controller/index.php::right()` 的首页语义对齐:
|
||||
* - 账户剩余条数:优先展示用户余额 `member.video_num`
|
||||
* - 授权账号:统计 `dy_video_user`
|
||||
* - 发布任务:统计 `dy_video_cron`
|
||||
* - 发布作品:统计抖音日志分表 `dys_video_log_{userid % 1000}`
|
||||
*
|
||||
* 查询失败时返回 null,避免前端把异常误展示为 0。
|
||||
*
|
||||
* @param Member $member 当前登录用户
|
||||
* @param array|null $productInfo 当前套餐信息
|
||||
* @return array
|
||||
*/
|
||||
private function getDashboardStats(Member $member, ?array $productInfo = null): array
|
||||
{
|
||||
$isUnlimitedQuota = $productInfo && (int) ($productInfo['video_num'] ?? 0) < 0;
|
||||
|
||||
$stats = [
|
||||
'remaining_quota' => $isUnlimitedQuota ? null : (int) ($member->video_num ?? 0),
|
||||
'remaining_quota_unlimited' => $isUnlimitedQuota,
|
||||
'published_works_count' => null,
|
||||
'authorized_account_count' => null,
|
||||
'published_task_count' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
$stats['authorized_account_count'] = DyVideoUser::countActiveByUserId((int) $member->userid);
|
||||
} catch (\Throwable $exception) {
|
||||
// 统计接口需要尽量稳健,单项查询失败时不阻断登录信息返回。
|
||||
}
|
||||
|
||||
try {
|
||||
$stats['published_task_count'] = DyVideoCron::countActiveByUserId((int) $member->userid);
|
||||
} catch (\Throwable $exception) {
|
||||
// 这里与 acgpmw 一样只排除 status=3 的任务,其余状态均计入首页统计。
|
||||
}
|
||||
|
||||
try {
|
||||
$stats['published_works_count'] = DysVideoLog::countPublishedByUserId((int) $member->userid);
|
||||
} catch (\Throwable $exception) {
|
||||
// 发布作品日志使用分表存储,查询失败时前端按“待接入”兜底展示。
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
* @param int $userid
|
||||
* @param string $oldPassword
|
||||
* @param string $newPassword
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function changePassword(int $userid, string $oldPassword, string $newPassword): bool
|
||||
{
|
||||
$member = Member::findByUserid($userid);
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 4006);
|
||||
}
|
||||
|
||||
if (!$member->verifyPassword($oldPassword)) {
|
||||
throw new \Exception('原密码错误', 4007);
|
||||
}
|
||||
|
||||
$member->password = Member::makePassword($newPassword);
|
||||
return $member->save();
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\service;
|
||||
|
||||
use app\api\model\DyVideoUser;
|
||||
use app\api\model\DyVideoUserTitk;
|
||||
use app\api\model\Member;
|
||||
|
||||
/**
|
||||
* 平台账号管理服务。
|
||||
*
|
||||
* 本服务只补齐小程序“平台账号管理”页面所需的最基础只读能力,
|
||||
* 关键查询与状态语义按 acgpmw `dyai/controller/platform.php` 的
|
||||
* `index()` / `get_zhanghu_list()` 对齐,不擅自扩展解绑、授权等高风险动作。
|
||||
*/
|
||||
class PlatformService
|
||||
{
|
||||
/**
|
||||
* 平台映射按 acgpmw `data/other/platforms_py.php` 对齐。
|
||||
*/
|
||||
private const PLATFORM_KEY_MAP = [
|
||||
0 => 'douyin',
|
||||
1 => 'kuaishou',
|
||||
2 => 'baijiahao',
|
||||
3 => 'xiaohongshu',
|
||||
4 => 'shipinhao',
|
||||
5 => 'bilibili',
|
||||
6 => 'gongzhonghao',
|
||||
10 => 'tiktok',
|
||||
];
|
||||
|
||||
/**
|
||||
* 页面展示名称。
|
||||
*
|
||||
* acgpmw 模板内部会通过 `$this->platform_info[$platform]` 展示中文平台名,
|
||||
* 这里在 API 中直接返回前端所需文案,避免小程序自行猜测。
|
||||
*/
|
||||
private const PLATFORM_NAME_MAP = [
|
||||
0 => 'D音',
|
||||
1 => 'K手',
|
||||
2 => 'B家号',
|
||||
3 => '小红薯',
|
||||
4 => '视P号',
|
||||
5 => 'B哩哔哩',
|
||||
6 => '公Z号',
|
||||
10 => 'TikTok',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取平台账号管理页列表数据。
|
||||
*
|
||||
* @param int $userid 当前登录用户ID
|
||||
* @param int|null $platform 当前筛选平台;为空时返回全部可见平台
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getAccountList(int $userid, ?int $platform = null): array
|
||||
{
|
||||
$member = Member::findByUserid($userid);
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 4006);
|
||||
}
|
||||
|
||||
$productInfo = $member->getProductInfo();
|
||||
$availablePlatforms = $this->resolveAvailablePlatforms($member, $productInfo);
|
||||
|
||||
if (empty($availablePlatforms)) {
|
||||
throw new \Exception('暂无平台管理权限', 4004);
|
||||
}
|
||||
|
||||
if ($platform !== null && !in_array($platform, $availablePlatforms, true)) {
|
||||
throw new \Exception('当前套餐暂无该平台权限', 4004);
|
||||
}
|
||||
|
||||
$targetPlatform = $platform;
|
||||
$accounts = DyVideoUser::getPlatformAccountsByUserId($userid, $targetPlatform);
|
||||
|
||||
$accountItems = [];
|
||||
foreach ($accounts as $account) {
|
||||
$accountPlatform = (int) $account->platform;
|
||||
|
||||
if (!in_array($accountPlatform, $availablePlatforms, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$accountItems[] = $this->buildAccountItem($account->toArray());
|
||||
}
|
||||
|
||||
$platformTabs = $this->buildPlatformTabs($availablePlatforms, $userid);
|
||||
$summary = $this->buildSummary($accountItems);
|
||||
|
||||
return [
|
||||
'filters' => [
|
||||
'current_platform' => $targetPlatform,
|
||||
'platforms' => $platformTabs,
|
||||
],
|
||||
'summary' => $summary,
|
||||
'list' => $accountItems,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前用户可见平台。
|
||||
*
|
||||
* 该逻辑直接对齐 acgpmw `platform::index()`:
|
||||
* 1. 先使用已按 `MemberController` 规则加工后的套餐平台权限
|
||||
* 2. 若为 146/147 且不在特殊账号名单中,仅允许抖音平台
|
||||
*
|
||||
* @param Member $member 当前登录用户
|
||||
* @param array|null $productInfo 套餐信息
|
||||
* @return array<int>
|
||||
*/
|
||||
private function resolveAvailablePlatforms(Member $member, ?array $productInfo): array
|
||||
{
|
||||
$platformText = trim((string) ($productInfo['platforms'] ?? ''), ',');
|
||||
if ($platformText === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$platforms = array_values(array_filter(array_map('intval', explode(',', $platformText)), static function ($item) {
|
||||
return array_key_exists($item, self::PLATFORM_NAME_MAP);
|
||||
}));
|
||||
|
||||
$specialAccounts = $this->loadSpecialAccounts();
|
||||
$isRestrictedVType = in_array((int) $member->v_type, [146, 147], true);
|
||||
$isSpecialAccount = in_array((int) $member->userid, $specialAccounts, true);
|
||||
|
||||
if ($isRestrictedVType && !$isSpecialAccount) {
|
||||
return [0];
|
||||
}
|
||||
|
||||
return $platforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建前端平台筛选项。
|
||||
*
|
||||
* @param array<int> $platforms 当前用户可见平台
|
||||
* @param int $userid 当前用户ID
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildPlatformTabs(array $platforms, int $userid): array
|
||||
{
|
||||
$tabs = [
|
||||
[
|
||||
'id' => null,
|
||||
'name' => '全部平台',
|
||||
'key' => 'all',
|
||||
'count' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
$allCount = 0;
|
||||
foreach ($platforms as $platform) {
|
||||
$count = DyVideoUser::where('userid', $userid)
|
||||
->where('disabled', 0)
|
||||
->where('platform', $platform)
|
||||
->count();
|
||||
|
||||
$allCount += (int) $count;
|
||||
$tabs[] = [
|
||||
'id' => $platform,
|
||||
'name' => self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform),
|
||||
'key' => self::PLATFORM_KEY_MAP[$platform] ?? ('platform_' . $platform),
|
||||
'count' => (int) $count,
|
||||
];
|
||||
}
|
||||
|
||||
$tabs[0]['count'] = $allCount;
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把数据库记录转换为前端可直接渲染的账号卡片。
|
||||
*
|
||||
* 状态字段解释按 acgpmw `get_zhanghu_list()` 三列展示对齐:
|
||||
* - `is_endauth` => 账号授权状态
|
||||
* - `aa_endauth` => 数据授权状态
|
||||
* - `browser` => 异常状态
|
||||
*
|
||||
* @param array $account dy_video_user 单条记录
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildAccountItem(array $account): array
|
||||
{
|
||||
$platform = (int) ($account['platform'] ?? 0);
|
||||
$countryName = '';
|
||||
|
||||
if ($platform === 10) {
|
||||
$titkInfo = DyVideoUserTitk::findByVuid((int) $account['id']);
|
||||
$countryName = (string) ($titkInfo->country_name ?? '');
|
||||
}
|
||||
|
||||
$accountAuth = (int) ($account['is_endauth'] ?? 0) === 1
|
||||
? $this->buildStatusBlock('授权到期', 'danger', '请重新完成平台授权')
|
||||
: $this->buildStatusBlock('授权正常', 'success', '当前账号授权可用');
|
||||
|
||||
$dataAuth = (int) ($account['aa_endauth'] ?? 0) === 1
|
||||
? $this->buildStatusBlock('授权到期', 'danger', '请重新完成数据授权')
|
||||
: $this->buildStatusBlock('授权正常', 'success', '当前数据授权可用');
|
||||
|
||||
$exceptionStatus = (int) ($account['browser'] ?? 0) === 1
|
||||
? $this->buildStatusBlock('发布异常', 'warning', '发布链路存在异常,需要人工处理')
|
||||
: $this->buildStatusBlock('使用正常', 'success', '当前账号发布链路正常');
|
||||
|
||||
return [
|
||||
'id' => (int) $account['id'],
|
||||
'platform' => $platform,
|
||||
'platform_name' => self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform),
|
||||
'platform_key' => self::PLATFORM_KEY_MAP[$platform] ?? ('platform_' . $platform),
|
||||
'nickname' => (string) ($account['dy_nickname'] ?? ''),
|
||||
'avatar' => (string) ($account['dy_avatar'] ?? ''),
|
||||
'intro' => (string) ($account['dy_intro'] ?? ''),
|
||||
'phone' => (string) ($account['info_shouji'] ?? ''),
|
||||
'country_name' => $countryName,
|
||||
'proviceid' => (int) ($account['proviceid'] ?? 0),
|
||||
'is_lanv' => (int) ($account['is_lanv'] ?? 0) === 1,
|
||||
'is_qyh' => (int) ($account['is_qyh'] ?? 0) === 1,
|
||||
'account_status' => $accountAuth,
|
||||
'data_status' => $dataAuth,
|
||||
'exception_status' => $exceptionStatus,
|
||||
'status_overview' => $this->buildOverviewStatus($accountAuth, $dataAuth, $exceptionStatus),
|
||||
'display_code' => '#P' . (int) $account['id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建单个状态块。
|
||||
*
|
||||
* @param string $text 显示文案
|
||||
* @param string $tone 视觉色调:success / warning / danger
|
||||
* @param string $hint 补充说明
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildStatusBlock(string $text, string $tone, string $hint): array
|
||||
{
|
||||
return [
|
||||
'text' => $text,
|
||||
'tone' => $tone,
|
||||
'hint' => $hint,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总账号卡片主状态,便于列表页用一个醒目标记表示当前账号健康度。
|
||||
*
|
||||
* @param array $accountAuth 账号授权状态
|
||||
* @param array $dataAuth 数据授权状态
|
||||
* @param array $exceptionStatus 异常状态
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildOverviewStatus(array $accountAuth, array $dataAuth, array $exceptionStatus): array
|
||||
{
|
||||
if ($accountAuth['tone'] === 'danger' || $dataAuth['tone'] === 'danger') {
|
||||
return $this->buildStatusBlock('需重新授权', 'danger', '账号授权或数据授权已到期');
|
||||
}
|
||||
|
||||
if ($exceptionStatus['tone'] === 'warning') {
|
||||
return $this->buildStatusBlock('存在异常', 'warning', '当前账号发布链路异常');
|
||||
}
|
||||
|
||||
return $this->buildStatusBlock('状态正常', 'success', '账号与发布状态均正常');
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计当前筛选结果。
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $items 列表项
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function buildSummary(array $items): array
|
||||
{
|
||||
$summary = [
|
||||
'total' => count($items),
|
||||
'need_reauth_count' => 0,
|
||||
'browser_exception_count' => 0,
|
||||
'normal_count' => 0,
|
||||
];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$needReauth = $item['account_status']['tone'] === 'danger' || $item['data_status']['tone'] === 'danger';
|
||||
$hasBrowserException = $item['exception_status']['tone'] === 'warning';
|
||||
|
||||
if ($needReauth) {
|
||||
$summary['need_reauth_count']++;
|
||||
}
|
||||
|
||||
if ($hasBrowserException) {
|
||||
$summary['browser_exception_count']++;
|
||||
}
|
||||
|
||||
if (!$needReauth && !$hasBrowserException) {
|
||||
$summary['normal_count']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 acgpmw 的特殊账号名单。
|
||||
*
|
||||
* 这里优先读取基线项目 `/root/work/acgpmw/data/other/tw_special_account.php`,
|
||||
* 保证 146/147 套餐用户的平台权限限制与原系统保持一致;
|
||||
* 若文件不存在,则退回空数组,接口仍可工作。
|
||||
*
|
||||
* @return array<int>
|
||||
*/
|
||||
private function loadSpecialAccounts(): array
|
||||
{
|
||||
$specialAccountFile = '/root/work/acgpmw/data/other/tw_special_account.php';
|
||||
|
||||
if (!is_file($specialAccountFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$accounts = require $specialAccountFile;
|
||||
|
||||
return is_array($accounts)
|
||||
? array_values(array_map('intval', $accounts))
|
||||
: [];
|
||||
}
|
||||
}
|
||||
@@ -1,713 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\service;
|
||||
|
||||
use app\api\model\DyVideoCron;
|
||||
use app\api\model\DyVideoUser;
|
||||
use app\api\model\Member;
|
||||
use think\Collection;
|
||||
use think\db\Query;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 发布计划服务。
|
||||
*
|
||||
* 本服务按 acgpmw 发布计划模块最小可用范围实现:
|
||||
* 1. 普通发布计划:`controller/cron.php::cron_list()`
|
||||
* 2. AI 项目计划:`controller/ai_project_cron.php::cron_list()`
|
||||
* 3. 启停动作:`controller/cron.php::fabu()` / `controller/cron.php::stop()`
|
||||
*
|
||||
* 当前不擅自补齐删除、编辑等复杂动作,避免偏离原始程序。
|
||||
*/
|
||||
class PublishPlanService
|
||||
{
|
||||
/**
|
||||
* 平台中文名直接按基线常用映射返回给前端,避免小程序自行猜测。
|
||||
*/
|
||||
private const PLATFORM_NAME_MAP = [
|
||||
0 => '抖音',
|
||||
1 => '快手',
|
||||
2 => '百家号',
|
||||
3 => '小红书',
|
||||
4 => '视频号',
|
||||
5 => 'B站',
|
||||
6 => '公众号',
|
||||
10 => 'TikTok',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取发布计划列表。
|
||||
*
|
||||
* @param int $userid 当前登录用户ID
|
||||
* @param array<string, mixed> $params 前端筛选参数
|
||||
* @return array<string, mixed>
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getPlanList(int $userid, array $params = []): array
|
||||
{
|
||||
$member = Member::findByUserid($userid);
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 4004);
|
||||
}
|
||||
|
||||
$statusFilter = $this->normalizeStatusFilter((string) ($params['status'] ?? 'all'));
|
||||
$page = max(1, (int) ($params['page'] ?? 1));
|
||||
$pageSize = min(50, max(1, (int) ($params['page_size'] ?? 20)));
|
||||
|
||||
// 基础查询范围严格按 acgpmw 发布计划模块两个列表控制器对齐。
|
||||
$baseQuery = DyVideoCron::buildPublishPlanQuery($userid);
|
||||
|
||||
$total = (int) (clone $this->applyStatusFilter(clone $baseQuery, $statusFilter))->count();
|
||||
$records = $this->applyStatusFilter(clone $baseQuery, $statusFilter)
|
||||
->field([
|
||||
'id',
|
||||
'userid',
|
||||
'name',
|
||||
'status',
|
||||
'jrstop',
|
||||
'project_id',
|
||||
'project_type',
|
||||
'platform',
|
||||
'fbvuids',
|
||||
'atvuids',
|
||||
'fbvuids_a',
|
||||
'time_range',
|
||||
'starttime',
|
||||
'endtime',
|
||||
'meirinum',
|
||||
'plnum',
|
||||
'yifanum',
|
||||
'suc_fbnum',
|
||||
'err_fbnum',
|
||||
'fbdatetime',
|
||||
'addtime',
|
||||
'tplids',
|
||||
'videotemplates',
|
||||
])
|
||||
->order(['id' => 'desc'])
|
||||
->page($page, $pageSize)
|
||||
->select();
|
||||
|
||||
$projectNameMap = $this->loadProjectNames($userid, $records);
|
||||
$accountInfoMap = $this->loadAccountInfos($records);
|
||||
|
||||
$items = [];
|
||||
foreach ($records as $record) {
|
||||
$items[] = $this->buildPlanItem($record->toArray(), $projectNameMap, $accountInfoMap);
|
||||
}
|
||||
|
||||
return [
|
||||
'filters' => $this->buildFilters($baseQuery, $statusFilter),
|
||||
'summary' => $this->buildSummary($baseQuery),
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'total' => $total,
|
||||
'has_more' => $page * $pageSize < $total,
|
||||
],
|
||||
'list' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启发布计划。
|
||||
*
|
||||
* 此处按 acgpmw `cron::fabu()` 对齐,只恢复 `jrstop=0`。
|
||||
*
|
||||
* @param int $userid 当前登录用户ID
|
||||
* @param int $id 计划ID
|
||||
* @return array<string, int>
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function startPlan(int $userid, int $id): array
|
||||
{
|
||||
$plan = $this->getOwnedPlan($userid, $id);
|
||||
if ((int) $plan->status === 3) {
|
||||
throw new \Exception('已完成计划不支持继续开启', 4004);
|
||||
}
|
||||
|
||||
$affected = DyVideoCron::where('id', $id)
|
||||
->where('userid', $userid)
|
||||
->update(['jrstop' => 0]);
|
||||
|
||||
if (!$affected) {
|
||||
throw new \Exception('设置失败', 500);
|
||||
}
|
||||
|
||||
return ['id' => $id, 'jrstop' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停发布计划。
|
||||
*
|
||||
* 此处按 acgpmw `cron::stop()` 对齐,除设置 `jrstop=1` 外,
|
||||
* 还尝试把 `dy_cron_account` 中当前待执行记录改成状态 5。
|
||||
*
|
||||
* @param int $userid 当前登录用户ID
|
||||
* @param int $id 计划ID
|
||||
* @return array<string, int>
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function stopPlan(int $userid, int $id): array
|
||||
{
|
||||
$plan = $this->getOwnedPlan($userid, $id);
|
||||
if ((int) $plan->status === 3) {
|
||||
throw new \Exception('已完成计划不支持暂停', 4004);
|
||||
}
|
||||
|
||||
$affected = DyVideoCron::where('id', $id)
|
||||
->where('userid', $userid)
|
||||
->update(['jrstop' => 1]);
|
||||
|
||||
if (!$affected) {
|
||||
throw new \Exception('设置失败', 500);
|
||||
}
|
||||
|
||||
// 按 acgpmw `cron::stop()` 对齐处理新模式账号执行队列。
|
||||
Db::connect('dbmember')
|
||||
->name('dy_cron_account')
|
||||
->where('userid', $userid)
|
||||
->where('cron_id', $id)
|
||||
->where('status', 0)
|
||||
->update(['status' => 5]);
|
||||
|
||||
return ['id' => $id, 'jrstop' => 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前页涉及到的项目名称。
|
||||
*
|
||||
* AI 项目计划列表在 acgpmw 中会把 `project_id` 映射成项目名,
|
||||
* 这里同样在服务端完成,减少小程序字段推断。
|
||||
*
|
||||
* @param int $userid 当前登录用户ID
|
||||
* @param Collection<int, DyVideoCron> $records 当前页记录
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function loadProjectNames(int $userid, Collection $records): array
|
||||
{
|
||||
$projectIds = [];
|
||||
foreach ($records as $record) {
|
||||
$projectId = (int) $record->project_id;
|
||||
if ($projectId > 0) {
|
||||
$projectIds[] = $projectId;
|
||||
}
|
||||
}
|
||||
|
||||
$projectIds = array_values(array_unique($projectIds));
|
||||
if (empty($projectIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var array<int, string> $projectMap */
|
||||
$projectMap = Db::connect('dbmember')
|
||||
->name('dy_ai_project')
|
||||
->where('userid', $userid)
|
||||
->whereIn('id', $projectIds)
|
||||
->column('name', 'id');
|
||||
|
||||
return $projectMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载当前页计划中用到的账号信息。
|
||||
*
|
||||
* @param Collection<int, DyVideoCron> $records 当前页记录
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function loadAccountInfos(Collection $records): array
|
||||
{
|
||||
$accountIds = [];
|
||||
foreach ($records as $record) {
|
||||
$accountIds = array_merge(
|
||||
$accountIds,
|
||||
$this->decodeIdList($record->fbvuids),
|
||||
$this->decodeIdList($record->atvuids),
|
||||
$this->decodeIdList($record->fbvuids_a)
|
||||
);
|
||||
}
|
||||
|
||||
$accountIds = array_values(array_unique(array_filter(array_map('intval', $accountIds))));
|
||||
if (empty($accountIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$accounts = DyVideoUser::whereIn('id', $accountIds)
|
||||
->field(['id', 'platform', 'dy_nickname'])
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$accountMap = [];
|
||||
foreach ($accounts as $account) {
|
||||
$accountMap[(int) $account['id']] = $account;
|
||||
}
|
||||
|
||||
return $accountMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把单条计划记录转换成小程序卡片数据。
|
||||
*
|
||||
* 字段映射依据:
|
||||
* - 发布进度:`plnum / yifanum / suc_fbnum / err_fbnum`
|
||||
* - 账号集合:`fbvuids / atvuids / fbvuids_a`
|
||||
* - 时间展示:`time_range / starttime / endtime / fbdatetime`
|
||||
* - 视频信息:`tplids / videotemplates`
|
||||
*
|
||||
* @param array<string, mixed> $record 原始记录
|
||||
* @param array<int, string> $projectNameMap 项目名称映射
|
||||
* @param array<int, array<string, mixed>> $accountInfoMap 账号信息映射
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPlanItem(array $record, array $projectNameMap, array $accountInfoMap): array
|
||||
{
|
||||
$publishAccountIds = $this->decodeIdList($record['fbvuids'] ?? null);
|
||||
$atAccountIds = $this->decodeIdList($record['atvuids'] ?? null);
|
||||
$syncAccountIds = $this->decodeIdList($record['fbvuids_a'] ?? null);
|
||||
$materialIds = $this->decodeIdList($record['tplids'] ?? null);
|
||||
$videoTemplateIds = $this->decodeIdList($record['videotemplates'] ?? null);
|
||||
|
||||
$publishAccounts = $this->resolveAccountNames($publishAccountIds, $accountInfoMap);
|
||||
$atAccounts = $this->resolveAccountNames($atAccountIds, $accountInfoMap);
|
||||
$syncAccounts = $this->resolveAccountNames($syncAccountIds, $accountInfoMap);
|
||||
|
||||
$statusInfo = $this->buildStatusBlock(
|
||||
(int) ($record['status'] ?? 0),
|
||||
(int) ($record['jrstop'] ?? 0)
|
||||
);
|
||||
$actionInfo = $this->buildActionBlock($statusInfo);
|
||||
|
||||
$projectId = (int) ($record['project_id'] ?? 0);
|
||||
$projectType = (int) ($record['project_type'] ?? 0);
|
||||
$platform = (int) ($record['platform'] ?? 0);
|
||||
|
||||
return [
|
||||
'id' => (int) ($record['id'] ?? 0),
|
||||
'name' => (string) ($record['name'] ?? ''),
|
||||
'plan_type' => $projectId > 0 ? 'ai_project' : 'normal',
|
||||
'plan_type_text' => $projectId > 0 ? 'AI 项目计划' : '发布任务',
|
||||
'project_id' => $projectId,
|
||||
'project_type' => $projectType,
|
||||
'project_name' => $projectId > 0
|
||||
? ($projectNameMap[$projectId] ?? '无项目(异常)')
|
||||
: '',
|
||||
'platform' => $platform,
|
||||
'platform_name' => $projectType === 1
|
||||
? '作品下载'
|
||||
: (self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform)),
|
||||
'status' => $statusInfo,
|
||||
'action' => $actionInfo,
|
||||
'publish_time_text' => $this->buildPublishTimeText($record),
|
||||
'schedule_text' => $this->buildScheduleText($record),
|
||||
'created_at_text' => $this->formatDateTime((int) ($record['addtime'] ?? 0)),
|
||||
'metrics' => [
|
||||
[
|
||||
'key' => 'progress',
|
||||
'label' => '已发 / 总量',
|
||||
'value' => (int) ($record['yifanum'] ?? 0) . ' / ' . (int) ($record['plnum'] ?? 0),
|
||||
'tone' => 'blue',
|
||||
],
|
||||
[
|
||||
'key' => 'result',
|
||||
'label' => '成功 / 失败',
|
||||
'value' => (int) ($record['suc_fbnum'] ?? 0) . ' / ' . (int) ($record['err_fbnum'] ?? 0),
|
||||
'tone' => 'green',
|
||||
],
|
||||
[
|
||||
'key' => 'accounts',
|
||||
'label' => '关联账号',
|
||||
'value' => (string) (count($publishAccountIds) + count($atAccountIds) + count($syncAccountIds)),
|
||||
'tone' => 'amber',
|
||||
],
|
||||
],
|
||||
'account_summary_text' => $this->buildAccountSummaryText($publishAccounts, $atAccounts, $syncAccounts),
|
||||
'video_info_text' => $this->buildVideoInfoText($materialIds, $videoTemplateIds),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建状态筛选项。
|
||||
*
|
||||
* 这里把小程序筛选态映射为更稳定的业务语义:
|
||||
* - `running`:`status != 3 and jrstop = 0`
|
||||
* - `stopped`:`status != 3 and jrstop = 1`
|
||||
* - `finished`:`status = 3`
|
||||
*
|
||||
* @param Query $baseQuery 发布计划基础查询
|
||||
* @param string $currentStatus 当前筛选值
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildFilters(Query $baseQuery, string $currentStatus): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($this->getStatusFilterMap() as $key => $label) {
|
||||
$count = (int) (clone $this->applyStatusFilter(clone $baseQuery, $key))->count();
|
||||
$items[] = [
|
||||
'key' => $key,
|
||||
'name' => $label,
|
||||
'count' => $count,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'current_status' => $currentStatus,
|
||||
'items' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建顶部统计卡片。
|
||||
*
|
||||
* @param Query $baseQuery 发布计划基础查询
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function buildSummary(Query $baseQuery): array
|
||||
{
|
||||
return [
|
||||
'total' => (int) (clone $baseQuery)->count(),
|
||||
'running_count' => (int) (clone $this->applyStatusFilter(clone $baseQuery, 'running'))->count(),
|
||||
'stopped_count' => (int) (clone $this->applyStatusFilter(clone $baseQuery, 'stopped'))->count(),
|
||||
'finished_count' => (int) (clone $this->applyStatusFilter(clone $baseQuery, 'finished'))->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用状态筛选。
|
||||
*
|
||||
* @param Query $query 查询对象
|
||||
* @param string $statusFilter 筛选值
|
||||
* @return Query
|
||||
*/
|
||||
private function applyStatusFilter(Query $query, string $statusFilter): Query
|
||||
{
|
||||
switch ($statusFilter) {
|
||||
case 'running':
|
||||
$query->where('status', '<>', 3)->where('jrstop', 0);
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
$query->where('status', '<>', 3)->where('jrstop', 1);
|
||||
break;
|
||||
|
||||
case 'finished':
|
||||
$query->where('status', 3);
|
||||
break;
|
||||
|
||||
case 'all':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化状态筛选参数。
|
||||
*
|
||||
* @param string $statusFilter 前端传入的筛选值
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function normalizeStatusFilter(string $statusFilter): string
|
||||
{
|
||||
$statusFilter = $statusFilter !== '' ? $statusFilter : 'all';
|
||||
if (!array_key_exists($statusFilter, $this->getStatusFilterMap())) {
|
||||
throw new \Exception('状态筛选参数错误', 400);
|
||||
}
|
||||
|
||||
return $statusFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取筛选映射。
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function getStatusFilterMap(): array
|
||||
{
|
||||
return [
|
||||
'all' => '全部',
|
||||
'running' => '进行中',
|
||||
'stopped' => '已停止',
|
||||
'finished' => '已完成',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建计划状态块。
|
||||
*
|
||||
* @param int $status 原始状态码
|
||||
* @param int $jrstop 今日暂停标记
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildStatusBlock(int $status, int $jrstop): array
|
||||
{
|
||||
if ($status === 3) {
|
||||
return [
|
||||
'key' => 'finished',
|
||||
'text' => '已完成',
|
||||
'tone' => 'success',
|
||||
'raw_status' => $status,
|
||||
'jrstop' => $jrstop,
|
||||
];
|
||||
}
|
||||
|
||||
if ($jrstop === 1) {
|
||||
return [
|
||||
'key' => 'stopped',
|
||||
'text' => '已停止',
|
||||
'tone' => 'danger',
|
||||
'raw_status' => $status,
|
||||
'jrstop' => $jrstop,
|
||||
];
|
||||
}
|
||||
|
||||
$statusTextMap = [
|
||||
1 => '发布中',
|
||||
5 => '定时执行',
|
||||
9 => '定时执行',
|
||||
10 => '计划执行中',
|
||||
20 => '作品生成中',
|
||||
];
|
||||
|
||||
return [
|
||||
'key' => 'running',
|
||||
'text' => $statusTextMap[$status] ?? ('状态 ' . $status),
|
||||
'tone' => 'primary',
|
||||
'raw_status' => $status,
|
||||
'jrstop' => $jrstop,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据状态块构建可执行动作。
|
||||
*
|
||||
* @param array<string, mixed> $statusInfo 状态块
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
private function buildActionBlock(array $statusInfo): ?array
|
||||
{
|
||||
if ($statusInfo['key'] === 'finished') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($statusInfo['key'] === 'stopped') {
|
||||
return [
|
||||
'type' => 'start',
|
||||
'text' => '继续计划',
|
||||
'tone' => 'primary',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'stop',
|
||||
'text' => '暂停计划',
|
||||
'tone' => 'danger',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成发布时间文案。
|
||||
*
|
||||
* @param array<string, mixed> $record 原始计划记录
|
||||
* @return string
|
||||
*/
|
||||
private function buildPublishTimeText(array $record): string
|
||||
{
|
||||
$fbdatetime = (int) ($record['fbdatetime'] ?? 0);
|
||||
if ($fbdatetime > 0) {
|
||||
return $this->formatDate($fbdatetime);
|
||||
}
|
||||
|
||||
return $this->formatDate((int) ($record['addtime'] ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成执行时间文案。
|
||||
*
|
||||
* 普通计划按 `time_range` / `starttime` / `endtime` 对齐展示,
|
||||
* 下载型项目计划没有明确发布时间段时,返回固定文案。
|
||||
*
|
||||
* @param array<string, mixed> $record 原始计划记录
|
||||
* @return string
|
||||
*/
|
||||
private function buildScheduleText(array $record): string
|
||||
{
|
||||
$projectType = (int) ($record['project_type'] ?? 0);
|
||||
if ($projectType === 1) {
|
||||
return '0点至24点';
|
||||
}
|
||||
|
||||
$timeRange = trim((string) ($record['time_range'] ?? ''));
|
||||
if ($timeRange !== '') {
|
||||
$timePoints = array_values(array_filter(array_map('trim', explode(',', $timeRange)), static function ($item) {
|
||||
return $item !== '';
|
||||
}));
|
||||
|
||||
if (!empty($timePoints)) {
|
||||
return implode('、', array_map(static function ($item) {
|
||||
return $item . '点';
|
||||
}, $timePoints));
|
||||
}
|
||||
}
|
||||
|
||||
$startTime = (int) ($record['starttime'] ?? 0);
|
||||
$endTime = (int) ($record['endtime'] ?? 0);
|
||||
if ($startTime > 0 || $endTime > 0) {
|
||||
return $startTime . '点至' . $endTime . '点';
|
||||
}
|
||||
|
||||
return '未设置';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成账号摘要文案。
|
||||
*
|
||||
* @param array<int, string> $publishAccounts 发布账号名
|
||||
* @param array<int, string> $atAccounts @账号名
|
||||
* @param array<int, string> $syncAccounts 同步账号名
|
||||
* @return string
|
||||
*/
|
||||
private function buildAccountSummaryText(array $publishAccounts, array $atAccounts, array $syncAccounts): string
|
||||
{
|
||||
$parts = [];
|
||||
if (!empty($publishAccounts)) {
|
||||
$parts[] = '发布 ' . count($publishAccounts) . ' 个';
|
||||
}
|
||||
if (!empty($atAccounts)) {
|
||||
$parts[] = '@账号 ' . count($atAccounts) . ' 个';
|
||||
}
|
||||
if (!empty($syncAccounts)) {
|
||||
$parts[] = '同步 ' . count($syncAccounts) . ' 个';
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return '暂无关联账号';
|
||||
}
|
||||
|
||||
return implode(' / ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成视频信息摘要。
|
||||
*
|
||||
* @param array<int> $materialIds 素材ID集合
|
||||
* @param array<int> $videoTemplateIds 视频模板ID集合
|
||||
* @return string
|
||||
*/
|
||||
private function buildVideoInfoText(array $materialIds, array $videoTemplateIds): string
|
||||
{
|
||||
$parts = [];
|
||||
if (!empty($materialIds)) {
|
||||
$parts[] = '素材 ' . count($materialIds) . ' 个';
|
||||
}
|
||||
if (!empty($videoTemplateIds)) {
|
||||
$parts[] = '视频模板 ' . count($videoTemplateIds) . ' 个';
|
||||
}
|
||||
|
||||
if (empty($parts)) {
|
||||
return '暂无视频信息';
|
||||
}
|
||||
|
||||
return implode(' / ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析序列化 ID 列表。
|
||||
*
|
||||
* `dy_video_cron` 中多个账号和素材字段都按 PHP serialize 存储,
|
||||
* 这里统一做兼容解析,避免前端处理历史格式。
|
||||
*
|
||||
* @param mixed $serializedValue 原始字段值
|
||||
* @return array<int>
|
||||
*/
|
||||
private function decodeIdList($serializedValue): array
|
||||
{
|
||||
if (empty($serializedValue) || !is_string($serializedValue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = @unserialize($serializedValue);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter(array_map('intval', $decoded))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把账号 ID 集合映射为账号名称集合。
|
||||
*
|
||||
* @param array<int> $accountIds 账号ID集合
|
||||
* @param array<int, array<string, mixed>> $accountInfoMap 账号信息映射
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveAccountNames(array $accountIds, array $accountInfoMap): array
|
||||
{
|
||||
$names = [];
|
||||
foreach ($accountIds as $accountId) {
|
||||
if (!isset($accountInfoMap[$accountId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$account = $accountInfoMap[$accountId];
|
||||
$platformName = self::PLATFORM_NAME_MAP[(int) ($account['platform'] ?? 0)] ?? '平台';
|
||||
$nickname = trim((string) ($account['dy_nickname'] ?? ''));
|
||||
$names[] = $nickname !== '' ? ($platformName . '·' . $nickname) : ($platformName . '·账号' . $accountId);
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户拥有的发布计划。
|
||||
*
|
||||
* @param int $userid 当前登录用户ID
|
||||
* @param int $id 计划ID
|
||||
* @return DyVideoCron
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getOwnedPlan(int $userid, int $id): DyVideoCron
|
||||
{
|
||||
$plan = DyVideoCron::buildPublishPlanQuery($userid)
|
||||
->where('id', $id)
|
||||
->find();
|
||||
|
||||
if (!$plan) {
|
||||
throw new \Exception('发布计划不存在', 404);
|
||||
}
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期。
|
||||
*
|
||||
* @param int $timestamp Unix 时间戳
|
||||
* @return string
|
||||
*/
|
||||
private function formatDate(int $timestamp): string
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return '未设置';
|
||||
}
|
||||
|
||||
return date('Y-m-d', $timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间。
|
||||
*
|
||||
* @param int $timestamp Unix 时间戳
|
||||
* @return string
|
||||
*/
|
||||
private function formatDateTime(int $timestamp): string
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return '未设置';
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i', $timestamp);
|
||||
}
|
||||
}
|
||||
@@ -1,662 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\api\service;
|
||||
|
||||
use app\api\model\DysVideoLog;
|
||||
use app\api\model\DyVideoUser;
|
||||
use app\api\model\Member;
|
||||
use think\db\Query;
|
||||
|
||||
/**
|
||||
* 视频作品服务。
|
||||
*
|
||||
* 该服务按 acgpmw `video_info.php` 的 `video_list()` / `get_videos()`
|
||||
* 最小可用范围对齐,为小程序提供视频作品列表的只读展示能力。
|
||||
*/
|
||||
class VideoWorkService
|
||||
{
|
||||
/**
|
||||
* 平台中文名按 acgpmw `data/other/platforms.php` 对齐。
|
||||
*/
|
||||
private const PLATFORM_NAME_MAP = [
|
||||
0 => 'D音',
|
||||
1 => 'K手',
|
||||
2 => 'B家号',
|
||||
3 => '小红薯',
|
||||
4 => '视P号',
|
||||
5 => 'B哩哔哩',
|
||||
6 => '公Z号',
|
||||
10 => 'TiTk',
|
||||
];
|
||||
|
||||
/**
|
||||
* 平台英文键按 acgpmw `data/other/platforms_py.php` 对齐。
|
||||
*/
|
||||
private const PLATFORM_KEY_MAP = [
|
||||
0 => 'douyin',
|
||||
1 => 'kuaishou',
|
||||
2 => 'baijiahao',
|
||||
3 => 'xiaohongshu',
|
||||
4 => 'shipinhao',
|
||||
5 => 'bilibili',
|
||||
6 => 'gongzhonghao',
|
||||
10 => 'tiktok',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取视频作品列表。
|
||||
*
|
||||
* @param int $userid 当前登录用户ID
|
||||
* @param array<string, mixed> $params 前端查询参数
|
||||
* @return array<string, mixed>
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getVideoList(int $userid, array $params = []): array
|
||||
{
|
||||
$member = Member::findByUserid($userid);
|
||||
if (!$member) {
|
||||
throw new \Exception('用户不存在', 4004);
|
||||
}
|
||||
|
||||
$platform = $this->normalizeOptionalInt($params['platform'] ?? 'all', '平台参数格式错误');
|
||||
$vuid = $this->normalizeOptionalInt($params['vuid'] ?? 'all', '账号参数格式错误');
|
||||
$page = max(1, (int) ($params['page'] ?? 1));
|
||||
$pageSize = min(30, max(1, (int) ($params['page_size'] ?? 12)));
|
||||
|
||||
$productInfo = $member->getProductInfo();
|
||||
$availablePlatforms = $this->resolveAvailablePlatforms($member, $productInfo);
|
||||
if (empty($availablePlatforms)) {
|
||||
throw new \Exception('暂无视频作品权限', 4004);
|
||||
}
|
||||
|
||||
if ($platform !== null && !in_array($platform, $availablePlatforms, true)) {
|
||||
throw new \Exception('当前套餐暂无该平台权限', 4004);
|
||||
}
|
||||
|
||||
$accountOptions = $this->loadAccountOptions($userid, $availablePlatforms);
|
||||
if ($vuid !== null && !array_key_exists($vuid, $accountOptions['map'])) {
|
||||
throw new \Exception('当前账号不存在或已解绑', 4004);
|
||||
}
|
||||
|
||||
$baseQuery = $this->buildFilteredQuery($userid, [
|
||||
'platform' => $platform,
|
||||
'vuid' => $vuid,
|
||||
]);
|
||||
|
||||
$total = (int) (clone $baseQuery)->count();
|
||||
$records = (clone $baseQuery)
|
||||
->field([
|
||||
'id',
|
||||
'vuid',
|
||||
'cron_id',
|
||||
'aweme_id',
|
||||
'aweme_nid',
|
||||
'dy_item_id',
|
||||
'dy_title',
|
||||
'kwords',
|
||||
'dy_video_status',
|
||||
'dy_cover',
|
||||
'dy_create_time',
|
||||
'dy_share_url',
|
||||
'dy_comment_count',
|
||||
'dy_digg_count',
|
||||
'dy_play_count',
|
||||
'addtime',
|
||||
'fabu_type',
|
||||
'disabled',
|
||||
'status',
|
||||
'fberrinfo',
|
||||
'platform',
|
||||
'tplid',
|
||||
])
|
||||
->order('id', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$items = [];
|
||||
foreach ($records as $record) {
|
||||
$items[] = $this->buildVideoItem($record, $accountOptions['map']);
|
||||
}
|
||||
|
||||
return [
|
||||
'filters' => [
|
||||
'current_platform' => $platform,
|
||||
'platforms' => $this->buildPlatformFilters($userid, $availablePlatforms, $vuid, $platform),
|
||||
'current_vuid' => $vuid,
|
||||
'accounts' => $accountOptions['items'],
|
||||
],
|
||||
'summary' => $this->buildSummary(clone $baseQuery),
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
'total' => $total,
|
||||
'has_more' => $page * $pageSize < $total,
|
||||
],
|
||||
'list' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载账号筛选项。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param array<int> $availablePlatforms 可见平台
|
||||
* @return array{items: array<int, array<string, mixed>>, map: array<int, array<string, mixed>>}
|
||||
*/
|
||||
private function loadAccountOptions(int $userid, array $availablePlatforms): array
|
||||
{
|
||||
$accounts = DyVideoUser::getVideoFilterAccountsByUserId($userid)->toArray();
|
||||
|
||||
$items = [
|
||||
[
|
||||
'id' => null,
|
||||
'name' => '全部账号',
|
||||
],
|
||||
];
|
||||
$accountMap = [];
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$platform = (int) ($account['platform'] ?? 0);
|
||||
if (!in_array($platform, $availablePlatforms, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$accountId = (int) ($account['id'] ?? 0);
|
||||
$accountMap[$accountId] = $account;
|
||||
$items[] = [
|
||||
'id' => $accountId,
|
||||
'name' => sprintf(
|
||||
'%s(%s)#P%d',
|
||||
self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform),
|
||||
(string) ($account['dy_nickname'] ?? '未命名账号'),
|
||||
$accountId
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'map' => $accountMap,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建筛选后的作品查询。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param array<string, int|null> $filters 筛选条件
|
||||
* @return Query
|
||||
*/
|
||||
private function buildFilteredQuery(int $userid, array $filters): Query
|
||||
{
|
||||
$query = DysVideoLog::buildUserQuery($userid);
|
||||
|
||||
if (($filters['vuid'] ?? null) !== null) {
|
||||
$query->where('vuid', (int) $filters['vuid']);
|
||||
}
|
||||
|
||||
if (($filters['platform'] ?? null) !== null) {
|
||||
$query->where('platform', (int) $filters['platform']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建平台筛选项。
|
||||
*
|
||||
* 这里按当前账号筛选条件统计数量,使前端切平台时与当前账号范围一致。
|
||||
*
|
||||
* @param int $userid 用户ID
|
||||
* @param array<int> $availablePlatforms 可见平台
|
||||
* @param int|null $vuid 当前账号筛选
|
||||
* @param int|null $currentPlatform 当前平台筛选
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function buildPlatformFilters(int $userid, array $availablePlatforms, ?int $vuid, ?int $currentPlatform): array
|
||||
{
|
||||
$tabs = [
|
||||
[
|
||||
'id' => null,
|
||||
'name' => '全部平台',
|
||||
'key' => 'all',
|
||||
'count' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
$allCount = 0;
|
||||
foreach ($availablePlatforms as $platform) {
|
||||
$query = $this->buildFilteredQuery($userid, [
|
||||
'platform' => $platform,
|
||||
'vuid' => $vuid,
|
||||
]);
|
||||
$count = (int) $query->count();
|
||||
$allCount += $count;
|
||||
|
||||
$tabs[] = [
|
||||
'id' => $platform,
|
||||
'name' => self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform),
|
||||
'key' => self::PLATFORM_KEY_MAP[$platform] ?? ('platform_' . $platform),
|
||||
'count' => $count,
|
||||
'active' => $currentPlatform === $platform,
|
||||
];
|
||||
}
|
||||
|
||||
$tabs[0]['count'] = $allCount;
|
||||
$tabs[0]['active'] = $currentPlatform === null;
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建顶部统计卡片。
|
||||
*
|
||||
* @param Query $query 已按当前筛选条件收窄的查询对象
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function buildSummary(Query $query): array
|
||||
{
|
||||
return [
|
||||
'total' => (int) (clone $query)->count(),
|
||||
'published_count' => (int) (clone $query)->whereRaw('(dy_video_status in (1,5) or aweme_id <> "" or dy_item_id <> "" or aweme_nid <> "")')->count(),
|
||||
'reviewing_count' => (int) (clone $query)->where('dy_video_status', 4)->count(),
|
||||
'failed_count' => (int) (clone $query)->whereIn('status', [2, 3])->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建前端作品卡片数据。
|
||||
*
|
||||
* @param array<string, mixed> $record 原始作品记录
|
||||
* @param array<int, array<string, mixed>> $accountMap 账号信息映射
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildVideoItem(array $record, array $accountMap): array
|
||||
{
|
||||
$platform = (int) ($record['platform'] ?? 0);
|
||||
$accountId = (int) ($record['vuid'] ?? 0);
|
||||
$account = $accountMap[$accountId] ?? [];
|
||||
$title = $this->buildVideoTitle($record);
|
||||
$previewUrl = $this->buildPreviewUrl($record);
|
||||
$statusInfo = $this->buildStatusInfo($record);
|
||||
$publishTimeText = $this->buildPublishTimeText($record);
|
||||
|
||||
return [
|
||||
'id' => (int) ($record['id'] ?? 0),
|
||||
'title' => $title,
|
||||
'cover' => (string) ($record['dy_cover'] ?? ''),
|
||||
'platform' => $platform,
|
||||
'platform_name' => self::PLATFORM_NAME_MAP[$platform] ?? ('平台' . $platform),
|
||||
'platform_key' => self::PLATFORM_KEY_MAP[$platform] ?? ('platform_' . $platform),
|
||||
'account_id' => $accountId,
|
||||
'account_name' => (string) ($account['dy_nickname'] ?? '未命名账号'),
|
||||
'account_avatar' => (string) ($account['dy_avatar'] ?? ''),
|
||||
'cron_id' => (int) ($record['cron_id'] ?? 0),
|
||||
'tplid' => (int) ($record['tplid'] ?? 0),
|
||||
'play_count' => (int) ($record['dy_play_count'] ?? 0),
|
||||
'digg_count' => (int) ($record['dy_digg_count'] ?? 0),
|
||||
'comment_count' => (int) ($record['dy_comment_count'] ?? 0),
|
||||
'publish_time_text' => $publishTimeText,
|
||||
'status' => $statusInfo,
|
||||
'preview_url' => $previewUrl,
|
||||
'has_preview' => $previewUrl !== '',
|
||||
'is_article' => (int) ($record['fabu_type'] ?? 0) === 101,
|
||||
'display_code' => '#V' . (int) ($record['id'] ?? 0),
|
||||
'task_code' => '#C' . (int) ($record['cron_id'] ?? 0),
|
||||
'material_code' => (int) ($record['tplid'] ?? 0) > 0 ? '#M' . (int) $record['tplid'] : '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装作品标题。
|
||||
*
|
||||
* 对齐 acgpmw:优先 `dy_title`,否则使用 `kwords` 拼接。
|
||||
*
|
||||
* @param array<string, mixed> $record 原始作品记录
|
||||
* @return string
|
||||
*/
|
||||
private function buildVideoTitle(array $record): string
|
||||
{
|
||||
$title = trim((string) ($record['dy_title'] ?? ''));
|
||||
if ($title !== '') {
|
||||
return $title;
|
||||
}
|
||||
|
||||
$keywords = trim((string) ($record['kwords'] ?? ''), ',');
|
||||
if ($keywords === '') {
|
||||
return '暂无作品信息';
|
||||
}
|
||||
|
||||
return '#' . str_replace(',', ' #', $keywords);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建作品状态块。
|
||||
*
|
||||
* 对齐 acgpmw `get_videos()` 的状态展示优先级:
|
||||
* 1. 定时中
|
||||
* 2. 发布失败
|
||||
* 3. 已发布 / 未公开 / 审核中 / 好友可见 / 私密视频
|
||||
* 4. 待处理
|
||||
*
|
||||
* @param array<string, mixed> $record 原始作品记录
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildStatusInfo(array $record): array
|
||||
{
|
||||
$platform = (int) ($record['platform'] ?? 0);
|
||||
$dyCreateTime = (int) ($record['dy_create_time'] ?? 0);
|
||||
$status = (int) ($record['status'] ?? 0);
|
||||
$dyVideoStatus = (int) ($record['dy_video_status'] ?? 0);
|
||||
$isPublished = !empty($record['aweme_id']) || !empty($record['dy_item_id']) || !empty($record['aweme_nid']);
|
||||
|
||||
if (in_array($platform, [0, 4], true) && $dyCreateTime > time()) {
|
||||
return [
|
||||
'text' => '定时中',
|
||||
'tone' => 'warning',
|
||||
'hint' => '作品已进入定时发布队列',
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($status, [2, 3], true)) {
|
||||
$errorText = $this->translatePublishError((string) ($record['fberrinfo'] ?? ''), $platform);
|
||||
return [
|
||||
'text' => '发布失败',
|
||||
'tone' => 'danger',
|
||||
'hint' => $errorText !== '' ? $errorText : '作品发布失败,请回到原系统查看详情',
|
||||
];
|
||||
}
|
||||
|
||||
if (in_array($dyVideoStatus, [1, 5], true) || $isPublished) {
|
||||
return [
|
||||
'text' => '已发布',
|
||||
'tone' => 'success',
|
||||
'hint' => '作品已成功发布到对应平台',
|
||||
];
|
||||
}
|
||||
|
||||
if ($dyVideoStatus === 2) {
|
||||
return [
|
||||
'text' => '未公开',
|
||||
'tone' => 'danger',
|
||||
'hint' => '作品当前为未公开状态',
|
||||
];
|
||||
}
|
||||
|
||||
if ($dyVideoStatus === 4) {
|
||||
return [
|
||||
'text' => '审核中',
|
||||
'tone' => 'primary',
|
||||
'hint' => '作品已提交平台审核,请稍后查看',
|
||||
];
|
||||
}
|
||||
|
||||
if ($dyVideoStatus === 6) {
|
||||
return [
|
||||
'text' => '好友可见',
|
||||
'tone' => 'info',
|
||||
'hint' => '作品仅好友可见',
|
||||
];
|
||||
}
|
||||
|
||||
if ($dyVideoStatus === 7) {
|
||||
return [
|
||||
'text' => '私密视频',
|
||||
'tone' => 'warning',
|
||||
'hint' => '作品当前为私密视频',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'text' => '待处理',
|
||||
'tone' => 'slate',
|
||||
'hint' => '作品正在处理或等待平台返回结果',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建发布时间文案。
|
||||
*
|
||||
* @param array<string, mixed> $record 原始作品记录
|
||||
* @return string
|
||||
*/
|
||||
private function buildPublishTimeText(array $record): string
|
||||
{
|
||||
$platform = (int) ($record['platform'] ?? 0);
|
||||
$dyCreateTime = (int) ($record['dy_create_time'] ?? 0);
|
||||
$addtime = (int) ($record['addtime'] ?? 0);
|
||||
|
||||
$publishTime = $addtime;
|
||||
if ($dyCreateTime > 0) {
|
||||
$publishTime = $dyCreateTime;
|
||||
}
|
||||
|
||||
if (in_array($platform, [0, 4], true) && $dyCreateTime > time()) {
|
||||
return '将于 ' . $this->formatDateTime($dyCreateTime) . ' 发布';
|
||||
}
|
||||
|
||||
return $this->formatDateTime($publishTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成作品预览链接。
|
||||
*
|
||||
* 对齐 acgpmw `get_videos()` 中各平台的链接处理规则。
|
||||
*
|
||||
* @param array<string, mixed> $record 原始作品记录
|
||||
* @return string
|
||||
*/
|
||||
private function buildPreviewUrl(array $record): string
|
||||
{
|
||||
$platform = (int) ($record['platform'] ?? 0);
|
||||
$status = (int) ($record['status'] ?? 0);
|
||||
$shareUrl = (string) ($record['dy_share_url'] ?? '');
|
||||
$awemeId = (string) ($record['aweme_id'] ?? '');
|
||||
$dyItemId = (string) ($record['dy_item_id'] ?? '');
|
||||
$dyCreateTime = (int) ($record['dy_create_time'] ?? 0);
|
||||
|
||||
if ($platform === 0 && $shareUrl !== '') {
|
||||
$shareUrl = 'https://www.douyin.com/video/' . $awemeId;
|
||||
if (strpos((string) ($record['dy_share_url'] ?? ''), 'https://video-cn.snssdk.com/') !== false) {
|
||||
$shareUrl = (string) ($record['dy_share_url'] ?? '');
|
||||
}
|
||||
if ($dyCreateTime < time() && $awemeId !== '') {
|
||||
$shareUrl = 'https://www.douyin.com/video/' . $awemeId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($platform === 5 && $status === 1 && $dyItemId !== '') {
|
||||
$shareUrl = 'https://www.bilibili.com/video/' . $dyItemId;
|
||||
}
|
||||
|
||||
if ($platform === 2 && $status === 1 && $dyItemId !== '') {
|
||||
$shareUrl = 'https://mbd.baidu.com/newspage/data/videolanding?nid=sv_' . $dyItemId;
|
||||
}
|
||||
|
||||
if ($platform === 4 && strpos($shareUrl, 'https://weixin.qq.com/sph/') === false) {
|
||||
$shareUrl = '';
|
||||
}
|
||||
|
||||
return $shareUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析发布异常原因。
|
||||
*
|
||||
* 这里直接沿用 acgpmw `video_info::getycinfo()` 的关键信息匹配规则。
|
||||
*
|
||||
* @param string $errorInfo 原始错误信息
|
||||
* @param int $platform 平台编号
|
||||
* @return string
|
||||
*/
|
||||
private function translatePublishError(string $errorInfo, int $platform): string
|
||||
{
|
||||
if ($errorInfo === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($platform === 0) {
|
||||
$map = [
|
||||
'"error_code":10008' => '授权过期',
|
||||
'用户未登录' => '授权过期',
|
||||
'上传视频按钮超时' => '视频上传超时',
|
||||
'寻找描述输入框超时' => '素材或内容处理超时',
|
||||
'"error_code":2114033,' => '包含不合法文字',
|
||||
'"status_code":-2158,' => '发布频繁或异常',
|
||||
'"error_code":2100005,' => '参数不合法',
|
||||
'"error_code":2190003,' => '授权异常',
|
||||
'"error_code":2114005,' => '视频投稿功能已封禁[#G]',
|
||||
'"error_code":2100004,' => '系统繁忙异常',
|
||||
'"status_code":-20' => '视频投稿功能已封禁[#M]',
|
||||
'"error_code":2190019,' => '系统发布失败或异常',
|
||||
'"status_msg":"sms"' => '发布短信验证',
|
||||
'健康分不足投稿功能受限' => '健康分不足投稿功能受限',
|
||||
];
|
||||
|
||||
return $this->matchErrorText($errorInfo, $map);
|
||||
}
|
||||
|
||||
if ($platform === 1) {
|
||||
return $this->matchErrorText($errorInfo, [
|
||||
'ACCESS_DENIED' => '授权过期',
|
||||
'user_request_limit' => '发布限制或异常',
|
||||
'video_not_uploaded' => '视频上传异常',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($platform === 2) {
|
||||
return $this->matchErrorText($errorInfo, [
|
||||
'"errno":60001009' => '账号内容质量不足,今日还可发布0篇内容',
|
||||
'"errno":60000005' => '账号状态异常',
|
||||
'"errno":105011' => '百家号开放平台业务调整,已关闭您的接口使用权限',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($platform === 3) {
|
||||
return $this->matchErrorText($errorInfo, [
|
||||
'当日发布数已达到上限' => '当日发布数已达到上限',
|
||||
'系统正在升级中' => '系统平台升级维护中',
|
||||
'发布笔记需要绑定手机' => '发布笔记需要绑定手机号',
|
||||
'笔记发布失败' => '作品笔记发布失败',
|
||||
'用户被封号' => '用户被封号',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($platform === 4) {
|
||||
return $this->matchErrorText($errorInfo, [
|
||||
'"errcode":-11217' => '暂未绑定手机号',
|
||||
'"errcode":-11216' => '完成实名后才可以发表',
|
||||
'cookie失效' => '授权过期',
|
||||
'"errCode":300334' => '授权过期(授权完有其他扫码操作)',
|
||||
]);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键字匹配错误提示。
|
||||
*
|
||||
* @param string $errorInfo 原始错误信息
|
||||
* @param array<string, string> $map 关键字到提示文案映射
|
||||
* @return string
|
||||
*/
|
||||
private function matchErrorText(string $errorInfo, array $map): string
|
||||
{
|
||||
foreach ($map as $needle => $message) {
|
||||
if (strpos($errorInfo, $needle) !== false) {
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算当前用户可见平台。
|
||||
*
|
||||
* 对齐 acgpmw:
|
||||
* 1. 先使用已按 `MemberController` 二次加工后的套餐平台
|
||||
* 2. 146/147 且不在特殊名单中,只允许 D音
|
||||
*
|
||||
* @param Member $member 当前登录用户
|
||||
* @param array<string, mixed>|null $productInfo 套餐信息
|
||||
* @return array<int>
|
||||
*/
|
||||
private function resolveAvailablePlatforms(Member $member, ?array $productInfo): array
|
||||
{
|
||||
$platformText = trim((string) ($productInfo['platforms'] ?? ''), ',');
|
||||
if ($platformText === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$platforms = array_values(array_filter(array_map('intval', explode(',', $platformText)), static function ($item) {
|
||||
return array_key_exists($item, self::PLATFORM_NAME_MAP);
|
||||
}));
|
||||
|
||||
$specialAccounts = $this->loadSpecialAccounts();
|
||||
$isRestrictedVType = in_array((int) $member->v_type, [146, 147], true);
|
||||
$isSpecialAccount = in_array((int) $member->userid, $specialAccounts, true);
|
||||
|
||||
if ($isRestrictedVType && !$isSpecialAccount) {
|
||||
return [0];
|
||||
}
|
||||
|
||||
return $platforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载云图文特殊账号名单。
|
||||
*
|
||||
* @return array<int>
|
||||
*/
|
||||
private function loadSpecialAccounts(): array
|
||||
{
|
||||
$file = root_path() . '../acgpmw/data/other/tw_special_account.php';
|
||||
if (!is_file($file)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$accounts = require $file;
|
||||
|
||||
return is_array($accounts) ? array_values(array_map('intval', $accounts)) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化可选整数参数。
|
||||
*
|
||||
* @param mixed $value 原始输入
|
||||
* @param string $errorMessage 错误提示
|
||||
* @return int|null
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function normalizeOptionalInt($value, string $errorMessage): ?int
|
||||
{
|
||||
if ($value === null || $value === '' || $value === 'all') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_numeric((string) $value)) {
|
||||
throw new \Exception($errorMessage, 400);
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一格式化时间。
|
||||
*
|
||||
* @param int $timestamp 时间戳
|
||||
* @return string
|
||||
*/
|
||||
private function formatDateTime(int $timestamp): string
|
||||
{
|
||||
if ($timestamp <= 0) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return date('Y-m-d H:i', $timestamp);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\controller;
|
||||
|
||||
/**
|
||||
* note 模块基础控制器
|
||||
*/
|
||||
abstract class BaseController extends \app\api\controller\BaseController
|
||||
{
|
||||
/**
|
||||
* 获取当前 note 模块登录用户 ID。
|
||||
*
|
||||
* 说明:
|
||||
* - note 模块复用全局 JWT 中间件
|
||||
* - 但要求 token 载荷中必须带 `guard=note`
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
protected function getCurrentNoteUserId(): int
|
||||
{
|
||||
$payload = $this->getLoginPayload();
|
||||
|
||||
if (($payload['guard'] ?? '') !== 'note') {
|
||||
throw new \RuntimeException('note 模块登录态无效', 401);
|
||||
}
|
||||
|
||||
return (int) ($payload['userid'] ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\note\controller\BaseController;
|
||||
use app\note\service\AiService;
|
||||
use app\note\service\NoteService;
|
||||
use think\App;
|
||||
|
||||
/**
|
||||
* 笔记 AI 能力控制器
|
||||
*/
|
||||
class Ai extends BaseController
|
||||
{
|
||||
/**
|
||||
* @var PlanningService
|
||||
*/
|
||||
protected $aiService;
|
||||
|
||||
/**
|
||||
* @var NoteService
|
||||
*/
|
||||
protected $noteService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->aiService = new AiService();
|
||||
$this->noteService = new NoteService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 AI 总结
|
||||
* POST /note/v1/ai/summary/:id
|
||||
*/
|
||||
public function summary(int $id)
|
||||
{
|
||||
try {
|
||||
$noteUserId = $this->getCurrentNoteUserId();
|
||||
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
$note = $this->noteService->getOwnedNote($noteUserId, $id);
|
||||
$data = $this->request->post();
|
||||
$result = $this->aiService->createSummary(
|
||||
$note,
|
||||
(string) ($data['summary_type'] ?? 'brief'),
|
||||
!empty($data['force_refresh'])
|
||||
);
|
||||
|
||||
return Response::success($result, '总结生成成功');
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看 AI 总结结果
|
||||
* GET /note/v1/ai/summary/:id
|
||||
*/
|
||||
public function readSummary(int $id)
|
||||
{
|
||||
try {
|
||||
$noteUserId = $this->getCurrentNoteUserId();
|
||||
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
$this->noteService->getOwnedNote($noteUserId, $id);
|
||||
$result = $this->aiService->getSummary($id);
|
||||
return Response::success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\note\controller\BaseController;
|
||||
use app\note\service\AuthService;
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
|
||||
/**
|
||||
* 笔记小程序认证控制器
|
||||
*/
|
||||
class Auth extends BaseController
|
||||
{
|
||||
/**
|
||||
* @var PlanningService
|
||||
*/
|
||||
protected $authService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->authService = new AuthService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序登录
|
||||
* POST /note/v1/auth/wechat-login
|
||||
*/
|
||||
public function wechatLogin()
|
||||
{
|
||||
try {
|
||||
$data = $this->request->post();
|
||||
|
||||
validate([
|
||||
'code' => 'require',
|
||||
], [
|
||||
'code.require' => '微信登录 code 不能为空',
|
||||
])->check($data);
|
||||
|
||||
$result = $this->authService->wechatLogin(
|
||||
(string) $data['code'],
|
||||
isset($data['nickname']) ? (string) $data['nickname'] : null,
|
||||
isset($data['avatar_url']) ? (string) $data['avatar_url'] : null
|
||||
);
|
||||
|
||||
return Response::success($result, '登录成功');
|
||||
} catch (ValidateException $e) {
|
||||
return Response::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前小程序用户信息
|
||||
* GET /note/v1/auth/me
|
||||
*/
|
||||
public function me()
|
||||
{
|
||||
try {
|
||||
$result = $this->authService->getUserInfo($this->getCurrentNoteUserId());
|
||||
return Response::success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\note\controller\BaseController;
|
||||
use app\note\service\PlanningService;
|
||||
use think\App;
|
||||
|
||||
/**
|
||||
* 笔记模块元信息控制器
|
||||
*/
|
||||
class c extends BaseController
|
||||
{
|
||||
/**
|
||||
* @var PlanningService
|
||||
*/
|
||||
protected $planningService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->planningService = new PlanningService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 note 模块接口规划概览
|
||||
* GET /note/v1/meta/interfaces
|
||||
*/
|
||||
public function interfaces()
|
||||
{
|
||||
return Response::success($this->planningService->getModuleOverview());
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\note\controller\BaseController;
|
||||
use app\note\service\NoteService;
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
use think\Request;
|
||||
|
||||
/**
|
||||
* 笔记控制器
|
||||
*/
|
||||
class Note extends BaseController
|
||||
{
|
||||
/**
|
||||
* @var PlanningService
|
||||
*/
|
||||
protected $noteService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->noteService = new NoteService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建笔记
|
||||
* POST /note/v1/item/create
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
try {
|
||||
$noteUserId = $this->getCurrentNoteUserId();
|
||||
$data = $this->request->post();
|
||||
|
||||
validate([
|
||||
'source_type' => 'require|in:text,audio,mix',
|
||||
], [
|
||||
'source_type.require' => '笔记来源类型不能为空',
|
||||
'source_type.in' => '笔记来源类型不正确',
|
||||
])->check($data);
|
||||
|
||||
$result = $this->noteService->create($noteUserId, $data);
|
||||
return Response::success($result, '创建成功');
|
||||
} catch (ValidateException $e) {
|
||||
return Response::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔记列表
|
||||
* GET /note/v1/item/list
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
$result = $this->noteService->getList($this->getCurrentNoteUserId(), $this->request->get());
|
||||
return Response::success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔记详情
|
||||
* GET /note/v1/item/:id
|
||||
*/
|
||||
public function read(int $id)
|
||||
{
|
||||
try {
|
||||
$noteUserId = $this->getCurrentNoteUserId();
|
||||
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
$result = $this->noteService->getDetail($noteUserId, $id);
|
||||
return Response::success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔记
|
||||
* POST /note/v1/item/update/:id
|
||||
*/
|
||||
public function update(int $id)
|
||||
{
|
||||
try {
|
||||
$noteUserId = $this->getCurrentNoteUserId();
|
||||
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
$result = $this->noteService->update($noteUserId, $id, $this->request->post());
|
||||
return Response::success($result, '更新成功');
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除笔记
|
||||
* POST /note/v1/item/delete/:id
|
||||
*/
|
||||
public function delete(int $id)
|
||||
{
|
||||
try {
|
||||
$noteUserId = $this->getCurrentNoteUserId();
|
||||
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
$result = $this->noteService->delete($noteUserId, $id);
|
||||
return Response::success($result, '删除成功');
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存实时转写内容
|
||||
* POST /note/v1/item/transcript/:id
|
||||
*/
|
||||
public function transcript(int $id)
|
||||
{
|
||||
try {
|
||||
$noteUserId = $this->getCurrentNoteUserId();
|
||||
$data = $this->request->post();
|
||||
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
validate([
|
||||
'full_text' => 'require',
|
||||
], [
|
||||
'full_text.require' => '转写文本不能为空',
|
||||
])->check($data);
|
||||
|
||||
$result = $this->noteService->saveTranscript($noteUserId, $id, $data);
|
||||
return Response::success($result, '转写保存成功');
|
||||
} catch (ValidateException $e) {
|
||||
return Response::error($e->getMessage(), 400);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传笔记录音
|
||||
* POST /note/v1/item/audio/:id
|
||||
*/
|
||||
public function audio(Request $request, int $id)
|
||||
{
|
||||
try {
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
$file = $request->file('audio');
|
||||
if (!$file) {
|
||||
return Response::error('录音文件不能为空', 400);
|
||||
}
|
||||
|
||||
$durationMs = (int) $request->post('audio_duration_ms', 0);
|
||||
$result = $this->noteService->uploadAudio($this->getCurrentNoteUserId(), $id, $file, $durationMs);
|
||||
return Response::success($result, '上传成功');
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传笔记图片
|
||||
* POST /note/v1/item/image/:id
|
||||
*/
|
||||
public function image(Request $request, int $id)
|
||||
{
|
||||
try {
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
$file = $request->file('image');
|
||||
if (!$file) {
|
||||
return Response::error('图片文件不能为空', 400);
|
||||
}
|
||||
|
||||
$result = $this->noteService->uploadImage($this->getCurrentNoteUserId(), $id, $file);
|
||||
return Response::success($result, '上传成功');
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\controller\v1;
|
||||
|
||||
use app\api\common\Response;
|
||||
use app\note\controller\BaseController;
|
||||
use app\note\service\NoteService;
|
||||
use think\App;
|
||||
|
||||
/**
|
||||
* 笔记分享控制器
|
||||
*/
|
||||
class Share extends BaseController
|
||||
{
|
||||
protected $noteService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->noteService = new NoteService();
|
||||
}
|
||||
|
||||
public function create(int $id)
|
||||
{
|
||||
try {
|
||||
if ($id <= 0) {
|
||||
return Response::error('笔记 ID 不正确', 400);
|
||||
}
|
||||
|
||||
$result = $this->noteService->createShare($this->getCurrentNoteUserId(), $id);
|
||||
return Response::success($result, '分享已生成');
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function read(string $token)
|
||||
{
|
||||
try {
|
||||
if (trim($token) === '') {
|
||||
return Response::error('分享标识不能为空', 400);
|
||||
}
|
||||
|
||||
$result = $this->noteService->getSharedDetail(trim($token));
|
||||
return Response::success($result);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* note 模块 AI 总结模型
|
||||
*/
|
||||
class NoteAiSummary extends Model
|
||||
{
|
||||
protected $connection = 'dbnote';
|
||||
|
||||
protected $name = 'note_ai_summary';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 获取笔记最新总结
|
||||
*
|
||||
* @param int $noteId
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findLatestByNoteId(int $noteId): ?self
|
||||
{
|
||||
return self::where('note_id', $noteId)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* note 模块录音附件模型
|
||||
*/
|
||||
class NoteAudio extends Model
|
||||
{
|
||||
protected $connection = 'dbnote';
|
||||
|
||||
protected $name = 'note_audio';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
public static function findLatestByNoteId(int $noteId): ?self
|
||||
{
|
||||
return self::where('note_id', $noteId)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\model;
|
||||
|
||||
use think\Model;
|
||||
use think\db\Query;
|
||||
|
||||
/**
|
||||
* note 模块笔记主表模型
|
||||
*/
|
||||
class NoteItem extends Model
|
||||
{
|
||||
protected $connection = 'dbnote';
|
||||
|
||||
protected $name = 'note_item';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 创建当前用户的基础查询
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @return Query
|
||||
*/
|
||||
public static function buildUserQuery(int $noteUserId): Query
|
||||
{
|
||||
return self::where('note_user_id', $noteUserId)
|
||||
->where('deleted_at', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户拥有的笔记
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findOwnedNote(int $noteUserId, int $id): ?self
|
||||
{
|
||||
return self::buildUserQuery($noteUserId)
|
||||
->where('id', $id)
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* note 模块分享模型
|
||||
*/
|
||||
class NoteShare extends Model
|
||||
{
|
||||
protected $connection = 'dbnote';
|
||||
|
||||
protected $name = 'note_share';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
public static function findActiveByNote(int $noteId, int $noteUserId): ?self
|
||||
{
|
||||
return self::where('note_id', $noteId)
|
||||
->where('note_user_id', $noteUserId)
|
||||
->where('status', 1)
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
}
|
||||
|
||||
public static function findByToken(string $token): ?self
|
||||
{
|
||||
return self::where('share_token', $token)
|
||||
->where('status', 1)
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* note 模块实时转写模型
|
||||
*/
|
||||
class NoteTranscript extends Model
|
||||
{
|
||||
protected $connection = 'dbnote';
|
||||
|
||||
protected $name = 'note_transcript';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 查找某条笔记的指定分片
|
||||
*
|
||||
* @param int $noteId
|
||||
* @param int $segmentNo
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findByNoteAndSegment(int $noteId, int $segmentNo): ?self
|
||||
{
|
||||
return self::where('note_id', $noteId)
|
||||
->where('segment_no', $segmentNo)
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* note 模块小程序用户模型
|
||||
*/
|
||||
class NoteUser extends Model
|
||||
{
|
||||
protected $connection = 'dbnote';
|
||||
|
||||
protected $name = 'note_user';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 根据 openid 查找用户
|
||||
*
|
||||
* @param string $openid
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findByOpenid(string $openid): ?self
|
||||
{
|
||||
return self::where('openid', $openid)
|
||||
->where('deleted_at', 0)
|
||||
->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据主键查找用户
|
||||
*
|
||||
* @param int $id
|
||||
* @return self|null
|
||||
*/
|
||||
public static function findActiveById(int $id): ?self
|
||||
{
|
||||
return self::where('id', $id)
|
||||
->where('deleted_at', 0)
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use think\facade\Route;
|
||||
use app\note\controller\v1\Ai;
|
||||
use app\note\controller\v1\Auth;
|
||||
use app\note\controller\v1\Meta;
|
||||
use app\note\controller\v1\Note;
|
||||
use app\note\controller\v1\Share;
|
||||
|
||||
/**
|
||||
* note 应用路由
|
||||
*
|
||||
* 由于项目启用了 think-multi-app,
|
||||
* `/note/...` 会优先进入 note 应用,
|
||||
* 因此 note 模块自己的接口必须定义在 `app/note/route/app.php` 下,
|
||||
* 路由前缀应从 `v1/...` 开始,而不是再重复写 `note/...`。
|
||||
*/
|
||||
|
||||
// v1 笔记模块接口规划(公开)
|
||||
Route::get('v1/meta/interfaces', [Meta::class, 'interfaces']);
|
||||
Route::post('v1/auth/wechat-login', [Auth::class, 'wechatLogin']);
|
||||
Route::get('v1/share/read/:token', [Share::class, 'read']);
|
||||
|
||||
// v1 笔记模块接口(需登录)
|
||||
Route::group('v1', function () {
|
||||
Route::get('auth/me', [Auth::class, 'me']);
|
||||
|
||||
Route::post('item/create', [Note::class, 'create']);
|
||||
Route::get('item/list', [Note::class, 'index']);
|
||||
Route::get('item/:id', [Note::class, 'read']);
|
||||
Route::post('item/update/:id', [Note::class, 'update']);
|
||||
Route::post('item/delete/:id', [Note::class, 'delete']);
|
||||
Route::post('item/transcript/:id', [Note::class, 'transcript']);
|
||||
Route::post('item/audio/:id', [Note::class, 'audio']);
|
||||
Route::post('item/image/:id', [Note::class, 'image']);
|
||||
|
||||
Route::post('ai/summary/:id', [Ai::class, 'summary']);
|
||||
Route::get('ai/summary/:id', [Ai::class, 'readSummary']);
|
||||
Route::post('share/create/:id', [Share::class, 'create']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
@@ -1,228 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\service;
|
||||
|
||||
use app\note\model\NoteAiSummary;
|
||||
use app\note\model\NoteItem;
|
||||
|
||||
/**
|
||||
* note 模块 AI 总结服务
|
||||
*
|
||||
* 当前先提供规则版总结,保证接口可用。
|
||||
* 后续如需接入大模型,可在本服务内部替换实现,不影响控制器接口。
|
||||
*/
|
||||
class AiService
|
||||
{
|
||||
/**
|
||||
* 生成或刷新总结
|
||||
*
|
||||
* @param NoteItem $note
|
||||
* @param string $summaryType
|
||||
* @param bool $forceRefresh
|
||||
* @return array
|
||||
*/
|
||||
public function createSummary(NoteItem $note, string $summaryType = 'brief', bool $forceRefresh = false): array
|
||||
{
|
||||
$summaryType = in_array($summaryType, ['brief', 'outline', 'todo'], true) ? $summaryType : 'brief';
|
||||
$existing = NoteAiSummary::findLatestByNoteId((int) $note->id);
|
||||
|
||||
if ($existing && !$forceRefresh && $existing->status === 'success') {
|
||||
return $this->formatSummary($existing);
|
||||
}
|
||||
|
||||
$sourceText = trim((string) $note->content);
|
||||
if ($sourceText === '') {
|
||||
$sourceText = trim((string) $note->transcript_text);
|
||||
}
|
||||
if ($sourceText === '') {
|
||||
throw new \Exception('笔记内容为空,无法生成总结', 400);
|
||||
}
|
||||
|
||||
$generated = $this->generateSummaryPayload($sourceText, $summaryType);
|
||||
$now = time();
|
||||
|
||||
$summary = $existing ?: new NoteAiSummary();
|
||||
if (!$existing) {
|
||||
$summary->note_id = (int) $note->id;
|
||||
$summary->created_at = $now;
|
||||
}
|
||||
|
||||
$summary->summary_type = $summaryType;
|
||||
$summary->summary_text = $generated['summary_text'];
|
||||
$summary->todo_list = json_encode($generated['todo_list'], JSON_UNESCAPED_UNICODE);
|
||||
$summary->keywords = json_encode($generated['keywords'], JSON_UNESCAPED_UNICODE);
|
||||
$summary->status = 'success';
|
||||
$summary->error_message = '';
|
||||
$summary->updated_at = $now;
|
||||
$summary->save();
|
||||
|
||||
$note->summary_status = 'success';
|
||||
$note->updated_at = $now;
|
||||
$note->save();
|
||||
|
||||
return $this->formatSummary($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取总结
|
||||
*
|
||||
* @param int $noteId
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getSummary(int $noteId): array
|
||||
{
|
||||
$summary = NoteAiSummary::findLatestByNoteId($noteId);
|
||||
if (!$summary) {
|
||||
throw new \Exception('该笔记暂无总结结果', 404);
|
||||
}
|
||||
|
||||
return $this->formatSummary($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成规则版总结
|
||||
*
|
||||
* @param string $text
|
||||
* @param string $summaryType
|
||||
* @return array
|
||||
*/
|
||||
private function generateSummaryPayload(string $text, string $summaryType): array
|
||||
{
|
||||
$sentences = $this->splitSentences($text);
|
||||
$summarySentences = array_slice($sentences, 0, $summaryType === 'outline' ? 5 : 3);
|
||||
$summaryText = implode("\n", array_map(function ($item, $index) use ($summaryType) {
|
||||
if ($summaryType === 'outline') {
|
||||
return sprintf('%d. %s', $index + 1, $item);
|
||||
}
|
||||
return $item;
|
||||
}, $summarySentences, array_keys($summarySentences)));
|
||||
|
||||
$todoList = $this->extractTodoList($text);
|
||||
$keywords = $this->extractKeywords($text);
|
||||
|
||||
if ($summaryType === 'todo' && !empty($todoList)) {
|
||||
$summaryText = "待办事项:\n" . implode("\n", array_map(function ($item, $index) {
|
||||
return sprintf('%d. %s', $index + 1, $item);
|
||||
}, $todoList, array_keys($todoList)));
|
||||
}
|
||||
|
||||
return [
|
||||
'summary_text' => $summaryText !== '' ? $summaryText : mb_substr($text, 0, 200),
|
||||
'todo_list' => $todoList,
|
||||
'keywords' => $keywords,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 切分句子
|
||||
*
|
||||
* @param string $text
|
||||
* @return array
|
||||
*/
|
||||
private function splitSentences(string $text): array
|
||||
{
|
||||
$normalized = preg_replace('/\s+/', ' ', trim($text));
|
||||
$parts = preg_split('/[。!?!?;;\n\r]+/u', (string) $normalized);
|
||||
$parts = array_values(array_filter(array_map('trim', $parts), function ($item) {
|
||||
return $item !== '';
|
||||
}));
|
||||
|
||||
return empty($parts) ? [mb_substr($normalized, 0, 200)] : $parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取待办
|
||||
*
|
||||
* @param string $text
|
||||
* @return array
|
||||
*/
|
||||
private function extractTodoList(string $text): array
|
||||
{
|
||||
$lines = preg_split('/[\n\r]+/u', $text);
|
||||
$todoList = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/^(待办|todo|todo:|TODO|TODO:|需要|安排|跟进|完成|处理)/u', $line)) {
|
||||
$todoList[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($todoList)) {
|
||||
return array_slice(array_values(array_unique($todoList)), 0, 10);
|
||||
}
|
||||
|
||||
$sentences = $this->splitSentences($text);
|
||||
$fallback = [];
|
||||
foreach ($sentences as $sentence) {
|
||||
if (preg_match('/(需要|安排|跟进|完成|处理|确认|整理|联系)/u', $sentence)) {
|
||||
$fallback[] = $sentence;
|
||||
}
|
||||
}
|
||||
|
||||
return array_slice(array_values(array_unique($fallback)), 0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取关键词
|
||||
*
|
||||
* @param string $text
|
||||
* @return array
|
||||
*/
|
||||
private function extractKeywords(string $text): array
|
||||
{
|
||||
preg_match_all('/[\x{4e00}-\x{9fa5}A-Za-z0-9]{2,20}/u', $text, $matches);
|
||||
$words = $matches[0] ?? [];
|
||||
$stopWords = ['我们', '你们', '他们', '这个', '那个', '然后', '以及', '因为', '所以', '可以', '进行', '一个', '没有', '已经', '需要', '今天', '目前', '如果', '就是'];
|
||||
$countMap = [];
|
||||
|
||||
foreach ($words as $word) {
|
||||
if (in_array($word, $stopWords, true)) {
|
||||
continue;
|
||||
}
|
||||
$countMap[$word] = ($countMap[$word] ?? 0) + 1;
|
||||
}
|
||||
|
||||
arsort($countMap);
|
||||
return array_slice(array_keys($countMap), 0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化总结结果
|
||||
*
|
||||
* @param NoteAiSummary $summary
|
||||
* @return array
|
||||
*/
|
||||
private function formatSummary(NoteAiSummary $summary): array
|
||||
{
|
||||
return [
|
||||
'summary_id' => (int) $summary->id,
|
||||
'note_id' => (int) $summary->note_id,
|
||||
'summary_type' => (string) $summary->summary_type,
|
||||
'summary_text' => (string) $summary->summary_text,
|
||||
'todo_list' => $this->decodeJsonList((string) $summary->todo_list),
|
||||
'keywords' => $this->decodeJsonList((string) $summary->keywords),
|
||||
'status' => (string) $summary->status,
|
||||
'error_message' => (string) $summary->error_message,
|
||||
'created_at' => (int) $summary->created_at,
|
||||
'updated_at' => (int) $summary->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 列表
|
||||
*
|
||||
* @param string $value
|
||||
* @return array
|
||||
*/
|
||||
private function decodeJsonList(string $value): array
|
||||
{
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\service;
|
||||
|
||||
use app\api\common\Jwt;
|
||||
use app\note\model\NoteUser;
|
||||
|
||||
/**
|
||||
* note 模块认证服务
|
||||
*/
|
||||
class AuthService
|
||||
{
|
||||
/**
|
||||
* 微信小程序登录
|
||||
*
|
||||
* @param string $code
|
||||
* @param string|null $nickname
|
||||
* @param string|null $avatarUrl
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function wechatLogin(string $code, ?string $nickname = null, ?string $avatarUrl = null): array
|
||||
{
|
||||
$session = $this->fetchWechatSession($code);
|
||||
$openid = trim((string) ($session['openid'] ?? ''));
|
||||
|
||||
if ($openid === '') {
|
||||
throw new \Exception('微信登录失败,未获取到 openid', 500);
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$user = NoteUser::findByOpenid($openid);
|
||||
$isNewUser = false;
|
||||
|
||||
if (!$user) {
|
||||
$user = new NoteUser();
|
||||
$user->openid = $openid;
|
||||
$user->created_at = $now;
|
||||
$isNewUser = true;
|
||||
}
|
||||
|
||||
if (!$isNewUser && (int) $user->status === 0) {
|
||||
throw new \Exception('小程序账号已被禁用', 403);
|
||||
}
|
||||
|
||||
$user->unionid = (string) ($session['unionid'] ?? '');
|
||||
$user->session_key = (string) ($session['session_key'] ?? '');
|
||||
$user->nickname = $nickname !== null ? trim($nickname) : (string) $user->nickname;
|
||||
$user->avatar_url = $avatarUrl !== null ? trim($avatarUrl) : (string) $user->avatar_url;
|
||||
$user->status = 1;
|
||||
$user->last_login_ip = (string) request()->ip();
|
||||
$user->last_login_time = $now;
|
||||
$user->updated_at = $now;
|
||||
$user->deleted_at = 0;
|
||||
|
||||
if (!$user->save()) {
|
||||
throw new \Exception('小程序用户登录保存失败', 500);
|
||||
}
|
||||
|
||||
$token = Jwt::encode([
|
||||
'userid' => (int) $user->id,
|
||||
'guard' => 'note',
|
||||
'openid' => $user->openid,
|
||||
]);
|
||||
|
||||
$refreshToken = Jwt::encode([
|
||||
'userid' => (int) $user->id,
|
||||
'guard' => 'note',
|
||||
'type' => 'refresh',
|
||||
'openid' => $user->openid,
|
||||
]);
|
||||
|
||||
return [
|
||||
'token' => $token,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_in' => config('jwt.expire', 604800),
|
||||
'user' => [
|
||||
'id' => (int) $user->id,
|
||||
'member_id' => (int) ($user->member_id ?? 0),
|
||||
'openid' => (string) $user->openid,
|
||||
'nickname' => (string) $user->nickname,
|
||||
'avatar_url' => (string) $user->avatar_url,
|
||||
'mobile' => (string) $user->mobile,
|
||||
'is_new_user' => $isNewUser,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前小程序用户信息
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getUserInfo(int $noteUserId): array
|
||||
{
|
||||
$user = NoteUser::findActiveById($noteUserId);
|
||||
if (!$user) {
|
||||
throw new \Exception('小程序用户不存在', 404);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $user->id,
|
||||
'member_id' => (int) ($user->member_id ?? 0),
|
||||
'openid' => (string) $user->openid,
|
||||
'nickname' => (string) $user->nickname,
|
||||
'avatar_url' => (string) $user->avatar_url,
|
||||
'mobile' => (string) $user->mobile,
|
||||
'status' => (int) $user->status,
|
||||
'last_login_time' => (int) $user->last_login_time,
|
||||
'created_at' => (int) $user->created_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求微信 code2Session
|
||||
*
|
||||
* @param string $code
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function fetchWechatSession(string $code): array
|
||||
{
|
||||
$appId = (string) env('WECHAT_MINI_APPID', '');
|
||||
$appSecret = (string) env('WECHAT_MINI_SECRET', '');
|
||||
|
||||
if ($appId === '' || $appSecret === '') {
|
||||
throw new \Exception('缺少微信小程序配置 WECHAT_MINI_APPID / WECHAT_MINI_SECRET', 500);
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code',
|
||||
urlencode($appId),
|
||||
urlencode($appSecret),
|
||||
urlencode($code)
|
||||
);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '' || $error) {
|
||||
throw new \Exception('微信登录请求失败', 502);
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
throw new \Exception('微信登录响应解析失败', 502);
|
||||
}
|
||||
|
||||
if (!empty($data['errcode'])) {
|
||||
throw new \Exception(sprintf('微信登录失败:%s', (string) ($data['errmsg'] ?? 'unknown error')), 400);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\service;
|
||||
|
||||
use app\note\model\NoteAudio;
|
||||
use app\note\model\NoteAiSummary;
|
||||
use app\note\model\NoteItem;
|
||||
use app\note\model\NoteShare;
|
||||
use app\note\model\NoteTranscript;
|
||||
use think\File;
|
||||
use think\facade\Filesystem;
|
||||
|
||||
/**
|
||||
* note 模块笔记服务
|
||||
*/
|
||||
class NoteService
|
||||
{
|
||||
/**
|
||||
* 创建笔记
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param array $data
|
||||
* @return array
|
||||
*/
|
||||
public function create(int $noteUserId, array $data): array
|
||||
{
|
||||
$now = time();
|
||||
$note = new NoteItem();
|
||||
$note->note_user_id = $noteUserId;
|
||||
$note->title = $this->normalizeTitle(
|
||||
(string) ($data['title'] ?? ''),
|
||||
(string) ($data['content'] ?? '')
|
||||
);
|
||||
$note->content = (string) ($data['content'] ?? '');
|
||||
$note->transcript_text = '';
|
||||
$note->source_type = (string) ($data['source_type'] ?? 'text');
|
||||
$note->status = (string) ($data['status'] ?? 'draft');
|
||||
$note->audio_duration_ms = (int) ($data['audio_duration_ms'] ?? 0);
|
||||
$note->summary_status = 'none';
|
||||
$note->last_transcript_time = 0;
|
||||
$note->created_at = $now;
|
||||
$note->updated_at = $now;
|
||||
$note->deleted_at = 0;
|
||||
$note->save();
|
||||
|
||||
return $this->formatNoteItem($note);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔记列表
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param array $params
|
||||
* @return array
|
||||
*/
|
||||
public function getList(int $noteUserId, array $params): array
|
||||
{
|
||||
$page = max(1, (int) ($params['page'] ?? 1));
|
||||
$pageSize = max(1, min(100, (int) ($params['page_size'] ?? 10)));
|
||||
$keyword = trim((string) ($params['keyword'] ?? ''));
|
||||
$status = trim((string) ($params['status'] ?? ''));
|
||||
|
||||
$query = NoteItem::buildUserQuery($noteUserId);
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
|
||||
if ($keyword !== '') {
|
||||
$query->where(function ($subQuery) use ($keyword) {
|
||||
$subQuery->whereLike('title', '%' . $keyword . '%')
|
||||
->whereOrLike('content', '%' . $keyword . '%')
|
||||
->whereOrLike('transcript_text', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$total = (int) $query->count();
|
||||
$list = $query->order('id', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select();
|
||||
|
||||
return [
|
||||
'list' => array_map(function ($item) {
|
||||
return $this->formatNoteItem($item);
|
||||
}, $list->all()),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取笔记详情
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getDetail(int $noteUserId, int $id): array
|
||||
{
|
||||
$note = $this->getOwnedNote($noteUserId, $id);
|
||||
$summary = NoteAiSummary::findLatestByNoteId($id);
|
||||
$audio = NoteAudio::findLatestByNoteId($id);
|
||||
|
||||
$result = $this->formatNoteItem($note);
|
||||
$result['audio'] = $audio ? $this->formatAudio($audio) : null;
|
||||
$result['summary'] = $summary ? [
|
||||
'summary_id' => (int) $summary->id,
|
||||
'summary_type' => (string) $summary->summary_type,
|
||||
'summary_text' => (string) $summary->summary_text,
|
||||
'todo_list' => $this->decodeJsonList((string) $summary->todo_list),
|
||||
'keywords' => $this->decodeJsonList((string) $summary->keywords),
|
||||
'status' => (string) $summary->status,
|
||||
] : null;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新笔记
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @param array $data
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function update(int $noteUserId, int $id, array $data): array
|
||||
{
|
||||
$note = $this->getOwnedNote($noteUserId, $id);
|
||||
|
||||
if (array_key_exists('title', $data)) {
|
||||
$note->title = $this->normalizeTitle((string) $data['title'], (string) ($data['content'] ?? $note->content));
|
||||
}
|
||||
if (array_key_exists('content', $data)) {
|
||||
$note->content = (string) $data['content'];
|
||||
if (trim((string) $note->title) === '') {
|
||||
$note->title = $this->normalizeTitle('', (string) $note->content);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('status', $data) && $data['status'] !== '') {
|
||||
$note->status = (string) $data['status'];
|
||||
}
|
||||
if (array_key_exists('audio_duration_ms', $data)) {
|
||||
$note->audio_duration_ms = max(0, (int) $data['audio_duration_ms']);
|
||||
}
|
||||
|
||||
$note->updated_at = time();
|
||||
$note->save();
|
||||
|
||||
return $this->formatNoteItem($note);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除笔记
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function delete(int $noteUserId, int $id): array
|
||||
{
|
||||
$note = $this->getOwnedNote($noteUserId, $id);
|
||||
$note->deleted_at = time();
|
||||
$note->updated_at = time();
|
||||
$note->save();
|
||||
|
||||
return [
|
||||
'deleted' => true,
|
||||
'id' => $id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存转写内容
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @param array $data
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function saveTranscript(int $noteUserId, int $id, array $data): array
|
||||
{
|
||||
$note = $this->getOwnedNote($noteUserId, $id);
|
||||
$segmentNo = max(0, (int) ($data['segment_no'] ?? 0));
|
||||
$now = time();
|
||||
|
||||
$transcript = NoteTranscript::findByNoteAndSegment($id, $segmentNo);
|
||||
if (!$transcript) {
|
||||
$transcript = new NoteTranscript();
|
||||
$transcript->note_id = $id;
|
||||
$transcript->segment_no = $segmentNo;
|
||||
$transcript->created_at = $now;
|
||||
}
|
||||
|
||||
$transcript->segment_text = (string) ($data['segment_text'] ?? '');
|
||||
$transcript->full_text = (string) ($data['full_text'] ?? '');
|
||||
$transcript->is_final = empty($data['is_final']) ? 0 : 1;
|
||||
$transcript->audio_duration_ms = max(0, (int) ($data['audio_duration_ms'] ?? 0));
|
||||
$transcript->save();
|
||||
|
||||
$note->transcript_text = $transcript->full_text;
|
||||
$note->audio_duration_ms = max($note->audio_duration_ms, (int) $transcript->audio_duration_ms);
|
||||
$note->last_transcript_time = $now;
|
||||
$note->updated_at = $now;
|
||||
if ($note->title === '') {
|
||||
$note->title = $this->normalizeTitle('', $note->transcript_text);
|
||||
}
|
||||
$note->save();
|
||||
|
||||
return [
|
||||
'note_id' => $id,
|
||||
'segment_no' => $segmentNo,
|
||||
'is_final' => (int) $transcript->is_final,
|
||||
'transcript_text' => (string) $note->transcript_text,
|
||||
'audio_duration_ms' => (int) $note->audio_duration_ms,
|
||||
'updated_at' => (int) $note->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传录音文件
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @param File $file
|
||||
* @param int $durationMs
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function uploadAudio(int $noteUserId, int $id, File $file, int $durationMs = 0): array
|
||||
{
|
||||
$note = $this->getOwnedNote($noteUserId, $id);
|
||||
$savedPath = str_replace('\\', '/', Filesystem::disk('public')->putFile('note/audio', $file));
|
||||
$now = time();
|
||||
|
||||
$audio = NoteAudio::findLatestByNoteId($id);
|
||||
if (!$audio) {
|
||||
$audio = new NoteAudio();
|
||||
$audio->note_id = $id;
|
||||
$audio->created_at = $now;
|
||||
}
|
||||
|
||||
$audio->disk = 'public';
|
||||
$audio->file_path = $savedPath;
|
||||
$audio->file_url = $this->buildPublicFileUrl($savedPath);
|
||||
$audio->file_size = (int) $file->getSize();
|
||||
/**
|
||||
* 线上环境若未启用 fileinfo/finfo,ThinkPHP 的 getMime() 会直接抛错。
|
||||
* 这里改为按文件扩展名做稳定兜底,避免“文件已写入磁盘但接口因取 mime 失败而整体报错”。
|
||||
*/
|
||||
$audio->mime_type = $this->detectAudioMimeType($savedPath, $file);
|
||||
$audio->duration_ms = max(0, $durationMs);
|
||||
$audio->updated_at = $now;
|
||||
$audio->save();
|
||||
|
||||
$note->audio_duration_ms = max((int) $note->audio_duration_ms, (int) $audio->duration_ms);
|
||||
if ($note->source_type === 'text') {
|
||||
$note->source_type = trim((string) $note->content) !== '' ? 'mix' : 'audio';
|
||||
}
|
||||
$note->updated_at = $now;
|
||||
$note->save();
|
||||
|
||||
return $this->formatAudio($audio);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传笔记图片。
|
||||
*
|
||||
* 这里不额外建表,直接返回公开图片地址,由前端把图片标记写回 note.content,
|
||||
* 以便在跨设备打开同一笔记时仍能恢复图片内容。
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @param File $file
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function uploadImage(int $noteUserId, int $id, File $file): array
|
||||
{
|
||||
$this->getOwnedNote($noteUserId, $id);
|
||||
$savedPath = str_replace('\\', '/', Filesystem::disk('public')->putFile('note/image', $file));
|
||||
|
||||
return [
|
||||
'disk' => 'public',
|
||||
'file_path' => $savedPath,
|
||||
'image_url' => $this->buildPublicFileUrl($savedPath),
|
||||
'file_size' => (int) $file->getSize(),
|
||||
'mime_type' => $this->detectImageMimeType($savedPath, $file),
|
||||
'updated_at' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分享
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function createShare(int $noteUserId, int $id): array
|
||||
{
|
||||
$note = $this->getOwnedNote($noteUserId, $id);
|
||||
$share = NoteShare::findActiveByNote($id, $noteUserId);
|
||||
$now = time();
|
||||
|
||||
if (!$share) {
|
||||
$share = new NoteShare();
|
||||
$share->note_id = $id;
|
||||
$share->note_user_id = $noteUserId;
|
||||
$share->share_token = bin2hex(random_bytes(16));
|
||||
$share->view_count = 0;
|
||||
$share->status = 1;
|
||||
$share->created_at = $now;
|
||||
}
|
||||
|
||||
$share->title = (string) $note->title;
|
||||
$share->updated_at = $now;
|
||||
$share->save();
|
||||
|
||||
return [
|
||||
'note_id' => $id,
|
||||
'share_token' => (string) $share->share_token,
|
||||
'share_path' => '/pages/note/edit?share_token=' . $share->share_token,
|
||||
'title' => (string) $note->title,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享详情
|
||||
*
|
||||
* @param string $token
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getSharedDetail(string $token): array
|
||||
{
|
||||
$share = NoteShare::findByToken($token);
|
||||
if (!$share) {
|
||||
throw new \Exception('分享内容不存在或已失效', 404);
|
||||
}
|
||||
|
||||
if ((int) $share->expired_at > 0 && (int) $share->expired_at < time()) {
|
||||
throw new \Exception('分享已过期', 410);
|
||||
}
|
||||
|
||||
$note = NoteItem::where('id', (int) $share->note_id)
|
||||
->where('deleted_at', 0)
|
||||
->find();
|
||||
if (!$note) {
|
||||
throw new \Exception('分享内容不存在', 404);
|
||||
}
|
||||
|
||||
$summary = NoteAiSummary::findLatestByNoteId((int) $note->id);
|
||||
$audio = NoteAudio::findLatestByNoteId((int) $note->id);
|
||||
|
||||
$share->view_count = (int) $share->view_count + 1;
|
||||
$share->last_view_time = time();
|
||||
$share->save();
|
||||
|
||||
$result = $this->formatNoteItem($note);
|
||||
$result['audio'] = $audio ? $this->formatAudio($audio) : null;
|
||||
$result['summary'] = $summary ? [
|
||||
'summary_text' => (string) $summary->summary_text,
|
||||
'status' => (string) $summary->status,
|
||||
] : null;
|
||||
$result['share'] = [
|
||||
'share_token' => (string) $share->share_token,
|
||||
'title' => (string) $share->title,
|
||||
'view_count' => (int) $share->view_count,
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户拥有的笔记
|
||||
*
|
||||
* @param int $noteUserId
|
||||
* @param int $id
|
||||
* @return NoteItem
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getOwnedNote(int $noteUserId, int $id): NoteItem
|
||||
{
|
||||
$note = NoteItem::findOwnedNote($noteUserId, $id);
|
||||
if (!$note) {
|
||||
throw new \Exception('笔记不存在', 404);
|
||||
}
|
||||
|
||||
return $note;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化笔记返回
|
||||
*
|
||||
* @param NoteItem $note
|
||||
* @return array
|
||||
*/
|
||||
private function formatNoteItem(NoteItem $note): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $note->id,
|
||||
'note_user_id' => (int) $note->note_user_id,
|
||||
'title' => (string) $note->title,
|
||||
'content' => (string) $note->content,
|
||||
'transcript_text' => (string) $note->transcript_text,
|
||||
'source_type' => (string) $note->source_type,
|
||||
'status' => (string) $note->status,
|
||||
'audio_duration_ms' => (int) $note->audio_duration_ms,
|
||||
'summary_status' => (string) $note->summary_status,
|
||||
'last_transcript_time' => (int) $note->last_transcript_time,
|
||||
'created_at' => (int) $note->created_at,
|
||||
'updated_at' => (int) $note->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化音频附件
|
||||
*
|
||||
* @param NoteAudio $audio
|
||||
* @return array
|
||||
*/
|
||||
private function formatAudio(NoteAudio $audio): array
|
||||
{
|
||||
return [
|
||||
'audio_id' => (int) $audio->id,
|
||||
'disk' => (string) $audio->disk,
|
||||
'file_path' => (string) $audio->file_path,
|
||||
'audio_url' => (string) $audio->file_url,
|
||||
'file_size' => (int) $audio->file_size,
|
||||
'mime_type' => (string) $audio->mime_type,
|
||||
'duration_ms' => (int) $audio->duration_ms,
|
||||
'updated_at' => (int) $audio->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断音频 MIME。
|
||||
*
|
||||
* 优先使用文件扩展名,避免依赖 fileinfo 扩展;若扩展名缺失,再尝试读取客户端原始文件名。
|
||||
*
|
||||
* @param string $savedPath
|
||||
* @param File $file
|
||||
* @return string
|
||||
*/
|
||||
private function detectAudioMimeType(string $savedPath, File $file): string
|
||||
{
|
||||
$extension = strtolower((string) pathinfo($savedPath, PATHINFO_EXTENSION));
|
||||
if ($extension === '') {
|
||||
$extension = strtolower((string) pathinfo((string) $file->getOriginalName(), PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
$mimeMap = [
|
||||
'aac' => 'audio/aac',
|
||||
'amr' => 'audio/amr',
|
||||
'm4a' => 'audio/mp4',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'mp4' => 'audio/mp4',
|
||||
'ogg' => 'audio/ogg',
|
||||
'pcm' => 'audio/L16',
|
||||
'wav' => 'audio/wav',
|
||||
'webm' => 'audio/webm',
|
||||
];
|
||||
|
||||
return $mimeMap[$extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断图片 MIME。
|
||||
*
|
||||
* 与音频上传一样,这里优先按扩展名兜底,避免依赖 fileinfo 扩展。
|
||||
*
|
||||
* @param string $savedPath
|
||||
* @param File $file
|
||||
* @return string
|
||||
*/
|
||||
private function detectImageMimeType(string $savedPath, File $file): string
|
||||
{
|
||||
$extension = strtolower((string) pathinfo($savedPath, PATHINFO_EXTENSION));
|
||||
if ($extension === '') {
|
||||
$extension = strtolower((string) pathinfo((string) $file->getOriginalName(), PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
$mimeMap = [
|
||||
'avif' => 'image/avif',
|
||||
'bmp' => 'image/bmp',
|
||||
'gif' => 'image/gif',
|
||||
'heic' => 'image/heic',
|
||||
'heif' => 'image/heif',
|
||||
'jpeg' => 'image/jpeg',
|
||||
'jpg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'svg' => 'image/svg+xml',
|
||||
'webp' => 'image/webp',
|
||||
];
|
||||
|
||||
return $mimeMap[$extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化标题
|
||||
*
|
||||
* @param string $title
|
||||
* @param string $fallback
|
||||
* @return string
|
||||
*/
|
||||
private function normalizeTitle(string $title, string $fallback): string
|
||||
{
|
||||
$title = trim($title);
|
||||
if ($title !== '') {
|
||||
return mb_substr($title, 0, 255);
|
||||
}
|
||||
|
||||
$fallback = trim(preg_replace('/\s+/', ' ', $fallback));
|
||||
if ($fallback === '') {
|
||||
return '未命名笔记';
|
||||
}
|
||||
|
||||
return mb_substr($fallback, 0, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 列表
|
||||
*
|
||||
* @param string $value
|
||||
* @return array
|
||||
*/
|
||||
private function decodeJsonList(string $value): array
|
||||
{
|
||||
$decoded = json_decode($value, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接公开文件 URL
|
||||
*
|
||||
* @param string $savedPath
|
||||
* @return string
|
||||
*/
|
||||
private function buildPublicFileUrl(string $savedPath): string
|
||||
{
|
||||
return rtrim((string) request()->domain(), '/') . '/storage/' . ltrim($savedPath, '/');
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\note\service;
|
||||
|
||||
/**
|
||||
* 笔记小程序接口规划服务
|
||||
*
|
||||
* 说明:
|
||||
* - 当前阶段先在独立 `note` 模块沉淀接口规划与模块骨架
|
||||
* - 暂未落真实业务逻辑,接口先返回规划结构与字段建议
|
||||
* - 后续确定表结构后,可直接在 `app/note` 模块内继续补 Model / Service / Controller
|
||||
*/
|
||||
class PlanningService
|
||||
{
|
||||
/**
|
||||
* 获取整个 note 模块接口概览。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getModuleOverview(): array
|
||||
{
|
||||
return [
|
||||
'module' => 'note',
|
||||
'version' => 'v1',
|
||||
'description' => '笔记小程序独立模块,负责小程序登录、笔记、转写、AI 总结能力',
|
||||
'business_blocks' => [
|
||||
[
|
||||
'name' => '小程序用户登录',
|
||||
'goal' => '基于微信 code 登录,换取 openid / session_key,并建立本地用户映射与 JWT 登录态',
|
||||
],
|
||||
[
|
||||
'name' => '笔记创建与实时录音转文字',
|
||||
'goal' => '支持纯文本笔记、语音笔记、实时转写文本落地、后续编辑与查看',
|
||||
],
|
||||
[
|
||||
'name' => 'AI 总结',
|
||||
'goal' => '对单条笔记或一次录音转写结果生成结构化摘要、待办、标签等内容',
|
||||
],
|
||||
],
|
||||
'routes' => [
|
||||
['method' => 'GET', 'path' => '/note/v1/meta/interfaces', 'desc' => '获取 note 模块接口规划'],
|
||||
['method' => 'POST', 'path' => '/note/v1/auth/wechat-login', 'desc' => '微信小程序登录'],
|
||||
['method' => 'GET', 'path' => '/note/v1/auth/me', 'desc' => '获取当前小程序用户信息'],
|
||||
['method' => 'POST', 'path' => '/note/v1/item/create', 'desc' => '创建笔记'],
|
||||
['method' => 'GET', 'path' => '/note/v1/item/list', 'desc' => '笔记列表'],
|
||||
['method' => 'GET', 'path' => '/note/v1/item/:id', 'desc' => '笔记详情'],
|
||||
['method' => 'POST', 'path' => '/note/v1/item/update/:id', 'desc' => '更新笔记'],
|
||||
['method' => 'POST', 'path' => '/note/v1/item/delete/:id', 'desc' => '删除笔记'],
|
||||
['method' => 'POST', 'path' => '/note/v1/item/transcript/:id', 'desc' => '保存实时转写内容'],
|
||||
['method' => 'POST', 'path' => '/note/v1/item/audio/:id', 'desc' => '上传录音附件'],
|
||||
['method' => 'POST', 'path' => '/note/v1/ai/summary/:id', 'desc' => '发起 AI 总结'],
|
||||
['method' => 'GET', 'path' => '/note/v1/ai/summary/:id', 'desc' => '查看 AI 总结结果'],
|
||||
['method' => 'POST', 'path' => '/note/v1/share/create/:id', 'desc' => '生成分享标识'],
|
||||
['method' => 'GET', 'path' => '/note/v1/share/read/:token', 'desc' => '读取分享内容'],
|
||||
],
|
||||
'suggested_tables' => [
|
||||
'note_user',
|
||||
'note_item',
|
||||
'note_transcript',
|
||||
'note_ai_summary',
|
||||
'note_audio',
|
||||
'note_share',
|
||||
],
|
||||
'development_priority' => [
|
||||
'1. 先落小程序登录,打通微信 openid 与 JWT',
|
||||
'2. 再落笔记主表与笔记 CRUD',
|
||||
'3. 再补实时转写保存接口',
|
||||
'4. 最后接入 AI 总结任务与结果存储',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序登录接口规划。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getWechatLoginSpec(): array
|
||||
{
|
||||
return [
|
||||
'name' => '微信小程序登录',
|
||||
'route' => 'POST /note/v1/auth/wechat-login',
|
||||
'request' => [
|
||||
'code' => '微信 wx.login 获取的临时 code,必填',
|
||||
'nickname' => '用户昵称,可选',
|
||||
'avatar_url' => '头像地址,可选',
|
||||
'device' => '设备信息,可选',
|
||||
],
|
||||
'response' => [
|
||||
'token' => '沿用 tp 现有 JWT 登录态',
|
||||
'refresh_token' => '刷新令牌',
|
||||
'expires_in' => '过期时间',
|
||||
'user' => [
|
||||
'id' => 'note_user 主键 ID',
|
||||
'member_id' => '关联 tp 现有 member 用户 ID,可为空',
|
||||
'openid' => '微信 openid',
|
||||
'nickname' => '昵称',
|
||||
'avatar_url' => '头像',
|
||||
'is_new_user' => '是否首次登录',
|
||||
],
|
||||
],
|
||||
'depends_on' => [
|
||||
'微信 code2Session',
|
||||
'note_user 表',
|
||||
'是否与现有 member 表映射的业务策略',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 笔记接口规划。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getNoteSpecs(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'route' => 'POST /note/v1/item/create',
|
||||
'request' => [
|
||||
'title' => '标题,可选',
|
||||
'content' => '正文,可选',
|
||||
'source_type' => '来源类型:text / audio / mix',
|
||||
'audio_duration_ms' => '录音时长,可选',
|
||||
'status' => '状态:draft / completed,默认 draft',
|
||||
],
|
||||
],
|
||||
'list' => [
|
||||
'route' => 'GET /note/v1/item/list',
|
||||
'request' => [
|
||||
'page' => '页码,默认 1',
|
||||
'page_size' => '每页数量,默认 10',
|
||||
'keyword' => '标题/正文搜索,可选',
|
||||
'status' => 'draft / completed,可选',
|
||||
],
|
||||
],
|
||||
'detail' => [
|
||||
'route' => 'GET /note/v1/item/:id',
|
||||
'response' => [
|
||||
'id' => '笔记 ID',
|
||||
'title' => '标题',
|
||||
'content' => '正文',
|
||||
'transcript_text' => '转写文本',
|
||||
'audio_duration_ms' => '录音时长',
|
||||
'summary' => 'AI 总结结果,可选',
|
||||
],
|
||||
],
|
||||
'update' => [
|
||||
'route' => 'POST /note/v1/item/update/:id',
|
||||
],
|
||||
'delete' => [
|
||||
'route' => 'POST /note/v1/item/delete/:id',
|
||||
],
|
||||
'transcript' => [
|
||||
'route' => 'POST /note/v1/item/transcript/:id',
|
||||
'request' => [
|
||||
'segment_no' => '分片序号',
|
||||
'segment_text' => '本次实时转写文本,可选',
|
||||
'full_text' => '当前整段累计文本,必填',
|
||||
'is_final' => '是否最终片段',
|
||||
'audio_duration_ms' => '当前累计录音时长,可选',
|
||||
],
|
||||
],
|
||||
'audio' => [
|
||||
'route' => 'POST /note/v1/item/audio/:id',
|
||||
'request' => [
|
||||
'audio' => '录音文件 multipart 字段,必填',
|
||||
'audio_duration_ms' => '录音时长,可选',
|
||||
],
|
||||
],
|
||||
'share' => [
|
||||
'route' => 'POST /note/v1/share/create/:id',
|
||||
'response' => [
|
||||
'share_token' => '分享 token',
|
||||
'share_path' => '小程序分享路径',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 总结接口规划。
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getAiSpecs(): array
|
||||
{
|
||||
return [
|
||||
'create_summary' => [
|
||||
'route' => 'POST /note/v1/ai/summary/:id',
|
||||
'request' => [
|
||||
'summary_type' => '摘要类型:brief / outline / todo',
|
||||
'force_refresh' => '是否强制重新生成,可选',
|
||||
],
|
||||
],
|
||||
'read_summary' => [
|
||||
'route' => 'GET /note/v1/ai/summary/:id',
|
||||
'response' => [
|
||||
'summary_id' => '总结记录 ID',
|
||||
'summary_type' => '摘要类型',
|
||||
'summary_text' => '总结内容',
|
||||
'todo_list' => '待办列表,可选',
|
||||
'keywords' => '关键词列表,可选',
|
||||
'status' => '状态',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回接口尚未落库的统一说明。
|
||||
*
|
||||
* @param array $spec
|
||||
* @return array
|
||||
*/
|
||||
public function buildPendingImplementationPayload(array $spec): array
|
||||
{
|
||||
return [
|
||||
'implemented' => false,
|
||||
'reason' => '当前阶段仅完成 note 独立模块接口规划骨架,数据库表已设计,真实业务逻辑待继续实现',
|
||||
'spec' => $spec,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\common;
|
||||
|
||||
class Response
|
||||
{
|
||||
public static function success($data = [], string $message = 'success', int $code = 200)
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'msg' => $message,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
'time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function error(string $message = 'error', int $code = 400, $data = [])
|
||||
{
|
||||
return json([
|
||||
'code' => $code,
|
||||
'msg' => $message,
|
||||
'message' => $message,
|
||||
'data' => $data,
|
||||
'time' => time(),
|
||||
], $code >= 400 && $code < 600 ? $code : 400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\controller;
|
||||
|
||||
use think\App;
|
||||
use think\exception\ValidateException;
|
||||
use think\Validate;
|
||||
|
||||
abstract class BaseController
|
||||
{
|
||||
protected $app;
|
||||
protected $request;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
$this->app = $app;
|
||||
$this->request = $app->request;
|
||||
}
|
||||
|
||||
protected function validate(array $data, $validate, array $message = [], bool $batch = false): array
|
||||
{
|
||||
if (is_array($validate)) {
|
||||
$validator = new Validate();
|
||||
$validator->rule($validate);
|
||||
} else {
|
||||
$validator = new $validate();
|
||||
}
|
||||
|
||||
$validator->message($message)->batch($batch);
|
||||
if (!$validator->check($data)) {
|
||||
throw new ValidateException($validator->getError());
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getCurrentSmtUserId(): int
|
||||
{
|
||||
$userId = (int) $this->request->middleware('smt_user_id', 0);
|
||||
if ($userId <= 0) {
|
||||
throw new \RuntimeException('未登录或登录已过期', 401);
|
||||
}
|
||||
|
||||
return $userId;
|
||||
}
|
||||
|
||||
protected function getCurrentSmtUser(): array
|
||||
{
|
||||
$user = $this->request->middleware('smt_user', []);
|
||||
if (!is_array($user) || empty($user['id'])) {
|
||||
throw new \RuntimeException('未登录或登录已过期', 401);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\controller\v1;
|
||||
|
||||
use app\smt\common\Response;
|
||||
use app\smt\controller\BaseController;
|
||||
use app\smt\service\AuthService;
|
||||
use think\App;
|
||||
|
||||
class Auth extends BaseController
|
||||
{
|
||||
protected $authService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->authService = new AuthService();
|
||||
}
|
||||
|
||||
public function login()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->authService->loginWithCode($this->request->post()), '登录成功');
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function devLogin()
|
||||
{
|
||||
try {
|
||||
return Response::success(
|
||||
$this->authService->devLogin((int) ($this->request->post('mini_program_id', 2))),
|
||||
'登录成功'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function me()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->authService->getUserInfo($this->getCurrentSmtUserId()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function profile()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->authService->updateProfile($this->getCurrentSmtUserId(), $this->request->post()), '更新成功');
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function miniProgramTestCode()
|
||||
{
|
||||
try {
|
||||
$image = $this->authService->getMiniProgramTestCode(
|
||||
$this->getCurrentSmtUserId(),
|
||||
(string) $this->request->get('path', ''),
|
||||
(int) $this->request->get('width', 280)
|
||||
);
|
||||
|
||||
return response($image)
|
||||
->code(200)
|
||||
->contentType('image/png')
|
||||
->header(['Cache-Control' => 'no-store']);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error('获取小程序码失败', $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\controller\v1;
|
||||
|
||||
use app\smt\common\Response;
|
||||
use app\smt\controller\BaseController;
|
||||
use app\smt\service\QuitCheckinService;
|
||||
use think\App;
|
||||
|
||||
class QuitCheckin extends BaseController
|
||||
{
|
||||
protected QuitCheckinService $service;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->service = new QuitCheckinService();
|
||||
}
|
||||
|
||||
public function profile()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->getProfile($this->getCurrentSmtUser()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveProfile()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->upsertProfile($this->getCurrentSmtUser(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function home()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->home($this->getCurrentSmtUserId()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkin()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->checkin($this->getCurrentSmtUserId(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function dreamPresets()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->listDreamPresets());
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function rewardGoals()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->listRewardGoals($this->getCurrentSmtUserId(), (string) $this->request->get('status', 'all')));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function createRewardGoal()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->createRewardGoal($this->getCurrentSmtUserId(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateRewardGoal(int $id)
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->updateRewardGoal($this->getCurrentSmtUserId(), $id, $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function createSupervisorInvite()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->createSupervisorInvite($this->getCurrentSmtUserId(), (int) $this->request->post('days', 7)));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function bindSupervisorInvite()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->bindSupervisorInvite($this->getCurrentSmtUserId(), (string) $this->request->post('token', '')));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function supervisorOverview()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->supervisorOverview($this->getCurrentSmtUserId()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function supervisorStatus()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->supervisorStatus($this->getCurrentSmtUserId()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function revokeSupervisorBinding()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->revokeSupervisorBinding($this->getCurrentSmtUserId(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function reminderSettings()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->getReminderSettings($this->getCurrentSmtUserId()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateReminderSettings()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->updateReminderSettings($this->getCurrentSmtUserId(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function runReminders()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->service->runReminders($this->getCurrentSmtUserId()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\controller\v1;
|
||||
|
||||
use app\smt\common\Response;
|
||||
use app\smt\controller\BaseController;
|
||||
use app\smt\service\AchievementService;
|
||||
use app\smt\service\QuitPlanService;
|
||||
use app\smt\service\SmokeAiService;
|
||||
use app\smt\service\SmokeService;
|
||||
use app\smt\service\Support;
|
||||
use think\App;
|
||||
|
||||
class Smoke extends BaseController
|
||||
{
|
||||
protected $smokeService;
|
||||
|
||||
protected $smokeAiService;
|
||||
|
||||
protected $quitPlanService;
|
||||
|
||||
protected $achievementService;
|
||||
|
||||
public function __construct(App $app)
|
||||
{
|
||||
parent::__construct($app);
|
||||
$this->smokeService = new SmokeService();
|
||||
$this->smokeAiService = new SmokeAiService();
|
||||
$this->quitPlanService = new QuitPlanService();
|
||||
$this->achievementService = new AchievementService();
|
||||
}
|
||||
|
||||
public function home()
|
||||
{
|
||||
try {
|
||||
$user = $this->getCurrentSmtUser();
|
||||
$uid = (int) $user['id'];
|
||||
$now = Support::now();
|
||||
$planDate = Support::dateOnly($now);
|
||||
|
||||
$profileView = $this->smokeService->getProfileView($uid);
|
||||
$defaultSuggestion = $this->smokeService->getDefaultNextSuggestion($uid, $now, $planDate, $profileView);
|
||||
$homeSummary = $this->smokeService->getHomeSummary($uid, $now);
|
||||
$motivation = $this->smokeService->motivation($uid, $profileView['profile'], $now);
|
||||
|
||||
$adviceDate = Support::dateOnly($now->modify('-1 day'));
|
||||
$adviceCard = [
|
||||
'title' => '智能控烟建议',
|
||||
'date' => $adviceDate->format(Support::DATE_LAYOUT),
|
||||
'message' => '',
|
||||
'model' => '',
|
||||
'status' => 'empty',
|
||||
];
|
||||
|
||||
try {
|
||||
$advice = $this->smokeAiService->getOrGenerateAdvice($user, $adviceDate, 'v2');
|
||||
$adviceCard['message'] = (string) ($advice['advice'] ?? '');
|
||||
$adviceCard['model'] = (string) ($advice['model'] ?? '');
|
||||
$adviceCard['status'] = 'available';
|
||||
} catch (\RuntimeException $e) {
|
||||
if ($e->getCode() === 403) {
|
||||
$adviceCard['status'] = 'locked';
|
||||
} elseif ($e->getCode() === 400) {
|
||||
$adviceCard['status'] = 'no_data';
|
||||
} else {
|
||||
$adviceCard['status'] = 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
$timer = [
|
||||
'label' => '距上次抽烟',
|
||||
'last_smoke_at' => (string) ($homeSummary['last_smoke_at'] ?? ''),
|
||||
'seconds_since_last' => (int) ($homeSummary['seconds_since_last'] ?? -1),
|
||||
'next_suggested_at' => (string) ($defaultSuggestion['next_smoke_at'] ?? ''),
|
||||
'next_suggested_clock' => Support::formatClock((string) ($defaultSuggestion['next_smoke_at'] ?? '')),
|
||||
'not_before_at' => (string) ($defaultSuggestion['next_smoke_at'] ?? ''),
|
||||
'suggestion_source' => 'default',
|
||||
'suggestion_algorithm' => (string) ($defaultSuggestion['algorithm'] ?? ''),
|
||||
];
|
||||
|
||||
$cachedAiNext = $this->smokeAiService->getCachedNextSmoke($user, $planDate, 'v1');
|
||||
if ($cachedAiNext) {
|
||||
$timer['suggestion_source'] = 'ai';
|
||||
$timer['suggestion_algorithm'] = 'ai_next_smoke_v1';
|
||||
$timer['next_suggested_at'] = (string) ($cachedAiNext['suggested_at'] ?? '');
|
||||
$timer['next_suggested_clock'] = Support::formatClock((string) ($cachedAiNext['suggested_at'] ?? ''));
|
||||
$timer['not_before_at'] = (string) ($cachedAiNext['not_before_at'] ?? '');
|
||||
$timer['ai_time_nodes'] = $cachedAiNext['time_nodes'] ?? [];
|
||||
$timer['ai_advice'] = (string) ($cachedAiNext['advice'] ?? '');
|
||||
$timer['ai_model'] = (string) ($cachedAiNext['model'] ?? '');
|
||||
}
|
||||
|
||||
$dailySummaryRecord = $this->smokeAiService->getCachedByType($uid, SmokeAiService::TYPE_DAILY_SUMMARY, $planDate, 'v1');
|
||||
$dailySummary = null;
|
||||
if ($dailySummaryRecord) {
|
||||
$dailySummary = [
|
||||
'date' => (string) ($dailySummaryRecord['date'] ?? $planDate->format(Support::DATE_LAYOUT)),
|
||||
'content' => (string) ($dailySummaryRecord['content'] ?? ''),
|
||||
'model' => (string) ($dailySummaryRecord['model'] ?? ''),
|
||||
'status' => 'available',
|
||||
];
|
||||
}
|
||||
|
||||
return Response::success([
|
||||
'greeting' => $this->buildGreeting((string) ($user['nickname'] ?? ''), (string) ($user['avatar_url'] ?? ''), $now),
|
||||
'profile' => $profileView,
|
||||
'advice_card' => $adviceCard,
|
||||
'campaign_card' => [
|
||||
'title' => '绿色生活,从戒烟开始',
|
||||
'subtitle' => 'BRAND CAMPAIGN',
|
||||
'badge' => '广告',
|
||||
],
|
||||
'timer' => $timer,
|
||||
'summary' => [
|
||||
'today_count' => (int) ($homeSummary['today_count'] ?? 0),
|
||||
'daily_target' => (int) (($profileView['profile']['baseline_cigs_per_day'] ?? 0)),
|
||||
'resisted_count' => (int) ($homeSummary['resisted_count'] ?? 0),
|
||||
'reduced_from_yesterday' => (int) ($homeSummary['reduced_from_yesterday'] ?? 0),
|
||||
'exceeded_yesterday' => (bool) ($homeSummary['exceeded_yesterday'] ?? false),
|
||||
'profile_completed' => (bool) ($profileView['is_completed'] ?? false),
|
||||
],
|
||||
'daily_summary' => $dailySummary,
|
||||
'motivation' => $motivation,
|
||||
'quick_actions' => [
|
||||
['type' => 'log_smoke', 'title' => '记录抽烟', 'primary' => false],
|
||||
['type' => 'resist', 'title' => '想抽忍住了', 'primary' => true],
|
||||
],
|
||||
'data_sources' => [
|
||||
'ai_advice_date' => $adviceDate->format(Support::DATE_LAYOUT),
|
||||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function profile()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->getProfileView($this->getCurrentSmtUserId()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveProfile()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->upsertProfile($this->getCurrentSmtUserId(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function createLog()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->createLog($this->getCurrentSmtUserId(), $this->request->post(), false));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function createResistedLog()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->createLog($this->getCurrentSmtUserId(), $this->request->post(), true));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function readLog(int $id)
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->getLog($this->getCurrentSmtUserId(), $id));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function logs()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->listLogs($this->getCurrentSmtUserId(), $this->request->get()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function latestLogs()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->latestLogs($this->getCurrentSmtUserId(), (int) $this->request->get('limit', 20)));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateLog(int $id)
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->updateLog($this->getCurrentSmtUserId(), $id, $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteLog(int $id)
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->deleteLog($this->getCurrentSmtUserId(), $id));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function dashboard()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->dashboard($this->getCurrentSmtUserId(), $this->request->get()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function stats()
|
||||
{
|
||||
try {
|
||||
$uid = $this->getCurrentSmtUserId();
|
||||
return Response::success($this->smokeService->stats($uid, $this->request->get(), $this->smokeService->getProfile($uid)));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function motivation()
|
||||
{
|
||||
try {
|
||||
$uid = $this->getCurrentSmtUserId();
|
||||
return Response::success($this->smokeService->motivation($uid, $this->smokeService->getProfile($uid)));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function nextSmokeTime()
|
||||
{
|
||||
try {
|
||||
$user = $this->getCurrentSmtUser();
|
||||
$uid = (int) $user['id'];
|
||||
$now = Support::now();
|
||||
$planDate = $this->resolvePlanDate((string) $this->request->get('date', ''), $now);
|
||||
if ($planDate < Support::dateOnly($now)) {
|
||||
throw new \RuntimeException('date 不能早于今天', 400);
|
||||
}
|
||||
|
||||
$mode = strtolower(trim((string) $this->request->get('mode', 'auto')));
|
||||
if (!in_array($mode, ['auto', 'ai', 'default'], true)) {
|
||||
throw new \RuntimeException('mode 参数错误,应为 auto|ai|default', 400);
|
||||
}
|
||||
|
||||
$profileView = $this->smokeService->getProfileView($uid);
|
||||
$defaultSuggestion = $this->smokeService->getDefaultNextSuggestion($uid, $now, $planDate, $profileView);
|
||||
$homeSummary = $this->smokeService->getHomeSummary($uid, $now);
|
||||
|
||||
$response = [
|
||||
'source' => 'default',
|
||||
'not_before_at' => (string) ($defaultSuggestion['next_smoke_at'] ?? ''),
|
||||
'suggested_at' => (string) ($defaultSuggestion['next_smoke_at'] ?? ''),
|
||||
'last_smoke_at' => (string) ($homeSummary['last_smoke_at'] ?? ''),
|
||||
'today_count' => (int) ($homeSummary['today_count'] ?? 0),
|
||||
'resisted_count' => (int) ($homeSummary['resisted_count'] ?? 0),
|
||||
'reduced_from_yesterday' => (int) ($homeSummary['reduced_from_yesterday'] ?? 0),
|
||||
'exceeded_yesterday' => (bool) ($homeSummary['exceeded_yesterday'] ?? false),
|
||||
'default' => $defaultSuggestion,
|
||||
];
|
||||
|
||||
if ($mode !== 'default') {
|
||||
$aiSuggestion = $mode === 'ai'
|
||||
? $this->smokeAiService->getOrGenerateNextSmoke($user, $now, $planDate, 'v1', $defaultSuggestion)
|
||||
: $this->smokeAiService->getCachedNextSmoke($user, $planDate, 'v1');
|
||||
|
||||
if ($aiSuggestion) {
|
||||
$response['source'] = 'ai';
|
||||
$response['not_before_at'] = (string) ($aiSuggestion['not_before_at'] ?? '');
|
||||
$response['suggested_at'] = (string) ($aiSuggestion['suggested_at'] ?? '');
|
||||
$response['time_nodes'] = $aiSuggestion['time_nodes'] ?? [];
|
||||
$response['advice'] = (string) ($aiSuggestion['advice'] ?? '');
|
||||
$response['ai'] = $aiSuggestion;
|
||||
}
|
||||
}
|
||||
|
||||
return Response::success($response);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function aiNextSmokeTime()
|
||||
{
|
||||
return $this->nextSmokeTime();
|
||||
}
|
||||
|
||||
public function aiAdvice()
|
||||
{
|
||||
try {
|
||||
$user = $this->getCurrentSmtUser();
|
||||
$date = !empty($this->request->get('date'))
|
||||
? Support::parseDate((string) $this->request->get('date'), 'date')
|
||||
: Support::dateOnly(Support::now()->modify('-1 day'));
|
||||
$record = $this->smokeAiService->getOrGenerateAdvice($user, $date, 'v2');
|
||||
|
||||
return Response::success([
|
||||
'date' => (string) ($record['date'] ?? $date->format(Support::DATE_LAYOUT)),
|
||||
'advice' => (string) ($record['advice'] ?? ''),
|
||||
'model' => (string) ($record['model'] ?? ''),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function unlockAiAdvice()
|
||||
{
|
||||
try {
|
||||
$date = !empty($this->request->post('date'))
|
||||
? Support::parseDate((string) $this->request->post('date'), 'date')
|
||||
: Support::dateOnly(Support::now()->modify('-1 day'));
|
||||
return Response::success($this->smokeAiService->unlock($this->getCurrentSmtUser(), $date));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function aiDailySummary()
|
||||
{
|
||||
try {
|
||||
$user = $this->getCurrentSmtUser();
|
||||
$date = !empty($this->request->get('date'))
|
||||
? Support::parseDate((string) $this->request->get('date'), 'date')
|
||||
: Support::dateOnly();
|
||||
$record = $this->smokeAiService->getOrGenerateDailySummary($user, $date, 'v1');
|
||||
|
||||
return Response::success([
|
||||
'date' => (string) ($record['date'] ?? $date->format(Support::DATE_LAYOUT)),
|
||||
'content' => (string) ($record['content'] ?? ''),
|
||||
'model' => (string) ($record['model'] ?? ''),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function createShare()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->createShare($this->getCurrentSmtUserId(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function shareRead(string $token)
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->getShareView($token, $this->request->get()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function revokeShare(string $token)
|
||||
{
|
||||
try {
|
||||
return Response::success($this->smokeService->revokeShare($this->getCurrentSmtUserId(), $token));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function generateQuitPlan()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->quitPlanService->generate($this->getCurrentSmtUserId(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function quitPlan()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->quitPlanService->getActivePlan($this->getCurrentSmtUserId()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function quitPlanDays()
|
||||
{
|
||||
try {
|
||||
$planId = (int) $this->request->get('plan_id', 0);
|
||||
return Response::success($this->quitPlanService->getPlanDays($this->getCurrentSmtUserId(), $planId > 0 ? $planId : null));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function resetQuitPlan()
|
||||
{
|
||||
try {
|
||||
return Response::success($this->quitPlanService->reset($this->getCurrentSmtUserId(), $this->request->post()));
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function achievementThemes()
|
||||
{
|
||||
try {
|
||||
return Response::success(['themes' => $this->achievementService->listActiveThemes()]);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function achievement()
|
||||
{
|
||||
try {
|
||||
$uid = $this->getCurrentSmtUserId();
|
||||
$profile = $this->smokeService->getProfile($uid);
|
||||
if (!$profile || empty($profile['achievement_theme_id'])) {
|
||||
return Response::success(['achievement' => null]);
|
||||
}
|
||||
|
||||
$days = $this->smokeService->getStreakDays($uid);
|
||||
$achievement = $this->achievementService->getUserAchievement((int) $profile['achievement_theme_id'], $days);
|
||||
|
||||
return Response::success(['achievement' => $achievement]);
|
||||
} catch (\Throwable $e) {
|
||||
return Response::error($e->getMessage(), $e->getCode() ?: 500);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolvePlanDate(string $raw, \DateTimeImmutable $now): \DateTimeImmutable
|
||||
{
|
||||
$value = strtolower(trim($raw));
|
||||
if ($value === '' || $value === 'today') {
|
||||
return Support::dateOnly($now);
|
||||
}
|
||||
if ($value === 'tomorrow') {
|
||||
return Support::dateOnly($now->modify('+1 day'));
|
||||
}
|
||||
|
||||
return Support::parseDate($value, 'date');
|
||||
}
|
||||
|
||||
private function buildGreeting(string $nickname, string $avatarUrl, \DateTimeImmutable $now): array
|
||||
{
|
||||
$nickname = trim($nickname) !== '' ? trim($nickname) : '朋友';
|
||||
$hour = (int) $now->format('H');
|
||||
|
||||
if ($hour >= 5 && $hour < 11) {
|
||||
[$timeOfDay, $title, $subtitle] = ['morning', '早安', '今天也是清爽的一天'];
|
||||
} elseif ($hour >= 11 && $hour < 14) {
|
||||
[$timeOfDay, $title, $subtitle] = ['noon', '午安', '补充水分和能量'];
|
||||
} elseif ($hour >= 14 && $hour < 19) {
|
||||
[$timeOfDay, $title, $subtitle] = ['afternoon', '下午好', '把烟瘾留在昨天'];
|
||||
} else {
|
||||
[$timeOfDay, $title, $subtitle] = ['evening', '晚上好', '今晚早点休息'];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title . ',' . $nickname,
|
||||
'subtitle' => $subtitle,
|
||||
'nickname' => $nickname,
|
||||
'time_of_day' => $timeOfDay,
|
||||
'avatar_url' => $avatarUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\middleware;
|
||||
|
||||
use app\smt\common\Response;
|
||||
use app\smt\model\User;
|
||||
|
||||
/**
|
||||
* smt 小程序 Bearer session_key 鉴权
|
||||
*/
|
||||
class Auth
|
||||
{
|
||||
public function handle($request, \Closure $next)
|
||||
{
|
||||
$token = $this->extractToken((string) $request->header('Authorization', ''));
|
||||
if ($token === '') {
|
||||
return Response::error('未提供登录凭证', 401);
|
||||
}
|
||||
|
||||
$user = User::findBySessionKey($token);
|
||||
if (!$user) {
|
||||
return Response::error('登录已过期,请重新登录', 401);
|
||||
}
|
||||
|
||||
$request->withMiddleware([
|
||||
'smt_user_id' => (int) $user->id,
|
||||
'smt_user' => [
|
||||
'id' => (int) $user->id,
|
||||
'mini_program_id' => (int) $user->mini_program_id,
|
||||
'open_id' => (string) $user->open_id,
|
||||
'union_id' => (string) $user->union_id,
|
||||
'nickname' => (string) $user->nick_name,
|
||||
'avatar_url' => (string) $user->avatar_url,
|
||||
'gender' => (int) $user->gender,
|
||||
'phone' => (string) $user->phone,
|
||||
'session_key' => (string) $user->session_key,
|
||||
],
|
||||
]);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function extractToken(string $authorization): string
|
||||
{
|
||||
if ($authorization === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!preg_match('/Bearer\s+(.+)/i', $authorization, $matches)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) ($matches[1] ?? ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class AchievementLevel extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_achievement_level';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class AchievementTheme extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_achievement_theme';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
abstract class BaseBizModel extends Model
|
||||
{
|
||||
protected $connection = 'mysql';
|
||||
|
||||
protected $autoWriteTimestamp = false;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class MiniProgram extends BaseBizModel
|
||||
{
|
||||
protected $name = 'mini_programs';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
public static function findActiveById(int $id): ?self
|
||||
{
|
||||
return self::where('id', $id)->whereNull('deleted_at')->find();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeAIAdvice extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_ai_advice';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeAIAdviceUnlock extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_ai_advice_unlocks';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeAINextSmoke extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_ai_next_smoke';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeLog extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_log';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeMotivationQuote extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_motivation_quote';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeQuitPlan extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_quit_plan';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeQuitPlanDay extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_quit_plan_day';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeShare extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_share';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class SmokeUserProfile extends BaseBizModel
|
||||
{
|
||||
protected $name = 'fa_smoke_user_profile';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
public static function findByUid(int $uid): ?self
|
||||
{
|
||||
return self::where('uid', $uid)->whereNull('deleted_at')->find();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class User extends BaseBizModel
|
||||
{
|
||||
protected $name = 'users';
|
||||
|
||||
protected $pk = 'id';
|
||||
|
||||
public static function findBySessionKey(string $sessionKey): ?self
|
||||
{
|
||||
return self::where('session_key', $sessionKey)->whereNull('deleted_at')->find();
|
||||
}
|
||||
|
||||
public static function findActiveById(int $id): ?self
|
||||
{
|
||||
return self::where('id', $id)->whereNull('deleted_at')->find();
|
||||
}
|
||||
|
||||
public static function findByMiniProgramOpenId(int $miniProgramId, string $openId): ?self
|
||||
{
|
||||
return self::where('mini_program_id', $miniProgramId)
|
||||
->where('open_id', $openId)
|
||||
->whereNull('deleted_at')
|
||||
->find();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\model;
|
||||
|
||||
class UserMembership extends BaseBizModel
|
||||
{
|
||||
protected $name = 'user_memberships';
|
||||
|
||||
protected $pk = 'id';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use app\smt\controller\v1\Auth;
|
||||
use app\smt\controller\v1\QuitCheckin;
|
||||
use app\smt\controller\v1\Smoke;
|
||||
use think\facade\Route;
|
||||
|
||||
/**
|
||||
* smt 戒烟小程序路由
|
||||
*/
|
||||
|
||||
Route::post('v1/auth/login', [Auth::class, 'login']);
|
||||
Route::post('v1/auth/dev-login', [Auth::class, 'devLogin']);
|
||||
Route::get('v1/smoke/share/:token', [Smoke::class, 'shareRead']);
|
||||
|
||||
Route::group('v1', function () {
|
||||
Route::get('auth/me', [Auth::class, 'me']);
|
||||
Route::get('auth/mini-program-test-code', [Auth::class, 'miniProgramTestCode']);
|
||||
Route::put('auth/profile', [Auth::class, 'profile']);
|
||||
Route::post('auth/profile', [Auth::class, 'profile']);
|
||||
|
||||
Route::get('smoke/home', [Smoke::class, 'home']);
|
||||
Route::get('smoke/profile', [Smoke::class, 'profile']);
|
||||
Route::post('smoke/profile', [Smoke::class, 'saveProfile']);
|
||||
Route::get('smoke/next_smoke_time', [Smoke::class, 'nextSmokeTime']);
|
||||
Route::get('smoke/dashboard', [Smoke::class, 'dashboard']);
|
||||
Route::get('smoke/stats', [Smoke::class, 'stats']);
|
||||
Route::post('smoke/logs', [Smoke::class, 'createLog']);
|
||||
Route::post('smoke/logs/resisted', [Smoke::class, 'createResistedLog']);
|
||||
Route::get('smoke/logs', [Smoke::class, 'logs']);
|
||||
Route::get('smoke/logs/latest', [Smoke::class, 'latestLogs']);
|
||||
Route::get('smoke/logs/:id', [Smoke::class, 'readLog']);
|
||||
Route::post('smoke/logs/:id', [Smoke::class, 'updateLog']);
|
||||
Route::delete('smoke/logs/:id', [Smoke::class, 'deleteLog']);
|
||||
Route::get('smoke/motivation', [Smoke::class, 'motivation']);
|
||||
|
||||
Route::get('smoke/ai/advice', [Smoke::class, 'aiAdvice']);
|
||||
Route::post('smoke/ai/advice_unlocks', [Smoke::class, 'unlockAiAdvice']);
|
||||
Route::get('smoke/ai/next_smoke_time', [Smoke::class, 'aiNextSmokeTime']);
|
||||
Route::get('smoke/ai/daily_summary', [Smoke::class, 'aiDailySummary']);
|
||||
|
||||
Route::post('smoke/share', [Smoke::class, 'createShare']);
|
||||
Route::post('smoke/share/:token/revoke', [Smoke::class, 'revokeShare']);
|
||||
|
||||
Route::post('smoke/quit-plan/generate', [Smoke::class, 'generateQuitPlan']);
|
||||
Route::get('smoke/quit-plan', [Smoke::class, 'quitPlan']);
|
||||
Route::get('smoke/quit-plan/days', [Smoke::class, 'quitPlanDays']);
|
||||
Route::post('smoke/quit-plan/reset', [Smoke::class, 'resetQuitPlan']);
|
||||
|
||||
Route::get('smoke/achievement/themes', [Smoke::class, 'achievementThemes']);
|
||||
Route::get('smoke/achievement', [Smoke::class, 'achievement']);
|
||||
})->middleware(\app\smt\middleware\Auth::class);
|
||||
|
||||
Route::group('v2', function () {
|
||||
Route::get('profile', [QuitCheckin::class, 'profile']);
|
||||
Route::post('profile', [QuitCheckin::class, 'saveProfile']);
|
||||
Route::get('checkin/home', [QuitCheckin::class, 'home']);
|
||||
Route::post('checkin/check', [QuitCheckin::class, 'checkin']);
|
||||
|
||||
Route::get('dream-presets', [QuitCheckin::class, 'dreamPresets']);
|
||||
Route::get('reward-goals', [QuitCheckin::class, 'rewardGoals']);
|
||||
Route::post('reward-goals', [QuitCheckin::class, 'createRewardGoal']);
|
||||
Route::put('reward-goals/:id', [QuitCheckin::class, 'updateRewardGoal']);
|
||||
|
||||
Route::post('supervisor/invites', [QuitCheckin::class, 'createSupervisorInvite']);
|
||||
Route::post('supervisor/bind', [QuitCheckin::class, 'bindSupervisorInvite']);
|
||||
Route::post('supervisor/revoke', [QuitCheckin::class, 'revokeSupervisorBinding']);
|
||||
Route::get('supervisor/overview', [QuitCheckin::class, 'supervisorOverview']);
|
||||
Route::get('supervisor/status', [QuitCheckin::class, 'supervisorStatus']);
|
||||
Route::get('supervisor/reminders/settings', [QuitCheckin::class, 'reminderSettings']);
|
||||
Route::put('supervisor/reminders/settings', [QuitCheckin::class, 'updateReminderSettings']);
|
||||
Route::post('supervisor/reminders/run', [QuitCheckin::class, 'runReminders']);
|
||||
})->middleware(\app\smt\middleware\Auth::class);
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\service;
|
||||
|
||||
use app\smt\model\AchievementLevel;
|
||||
use app\smt\model\AchievementTheme;
|
||||
|
||||
class AchievementService
|
||||
{
|
||||
public function listActiveThemes(): array
|
||||
{
|
||||
$themes = AchievementTheme::where('is_active', 1)
|
||||
->whereNull('deleted_at')
|
||||
->order('sort_order', 'asc')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->all();
|
||||
|
||||
if (empty($themes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$themeIds = array_map(static function ($item) {
|
||||
return (int) $item->id;
|
||||
}, $themes);
|
||||
|
||||
$levels = AchievementLevel::whereIn('theme_id', $themeIds)
|
||||
->whereNull('deleted_at')
|
||||
->order('required_days', 'asc')
|
||||
->order('sort_order', 'asc')
|
||||
->select()
|
||||
->all();
|
||||
|
||||
$groupedLevels = [];
|
||||
foreach ($levels as $level) {
|
||||
$groupedLevels[(int) $level->theme_id][] = [
|
||||
'id' => (int) $level->id,
|
||||
'theme_id' => (int) $level->theme_id,
|
||||
'name' => (string) $level->name,
|
||||
'icon' => (string) $level->icon,
|
||||
'required_days' => (int) $level->required_days,
|
||||
'sort_order' => (int) $level->sort_order,
|
||||
];
|
||||
}
|
||||
|
||||
return array_map(static function ($theme) use ($groupedLevels) {
|
||||
return [
|
||||
'id' => (int) $theme->id,
|
||||
'name' => (string) $theme->name,
|
||||
'key' => (string) $theme->key,
|
||||
'icon' => (string) $theme->icon,
|
||||
'sort_order' => (int) $theme->sort_order,
|
||||
'is_active' => (bool) $theme->is_active,
|
||||
'levels' => $groupedLevels[(int) $theme->id] ?? [],
|
||||
];
|
||||
}, $themes);
|
||||
}
|
||||
|
||||
public function getUserAchievement(int $themeId, int $days): ?array
|
||||
{
|
||||
$theme = AchievementTheme::where('id', $themeId)->whereNull('deleted_at')->find();
|
||||
if (!$theme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$levels = AchievementLevel::where('theme_id', $themeId)
|
||||
->whereNull('deleted_at')
|
||||
->order('required_days', 'asc')
|
||||
->order('sort_order', 'asc')
|
||||
->select()
|
||||
->all();
|
||||
|
||||
$current = null;
|
||||
$next = null;
|
||||
foreach ($levels as $index => $level) {
|
||||
if ($days >= (int) $level->required_days) {
|
||||
$current = $level;
|
||||
$next = $levels[$index + 1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$progress = 0.0;
|
||||
if ($current && $next) {
|
||||
$rangeTotal = (int) $next->required_days - (int) $current->required_days;
|
||||
if ($rangeTotal > 0) {
|
||||
$progress = max(0, min(1, ($days - (int) $current->required_days) / $rangeTotal));
|
||||
}
|
||||
} elseif ($current) {
|
||||
$progress = 1.0;
|
||||
}
|
||||
|
||||
return [
|
||||
'theme_id' => (int) $theme->id,
|
||||
'theme_name' => (string) $theme->name,
|
||||
'theme_key' => (string) $theme->key,
|
||||
'theme_icon' => (string) $theme->icon,
|
||||
'days' => max(0, $days),
|
||||
'current' => $current ? $this->formatLevel($current) : null,
|
||||
'next' => $next ? $this->formatLevel($next) : null,
|
||||
'progress' => $progress,
|
||||
];
|
||||
}
|
||||
|
||||
private function formatLevel(AchievementLevel $level): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $level->id,
|
||||
'theme_id' => (int) $level->theme_id,
|
||||
'name' => (string) $level->name,
|
||||
'icon' => (string) $level->icon,
|
||||
'required_days' => (int) $level->required_days,
|
||||
'sort_order' => (int) $level->sort_order,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\service;
|
||||
|
||||
use app\smt\model\MiniProgram;
|
||||
use app\smt\model\SmokeUserProfile;
|
||||
use app\smt\model\User;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
private const DEFAULT_AVATAR_URL = 'https://linghu-wmr.oss-cn-beijing.aliyuncs.com/smt/avatar.png';
|
||||
|
||||
public function loginWithCode(array $data): array
|
||||
{
|
||||
$miniProgramId = (int) ($data['mini_program_id'] ?? 0);
|
||||
$code = trim((string) ($data['code'] ?? ''));
|
||||
if ($miniProgramId <= 0) {
|
||||
throw new \RuntimeException('mini_program_id is required', 400);
|
||||
}
|
||||
if ($code === '') {
|
||||
throw new \RuntimeException('code is required', 400);
|
||||
}
|
||||
|
||||
$miniProgram = MiniProgram::findActiveById($miniProgramId);
|
||||
if (!$miniProgram) {
|
||||
throw new \RuntimeException('mini program not found', 400);
|
||||
}
|
||||
|
||||
$session = $this->fetchWechatSession((string) $miniProgram->app_id, (string) $miniProgram->app_secret, $code);
|
||||
$openId = trim((string) ($session['openid'] ?? ''));
|
||||
if ($openId === '') {
|
||||
throw new \RuntimeException('微信登录失败,未获取到 openid', 502);
|
||||
}
|
||||
|
||||
$user = User::findByMiniProgramOpenId($miniProgramId, $openId);
|
||||
$isNew = $user === null;
|
||||
if (!$user) {
|
||||
$user = new User();
|
||||
$user->mini_program_id = $miniProgramId;
|
||||
$user->open_id = $openId;
|
||||
$user->created_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
}
|
||||
|
||||
$avatarUrl = trim((string) ($data['avatar_url'] ?? ''));
|
||||
$user->union_id = (string) ($session['unionid'] ?? '');
|
||||
$user->nick_name = trim((string) ($data['nickname'] ?? (string) $user->nick_name));
|
||||
$user->avatar_url = $avatarUrl !== '' ? $avatarUrl : ((string) $user->avatar_url !== '' ? (string) $user->avatar_url : self::DEFAULT_AVATAR_URL);
|
||||
$user->gender = array_key_exists('gender', $data) ? (int) ($data['gender'] ?? 0) : (int) ($user->gender ?? 0);
|
||||
$user->phone = trim((string) ($data['phone'] ?? (string) $user->phone));
|
||||
$user->session_key = (string) ($session['session_key'] ?? '');
|
||||
$user->updated_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$user->save();
|
||||
|
||||
$payload = $this->formatUser($user);
|
||||
$payload['is_new_user'] = $isNew;
|
||||
$mode = $this->getSmokeMode((int) $user->id);
|
||||
if ($mode !== '') {
|
||||
$payload['mode'] = $mode;
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $payload,
|
||||
'session_key' => (string) $user->session_key,
|
||||
'mini_program' => [
|
||||
'id' => (int) $miniProgram->id,
|
||||
'name' => (string) $miniProgram->name,
|
||||
'app_id' => (string) $miniProgram->app_id,
|
||||
'description' => (string) $miniProgram->description,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function devLogin(int $miniProgramId): array
|
||||
{
|
||||
if ($miniProgramId <= 0) {
|
||||
throw new \RuntimeException('mini_program_id is required', 400);
|
||||
}
|
||||
|
||||
$miniProgram = MiniProgram::findActiveById($miniProgramId);
|
||||
if (!$miniProgram) {
|
||||
throw new \RuntimeException('mini program not found', 400);
|
||||
}
|
||||
|
||||
$openId = 'dev_test_user';
|
||||
$sessionKey = 'dev_session_' . $miniProgramId;
|
||||
$user = User::findByMiniProgramOpenId($miniProgramId, $openId);
|
||||
if (!$user) {
|
||||
$user = new User();
|
||||
$user->mini_program_id = $miniProgramId;
|
||||
$user->open_id = $openId;
|
||||
$user->nick_name = '开发测试用户';
|
||||
$user->avatar_url = self::DEFAULT_AVATAR_URL;
|
||||
$user->created_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
}
|
||||
|
||||
$user->session_key = $sessionKey;
|
||||
$user->updated_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$user->save();
|
||||
|
||||
$payload = $this->formatUser($user);
|
||||
$mode = $this->getSmokeMode((int) $user->id);
|
||||
if ($mode !== '') {
|
||||
$payload['mode'] = $mode;
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => $payload,
|
||||
'session_key' => $sessionKey,
|
||||
'mini_program' => [
|
||||
'id' => (int) $miniProgram->id,
|
||||
'name' => (string) $miniProgram->name,
|
||||
'app_id' => (string) $miniProgram->app_id,
|
||||
'description' => (string) $miniProgram->description,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getUserInfo(int $userId): array
|
||||
{
|
||||
$user = User::findActiveById($userId);
|
||||
if (!$user) {
|
||||
throw new \RuntimeException('用户不存在', 404);
|
||||
}
|
||||
|
||||
$payload = $this->formatUser($user);
|
||||
$mode = $this->getSmokeMode((int) $user->id);
|
||||
if ($mode !== '') {
|
||||
$payload['mode'] = $mode;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function updateProfile(int $userId, array $data): array
|
||||
{
|
||||
$user = User::findActiveById($userId);
|
||||
if (!$user) {
|
||||
throw new \RuntimeException('用户不存在', 404);
|
||||
}
|
||||
|
||||
$nickname = trim((string) ($data['nickname'] ?? ''));
|
||||
$avatarUrl = trim((string) ($data['avatar_url'] ?? ''));
|
||||
if ($nickname === '' && $avatarUrl === '') {
|
||||
throw new \RuntimeException('请提供昵称或头像', 400);
|
||||
}
|
||||
|
||||
if ($nickname !== '') {
|
||||
$user->nick_name = $nickname;
|
||||
}
|
||||
if ($avatarUrl !== '') {
|
||||
$user->avatar_url = $avatarUrl;
|
||||
}
|
||||
$user->save();
|
||||
|
||||
return [
|
||||
'id' => (int) $user->id,
|
||||
'nickname' => (string) $user->nick_name,
|
||||
'avatar_url' => (string) $user->avatar_url,
|
||||
];
|
||||
}
|
||||
|
||||
public function getMiniProgramTestCode(int $userId, string $path, int $width): string
|
||||
{
|
||||
$user = User::findActiveById($userId);
|
||||
if (!$user) {
|
||||
throw new \RuntimeException('用户不存在', 404);
|
||||
}
|
||||
|
||||
$miniProgramId = (int) $user->mini_program_id;
|
||||
if ($miniProgramId <= 0) {
|
||||
throw new \RuntimeException('用户小程序配置缺失', 500);
|
||||
}
|
||||
|
||||
$miniProgram = MiniProgram::findActiveById($miniProgramId);
|
||||
if (!$miniProgram) {
|
||||
throw new \RuntimeException('mini program not found', 400);
|
||||
}
|
||||
|
||||
$path = trim($path);
|
||||
if ($path === '') {
|
||||
$path = 'pages/nsti/test?resume=0';
|
||||
}
|
||||
if ($width <= 0) {
|
||||
$width = 280;
|
||||
}
|
||||
|
||||
return $this->fetchWXACode((string) $miniProgram->app_id, (string) $miniProgram->app_secret, $path, $width);
|
||||
}
|
||||
|
||||
private function getSmokeMode(int $userId): string
|
||||
{
|
||||
$profile = SmokeUserProfile::findByUid($userId);
|
||||
if (!$profile) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return Support::normalizedMode((string) $profile->mode);
|
||||
}
|
||||
|
||||
private function formatUser(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $user->id,
|
||||
'mini_program_id' => (int) $user->mini_program_id,
|
||||
'open_id' => (string) $user->open_id,
|
||||
'union_id' => (string) $user->union_id,
|
||||
'nickname' => (string) $user->nick_name,
|
||||
'avatar_url' => (string) $user->avatar_url,
|
||||
'gender' => (int) $user->gender,
|
||||
'phone' => (string) $user->phone,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchWechatSession(string $appId, string $appSecret, string $code): array
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://api.weixin.qq.com/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code',
|
||||
urlencode($appId),
|
||||
urlencode($appSecret),
|
||||
urlencode($code)
|
||||
);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '' || $error) {
|
||||
throw new \RuntimeException('微信登录请求失败', 502);
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data)) {
|
||||
throw new \RuntimeException('微信登录响应解析失败', 502);
|
||||
}
|
||||
if (!empty($data['errcode'])) {
|
||||
throw new \RuntimeException(sprintf('微信登录失败:%s', (string) ($data['errmsg'] ?? 'unknown error')), 400);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function fetchAccessToken(string $appId, string $appSecret): string
|
||||
{
|
||||
$url = sprintf(
|
||||
'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s',
|
||||
urlencode($appId),
|
||||
urlencode($appSecret)
|
||||
);
|
||||
|
||||
$data = $this->getJson($url, '微信 access_token 请求失败');
|
||||
if (!empty($data['errcode'])) {
|
||||
throw new \RuntimeException(sprintf('微信 access_token 获取失败:%s', (string) ($data['errmsg'] ?? 'unknown error')), 502);
|
||||
}
|
||||
|
||||
$token = trim((string) ($data['access_token'] ?? ''));
|
||||
if ($token === '') {
|
||||
throw new \RuntimeException('微信 access_token 缺失', 502);
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
private function fetchWXACode(string $appId, string $appSecret, string $path, int $width): string
|
||||
{
|
||||
$accessToken = $this->fetchAccessToken($appId, $appSecret);
|
||||
$url = sprintf('https://api.weixin.qq.com/wxa/getwxacode?access_token=%s', urlencode($accessToken));
|
||||
$payload = json_encode([
|
||||
'path' => $path,
|
||||
'width' => $width,
|
||||
'auto_color' => false,
|
||||
'is_hyaline' => true,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '' || $error) {
|
||||
throw new \RuntimeException('微信小程序码请求失败', 502);
|
||||
}
|
||||
if ($status !== 200) {
|
||||
throw new \RuntimeException('微信小程序码接口状态异常', 502);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $response, true);
|
||||
if (is_array($decoded) && !empty($decoded['errcode'])) {
|
||||
throw new \RuntimeException(sprintf('微信小程序码获取失败:%s', (string) ($decoded['errmsg'] ?? 'unknown error')), 502);
|
||||
}
|
||||
|
||||
return (string) $response;
|
||||
}
|
||||
|
||||
private function getJson(string $url, string $failMessage): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '' || $error || $status !== 200) {
|
||||
throw new \RuntimeException($failMessage, 502);
|
||||
}
|
||||
|
||||
$data = json_decode((string) $response, true);
|
||||
if (!is_array($data)) {
|
||||
throw new \RuntimeException($failMessage, 502);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\service;
|
||||
|
||||
use app\smt\model\User;
|
||||
use DateInterval;
|
||||
use think\facade\Db;
|
||||
|
||||
class QuitCheckinService
|
||||
{
|
||||
public function getProfile(array $user): array
|
||||
{
|
||||
$profile = $this->loadOrInitProfile((int) $user['id']);
|
||||
return $this->formatProfile($profile, $user);
|
||||
}
|
||||
|
||||
public function upsertProfile(array $user, array $data): array
|
||||
{
|
||||
$uid = (int) $user['id'];
|
||||
$profile = $this->loadOrInitProfile($uid);
|
||||
$updates = ['updated_at' => Support::now()->format(Support::DATETIME_LAYOUT)];
|
||||
|
||||
if (isset($data['quit_start_date']) && trim((string) $data['quit_start_date']) !== '') {
|
||||
$updates['quit_start_date'] = Support::parseDate((string) $data['quit_start_date'], 'quit_start_date')->format(Support::DATE_LAYOUT);
|
||||
}
|
||||
if (array_key_exists('pack_price_cent', $data)) {
|
||||
$updates['pack_price_cent'] = max(0, (int) $data['pack_price_cent']);
|
||||
}
|
||||
if (array_key_exists('baseline_cigs_per_day', $data)) {
|
||||
$updates['baseline_cigs_per_day'] = max(0, (int) $data['baseline_cigs_per_day']);
|
||||
}
|
||||
if (array_key_exists('motivation', $data)) {
|
||||
$updates['motivation'] = Support::truncate(trim((string) $data['motivation']), 200);
|
||||
}
|
||||
if (array_key_exists('notify_time', $data)) {
|
||||
$notifyTime = trim((string) $data['notify_time']);
|
||||
if ($notifyTime !== '') {
|
||||
$this->assertHHMM($notifyTime);
|
||||
}
|
||||
$updates['notify_time'] = $notifyTime !== '' ? $notifyTime : '21:00';
|
||||
}
|
||||
|
||||
Db::connect('mysql')->name('fa_quit_checkin_profile')->where('uid', $uid)->whereNull('deleted_at')->update($updates);
|
||||
return $this->formatProfile(array_merge($profile, $updates), $user);
|
||||
}
|
||||
|
||||
public function home(int $uid): array
|
||||
{
|
||||
$today = Support::dateOnly()->format(Support::DATE_LAYOUT);
|
||||
return [
|
||||
'daily_status' => $this->dailyStatus($uid, $today),
|
||||
'summary' => $this->summary($uid),
|
||||
'goal' => $this->activeGoal($uid),
|
||||
'badges' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function checkin(int $uid, array $data): array
|
||||
{
|
||||
$date = Support::parseDate((string) ($data['date'] ?? ''), 'date') ?: Support::dateOnly();
|
||||
$dateText = $date->format(Support::DATE_LAYOUT);
|
||||
$now = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$note = Support::truncate(trim((string) ($data['note'] ?? '')), 200);
|
||||
$db = Db::connect('mysql');
|
||||
$existing = $db->name('fa_quit_checkin_daily_status')->where('uid', $uid)->where('date', $dateText)->whereNull('deleted_at')->find();
|
||||
$payload = [
|
||||
'uid' => $uid,
|
||||
'date' => $dateText,
|
||||
'status' => 'checked_in',
|
||||
'check_in_at' => $now,
|
||||
'relapsed_at' => null,
|
||||
'relapse_num' => 0,
|
||||
'reason' => '',
|
||||
'note' => $note,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if ($existing) {
|
||||
$db->name('fa_quit_checkin_daily_status')->where('id', (int) $existing['id'])->update($payload);
|
||||
} else {
|
||||
$payload['created_at'] = $now;
|
||||
$db->name('fa_quit_checkin_daily_status')->insert($payload);
|
||||
}
|
||||
|
||||
return ['daily_status' => $this->dailyStatus($uid, $dateText), 'summary' => $this->summary($uid)];
|
||||
}
|
||||
|
||||
public function listDreamPresets(): array
|
||||
{
|
||||
return Db::connect('mysql')->name('fa_dream_goal_preset')
|
||||
->where('is_active', 1)
|
||||
->whereNull('deleted_at')
|
||||
->order('sort_order asc,id asc')
|
||||
->select()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function listRewardGoals(int $uid, string $status): array
|
||||
{
|
||||
$query = Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->where('uid', $uid)->whereNull('deleted_at');
|
||||
if (in_array($status, ['active', 'completed', 'archived'], true)) {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
$rows = $query->order('id desc')->select()->toArray();
|
||||
$saved = $this->summary($uid)['saved_money_cent'];
|
||||
$items = array_map(fn (array $row): array => $this->formatGoal($row, $saved), $rows);
|
||||
return ['items' => $items, 'total' => count($items)];
|
||||
}
|
||||
|
||||
public function createRewardGoal(int $uid, array $data): array
|
||||
{
|
||||
$title = Support::truncate(trim((string) ($data['title'] ?? '')), 64);
|
||||
$amount = (int) ($data['target_amount_cent'] ?? 0);
|
||||
if ($title === '' || $amount <= 0) {
|
||||
throw new \RuntimeException('目标名称和金额不能为空', 400);
|
||||
}
|
||||
$now = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$id = Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->insertGetId([
|
||||
'uid' => $uid,
|
||||
'title' => $title,
|
||||
'target_amount_cent' => $amount,
|
||||
'cover_image' => Support::truncate(trim((string) ($data['cover_image'] ?? '')), 500),
|
||||
'status' => 'active',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
return $this->formatGoal($this->findGoal($uid, (int) $id), $this->summary($uid)['saved_money_cent']);
|
||||
}
|
||||
|
||||
public function updateRewardGoal(int $uid, int $id, array $data): array
|
||||
{
|
||||
$goal = $this->findGoal($uid, $id);
|
||||
$updates = ['updated_at' => Support::now()->format(Support::DATETIME_LAYOUT)];
|
||||
if (array_key_exists('title', $data)) {
|
||||
$title = Support::truncate(trim((string) $data['title']), 64);
|
||||
if ($title !== '') {
|
||||
$updates['title'] = $title;
|
||||
}
|
||||
}
|
||||
if (array_key_exists('target_amount_cent', $data)) {
|
||||
$amount = (int) $data['target_amount_cent'];
|
||||
if ($amount > 0) {
|
||||
$updates['target_amount_cent'] = $amount;
|
||||
}
|
||||
}
|
||||
if (array_key_exists('cover_image', $data)) {
|
||||
$updates['cover_image'] = Support::truncate(trim((string) $data['cover_image']), 500);
|
||||
}
|
||||
if (array_key_exists('status', $data) && in_array((string) $data['status'], ['active', 'completed', 'archived'], true)) {
|
||||
$updates['status'] = (string) $data['status'];
|
||||
$updates['completed_at'] = $updates['status'] === 'completed' ? $updates['updated_at'] : null;
|
||||
}
|
||||
Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->where('id', (int) $goal['id'])->update($updates);
|
||||
return $this->formatGoal($this->findGoal($uid, $id), $this->summary($uid)['saved_money_cent']);
|
||||
}
|
||||
|
||||
public function createSupervisorInvite(int $ownerUID, int $days): array
|
||||
{
|
||||
$days = $days > 0 ? min($days, 30) : 7;
|
||||
$now = Support::now();
|
||||
$token = bin2hex(random_bytes(16));
|
||||
$expireAt = $now->add(new DateInterval('P' . $days . 'D'))->format(Support::DATETIME_LAYOUT);
|
||||
|
||||
Db::connect('mysql')->name('fa_quit_checkin_supervisor_invite')->insert([
|
||||
'owner_uid' => $ownerUID,
|
||||
'token' => $token,
|
||||
'expire_at' => $expireAt,
|
||||
'created_at' => $now->format(Support::DATETIME_LAYOUT),
|
||||
'updated_at' => $now->format(Support::DATETIME_LAYOUT),
|
||||
]);
|
||||
|
||||
return ['token' => $token, 'expire_at' => Support::formatRfc3339($expireAt)];
|
||||
}
|
||||
|
||||
public function bindSupervisorInvite(int $supervisorUID, string $token): array
|
||||
{
|
||||
$token = trim($token);
|
||||
if ($token === '') {
|
||||
throw new \RuntimeException('请求参数错误', 400);
|
||||
}
|
||||
|
||||
$db = Db::connect('mysql');
|
||||
$invite = $db->name('fa_quit_checkin_supervisor_invite')->where('token', $token)->whereNull('deleted_at')->find();
|
||||
if (!$invite) {
|
||||
throw new \RuntimeException('邀请不存在', 400);
|
||||
}
|
||||
if (!empty($invite['used_at']) || !empty($invite['used_by_uid'])) {
|
||||
throw new \RuntimeException('邀请已被使用', 400);
|
||||
}
|
||||
if (Support::toDateTime($invite['expire_at']) < Support::now()) {
|
||||
throw new \RuntimeException('邀请已过期', 400);
|
||||
}
|
||||
|
||||
$ownerUID = (int) $invite['owner_uid'];
|
||||
if ($ownerUID === $supervisorUID) {
|
||||
throw new \RuntimeException('不能绑定自己', 400);
|
||||
}
|
||||
|
||||
$exists = $db->name('fa_quit_checkin_supervisor_binding')
|
||||
->where('owner_uid', $ownerUID)
|
||||
->where('supervisor_uid', $supervisorUID)
|
||||
->where('status', 'active')
|
||||
->whereNull('deleted_at')
|
||||
->find();
|
||||
if ($exists) {
|
||||
throw new \RuntimeException('已绑定,无需重复操作', 400);
|
||||
}
|
||||
|
||||
$count = (int) $db->name('fa_quit_checkin_supervisor_binding')
|
||||
->where('owner_uid', $ownerUID)
|
||||
->where('status', 'active')
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
if ($count >= 3) {
|
||||
throw new \RuntimeException('对方监督人已达上限(最多 3 人)', 400);
|
||||
}
|
||||
|
||||
$now = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$db->startTrans();
|
||||
try {
|
||||
$db->name('fa_quit_checkin_supervisor_binding')->insert([
|
||||
'owner_uid' => $ownerUID,
|
||||
'supervisor_uid' => $supervisorUID,
|
||||
'status' => 'active',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$db->name('fa_quit_checkin_supervisor_invite')->where('id', (int) $invite['id'])->update([
|
||||
'used_at' => $now,
|
||||
'used_by_uid' => $supervisorUID,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$db->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->rollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public function supervisorOverview(int $supervisorUID): array
|
||||
{
|
||||
$bindings = Db::connect('mysql')->name('fa_quit_checkin_supervisor_binding')
|
||||
->where('supervisor_uid', $supervisorUID)
|
||||
->where('status', 'active')
|
||||
->whereNull('deleted_at')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$items = [];
|
||||
foreach ($bindings as $binding) {
|
||||
$ownerUID = (int) $binding['owner_uid'];
|
||||
$owner = User::findActiveById($ownerUID);
|
||||
if (!$owner) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'owner' => $this->userSummary($owner),
|
||||
'home' => $this->home($ownerUID),
|
||||
];
|
||||
}
|
||||
|
||||
return ['items' => $items];
|
||||
}
|
||||
|
||||
public function supervisorStatus(int $ownerUID): array
|
||||
{
|
||||
$bindings = Db::connect('mysql')->name('fa_quit_checkin_supervisor_binding')
|
||||
->where('owner_uid', $ownerUID)
|
||||
->where('status', 'active')
|
||||
->whereNull('deleted_at')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$items = [];
|
||||
foreach ($bindings as $binding) {
|
||||
$user = User::findActiveById((int) $binding['supervisor_uid']);
|
||||
if ($user) {
|
||||
$items[] = $this->userSummary($user);
|
||||
}
|
||||
}
|
||||
return ['items' => $items];
|
||||
}
|
||||
|
||||
public function revokeSupervisorBinding(int $actorUID, array $data): array
|
||||
{
|
||||
$ownerUID = (int) ($data['owner_uid'] ?? 0);
|
||||
$supervisorUID = (int) ($data['supervisor_uid'] ?? 0);
|
||||
if ($ownerUID <= 0 || $supervisorUID <= 0 || ($actorUID !== $ownerUID && $actorUID !== $supervisorUID)) {
|
||||
throw new \RuntimeException('解除失败', 400);
|
||||
}
|
||||
|
||||
Db::connect('mysql')->name('fa_quit_checkin_supervisor_binding')
|
||||
->where('owner_uid', $ownerUID)
|
||||
->where('supervisor_uid', $supervisorUID)
|
||||
->where('status', 'active')
|
||||
->update(['status' => 'revoked', 'updated_at' => Support::now()->format(Support::DATETIME_LAYOUT)]);
|
||||
|
||||
return ['ok' => true];
|
||||
}
|
||||
|
||||
public function getReminderSettings(int $ownerUID): array
|
||||
{
|
||||
$row = $this->loadOrInitReminderSetting($ownerUID);
|
||||
return [
|
||||
'enabled' => (bool) $row['enabled'],
|
||||
'notify_time' => (string) $row['notify_time'],
|
||||
'max_per_day' => (int) $row['max_per_day'],
|
||||
];
|
||||
}
|
||||
|
||||
public function updateReminderSettings(int $ownerUID, array $data): array
|
||||
{
|
||||
$this->loadOrInitReminderSetting($ownerUID);
|
||||
$updates = ['updated_at' => Support::now()->format(Support::DATETIME_LAYOUT)];
|
||||
if (array_key_exists('enabled', $data)) {
|
||||
$updates['enabled'] = (bool) $data['enabled'] ? 1 : 0;
|
||||
}
|
||||
if (array_key_exists('notify_time', $data)) {
|
||||
$notifyTime = trim((string) $data['notify_time']);
|
||||
$this->assertHHMM($notifyTime);
|
||||
$updates['notify_time'] = $notifyTime;
|
||||
}
|
||||
if (array_key_exists('max_per_day', $data)) {
|
||||
$max = (int) $data['max_per_day'];
|
||||
if ($max < 0 || $max > 10) {
|
||||
throw new \RuntimeException('保存提醒设置失败', 400);
|
||||
}
|
||||
$updates['max_per_day'] = $max;
|
||||
}
|
||||
|
||||
Db::connect('mysql')->name('fa_quit_checkin_supervisor_reminder_setting')
|
||||
->where('owner_uid', $ownerUID)
|
||||
->whereNull('deleted_at')
|
||||
->update($updates);
|
||||
|
||||
return $this->getReminderSettings($ownerUID);
|
||||
}
|
||||
|
||||
public function runReminders(int $supervisorUID): array
|
||||
{
|
||||
$db = Db::connect('mysql');
|
||||
$today = Support::dateOnly()->format(Support::DATE_LAYOUT);
|
||||
$now = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$bindings = $db->name('fa_quit_checkin_supervisor_binding')
|
||||
->where('supervisor_uid', $supervisorUID)
|
||||
->where('status', 'active')
|
||||
->whereNull('deleted_at')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
foreach ($bindings as $binding) {
|
||||
$ownerUID = (int) $binding['owner_uid'];
|
||||
$setting = $this->loadOrInitReminderSetting($ownerUID);
|
||||
if (!(bool) $setting['enabled'] || (int) $setting['max_per_day'] <= 0 || !$this->isAfterNotifyTime((string) $setting['notify_time'])) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$status = $db->name('fa_quit_checkin_daily_status')->where('uid', $ownerUID)->where('date', $today)->whereNull('deleted_at')->find();
|
||||
if ($status && in_array((string) $status['status'], ['checked_in', 'relapsed'], true)) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$count = (int) $db->name('fa_quit_checkin_supervisor_reminder_log')
|
||||
->where('owner_uid', $ownerUID)
|
||||
->where('supervisor_uid', $supervisorUID)
|
||||
->where('reminder_date', $today)
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
if ($count >= (int) $setting['max_per_day']) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$db->name('fa_quit_checkin_supervisor_reminder_log')->insert([
|
||||
'owner_uid' => $ownerUID,
|
||||
'supervisor_uid' => $supervisorUID,
|
||||
'reminder_date' => $today,
|
||||
'reminder_at' => $now,
|
||||
'type' => 'missed_checkin',
|
||||
'status' => 'stubbed',
|
||||
'channel' => 'stub',
|
||||
'message' => '提醒:戒烟用户今天还没打卡',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$created++;
|
||||
}
|
||||
|
||||
return ['created' => $created, 'skipped' => $skipped];
|
||||
}
|
||||
|
||||
private function dailyStatus(int $uid, string $date): array
|
||||
{
|
||||
$row = Db::connect('mysql')->name('fa_quit_checkin_daily_status')->where('uid', $uid)->where('date', $date)->whereNull('deleted_at')->find();
|
||||
if (!$row) {
|
||||
return ['date' => $date, 'status' => 'pending', 'checkin_at' => null, 'relapsed_at' => null, 'relapse_num' => null, 'note' => null];
|
||||
}
|
||||
return [
|
||||
'date' => (string) $row['date'],
|
||||
'status' => (string) $row['status'],
|
||||
'checkin_at' => !empty($row['check_in_at']) ? Support::formatRfc3339($row['check_in_at']) : null,
|
||||
'relapsed_at' => !empty($row['relapsed_at']) ? Support::formatRfc3339($row['relapsed_at']) : null,
|
||||
'relapse_num' => isset($row['relapse_num']) ? (int) $row['relapse_num'] : null,
|
||||
'note' => $row['note'] !== null ? (string) $row['note'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function summary(int $uid): array
|
||||
{
|
||||
$profile = Db::connect('mysql')->name('fa_quit_checkin_profile')->where('uid', $uid)->whereNull('deleted_at')->find();
|
||||
$start = Support::dateOnly($profile['quit_start_date'] ?? null);
|
||||
$days = max(0, (int) $start->diff(Support::dateOnly())->days + 1);
|
||||
$baseline = (int) ($profile['baseline_cigs_per_day'] ?? 0);
|
||||
$packPrice = (int) ($profile['pack_price_cent'] ?? 0);
|
||||
$relapses = (int) Db::connect('mysql')->name('fa_quit_checkin_daily_status')->where('uid', $uid)->where('status', 'relapsed')->whereNull('deleted_at')->count();
|
||||
$streak = max(0, $days - $relapses);
|
||||
$avoided = $streak * $baseline;
|
||||
$saved = $baseline > 0 ? (int) round($avoided * ($packPrice / 20)) : 0;
|
||||
return [
|
||||
'current_streak_days' => $streak,
|
||||
'max_streak_days' => $streak,
|
||||
'milestone_days' => 7,
|
||||
'days_to_next_milestone' => max(0, 7 - $streak),
|
||||
'saved_money_cent' => $saved,
|
||||
'avoided_cigs' => $avoided,
|
||||
'avoided_cigs_mode' => 'baseline',
|
||||
'health_recovery_percent' => min(100, $streak * 2),
|
||||
'hp_current' => isset($profile['hp_current']) ? (int) $profile['hp_current'] : 100,
|
||||
'hp_change_today' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
private function activeGoal(int $uid): ?array
|
||||
{
|
||||
$row = Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->where('uid', $uid)->where('status', 'active')->whereNull('deleted_at')->order('id desc')->find();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
$saved = $this->summary($uid)['saved_money_cent'];
|
||||
return $this->formatGoal($row, $saved);
|
||||
}
|
||||
|
||||
private function formatGoal(array $row, int $saved): array
|
||||
{
|
||||
$target = max(1, (int) $row['target_amount_cent']);
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'user_id' => (int) $row['uid'],
|
||||
'title' => (string) $row['title'],
|
||||
'target_amount_cent' => (int) $row['target_amount_cent'],
|
||||
'current_amount_cent' => min($saved, (int) $row['target_amount_cent']),
|
||||
'progress_percent' => min(100, (int) floor($saved * 100 / $target)),
|
||||
'cover_image' => (string) ($row['cover_image'] ?? ''),
|
||||
'status' => (string) $row['status'],
|
||||
'completed_at' => !empty($row['completed_at']) ? Support::formatRfc3339($row['completed_at']) : null,
|
||||
'created_at' => Support::formatRfc3339($row['created_at'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private function findGoal(int $uid, int $id): array
|
||||
{
|
||||
$row = Db::connect('mysql')->name('fa_quit_checkin_reward_goal')->where('id', $id)->where('uid', $uid)->whereNull('deleted_at')->find();
|
||||
if (!$row) {
|
||||
throw new \RuntimeException('目标不存在', 404);
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function loadOrInitProfile(int $uid): array
|
||||
{
|
||||
$db = Db::connect('mysql');
|
||||
$row = $db->name('fa_quit_checkin_profile')->where('uid', $uid)->whereNull('deleted_at')->find();
|
||||
if ($row) {
|
||||
return $row;
|
||||
}
|
||||
$now = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$payload = [
|
||||
'uid' => $uid,
|
||||
'quit_start_date' => Support::dateOnly()->format(Support::DATE_LAYOUT),
|
||||
'pack_price_cent' => 0,
|
||||
'baseline_cigs_per_day' => 0,
|
||||
'motivation' => '',
|
||||
'notify_time' => '21:00',
|
||||
'reset_rule' => 'any_relapse_reset',
|
||||
'hp_current' => 100,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$payload['id'] = $db->name('fa_quit_checkin_profile')->insertGetId($payload);
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function formatProfile(array $row, array $user): array
|
||||
{
|
||||
return [
|
||||
'user_id' => (int) $user['id'],
|
||||
'nickname' => (string) ($user['nickname'] ?? ''),
|
||||
'avatar_url' => (string) ($user['avatar_url'] ?? ''),
|
||||
'quit_start_date' => (string) ($row['quit_start_date'] ?? ''),
|
||||
'pack_price_cent' => (int) ($row['pack_price_cent'] ?? 0),
|
||||
'baseline_cigs_per_day' => (int) ($row['baseline_cigs_per_day'] ?? 0),
|
||||
'motivation' => (string) ($row['motivation'] ?? ''),
|
||||
'notify_time' => (string) ($row['notify_time'] ?? '21:00'),
|
||||
'reset_rule' => (string) ($row['reset_rule'] ?? 'any_relapse_reset'),
|
||||
'created_at' => Support::formatRfc3339($row['created_at'] ?? null),
|
||||
'updated_at' => Support::formatRfc3339($row['updated_at'] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
private function loadOrInitReminderSetting(int $ownerUID): array
|
||||
{
|
||||
$db = Db::connect('mysql');
|
||||
$row = $db->name('fa_quit_checkin_supervisor_reminder_setting')->where('owner_uid', $ownerUID)->whereNull('deleted_at')->find();
|
||||
if ($row) {
|
||||
return $row;
|
||||
}
|
||||
$now = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$payload = ['owner_uid' => $ownerUID, 'enabled' => 0, 'notify_time' => '21:00', 'max_per_day' => 1, 'channel_hint' => 'stub', 'created_at' => $now, 'updated_at' => $now];
|
||||
$payload['id'] = $db->name('fa_quit_checkin_supervisor_reminder_setting')->insertGetId($payload);
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function userSummary(User $user): array
|
||||
{
|
||||
return ['user_id' => (int) $user->id, 'nickname' => (string) $user->nick_name, 'avatar_url' => (string) $user->avatar_url];
|
||||
}
|
||||
|
||||
private function assertHHMM(string $value): void
|
||||
{
|
||||
if (!preg_match('/^\d{2}:\d{2}$/', $value)) {
|
||||
throw new \RuntimeException('时间格式错误,应为 HH:MM', 400);
|
||||
}
|
||||
[$hour, $minute] = array_map('intval', explode(':', $value));
|
||||
if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) {
|
||||
throw new \RuntimeException('时间格式错误,应为 HH:MM', 400);
|
||||
}
|
||||
}
|
||||
|
||||
private function isAfterNotifyTime(string $notifyTime): bool
|
||||
{
|
||||
$notifyTime = $notifyTime !== '' ? $notifyTime : '21:00';
|
||||
$this->assertHHMM($notifyTime);
|
||||
return Support::now()->format('H:i') >= $notifyTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\service;
|
||||
|
||||
use app\smt\model\SmokeQuitPlan;
|
||||
use app\smt\model\SmokeQuitPlanDay;
|
||||
use app\smt\model\SmokeUserProfile;
|
||||
use think\facade\Db;
|
||||
|
||||
class QuitPlanService
|
||||
{
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STAGE_RECORDING = 'recording';
|
||||
public const STAGE_REDUCING = 'reducing';
|
||||
public const STAGE_CONSOLIDATING = 'consolidating';
|
||||
|
||||
public function generate(int $userId, array $data = []): array
|
||||
{
|
||||
if ($this->findActivePlan($userId)) {
|
||||
throw new \RuntimeException('已有进行中的戒烟计划,请先重置', 409);
|
||||
}
|
||||
|
||||
$profile = SmokeUserProfile::findByUid($userId);
|
||||
if (!$profile || (int) $profile->baseline_cigs_per_day <= 0) {
|
||||
throw new \RuntimeException('请先完成个人资料填写', 400);
|
||||
}
|
||||
|
||||
$startDate = Support::parseDate((string) ($data['start_date'] ?? ''), 'start_date') ?? Support::dateOnly();
|
||||
$endDate = $startDate->modify('+29 day');
|
||||
$days = $this->buildPlanDays($profile->toArray(), $startDate);
|
||||
$now = time();
|
||||
$summary = $this->buildSummary($profile->toArray());
|
||||
|
||||
Db::connect('mysql')->transaction(function () use ($userId, $profile, $startDate, $endDate, $days, $summary, $now) {
|
||||
$plan = new SmokeQuitPlan();
|
||||
$plan->uid = $userId;
|
||||
$plan->status = self::STATUS_ACTIVE;
|
||||
$plan->start_date = $startDate->format(Support::DATE_LAYOUT);
|
||||
$plan->end_date = $endDate->format(Support::DATE_LAYOUT);
|
||||
$plan->baseline_cigs_per_day = (int) $profile->baseline_cigs_per_day;
|
||||
$plan->smoking_years = (float) $profile->smoking_years;
|
||||
$plan->pack_price_cent = (int) $profile->pack_price_cent;
|
||||
$plan->current_stage = self::STAGE_RECORDING;
|
||||
$plan->current_day = 1;
|
||||
$plan->completed_days = 0;
|
||||
$plan->prompt_version = 'rule_v1';
|
||||
$plan->provider = 'built-in';
|
||||
$plan->model = 'rule-based';
|
||||
$plan->summary = $summary;
|
||||
$plan->createtime = $now;
|
||||
$plan->updatetime = $now;
|
||||
$plan->save();
|
||||
|
||||
foreach ($days as $day) {
|
||||
$row = new SmokeQuitPlanDay();
|
||||
$row->plan_id = (int) $plan->id;
|
||||
$row->uid = $userId;
|
||||
$row->plan_date = $day['plan_date'];
|
||||
$row->stage = $day['stage'];
|
||||
$row->day = $day['day'];
|
||||
$row->target_cigs = $day['target_cigs'];
|
||||
$row->target_reduced = $day['target_reduced'] ? 1 : 0;
|
||||
$row->advice = $day['advice'];
|
||||
$row->createtime = $now;
|
||||
$row->updatetime = $now;
|
||||
$row->save();
|
||||
}
|
||||
});
|
||||
|
||||
return $this->getActivePlan($userId);
|
||||
}
|
||||
|
||||
public function reset(int $userId, array $data = []): array
|
||||
{
|
||||
$existing = $this->findActivePlan($userId);
|
||||
if ($existing) {
|
||||
$existing->status = self::STATUS_FAILED;
|
||||
$existing->updatetime = time();
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
return $this->generate($userId, $data);
|
||||
}
|
||||
|
||||
public function getActivePlan(int $userId): array
|
||||
{
|
||||
$plan = $this->findActivePlan($userId);
|
||||
if (!$plan) {
|
||||
throw new \RuntimeException('暂无进行中的戒烟计划', 404);
|
||||
}
|
||||
|
||||
$today = Support::dateOnly();
|
||||
$startDate = Support::dateOnly((string) $plan->start_date);
|
||||
if ($today < $startDate) {
|
||||
$currentDay = 1;
|
||||
} else {
|
||||
$currentDay = max(1, min(30, (int) $startDate->diff($today)->days + 1));
|
||||
}
|
||||
|
||||
$currentStage = $this->resolveStageByDay($currentDay);
|
||||
$dayPlan = SmokeQuitPlanDay::where('uid', $userId)
|
||||
->where('plan_date', $today->format(Support::DATE_LAYOUT))
|
||||
->whereNull('deleted_at')
|
||||
->find();
|
||||
|
||||
return [
|
||||
'id' => (int) $plan->id,
|
||||
'status' => (string) $plan->status,
|
||||
'start_date' => (string) $plan->start_date,
|
||||
'end_date' => (string) $plan->end_date,
|
||||
'current_stage' => $currentStage,
|
||||
'current_day' => $currentDay,
|
||||
'completed_days' => (int) $plan->completed_days,
|
||||
'baseline_cigs' => (int) $plan->baseline_cigs_per_day,
|
||||
'summary' => (string) $plan->summary,
|
||||
'today_target' => $dayPlan ? (int) $dayPlan->target_cigs : null,
|
||||
'today_advice' => $dayPlan ? (string) $dayPlan->advice : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function getPlanDays(int $userId, ?int $planId = null): array
|
||||
{
|
||||
if ($planId === null || $planId <= 0) {
|
||||
$active = $this->findActivePlan($userId);
|
||||
if (!$active) {
|
||||
throw new \RuntimeException('暂无进行中的戒烟计划', 404);
|
||||
}
|
||||
$planId = (int) $active->id;
|
||||
}
|
||||
|
||||
$plan = SmokeQuitPlan::where('id', $planId)->where('uid', $userId)->whereNull('deleted_at')->find();
|
||||
if (!$plan) {
|
||||
throw new \RuntimeException('戒烟计划不存在', 404);
|
||||
}
|
||||
|
||||
$rows = SmokeQuitPlanDay::where('plan_id', $planId)
|
||||
->whereNull('deleted_at')
|
||||
->order('day', 'asc')
|
||||
->select()
|
||||
->all();
|
||||
|
||||
$days = array_map(static function ($row) {
|
||||
return [
|
||||
'day' => (int) $row->day,
|
||||
'plan_date' => (string) $row->plan_date,
|
||||
'stage' => (string) $row->stage,
|
||||
'target_cigs' => (int) $row->target_cigs,
|
||||
'target_reduced' => (bool) $row->target_reduced,
|
||||
'advice' => (string) $row->advice,
|
||||
'actual_cigs' => $row->actual_cigs !== null ? (int) $row->actual_cigs : null,
|
||||
'resisted_cnt' => $row->resisted_cnt !== null ? (int) $row->resisted_cnt : null,
|
||||
'achieved' => $row->achieved !== null ? (bool) $row->achieved : null,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return [
|
||||
'plan_id' => $planId,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
private function findActivePlan(int $userId): ?SmokeQuitPlan
|
||||
{
|
||||
return SmokeQuitPlan::where('uid', $userId)
|
||||
->where('status', self::STATUS_ACTIVE)
|
||||
->whereNull('deleted_at')
|
||||
->find();
|
||||
}
|
||||
|
||||
private function buildPlanDays(array $profile, \DateTimeImmutable $startDate): array
|
||||
{
|
||||
$baseline = max(1, (int) ($profile['baseline_cigs_per_day'] ?? 0));
|
||||
$quitMotivation = Support::jsonArray($profile['quit_motivations'] ?? []);
|
||||
$smokeMotivation = Support::jsonArray($profile['smoke_motivations'] ?? []);
|
||||
$days = [];
|
||||
$previousTarget = $baseline;
|
||||
$reducingFloor = max(1, (int) round($baseline * 0.4));
|
||||
|
||||
for ($day = 1; $day <= 30; $day++) {
|
||||
$stage = $this->resolveStageByDay($day);
|
||||
if ($day <= 7) {
|
||||
$target = $baseline;
|
||||
} elseif ($day <= 21) {
|
||||
$progress = ($day - 8) / 13;
|
||||
$target = (int) round($baseline - (($baseline - $reducingFloor) * $progress));
|
||||
} else {
|
||||
$progress = ($day - 22) / 8;
|
||||
$target = (int) round($reducingFloor * (1 - $progress));
|
||||
}
|
||||
|
||||
$target = max(0, min($baseline, $target));
|
||||
$days[] = [
|
||||
'day' => $day,
|
||||
'plan_date' => $startDate->modify('+' . ($day - 1) . ' day')->format(Support::DATE_LAYOUT),
|
||||
'stage' => $stage,
|
||||
'target_cigs' => $target,
|
||||
'target_reduced' => $day > 1 && $target < $previousTarget,
|
||||
'advice' => $this->buildDayAdvice($stage, $target, $quitMotivation, $smokeMotivation),
|
||||
];
|
||||
$previousTarget = $target;
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
private function buildSummary(array $profile): string
|
||||
{
|
||||
$baseline = max(1, (int) ($profile['baseline_cigs_per_day'] ?? 0));
|
||||
$motivation = Support::jsonArray($profile['quit_motivations'] ?? []);
|
||||
$goal = $motivation[0] ?? '让自己回到更轻松的状态';
|
||||
|
||||
return sprintf(
|
||||
'这是一个 30 天的渐进式戒烟计划:前 7 天建立稳定记录,第 8-21 天逐步减量,最后 9 天把目标压低到接近清零。请始终把“%s”作为你每天复盘时的提醒。当前基线按日均 %d 支计算。',
|
||||
$goal,
|
||||
$baseline
|
||||
);
|
||||
}
|
||||
|
||||
private function buildDayAdvice(string $stage, int $target, array $quitMotivation, array $smokeMotivation): string
|
||||
{
|
||||
$quitLine = $quitMotivation[0] ?? '提醒自己坚持的理由';
|
||||
$smokeLine = $smokeMotivation[0] ?? '高压或社交场景';
|
||||
|
||||
if ($stage === self::STAGE_RECORDING) {
|
||||
return sprintf('今天先把记录做完整,目标控制在 %d 支以内。特别留意 %s 这些触发场景,先不急着求完美,把每次想抽前的情绪和时间点记下来。', $target, $smokeLine);
|
||||
}
|
||||
if ($stage === self::STAGE_REDUCING) {
|
||||
return sprintf('今天目标降到 %d 支。把最容易多抽的一个时段延后 10-15 分钟,用喝水、走动或深呼吸替代,复盘时提醒自己:%s。', $target, $quitLine);
|
||||
}
|
||||
|
||||
return sprintf('今天进入巩固期,目标控制在 %d 支。优先守住“第一根烟”和“最晚一根烟”两个节点,把注意力放在连续完成目标上,继续记住:%s。', $target, $quitLine);
|
||||
}
|
||||
|
||||
private function resolveStageByDay(int $day): string
|
||||
{
|
||||
if ($day <= 7) {
|
||||
return self::STAGE_RECORDING;
|
||||
}
|
||||
if ($day <= 21) {
|
||||
return self::STAGE_REDUCING;
|
||||
}
|
||||
|
||||
return self::STAGE_CONSOLIDATING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\service;
|
||||
|
||||
use app\smt\model\SmokeAIAdvice;
|
||||
use app\smt\model\SmokeAIAdviceUnlock;
|
||||
use app\smt\model\SmokeAINextSmoke;
|
||||
use app\smt\model\SmokeLog;
|
||||
use app\smt\model\SmokeUserProfile;
|
||||
use app\smt\model\UserMembership;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use think\facade\Db;
|
||||
|
||||
class SmokeAiService
|
||||
{
|
||||
public const TYPE_DAILY_ADVICE = 'daily_advice';
|
||||
public const TYPE_NEXT_SMOKE = 'next_smoke_time';
|
||||
public const TYPE_DAILY_SUMMARY = 'daily_summary';
|
||||
|
||||
public function getOrGenerateAdvice(array $user, DateTimeImmutable $adviceDate, string $promptVersion = 'v2'): array
|
||||
{
|
||||
$cached = $this->getCachedByType((int) $user['id'], self::TYPE_DAILY_ADVICE, $adviceDate, $promptVersion);
|
||||
if ($cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$this->isAllowed($user, $adviceDate)) {
|
||||
throw new \RuntimeException('需要会员或观看广告解锁后才可生成建议', 403);
|
||||
}
|
||||
|
||||
[$snapshot, $snapshotJson] = $this->buildSnapshot((int) $user['id'], $adviceDate);
|
||||
[$content, $meta] = $this->generateAdviceText($snapshot);
|
||||
|
||||
return $this->saveAdviceRecord(
|
||||
(int) $user['id'],
|
||||
self::TYPE_DAILY_ADVICE,
|
||||
$adviceDate,
|
||||
$promptVersion,
|
||||
$snapshotJson,
|
||||
$content,
|
||||
$meta
|
||||
);
|
||||
}
|
||||
|
||||
public function unlock(array $user, DateTimeImmutable $unlockDate): array
|
||||
{
|
||||
$unlockDate = Support::dateOnly($unlockDate);
|
||||
$now = Support::now();
|
||||
$existing = SmokeAIAdviceUnlock::where('uid', (int) $user['id'])
|
||||
->where('unlock_date', $unlockDate->format(Support::DATE_LAYOUT))
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->find();
|
||||
|
||||
if (!$existing) {
|
||||
$existing = new SmokeAIAdviceUnlock();
|
||||
$existing->uid = (int) $user['id'];
|
||||
$existing->unlock_date = $unlockDate->format(Support::DATE_LAYOUT);
|
||||
$existing->createtime = $now->getTimestamp();
|
||||
}
|
||||
|
||||
$existing->ad_watched_at = $now->format(Support::DATETIME_LAYOUT);
|
||||
$existing->updatetime = $now->getTimestamp();
|
||||
$existing->save();
|
||||
|
||||
return [
|
||||
'unlocked' => true,
|
||||
'date' => $unlockDate->format(Support::DATE_LAYOUT),
|
||||
];
|
||||
}
|
||||
|
||||
public function getCachedByType(int $userId, string $type, DateTimeImmutable $date, string $promptVersion): ?array
|
||||
{
|
||||
$row = SmokeAIAdvice::where('uid', $userId)
|
||||
->where('type', $type)
|
||||
->where('advice_date', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
|
||||
->where('prompt_version', $promptVersion)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->find();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->formatAdviceRecord($row->toArray());
|
||||
}
|
||||
|
||||
public function getOrGenerateDailySummary(array $user, DateTimeImmutable $summaryDate, string $promptVersion = 'v1'): array
|
||||
{
|
||||
$cached = $this->getCachedByType((int) $user['id'], self::TYPE_DAILY_SUMMARY, $summaryDate, $promptVersion);
|
||||
if ($cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$this->isAllowed($user, $summaryDate)) {
|
||||
throw new \RuntimeException('需要会员或观看广告解锁后才可生成总结', 403);
|
||||
}
|
||||
|
||||
[$snapshot, $snapshotJson] = $this->buildSnapshot((int) $user['id'], $summaryDate, true);
|
||||
[$content, $meta] = $this->generateDailySummaryText($snapshot);
|
||||
|
||||
return $this->saveAdviceRecord(
|
||||
(int) $user['id'],
|
||||
self::TYPE_DAILY_SUMMARY,
|
||||
$summaryDate,
|
||||
$promptVersion,
|
||||
$snapshotJson,
|
||||
$content,
|
||||
$meta
|
||||
);
|
||||
}
|
||||
|
||||
public function getCachedNextSmoke(array $user, DateTimeImmutable $planDate, string $promptVersion = 'v1'): ?array
|
||||
{
|
||||
$advice = SmokeAIAdvice::where('uid', (int) $user['id'])
|
||||
->where('type', self::TYPE_NEXT_SMOKE)
|
||||
->where('advice_date', Support::dateOnly($planDate)->format(Support::DATE_LAYOUT))
|
||||
->where('prompt_version', $promptVersion)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
if (!$advice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$suggestion = $this->buildNextSuggestionFromCache($advice);
|
||||
if ($this->shouldRefreshNextCache($suggestion, $planDate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $suggestion;
|
||||
}
|
||||
|
||||
public function getOrGenerateNextSmoke(
|
||||
array $user,
|
||||
DateTimeImmutable $asOf,
|
||||
DateTimeImmutable $planDate,
|
||||
string $promptVersion,
|
||||
array $defaultSuggestion
|
||||
): array {
|
||||
$planDate = Support::dateOnly($planDate);
|
||||
$cached = $this->getCachedNextSmoke($user, $planDate, $promptVersion);
|
||||
if ($cached) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
if (!$this->isAllowed($user, $planDate)) {
|
||||
throw new \RuntimeException('需要观看广告解锁后才可生成', 403);
|
||||
}
|
||||
|
||||
$recent3Days = $this->loadRecent3Days((int) $user['id'], $planDate);
|
||||
$profile = $this->loadProfileContext((int) $user['id']);
|
||||
[$content, $nodes, $notBeforeAt, $suggestedAt, $meta] = $this->generateNextSmokeSuggestion($asOf, $planDate, $defaultSuggestion, $recent3Days, $profile);
|
||||
|
||||
$advice = SmokeAIAdvice::where('uid', (int) $user['id'])
|
||||
->where('type', self::TYPE_NEXT_SMOKE)
|
||||
->where('advice_date', $planDate->format(Support::DATE_LAYOUT))
|
||||
->where('prompt_version', $promptVersion)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->find();
|
||||
|
||||
$snapshot = [
|
||||
'as_of' => $asOf->format(DATE_ATOM),
|
||||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||||
'default_suggestion' => $defaultSuggestion,
|
||||
'profile' => $profile,
|
||||
'recent_3_days' => $recent3Days,
|
||||
];
|
||||
$snapshotJson = json_encode($snapshot, JSON_UNESCAPED_UNICODE);
|
||||
$now = time();
|
||||
|
||||
if (!$advice) {
|
||||
$advice = new SmokeAIAdvice();
|
||||
$advice->uid = (int) $user['id'];
|
||||
$advice->type = self::TYPE_NEXT_SMOKE;
|
||||
$advice->advice_date = $planDate->format(Support::DATE_LAYOUT);
|
||||
$advice->prompt_version = $promptVersion;
|
||||
$advice->createtime = $now;
|
||||
}
|
||||
|
||||
$advice->provider = $meta['provider'];
|
||||
$advice->model = $meta['model'];
|
||||
$advice->input_snapshot = $snapshotJson;
|
||||
$advice->advice = $content;
|
||||
$advice->tokens_in = $meta['tokens_in'];
|
||||
$advice->tokens_out = $meta['tokens_out'];
|
||||
$advice->updatetime = $now;
|
||||
$advice->save();
|
||||
|
||||
SmokeAINextSmoke::where('ai_advice_id', (int) $advice->id)->delete();
|
||||
$this->saveNextNodes((int) $user['id'], (int) $advice->id, $planDate, $notBeforeAt, $suggestedAt, $nodes);
|
||||
|
||||
return [
|
||||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||||
'not_before_at' => $notBeforeAt->format(DATE_ATOM),
|
||||
'suggested_at' => $suggestedAt->format(DATE_ATOM),
|
||||
'time_nodes' => $nodes,
|
||||
'advice' => $content,
|
||||
'prompt_version' => $promptVersion,
|
||||
'model' => $meta['model'],
|
||||
'provider' => $meta['provider'],
|
||||
];
|
||||
}
|
||||
|
||||
private function saveAdviceRecord(
|
||||
int $userId,
|
||||
string $type,
|
||||
DateTimeImmutable $date,
|
||||
string $promptVersion,
|
||||
string $snapshotJson,
|
||||
string $content,
|
||||
array $meta
|
||||
): array {
|
||||
$now = time();
|
||||
$row = new SmokeAIAdvice();
|
||||
$row->uid = $userId;
|
||||
$row->type = $type;
|
||||
$row->advice_date = Support::dateOnly($date)->format(Support::DATE_LAYOUT);
|
||||
$row->prompt_version = $promptVersion;
|
||||
$row->provider = $meta['provider'];
|
||||
$row->model = $meta['model'];
|
||||
$row->input_snapshot = $snapshotJson;
|
||||
$row->advice = $content;
|
||||
$row->tokens_in = $meta['tokens_in'];
|
||||
$row->tokens_out = $meta['tokens_out'];
|
||||
$row->createtime = $now;
|
||||
$row->updatetime = $now;
|
||||
$row->save();
|
||||
|
||||
return $this->formatAdviceRecord($row->toArray());
|
||||
}
|
||||
|
||||
private function formatAdviceRecord(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'uid' => (int) ($row['uid'] ?? 0),
|
||||
'type' => (string) ($row['type'] ?? ''),
|
||||
'date' => (string) ($row['advice_date'] ?? ''),
|
||||
'advice_date' => (string) ($row['advice_date'] ?? ''),
|
||||
'prompt_version' => (string) ($row['prompt_version'] ?? ''),
|
||||
'provider' => (string) ($row['provider'] ?? ''),
|
||||
'model' => (string) ($row['model'] ?? ''),
|
||||
'advice' => (string) ($row['advice'] ?? ''),
|
||||
'content' => (string) ($row['advice'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSnapshot(int $userId, DateTimeImmutable $date, bool $todayMessage = false): array
|
||||
{
|
||||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->where('smoke_time', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) ASC')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
if (empty($rows)) {
|
||||
throw new \RuntimeException($todayMessage ? '今天还没有抽烟记录,无法生成总结' : '该日期没有抽烟记录,无法生成建议', 400);
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
$nodes = [];
|
||||
foreach ($rows as $row) {
|
||||
$total += (int) ($row['num'] ?? 0);
|
||||
$eventAt = Support::logEventAt($row);
|
||||
$nodes[] = [
|
||||
'time' => $eventAt ? $eventAt->format('H:i') : '',
|
||||
'num' => (int) ($row['num'] ?? 0),
|
||||
'level' => (int) ($row['level'] ?? 1),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$profile = $this->loadProfileContext($userId);
|
||||
$snapshot = [
|
||||
'date' => Support::dateOnly($date)->format(Support::DATE_LAYOUT),
|
||||
'total_num' => $total,
|
||||
'nodes' => $nodes,
|
||||
'profile' => $profile,
|
||||
];
|
||||
|
||||
return [$snapshot, json_encode($snapshot, JSON_UNESCAPED_UNICODE)];
|
||||
}
|
||||
|
||||
private function loadProfileContext(int $userId): ?array
|
||||
{
|
||||
$profile = SmokeUserProfile::findByUid($userId);
|
||||
if (!$profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$wakeUpTime = trim((string) $profile->wake_up_time);
|
||||
$sleepTime = trim((string) $profile->sleep_time);
|
||||
$awakeMinutes = 16 * 60;
|
||||
try {
|
||||
$awakeMinutes = Support::awakeMinutes($wakeUpTime, $sleepTime);
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
return [
|
||||
'baseline_cigs_per_day' => (int) $profile->baseline_cigs_per_day,
|
||||
'smoking_years' => (float) $profile->smoking_years,
|
||||
'pack_price_cent' => (int) $profile->pack_price_cent,
|
||||
'smoke_motivations' => Support::jsonArray($profile->smoke_motivations),
|
||||
'quit_motivations' => Support::jsonArray($profile->quit_motivations),
|
||||
'wake_up_time' => $wakeUpTime,
|
||||
'sleep_time' => $sleepTime,
|
||||
'awake_minutes' => $awakeMinutes,
|
||||
'baseline_interval_minutes' => Support::baselineIntervalMinutes($awakeMinutes, (int) $profile->baseline_cigs_per_day),
|
||||
'user_segment' => Support::deriveUserSegment((int) $profile->baseline_cigs_per_day, (float) $profile->smoking_years),
|
||||
];
|
||||
}
|
||||
|
||||
private function isAllowed(array $user, DateTimeImmutable $date): bool
|
||||
{
|
||||
return $this->hasActiveMembership($user) || $this->isUnlocked((int) $user['id'], $date);
|
||||
}
|
||||
|
||||
private function hasActiveMembership(array $user): bool
|
||||
{
|
||||
$count = UserMembership::where('mini_program_id', (int) $user['mini_program_id'])
|
||||
->where('user_id', (int) $user['id'])
|
||||
->where('status', 'active')
|
||||
->where('ends_at', '>', Support::now()->format(Support::DATETIME_LAYOUT))
|
||||
->whereNull('deleted_at')
|
||||
->count();
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
private function isUnlocked(int $userId, DateTimeImmutable $date): bool
|
||||
{
|
||||
return SmokeAIAdviceUnlock::where('uid', $userId)
|
||||
->where('unlock_date', Support::dateOnly($date)->format(Support::DATE_LAYOUT))
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->find() !== null;
|
||||
}
|
||||
|
||||
private function generateAdviceText(array $snapshot): array
|
||||
{
|
||||
$fallback = $this->buildFallbackAdvice($snapshot);
|
||||
if (!$this->hasAiConfig()) {
|
||||
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||||
}
|
||||
|
||||
$systemPrompt = trim('你是一名专业的戒烟教练。请基于用户昨天的抽烟记录,输出中文建议:先给出 1-3 条模式分析,再给出至少 5 条今天的具体行动建议,最后补充一个 60 秒顶住烟瘾的应对流程。');
|
||||
$userPrompt = '用户昨日数据(JSON):' . PHP_EOL . json_encode($snapshot, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
try {
|
||||
$resp = $this->callChat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
]);
|
||||
|
||||
return [$resp['content'], $resp];
|
||||
} catch (\Throwable $e) {
|
||||
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||||
}
|
||||
}
|
||||
|
||||
private function generateDailySummaryText(array $snapshot): array
|
||||
{
|
||||
$fallback = $this->buildFallbackDailySummary($snapshot);
|
||||
if (!$this->hasAiConfig()) {
|
||||
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||||
}
|
||||
|
||||
$systemPrompt = trim('你是一名专业的戒烟教练。请严格输出 JSON,字段为 summary、highlights、suggestion。内容基于用户当天抽烟数据,语气鼓励。');
|
||||
$userPrompt = '用户今日数据(JSON):' . PHP_EOL . json_encode($snapshot, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
try {
|
||||
$resp = $this->callChat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
]);
|
||||
|
||||
$jsonText = $this->extractJson($resp['content']);
|
||||
if ($jsonText !== '') {
|
||||
return [$jsonText, $resp];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
return [$fallback, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||||
}
|
||||
|
||||
private function generateNextSmokeSuggestion(
|
||||
DateTimeImmutable $asOf,
|
||||
DateTimeImmutable $planDate,
|
||||
array $defaultSuggestion,
|
||||
array $recent3Days,
|
||||
?array $profile
|
||||
): array {
|
||||
[$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt] = $this->buildFallbackNextSmoke($asOf, $planDate, $defaultSuggestion, $profile);
|
||||
if (!$this->hasAiConfig()) {
|
||||
return [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||||
}
|
||||
|
||||
$input = [
|
||||
'as_of' => $asOf->format(DATE_ATOM),
|
||||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||||
'default_suggestion' => $defaultSuggestion,
|
||||
'profile' => $profile,
|
||||
'recent_3_days' => $recent3Days,
|
||||
];
|
||||
$systemPrompt = trim('你是一名专业的戒烟教练。请严格输出 JSON:not_before_at、suggested_at、time_nodes、advice。所有时间必须属于 plan_date,time_nodes 用 HH:MM。');
|
||||
$userPrompt = '输入(JSON):' . PHP_EOL . json_encode($input, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
try {
|
||||
$resp = $this->callChat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
]);
|
||||
$jsonText = $this->extractJson($resp['content']);
|
||||
if ($jsonText !== '') {
|
||||
$decoded = json_decode($jsonText, true);
|
||||
if (is_array($decoded)) {
|
||||
$notBeforeAt = $this->parseFlexibleTime((string) ($decoded['not_before_at'] ?? ''), $planDate, $fallbackNotBeforeAt);
|
||||
$suggestedAt = $this->parseFlexibleTime((string) ($decoded['suggested_at'] ?? ''), $planDate, $fallbackSuggestedAt);
|
||||
if ($suggestedAt < $notBeforeAt) {
|
||||
$suggestedAt = $notBeforeAt;
|
||||
}
|
||||
$nodes = $this->normalizeNodes($decoded['time_nodes'] ?? [], $planDate, $notBeforeAt, $profile);
|
||||
|
||||
return [
|
||||
trim((string) ($decoded['advice'] ?? $fallbackContent)),
|
||||
$nodes,
|
||||
$notBeforeAt,
|
||||
$suggestedAt,
|
||||
$resp,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
return [$fallbackContent, $fallbackNodes, $fallbackNotBeforeAt, $fallbackSuggestedAt, ['provider' => 'built-in', 'model' => 'rule-based', 'tokens_in' => null, 'tokens_out' => null]];
|
||||
}
|
||||
|
||||
private function buildFallbackAdvice(array $snapshot): string
|
||||
{
|
||||
$total = (int) ($snapshot['total_num'] ?? 0);
|
||||
$nodes = $snapshot['nodes'] ?? [];
|
||||
$first = $nodes[0]['time'] ?? '';
|
||||
$last = $nodes[count($nodes) - 1]['time'] ?? '';
|
||||
$quitMotivation = $snapshot['profile']['quit_motivations'][0] ?? '把状态调整回来';
|
||||
|
||||
$lines = [
|
||||
sprintf('昨天你一共记录了 %d 支烟。%s%s', $total, $first !== '' ? '第一支大约在 ' . $first . ',' : '', $last !== '' ? '最后一支在 ' . $last . '。' : ''),
|
||||
'今天先盯住最容易失守的一个时段,把第一根或最顺手的一根至少延后 10 分钟。',
|
||||
'每次想抽的时候先喝半杯水,站起来走 30-60 秒,再决定要不要点烟。',
|
||||
'如果是饭后或社交触发,提前准备口香糖、无糖饮料或离开吸烟环境 3 分钟。',
|
||||
'把今天的目标改成“少一支也算赢”,不要追求一次性完美。',
|
||||
sprintf('情绪上来时,重复提醒自己:%s。', $quitMotivation),
|
||||
'60 秒应对流程:先深呼吸 4 次,然后喝水 3 口,再拖延 1 分钟,通常烟瘾峰值会先过去。',
|
||||
];
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function buildFallbackDailySummary(array $snapshot): string
|
||||
{
|
||||
$total = (int) ($snapshot['total_num'] ?? 0);
|
||||
$nodes = $snapshot['nodes'] ?? [];
|
||||
$times = array_values(array_filter(array_map(static function ($item) {
|
||||
return trim((string) ($item['time'] ?? ''));
|
||||
}, $nodes)));
|
||||
$highlights = [];
|
||||
if (!empty($times)) {
|
||||
$highlights[] = sprintf('今天首支烟时间大约在 %s,最后一次记录在 %s。', $times[0], $times[count($times) - 1]);
|
||||
}
|
||||
$highlights[] = sprintf('今日总量为 %d 支,后续可重点观察最容易连续抽烟的时段。', $total);
|
||||
$highlights[] = '如果明天只盯住一个节点,优先尝试把最早或最顺手的一支往后拖 10 分钟。';
|
||||
|
||||
return json_encode([
|
||||
'summary' => sprintf('今天共记录 %d 支烟,整体节奏已经被你清楚地记录下来,这本身就是建立改变的第一步。接下来重点不是苛责自己,而是抓住一个最容易多抽的时段做微调。', $total),
|
||||
'highlights' => array_slice($highlights, 0, 3),
|
||||
'suggestion' => '明天先只做一个动作:把最容易点上的那一支延后 10 分钟,并在这段时间用喝水或起身走动替代。',
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
private function buildFallbackNextSmoke(
|
||||
DateTimeImmutable $asOf,
|
||||
DateTimeImmutable $planDate,
|
||||
array $defaultSuggestion,
|
||||
?array $profile
|
||||
): array {
|
||||
$notBeforeAt = !empty($defaultSuggestion['next_smoke_at'])
|
||||
? Support::toDateTime((string) $defaultSuggestion['next_smoke_at'])
|
||||
: $asOf->add(new DateInterval('PT5M'));
|
||||
|
||||
if (Support::dateOnly($planDate) > Support::dateOnly($asOf)) {
|
||||
$notBeforeAt = Support::dateOnly($planDate)->setTime(7, 0);
|
||||
if ($profile && !empty($profile['wake_up_time'])) {
|
||||
$wakeMin = Support::parseHHMM((string) $profile['wake_up_time']);
|
||||
$notBeforeAt = Support::dateOnly($planDate)->setTime(intdiv($wakeMin, 60), $wakeMin % 60);
|
||||
}
|
||||
}
|
||||
|
||||
$suggestedAt = $notBeforeAt;
|
||||
$interval = max(20, min(120, (int) ($defaultSuggestion['interval_minutes'] ?? 60)));
|
||||
$nodes = [];
|
||||
$cursor = $suggestedAt;
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
if (Support::dateOnly($cursor) != Support::dateOnly($planDate)) {
|
||||
break;
|
||||
}
|
||||
$nodes[] = $cursor->format('H:i');
|
||||
$cursor = $cursor->add(new DateInterval('PT' . max(15, (int) round($interval * 0.7)) . 'M'));
|
||||
}
|
||||
|
||||
$content = sprintf('先按默认节奏走,建议至少等到 %s。如果这段时间烟瘾明显上来,先用喝水、深呼吸或短暂走动顶一轮,再决定要不要抽。', $suggestedAt->format('H:i'));
|
||||
|
||||
return [$content, array_values(array_unique($nodes)), $notBeforeAt, $suggestedAt];
|
||||
}
|
||||
|
||||
private function loadRecent3Days(int $userId, DateTimeImmutable $planDate): array
|
||||
{
|
||||
$today = Support::dateOnly();
|
||||
$end = $planDate > $today ? $today : $planDate;
|
||||
$start = $end->modify('-2 day');
|
||||
|
||||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->order('smoke_time', 'asc')
|
||||
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) ASC')
|
||||
->order('id', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$grouped = [];
|
||||
foreach ($rows as $row) {
|
||||
$day = (string) ($row['smoke_time'] ?? '');
|
||||
if (!isset($grouped[$day])) {
|
||||
$grouped[$day] = ['date' => $day, 'total_num' => 0, 'resisted_count' => 0, 'nodes' => []];
|
||||
}
|
||||
$isResisted = (int) ($row['level'] ?? 1) === 0 && (int) ($row['num'] ?? 1) === 0;
|
||||
if ($isResisted) {
|
||||
$grouped[$day]['resisted_count']++;
|
||||
} else {
|
||||
$grouped[$day]['total_num'] += (int) ($row['num'] ?? 0);
|
||||
}
|
||||
|
||||
$eventAt = Support::logEventAt($row);
|
||||
$grouped[$day]['nodes'][] = [
|
||||
'time' => $eventAt ? $eventAt->format('H:i') : '',
|
||||
'num' => (int) ($row['num'] ?? 0),
|
||||
'level' => (int) ($row['level'] ?? 1),
|
||||
'is_resisted' => $isResisted,
|
||||
'remark' => Support::truncate((string) ($row['remark'] ?? ''), 80),
|
||||
];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->add(new DateInterval('P1D'))) {
|
||||
$key = $cursor->format(Support::DATE_LAYOUT);
|
||||
$result[] = $grouped[$key] ?? ['date' => $key, 'total_num' => 0, 'resisted_count' => 0, 'nodes' => []];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function saveNextNodes(int $userId, int $adviceId, DateTimeImmutable $planDate, DateTimeImmutable $notBeforeAt, DateTimeImmutable $suggestedAt, array $nodes): void
|
||||
{
|
||||
$rows = [
|
||||
[
|
||||
'uid' => $userId,
|
||||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||||
'ai_advice_id' => $adviceId,
|
||||
'node_type' => 'not_before',
|
||||
'node_at' => $notBeforeAt->format(Support::DATETIME_LAYOUT),
|
||||
'createtime' => time(),
|
||||
'updatetime' => time(),
|
||||
],
|
||||
[
|
||||
'uid' => $userId,
|
||||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||||
'ai_advice_id' => $adviceId,
|
||||
'node_type' => 'suggested',
|
||||
'node_at' => $suggestedAt->format(Support::DATETIME_LAYOUT),
|
||||
'createtime' => time(),
|
||||
'updatetime' => time(),
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$nodeAt = $this->parseFlexibleTime((string) $node, $planDate, $suggestedAt);
|
||||
$rows[] = [
|
||||
'uid' => $userId,
|
||||
'plan_date' => $planDate->format(Support::DATE_LAYOUT),
|
||||
'ai_advice_id' => $adviceId,
|
||||
'node_type' => 'node',
|
||||
'node_at' => $nodeAt->format(Support::DATETIME_LAYOUT),
|
||||
'createtime' => time(),
|
||||
'updatetime' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
Db::connect('mysql')->name('fa_smoke_ai_next_smoke')->insertAll($rows);
|
||||
}
|
||||
|
||||
private function buildNextSuggestionFromCache(SmokeAIAdvice $advice): array
|
||||
{
|
||||
$rows = SmokeAINextSmoke::where('ai_advice_id', (int) $advice->id)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->order('node_at', 'asc')
|
||||
->select()
|
||||
->all();
|
||||
|
||||
$notBeforeAt = '';
|
||||
$suggestedAt = '';
|
||||
$nodes = [];
|
||||
foreach ($rows as $row) {
|
||||
if ((string) $row->node_type === 'not_before') {
|
||||
$notBeforeAt = Support::formatRfc3339((string) $row->node_at);
|
||||
} elseif ((string) $row->node_type === 'suggested') {
|
||||
$suggestedAt = Support::formatRfc3339((string) $row->node_at);
|
||||
} elseif ((string) $row->node_type === 'node') {
|
||||
$nodes[] = Support::formatClock((string) $row->node_at);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'plan_date' => (string) $advice->advice_date,
|
||||
'not_before_at' => $notBeforeAt,
|
||||
'suggested_at' => $suggestedAt !== '' ? $suggestedAt : $notBeforeAt,
|
||||
'time_nodes' => $nodes,
|
||||
'advice' => (string) $advice->advice,
|
||||
'prompt_version' => (string) $advice->prompt_version,
|
||||
'model' => (string) $advice->model,
|
||||
'provider' => (string) $advice->provider,
|
||||
];
|
||||
}
|
||||
|
||||
private function shouldRefreshNextCache(array $suggestion, DateTimeImmutable $planDate): bool
|
||||
{
|
||||
$nodes = $suggestion['time_nodes'] ?? [];
|
||||
if (empty($nodes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$suggestedAt = trim((string) ($suggestion['suggested_at'] ?? ''));
|
||||
if ($suggestedAt === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$suggested = $this->parseFlexibleTime($suggestedAt, $planDate, Support::now());
|
||||
$today = Support::dateOnly();
|
||||
if (Support::dateOnly($planDate) == $today && $suggested <= Support::now()->add(new DateInterval('PT2M'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalizeNodes(array $nodes, DateTimeImmutable $planDate, DateTimeImmutable $notBeforeAt, ?array $profile): array
|
||||
{
|
||||
$seen = [];
|
||||
$result = [];
|
||||
foreach ($nodes as $node) {
|
||||
try {
|
||||
$nodeAt = $this->parseFlexibleTime((string) $node, $planDate, $notBeforeAt);
|
||||
} catch (\Throwable $e) {
|
||||
continue;
|
||||
}
|
||||
if ($nodeAt < $notBeforeAt) {
|
||||
continue;
|
||||
}
|
||||
if ($profile && !empty($profile['wake_up_time']) && !empty($profile['sleep_time'])) {
|
||||
$nodeAt = $this->adjustToWakeIfInSleep($nodeAt, (string) $profile['wake_up_time'], (string) $profile['sleep_time']);
|
||||
}
|
||||
if (Support::dateOnly($nodeAt) != Support::dateOnly($planDate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = $nodeAt->format('H:i');
|
||||
if (isset($seen[$label])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$label] = true;
|
||||
$result[] = $label;
|
||||
if (count($result) >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function adjustToWakeIfInSleep(DateTimeImmutable $time, string $wakeUpTime, string $sleepTime): DateTimeImmutable
|
||||
{
|
||||
$wakeMin = Support::parseHHMM($wakeUpTime);
|
||||
$sleepMin = Support::parseHHMM($sleepTime);
|
||||
if ($wakeMin === $sleepMin) {
|
||||
return $time;
|
||||
}
|
||||
|
||||
$minuteOfDay = ((int) $time->format('H')) * 60 + (int) $time->format('i');
|
||||
$inSleep = $wakeMin < $sleepMin
|
||||
? ($minuteOfDay < $wakeMin || $minuteOfDay >= $sleepMin)
|
||||
: ($minuteOfDay >= $sleepMin && $minuteOfDay < $wakeMin);
|
||||
|
||||
if (!$inSleep) {
|
||||
return $time;
|
||||
}
|
||||
|
||||
$wakeToday = Support::dateOnly($time)->setTime(intdiv($wakeMin, 60), $wakeMin % 60);
|
||||
if ($wakeToday <= $time) {
|
||||
$wakeToday = $wakeToday->add(new DateInterval('P1D'));
|
||||
}
|
||||
|
||||
return $wakeToday;
|
||||
}
|
||||
|
||||
private function parseFlexibleTime(string $value, DateTimeImmutable $planDate, DateTimeImmutable $fallback): DateTimeImmutable
|
||||
{
|
||||
$text = trim($value);
|
||||
if ($text === '') {
|
||||
return $fallback;
|
||||
}
|
||||
try {
|
||||
if (preg_match('/^\d{2}:\d{2}$/', $text)) {
|
||||
$minutes = Support::parseHHMM($text);
|
||||
return Support::dateOnly($planDate)->setTime(intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
return Support::toDateTime($text);
|
||||
} catch (\Throwable $e) {
|
||||
return $fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private function hasAiConfig(): bool
|
||||
{
|
||||
return trim((string) env('AI_BASE_URL', '')) !== ''
|
||||
&& trim((string) env('AI_API_KEY', '')) !== ''
|
||||
&& trim((string) env('AI_MODEL', '')) !== '';
|
||||
}
|
||||
|
||||
private function callChat(array $messages): array
|
||||
{
|
||||
$baseUrl = rtrim((string) env('AI_BASE_URL', ''), '/');
|
||||
$apiKey = trim((string) env('AI_API_KEY', ''));
|
||||
$model = trim((string) env('AI_MODEL', ''));
|
||||
$timeout = max(5, (int) env('AI_TIMEOUT_SECONDS', 15));
|
||||
|
||||
$payload = json_encode([
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => 0.7,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init($baseUrl . '/chat/completions');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$error = curl_error($ch);
|
||||
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $response === '' || $error) {
|
||||
throw new \RuntimeException('AI 请求失败', 502);
|
||||
}
|
||||
if ($statusCode !== 200) {
|
||||
throw new \RuntimeException('AI 服务响应异常', 502);
|
||||
}
|
||||
|
||||
$decoded = json_decode($response, true);
|
||||
$content = trim((string) ($decoded['choices'][0]['message']['content'] ?? ''));
|
||||
if ($content === '') {
|
||||
throw new \RuntimeException('AI 返回内容为空', 502);
|
||||
}
|
||||
|
||||
return [
|
||||
'content' => $content,
|
||||
'provider' => 'openai-compatible',
|
||||
'model' => (string) ($decoded['model'] ?? $model),
|
||||
'tokens_in' => isset($decoded['usage']['prompt_tokens']) ? (int) $decoded['usage']['prompt_tokens'] : null,
|
||||
'tokens_out' => isset($decoded['usage']['completion_tokens']) ? (int) $decoded['usage']['completion_tokens'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function extractJson(string $value): string
|
||||
{
|
||||
$start = strpos($value, '{');
|
||||
$end = strrpos($value, '}');
|
||||
if ($start === false || $end === false || $end <= $start) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return substr($value, $start, $end - $start + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\service;
|
||||
|
||||
use app\smt\model\SmokeLog;
|
||||
use app\smt\model\SmokeMotivationQuote;
|
||||
use app\smt\model\SmokeShare;
|
||||
use app\smt\model\SmokeUserProfile;
|
||||
use app\smt\model\User;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use think\facade\Db;
|
||||
|
||||
class SmokeService
|
||||
{
|
||||
public function getProfile(int $userId): ?array
|
||||
{
|
||||
$profile = SmokeUserProfile::findByUid($userId);
|
||||
if (!$profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->formatProfileRow($profile->toArray());
|
||||
}
|
||||
|
||||
public function getProfileView(int $userId): array
|
||||
{
|
||||
$profile = $this->getProfile($userId);
|
||||
if (!$profile) {
|
||||
return [
|
||||
'exists' => false,
|
||||
'profile' => null,
|
||||
'is_completed' => false,
|
||||
'awake_minutes' => 16 * 60,
|
||||
'baseline_interval_minutes' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
$awakeMinutes = Support::awakeMinutes((string) $profile['wake_up_time'], (string) $profile['sleep_time']);
|
||||
return [
|
||||
'exists' => true,
|
||||
'profile' => $profile,
|
||||
'is_completed' => $this->isProfileCompleted($profile),
|
||||
'awake_minutes' => $awakeMinutes,
|
||||
'baseline_interval_minutes' => Support::baselineIntervalMinutes($awakeMinutes, (int) $profile['baseline_cigs_per_day']),
|
||||
];
|
||||
}
|
||||
|
||||
public function upsertProfile(int $userId, array $data): array
|
||||
{
|
||||
$profile = SmokeUserProfile::findByUid($userId);
|
||||
$isNew = $profile === null;
|
||||
if (!$profile) {
|
||||
$profile = new SmokeUserProfile();
|
||||
$profile->uid = $userId;
|
||||
}
|
||||
|
||||
foreach (['baseline_cigs_per_day', 'pack_price_cent', 'achievement_theme_id'] as $field) {
|
||||
if (array_key_exists($field, $data) && $data[$field] !== null) {
|
||||
$profile->{$field} = (int) $data[$field];
|
||||
}
|
||||
}
|
||||
if (array_key_exists('smoking_years', $data) && $data['smoking_years'] !== null) {
|
||||
$profile->smoking_years = (float) $data['smoking_years'];
|
||||
}
|
||||
if (array_key_exists('mode', $data)) {
|
||||
$profile->mode = Support::normalizedMode((string) ($data['mode'] ?? ''));
|
||||
}
|
||||
if (array_key_exists('smoke_motivations', $data)) {
|
||||
$profile->smoke_motivations = Support::jsonEncodeArray((array) ($data['smoke_motivations'] ?? []));
|
||||
}
|
||||
if (array_key_exists('quit_motivations', $data)) {
|
||||
$profile->quit_motivations = Support::jsonEncodeArray((array) ($data['quit_motivations'] ?? []));
|
||||
}
|
||||
if (array_key_exists('wake_up_time', $data)) {
|
||||
$wakeUpTime = trim((string) ($data['wake_up_time'] ?? ''));
|
||||
if ($wakeUpTime !== '') {
|
||||
Support::parseHHMM($wakeUpTime);
|
||||
}
|
||||
$profile->wake_up_time = $wakeUpTime;
|
||||
}
|
||||
if (array_key_exists('sleep_time', $data)) {
|
||||
$sleepTime = trim((string) ($data['sleep_time'] ?? ''));
|
||||
if ($sleepTime !== '') {
|
||||
Support::parseHHMM($sleepTime);
|
||||
}
|
||||
$profile->sleep_time = $sleepTime;
|
||||
}
|
||||
if (array_key_exists('quit_date', $data)) {
|
||||
$quitDate = trim((string) ($data['quit_date'] ?? ''));
|
||||
$profile->quit_date = $quitDate === '' ? null : Support::parseDate($quitDate, 'quit_date')->format(Support::DATE_LAYOUT);
|
||||
}
|
||||
|
||||
$profileArray = $this->formatProfileRow(array_merge($profile->toArray(), ['deleted_at' => null]));
|
||||
if (empty($profile->onboarding_completed_at) && $this->isProfileCompleted($profileArray)) {
|
||||
$profile->onboarding_completed_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
}
|
||||
|
||||
if ($isNew) {
|
||||
$profile->created_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
}
|
||||
$profile->updated_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$profile->save();
|
||||
|
||||
return $this->getProfileView($userId);
|
||||
}
|
||||
|
||||
public function createLog(int $userId, array $data, bool $resisted = false): array
|
||||
{
|
||||
$smokeAt = array_key_exists('smoke_at', $data) ? Support::parseDateTime((string) ($data['smoke_at'] ?? ''), 'smoke_at') : null;
|
||||
$smokeTime = array_key_exists('smoke_time', $data) ? Support::parseDate((string) ($data['smoke_time'] ?? ''), 'smoke_time') : null;
|
||||
if ($smokeAt) {
|
||||
$smokeTime = Support::dateOnly($smokeAt);
|
||||
}
|
||||
if (!$smokeTime) {
|
||||
$smokeTime = Support::dateOnly();
|
||||
}
|
||||
|
||||
$level = $resisted ? 0 : max(0, (int) ($data['level'] ?? 1));
|
||||
$num = $resisted ? 0 : max(0, (int) ($data['num'] ?? 1));
|
||||
if (!$resisted && $num === 0) {
|
||||
throw new \RuntimeException('num=0 请使用 /smoke/logs/resisted', 400);
|
||||
}
|
||||
|
||||
$insertId = Db::connect('mysql')->name('fa_smoke_log')->insertGetId([
|
||||
'uid' => $userId,
|
||||
'smoke_time' => $smokeTime->format(Support::DATE_LAYOUT),
|
||||
'smoke_at' => $smokeAt ? $smokeAt->format(Support::DATETIME_LAYOUT) : null,
|
||||
'remark' => (string) ($data['remark'] ?? ''),
|
||||
'reason_tags' => Support::jsonEncodeArray((array) ($data['reason_tags'] ?? [])),
|
||||
'createtime' => time(),
|
||||
'updatetime' => time(),
|
||||
'level' => $level,
|
||||
'num' => $num,
|
||||
]);
|
||||
|
||||
return $this->getLog($userId, (int) $insertId);
|
||||
}
|
||||
|
||||
public function getLog(int $userId, int $id): array
|
||||
{
|
||||
$row = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('id', $id)
|
||||
->where('uid', $userId)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->find();
|
||||
|
||||
if (!$row) {
|
||||
throw new \RuntimeException('记录不存在', 404);
|
||||
}
|
||||
|
||||
return Support::formatLog($row);
|
||||
}
|
||||
|
||||
public function listLogs(int $userId, array $params = []): array
|
||||
{
|
||||
$page = max(1, (int) ($params['page'] ?? 1));
|
||||
$pageSize = min(200, max(1, (int) ($params['page_size'] ?? 20)));
|
||||
$type = strtolower(trim((string) ($params['type'] ?? 'all')));
|
||||
if (!in_array($type, ['all', 'smoke', 'resisted'], true)) {
|
||||
throw new \RuntimeException('type 应为 all|smoke|resisted', 400);
|
||||
}
|
||||
|
||||
$query = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)');
|
||||
|
||||
if (!empty($params['start'])) {
|
||||
$query->where('smoke_time', '>=', Support::parseDate((string) $params['start'], 'start')->format(Support::DATE_LAYOUT));
|
||||
}
|
||||
if (!empty($params['end'])) {
|
||||
$query->where('smoke_time', '<=', Support::parseDate((string) $params['end'], 'end')->format(Support::DATE_LAYOUT));
|
||||
}
|
||||
if ($type === 'smoke') {
|
||||
$query->where('num', '>', 0);
|
||||
} elseif ($type === 'resisted') {
|
||||
$query->where('level', 0)->where('num', 0);
|
||||
}
|
||||
|
||||
$total = (int) (clone $query)->count();
|
||||
$rows = $query->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC')
|
||||
->order('id', 'desc')
|
||||
->page($page, $pageSize)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return [
|
||||
'items' => array_map(static function ($row) {
|
||||
return Support::formatLog($row);
|
||||
}, $rows),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'page_size' => $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
public function latestLogs(int $userId, int $limit = 20): array
|
||||
{
|
||||
$limit = min(100, max(1, $limit));
|
||||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC')
|
||||
->order('id', 'desc')
|
||||
->limit($limit)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
return ['items' => array_map(static function ($row) {
|
||||
return Support::formatLog($row);
|
||||
}, $rows)];
|
||||
}
|
||||
|
||||
public function updateLog(int $userId, int $id, array $data): array
|
||||
{
|
||||
$this->getLog($userId, $id);
|
||||
|
||||
$updates = ['updatetime' => time()];
|
||||
if (array_key_exists('smoke_time', $data)) {
|
||||
$smokeTime = trim((string) ($data['smoke_time'] ?? ''));
|
||||
$updates['smoke_time'] = $smokeTime === '' ? null : Support::parseDate($smokeTime, 'smoke_time')->format(Support::DATE_LAYOUT);
|
||||
}
|
||||
if (array_key_exists('smoke_at', $data)) {
|
||||
$smokeAt = trim((string) ($data['smoke_at'] ?? ''));
|
||||
$updates['smoke_at'] = $smokeAt === '' ? null : Support::parseDateTime($smokeAt, 'smoke_at')->format(Support::DATETIME_LAYOUT);
|
||||
if ($updates['smoke_at'] !== null) {
|
||||
$updates['smoke_time'] = Support::dateOnly($updates['smoke_at'])->format(Support::DATE_LAYOUT);
|
||||
}
|
||||
}
|
||||
if (array_key_exists('remark', $data)) {
|
||||
$updates['remark'] = (string) ($data['remark'] ?? '');
|
||||
}
|
||||
if (array_key_exists('reason_tags', $data)) {
|
||||
$updates['reason_tags'] = Support::jsonEncodeArray((array) ($data['reason_tags'] ?? []));
|
||||
}
|
||||
if (array_key_exists('level', $data)) {
|
||||
$updates['level'] = max(0, (int) ($data['level'] ?? 1));
|
||||
}
|
||||
if (array_key_exists('num', $data)) {
|
||||
$updates['num'] = max(0, (int) ($data['num'] ?? 1));
|
||||
}
|
||||
|
||||
Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('id', $id)
|
||||
->where('uid', $userId)
|
||||
->update($updates);
|
||||
|
||||
return $this->getLog($userId, $id);
|
||||
}
|
||||
|
||||
public function deleteLog(int $userId, int $id): array
|
||||
{
|
||||
$affected = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('id', $id)
|
||||
->where('uid', $userId)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->update([
|
||||
'deletetime' => time(),
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
|
||||
if ((int) $affected <= 0) {
|
||||
throw new \RuntimeException('记录不存在', 404);
|
||||
}
|
||||
|
||||
return ['deleted' => true];
|
||||
}
|
||||
|
||||
public function dashboard(int $userId, array $params = []): array
|
||||
{
|
||||
$now = Support::now();
|
||||
[$defaultStart, $defaultEnd] = Support::weekRange($now);
|
||||
$start = !empty($params['start']) ? Support::parseDate((string) $params['start'], 'start') : $defaultStart;
|
||||
$end = !empty($params['end']) ? Support::parseDate((string) $params['end'], 'end') : (!empty($params['start']) ? $start->modify('+6 day') : $defaultEnd);
|
||||
if ($end < $start) {
|
||||
throw new \RuntimeException('end 不能早于 start', 400);
|
||||
}
|
||||
|
||||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->field('smoke_time, SUM(num) AS total')
|
||||
->where('uid', $userId)
|
||||
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->group('smoke_time')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
$counts[(string) $row['smoke_time']] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
$today = Support::dateOnly($now);
|
||||
$todayKey = $today->format(Support::DATE_LAYOUT);
|
||||
$todayCount = (int) Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->where('smoke_time', $todayKey)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->sum('num');
|
||||
|
||||
$lastSmoke = $this->findLastActualSmoke($userId);
|
||||
$minutesSinceLast = null;
|
||||
if ($lastSmoke) {
|
||||
$minutesSinceLast = max(0, (int) floor(($now->getTimestamp() - $lastSmoke->getTimestamp()) / 60));
|
||||
}
|
||||
|
||||
$weekly = [];
|
||||
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->add(new DateInterval('P1D'))) {
|
||||
$key = $cursor->format(Support::DATE_LAYOUT);
|
||||
$weekly[] = [
|
||||
'date' => $key,
|
||||
'count' => (int) ($counts[$key] ?? 0),
|
||||
'is_today' => $key === $todayKey,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'today_count' => $todayCount,
|
||||
'minutes_since_last' => $minutesSinceLast,
|
||||
'weekly' => $weekly,
|
||||
];
|
||||
}
|
||||
|
||||
public function getHomeSummary(int $userId, ?DateTimeImmutable $asOf = null): array
|
||||
{
|
||||
$asOf = $asOf ?: Support::now();
|
||||
$today = Support::dateOnly($asOf);
|
||||
$todayKey = $today->format(Support::DATE_LAYOUT);
|
||||
$yesterdayKey = $today->modify('-1 day')->format(Support::DATE_LAYOUT);
|
||||
|
||||
$todayCount = (int) Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->where('smoke_time', $todayKey)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->sum('num');
|
||||
$resistedCount = (int) Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->where('smoke_time', $todayKey)
|
||||
->where('level', 0)
|
||||
->where('num', 0)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->count();
|
||||
$yesterdayCount = (int) Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->where('smoke_time', $yesterdayKey)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->sum('num');
|
||||
|
||||
$diff = $yesterdayCount - $todayCount;
|
||||
$lastSmoke = $this->findLastActualSmoke($userId);
|
||||
$secondsSinceLast = -1;
|
||||
if ($lastSmoke) {
|
||||
$secondsSinceLast = max(0, $asOf->getTimestamp() - $lastSmoke->getTimestamp());
|
||||
}
|
||||
|
||||
return [
|
||||
'last_smoke_at' => $lastSmoke ? $lastSmoke->format(DATE_ATOM) : '',
|
||||
'today_count' => $todayCount,
|
||||
'resisted_count' => $resistedCount,
|
||||
'reduced_from_yesterday' => $diff > 0 ? $diff : abs($diff),
|
||||
'exceeded_yesterday' => $diff < 0,
|
||||
'seconds_since_last' => $secondsSinceLast,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDefaultNextSuggestion(int $userId, DateTimeImmutable $asOf, DateTimeImmutable $planDate, array $profileView): array
|
||||
{
|
||||
$base = (int) ($profileView['baseline_interval_minutes'] ?? 0);
|
||||
if ($base <= 0) {
|
||||
$base = 60;
|
||||
}
|
||||
|
||||
$lastSmokeAt = $this->findLastActualSmoke($userId) ?: $asOf;
|
||||
if ($lastSmokeAt > $asOf) {
|
||||
$lastSmokeAt = $asOf;
|
||||
}
|
||||
|
||||
$resisted7d = (int) Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->where('level', 0)
|
||||
->where('num', 0)
|
||||
->whereBetween('smoke_time', [$asOf->modify('-6 day')->format(Support::DATE_LAYOUT), Support::dateOnly($asOf)->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->count();
|
||||
|
||||
$stage = min(12, intdiv($resisted7d, 5));
|
||||
$interval = min(240, max(5, $base + $stage * 5));
|
||||
$nextSmokeAt = $lastSmokeAt->add(new DateInterval('PT' . $interval . 'M'));
|
||||
|
||||
if (Support::dateOnly($planDate) <= Support::dateOnly($asOf) && $nextSmokeAt < $asOf) {
|
||||
$elapsed = max(0, $asOf->getTimestamp() - $lastSmokeAt->getTimestamp());
|
||||
$missed = intdiv($elapsed, $interval * 60);
|
||||
$nextSmokeAt = $lastSmokeAt->add(new DateInterval('PT' . (($missed + 1) * $interval) . 'M'));
|
||||
}
|
||||
|
||||
$sleepAdjusted = false;
|
||||
$profile = $profileView['profile'] ?? null;
|
||||
$wakeUpTime = $profile['wake_up_time'] ?? '';
|
||||
$sleepTime = $profile['sleep_time'] ?? '';
|
||||
|
||||
if (Support::dateOnly($planDate) > Support::dateOnly($asOf)) {
|
||||
$minNotBefore = Support::dateOnly($planDate)->setTime(7, 0);
|
||||
if ($wakeUpTime !== '') {
|
||||
$wakeMin = Support::parseHHMM((string) $wakeUpTime);
|
||||
$minNotBefore = Support::dateOnly($planDate)->setTime(intdiv($wakeMin, 60), $wakeMin % 60);
|
||||
}
|
||||
if ($nextSmokeAt < $minNotBefore) {
|
||||
$nextSmokeAt = $minNotBefore;
|
||||
}
|
||||
}
|
||||
|
||||
if ($wakeUpTime !== '' && $sleepTime !== '') {
|
||||
$adjusted = $this->adjustToWakeIfInSleep($nextSmokeAt, (string) $wakeUpTime, (string) $sleepTime);
|
||||
if ($adjusted != $nextSmokeAt) {
|
||||
$nextSmokeAt = $adjusted;
|
||||
$sleepAdjusted = true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'last_smoke_at' => $lastSmokeAt->format(DATE_ATOM),
|
||||
'next_smoke_at' => $nextSmokeAt->format(DATE_ATOM),
|
||||
'base_interval_minutes' => $base,
|
||||
'interval_minutes' => $interval,
|
||||
'stage' => $stage,
|
||||
'resisted_7d' => $resisted7d,
|
||||
'sleep_adjusted' => $sleepAdjusted,
|
||||
'algorithm' => 'staircase_delay_v1',
|
||||
'as_of' => $asOf->format(DATE_ATOM),
|
||||
];
|
||||
}
|
||||
|
||||
public function motivation(int $userId, ?array $profile = null, ?DateTimeImmutable $asOf = null): array
|
||||
{
|
||||
$home = $this->getHomeSummary($userId, $asOf ?: Support::now());
|
||||
$minutesSinceLast = -1;
|
||||
if (!empty($home['last_smoke_at'])) {
|
||||
$minutesSinceLast = max(0, (int) floor((Support::now()->getTimestamp() - Support::toDateTime((string) $home['last_smoke_at'])->getTimestamp()) / 60));
|
||||
}
|
||||
|
||||
$dailyTarget = $profile ? (int) ($profile['baseline_cigs_per_day'] ?? 0) : 0;
|
||||
$quitMotivation = $profile && !empty($profile['quit_motivations']) ? (string) $profile['quit_motivations'][0] : '';
|
||||
|
||||
$scene = 'default';
|
||||
$fallback = ['message' => '保持连胜纪录!', 'type' => 'encourage'];
|
||||
if ((int) $home['resisted_count'] > 0 && $minutesSinceLast >= 0 && $minutesSinceLast < 30) {
|
||||
$scene = 'recent_resist';
|
||||
$fallback = ['message' => '太棒了!你刚刚成功抵抗了一次烟瘾', 'type' => 'praise'];
|
||||
} elseif ($dailyTarget > 0 && (int) $home['today_count'] < (int) floor($dailyTarget * 0.5)) {
|
||||
$scene = 'below_half_target';
|
||||
$fallback = ['message' => '今天的表现非常出色,继续保持!', 'type' => 'encourage'];
|
||||
} elseif ($dailyTarget > 0 && (int) $home['today_count'] === $dailyTarget - 1) {
|
||||
$scene = 'near_limit';
|
||||
$fallback = ['message' => '还剩最后一支配额,考虑把它留到睡前?', 'type' => 'hint'];
|
||||
} elseif ($dailyTarget > 0 && (int) $home['today_count'] > $dailyTarget) {
|
||||
$scene = 'over_target';
|
||||
$fallback = ['message' => '没关系,明天是新的一天。' . ($quitMotivation !== '' ? '记住你为什么要戒烟:' . $quitMotivation : ''), 'type' => 'comfort'];
|
||||
}
|
||||
|
||||
$quote = SmokeMotivationQuote::where('scene', $scene)
|
||||
->where('enabled', 1)
|
||||
->whereNull('deleted_at')
|
||||
->order('weight', 'desc')
|
||||
->order('id', 'asc')
|
||||
->find();
|
||||
if (!$quote && $scene !== 'default') {
|
||||
$quote = SmokeMotivationQuote::where('scene', 'default')
|
||||
->where('enabled', 1)
|
||||
->whereNull('deleted_at')
|
||||
->order('weight', 'desc')
|
||||
->order('id', 'asc')
|
||||
->find();
|
||||
}
|
||||
|
||||
if ($quote) {
|
||||
return [
|
||||
'message' => (string) $quote->message,
|
||||
'type' => (string) $quote->type,
|
||||
];
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
public function stats(int $userId, array $params = [], ?array $profile = null): array
|
||||
{
|
||||
$range = strtolower(trim((string) ($params['range'] ?? 'week')));
|
||||
$anchor = !empty($params['date'])
|
||||
? Support::parseDate((string) $params['date'], 'date')->setTime(23, 59, 59)
|
||||
: Support::now();
|
||||
|
||||
[$start, $end, $prevStart, $prevEnd, $trendUnit] = $this->buildStatsRange($range, $anchor);
|
||||
|
||||
if ($trendUnit === 'month') {
|
||||
[$trend, $total] = $this->loadMonthlyTrend($userId, $start, $end);
|
||||
} else {
|
||||
[$trend, $total] = $this->loadDailyTrend($userId, $start, $end);
|
||||
}
|
||||
$trend = $this->limitTrend($trend, 7);
|
||||
|
||||
$dayCount = Support::daysBetweenInclusive($start, $end);
|
||||
$dailyAverage = $dayCount > 0 ? (int) round($total / $dayCount) : 0;
|
||||
$prevTotal = $this->sumCigs($userId, $prevStart, $prevEnd);
|
||||
$changePercent = $prevTotal > 0 ? (int) round((($total - $prevTotal) / $prevTotal) * 100) : 0;
|
||||
$resistedTotal = $this->countResisted($userId, $start, $end);
|
||||
$streakDays = $this->getStreakDays($userId, $anchor);
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
'start' => $start->format(Support::DATE_LAYOUT),
|
||||
'end' => $end->format(Support::DATE_LAYOUT),
|
||||
'trend_unit' => $trendUnit,
|
||||
'trend' => $trend,
|
||||
'daily_average' => $dailyAverage,
|
||||
'change_percent' => $changePercent,
|
||||
'money' => $this->computeMoney($userId, $profile, $total, $start, $end),
|
||||
'health' => $this->computeHealth($userId, $anchor),
|
||||
'streak_days' => $streakDays,
|
||||
'resisted_total' => $resistedTotal,
|
||||
];
|
||||
}
|
||||
|
||||
public function getStreakDays(int $userId, ?DateTimeImmutable $asOf = null): int
|
||||
{
|
||||
$asOf = Support::dateOnly($asOf ?: Support::now());
|
||||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->distinct(true)
|
||||
->field('smoke_time')
|
||||
->where('uid', $userId)
|
||||
->whereBetween('smoke_time', [$asOf->modify('-400 day')->format(Support::DATE_LAYOUT), $asOf->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->order('smoke_time', 'desc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$daySet = [];
|
||||
foreach ($rows as $row) {
|
||||
$daySet[(string) $row['smoke_time']] = true;
|
||||
}
|
||||
|
||||
$streak = 0;
|
||||
for ($cursor = $asOf; ; $cursor = $cursor->modify('-1 day')) {
|
||||
$key = $cursor->format(Support::DATE_LAYOUT);
|
||||
if (!isset($daySet[$key])) {
|
||||
break;
|
||||
}
|
||||
$streak++;
|
||||
}
|
||||
|
||||
return $streak;
|
||||
}
|
||||
|
||||
public function createShare(int $userId, array $data = []): array
|
||||
{
|
||||
$days = (int) ($data['days'] ?? 0);
|
||||
if ($days <= 0) {
|
||||
$days = 7;
|
||||
}
|
||||
if ($days > 30) {
|
||||
$days = 30;
|
||||
}
|
||||
$share = new SmokeShare();
|
||||
$share->uid = $userId;
|
||||
$share->share_token = bin2hex(random_bytes(16));
|
||||
$share->expire_at = Support::now()->modify('+' . $days . ' day')->format(Support::DATETIME_LAYOUT);
|
||||
$share->view_count = 0;
|
||||
$share->created_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$share->updated_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$share->save();
|
||||
|
||||
return [
|
||||
'share_token' => (string) $share->share_token,
|
||||
'expire_at' => Support::formatRfc3339((string) $share->expire_at),
|
||||
'share_path' => 'pages/share/index?share_token=' . $share->share_token,
|
||||
];
|
||||
}
|
||||
|
||||
public function getShareView(string $token, array $params = []): array
|
||||
{
|
||||
$share = $this->findShareByToken($token);
|
||||
$this->touchShareViewed((int) $share->id);
|
||||
|
||||
$anchor = !empty($params['date'])
|
||||
? Support::parseDate((string) $params['date'], 'date')->setTime(23, 59, 59)
|
||||
: Support::now();
|
||||
$stats = $this->stats((int) $share->uid, [
|
||||
'range' => $params['range'] ?? 'week',
|
||||
'date' => $anchor->format(Support::DATE_LAYOUT),
|
||||
], $this->getProfile((int) $share->uid));
|
||||
$homeSummary = $this->getHomeSummary((int) $share->uid, $anchor);
|
||||
$logs = $this->listLogs((int) $share->uid, [
|
||||
'page' => $params['page'] ?? 1,
|
||||
'page_size' => $params['page_size'] ?? 20,
|
||||
'type' => $params['type'] ?? 'all',
|
||||
]);
|
||||
|
||||
$owner = User::findActiveById((int) $share->uid);
|
||||
|
||||
return [
|
||||
'owner' => [
|
||||
'nickname' => Support::maskNickname((string) ($owner->nick_name ?? '')),
|
||||
'avatar_url' => (string) ($owner->avatar_url ?? ''),
|
||||
],
|
||||
'share' => [
|
||||
'share_token' => (string) $share->share_token,
|
||||
'expire_at' => Support::formatRfc3339((string) $share->expire_at),
|
||||
'last_viewed_at' => Support::formatRfc3339((string) $share->last_viewed_at),
|
||||
'view_count' => (int) $share->view_count + 1,
|
||||
],
|
||||
'overview' => [
|
||||
'today_count' => (int) $homeSummary['today_count'],
|
||||
'resisted_count' => (int) $homeSummary['resisted_count'],
|
||||
'reduced_from_yesterday' => (int) $homeSummary['reduced_from_yesterday'],
|
||||
'exceeded_yesterday' => (bool) $homeSummary['exceeded_yesterday'],
|
||||
'last_smoke_at' => (string) $homeSummary['last_smoke_at'],
|
||||
'seconds_since_last' => (int) $homeSummary['seconds_since_last'],
|
||||
'streak_days' => (int) $stats['streak_days'],
|
||||
],
|
||||
'stats' => $stats,
|
||||
'logs' => $logs,
|
||||
];
|
||||
}
|
||||
|
||||
public function revokeShare(int $userId, string $token): array
|
||||
{
|
||||
$share = SmokeShare::where('share_token', trim($token))->whereNull('deleted_at')->find();
|
||||
if (!$share) {
|
||||
throw new \RuntimeException('分享不存在', 404);
|
||||
}
|
||||
if ((int) $share->uid !== $userId) {
|
||||
throw new \RuntimeException('无权限操作该分享', 403);
|
||||
}
|
||||
|
||||
$share->revoked_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$share->updated_at = Support::now()->format(Support::DATETIME_LAYOUT);
|
||||
$share->save();
|
||||
|
||||
return ['revoked' => true];
|
||||
}
|
||||
|
||||
private function formatProfileRow(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'baseline_cigs_per_day' => (int) ($row['baseline_cigs_per_day'] ?? 0),
|
||||
'smoking_years' => (float) ($row['smoking_years'] ?? 0),
|
||||
'pack_price_cent' => (int) ($row['pack_price_cent'] ?? 0),
|
||||
'smoke_motivations' => Support::jsonArray($row['smoke_motivations'] ?? []),
|
||||
'quit_motivations' => Support::jsonArray($row['quit_motivations'] ?? []),
|
||||
'mode' => Support::normalizedMode((string) ($row['mode'] ?? 'record')),
|
||||
'wake_up_time' => (string) ($row['wake_up_time'] ?? ''),
|
||||
'sleep_time' => (string) ($row['sleep_time'] ?? ''),
|
||||
'quit_date' => !empty($row['quit_date']) ? Support::formatRfc3339((string) $row['quit_date']) : '',
|
||||
'achievement_theme_id' => isset($row['achievement_theme_id']) && $row['achievement_theme_id'] !== null ? (int) $row['achievement_theme_id'] : null,
|
||||
'onboarding_completed_at' => !empty($row['onboarding_completed_at']) ? Support::formatRfc3339((string) $row['onboarding_completed_at']) : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function isProfileCompleted(array $profile): bool
|
||||
{
|
||||
return (int) ($profile['baseline_cigs_per_day'] ?? 0) > 0
|
||||
&& (int) ($profile['pack_price_cent'] ?? 0) > 0
|
||||
&& !empty($profile['quit_motivations'])
|
||||
&& trim((string) ($profile['wake_up_time'] ?? '')) !== ''
|
||||
&& trim((string) ($profile['sleep_time'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
private function adjustToWakeIfInSleep(DateTimeImmutable $time, string $wakeUpTime, string $sleepTime): DateTimeImmutable
|
||||
{
|
||||
$wakeMin = Support::parseHHMM($wakeUpTime);
|
||||
$sleepMin = Support::parseHHMM($sleepTime);
|
||||
if ($wakeMin === $sleepMin) {
|
||||
return $time;
|
||||
}
|
||||
|
||||
$minuteOfDay = ((int) $time->format('H')) * 60 + (int) $time->format('i');
|
||||
$inSleep = $wakeMin < $sleepMin
|
||||
? ($minuteOfDay < $wakeMin || $minuteOfDay >= $sleepMin)
|
||||
: ($minuteOfDay >= $sleepMin && $minuteOfDay < $wakeMin);
|
||||
if (!$inSleep) {
|
||||
return $time;
|
||||
}
|
||||
|
||||
$wakeToday = Support::dateOnly($time)->setTime(intdiv($wakeMin, 60), $wakeMin % 60);
|
||||
if ($wakeToday <= $time) {
|
||||
$wakeToday = $wakeToday->add(new DateInterval('P1D'));
|
||||
}
|
||||
|
||||
return $wakeToday;
|
||||
}
|
||||
|
||||
private function findLastActualSmoke(int $userId): ?DateTimeImmutable
|
||||
{
|
||||
$row = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->where('num', '>', 0)
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->orderRaw('COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC')
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
|
||||
return $row ? Support::logEventAt($row) : null;
|
||||
}
|
||||
|
||||
private function buildStatsRange(string $range, DateTimeImmutable $anchor): array
|
||||
{
|
||||
if ($range === 'month') {
|
||||
$start = Support::dateOnly($anchor->modify('first day of this month'));
|
||||
$end = Support::dateOnly($anchor->modify('last day of this month'));
|
||||
$prevEnd = $start->modify('-1 day');
|
||||
$prevStart = Support::dateOnly($prevEnd->modify('first day of this month'));
|
||||
return [$start, $end, $prevStart, $prevEnd, 'day'];
|
||||
}
|
||||
if ($range === 'year') {
|
||||
$start = Support::dateOnly(new DateTimeImmutable($anchor->format('Y-01-01'), Support::tz()));
|
||||
$end = Support::dateOnly(new DateTimeImmutable($anchor->format('Y-12-31'), Support::tz()));
|
||||
$prevStart = Support::dateOnly(new DateTimeImmutable(((int) $anchor->format('Y') - 1) . '-01-01', Support::tz()));
|
||||
$prevEnd = Support::dateOnly(new DateTimeImmutable(((int) $anchor->format('Y') - 1) . '-12-31', Support::tz()));
|
||||
return [$start, $end, $prevStart, $prevEnd, 'month'];
|
||||
}
|
||||
if ($range !== 'week') {
|
||||
throw new \RuntimeException('range 应为 week|month|year', 400);
|
||||
}
|
||||
|
||||
[$start, $end] = Support::weekRange($anchor);
|
||||
return [$start, $end, $start->modify('-7 day'), $end->modify('-7 day'), 'day'];
|
||||
}
|
||||
|
||||
private function loadDailyTrend(int $userId, DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->field('smoke_time, SUM(num) AS total')
|
||||
->where('uid', $userId)
|
||||
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->group('smoke_time')
|
||||
->order('smoke_time', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$counts = [];
|
||||
$total = 0;
|
||||
foreach ($rows as $row) {
|
||||
$counts[(string) $row['smoke_time']] = (int) ($row['total'] ?? 0);
|
||||
$total += (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
$trend = [];
|
||||
for ($cursor = $start; $cursor <= $end; $cursor = $cursor->add(new DateInterval('P1D'))) {
|
||||
$key = $cursor->format(Support::DATE_LAYOUT);
|
||||
$trend[] = ['label' => $key, 'count' => (int) ($counts[$key] ?? 0)];
|
||||
}
|
||||
|
||||
return [$trend, $total];
|
||||
}
|
||||
|
||||
private function loadMonthlyTrend(int $userId, DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
$rows = Db::connect('mysql')->name('fa_smoke_log')
|
||||
->field("DATE_FORMAT(smoke_time, '%Y-%m') AS month, SUM(num) AS total")
|
||||
->where('uid', $userId)
|
||||
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->group("DATE_FORMAT(smoke_time, '%Y-%m')")
|
||||
->order('month', 'asc')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$counts = [];
|
||||
$total = 0;
|
||||
foreach ($rows as $row) {
|
||||
$counts[(string) $row['month']] = (int) ($row['total'] ?? 0);
|
||||
$total += (int) ($row['total'] ?? 0);
|
||||
}
|
||||
|
||||
$trend = [];
|
||||
for ($cursor = Support::dateOnly($start->modify('first day of this month')); $cursor <= $end; $cursor = $cursor->modify('first day of next month')) {
|
||||
$key = $cursor->format('Y-m');
|
||||
$trend[] = ['label' => $key, 'count' => (int) ($counts[$key] ?? 0)];
|
||||
}
|
||||
|
||||
return [$trend, $total];
|
||||
}
|
||||
|
||||
private function limitTrend(array $items, int $max): array
|
||||
{
|
||||
if ($max <= 0 || count($items) <= $max) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
$lastIndex = count($items) - 1;
|
||||
$result = [];
|
||||
$seen = [];
|
||||
for ($i = 0; $i < $max; $i++) {
|
||||
$position = (int) round($i * $lastIndex / max(1, $max - 1));
|
||||
if (isset($seen[$position])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$position] = true;
|
||||
$result[] = $items[$position];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function sumCigs(int $userId, DateTimeImmutable $start, DateTimeImmutable $end): int
|
||||
{
|
||||
return (int) Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->sum('num');
|
||||
}
|
||||
|
||||
private function countResisted(int $userId, DateTimeImmutable $start, DateTimeImmutable $end): int
|
||||
{
|
||||
return (int) Db::connect('mysql')->name('fa_smoke_log')
|
||||
->where('uid', $userId)
|
||||
->where('level', 0)
|
||||
->where('num', 0)
|
||||
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->count();
|
||||
}
|
||||
|
||||
private function computeMoney(int $userId, ?array $profile, int $actualTotal, DateTimeImmutable $start, DateTimeImmutable $end): array
|
||||
{
|
||||
if (!$profile || (int) ($profile['baseline_cigs_per_day'] ?? 0) <= 0 || (int) ($profile['pack_price_cent'] ?? 0) <= 0) {
|
||||
return ['available' => false];
|
||||
}
|
||||
|
||||
$activeDays = (int) Db::connect('mysql')->name('fa_smoke_log')
|
||||
->distinct(true)
|
||||
->field('smoke_time')
|
||||
->where('uid', $userId)
|
||||
->whereBetween('smoke_time', [$start->format(Support::DATE_LAYOUT), $end->format(Support::DATE_LAYOUT)])
|
||||
->whereRaw('(deletetime IS NULL OR deletetime = 0)')
|
||||
->count();
|
||||
|
||||
$expectedTotal = (int) $profile['baseline_cigs_per_day'] * max(0, $activeDays);
|
||||
$savedCigs = max(0, $expectedTotal - $actualTotal);
|
||||
$savedCent = (int) round(($savedCigs / 20) * (int) $profile['pack_price_cent']);
|
||||
|
||||
return [
|
||||
'available' => true,
|
||||
'pack_price_cent' => (int) $profile['pack_price_cent'],
|
||||
'cigs_per_pack' => 20,
|
||||
'expected_total' => $expectedTotal,
|
||||
'actual_total' => $actualTotal,
|
||||
'saved_cent' => $savedCent,
|
||||
];
|
||||
}
|
||||
|
||||
private function computeHealth(int $userId, DateTimeImmutable $asOf): array
|
||||
{
|
||||
$lastSmoke = $this->findLastActualSmoke($userId);
|
||||
if (!$lastSmoke) {
|
||||
return ['available' => false];
|
||||
}
|
||||
|
||||
$minutes = max(0, (int) floor(($asOf->getTimestamp() - $lastSmoke->getTimestamp()) / 60));
|
||||
return [
|
||||
'available' => true,
|
||||
'smoke_free_minutes' => $minutes,
|
||||
'lung_recovery_percent' => $this->computeLungRecoveryPercent($minutes),
|
||||
'milestones' => $this->buildHealthMilestones($minutes),
|
||||
];
|
||||
}
|
||||
|
||||
private function computeLungRecoveryPercent(int $minutes): int
|
||||
{
|
||||
$days = $minutes / (24 * 60);
|
||||
if ($days < 14) {
|
||||
return (int) round(($days / 14) * 15);
|
||||
}
|
||||
if ($days < 30) {
|
||||
return (int) round(15 + (($days - 14) / 16) * 15);
|
||||
}
|
||||
if ($days < 90) {
|
||||
return (int) round(30 + (($days - 30) / 60) * 20);
|
||||
}
|
||||
|
||||
return (int) round(min(100, 50 + (($days - 90) / 275) * 50));
|
||||
}
|
||||
|
||||
private function buildHealthMilestones(int $minutes): array
|
||||
{
|
||||
$steps = [
|
||||
['name' => '心率血压恢复正常', 'minutes' => 20],
|
||||
['name' => '血氧水平恢复', 'minutes' => 8 * 60],
|
||||
['name' => '心脏病风险开始下降', 'minutes' => 24 * 60],
|
||||
['name' => '嗅觉味觉开始恢复', 'minutes' => 48 * 60],
|
||||
['name' => '肺功能提升 15%', 'minutes' => 14 * 24 * 60],
|
||||
['name' => '肺功能提升 30%', 'minutes' => 30 * 24 * 60],
|
||||
['name' => '肺功能提升 50%', 'minutes' => 90 * 24 * 60],
|
||||
['name' => '心脏病风险降低 50%', 'minutes' => 365 * 24 * 60],
|
||||
];
|
||||
|
||||
return array_map(static function ($step) use ($minutes) {
|
||||
return [
|
||||
'name' => $step['name'],
|
||||
'minutes' => $step['minutes'],
|
||||
'reached' => $minutes >= $step['minutes'],
|
||||
];
|
||||
}, $steps);
|
||||
}
|
||||
|
||||
private function findShareByToken(string $token): SmokeShare
|
||||
{
|
||||
$share = SmokeShare::where('share_token', trim($token))->whereNull('deleted_at')->find();
|
||||
if (!$share) {
|
||||
throw new \RuntimeException('分享不存在', 404);
|
||||
}
|
||||
if (!empty($share->revoked_at)) {
|
||||
throw new \RuntimeException('分享已失效', 404);
|
||||
}
|
||||
if (!empty($share->expire_at) && Support::toDateTime((string) $share->expire_at) < Support::now()) {
|
||||
throw new \RuntimeException('分享已过期', 404);
|
||||
}
|
||||
|
||||
return $share;
|
||||
}
|
||||
|
||||
private function touchShareViewed(int $shareId): void
|
||||
{
|
||||
Db::connect('mysql')->name('fa_smoke_share')
|
||||
->where('id', $shareId)
|
||||
->update([
|
||||
'last_viewed_at' => Support::now()->format(Support::DATETIME_LAYOUT),
|
||||
'view_count' => Db::raw('view_count + 1'),
|
||||
'updated_at' => Support::now()->format(Support::DATETIME_LAYOUT),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\smt\service;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use DateTimeZone;
|
||||
|
||||
final class Support
|
||||
{
|
||||
public const DATE_LAYOUT = 'Y-m-d';
|
||||
public const DATETIME_LAYOUT = 'Y-m-d H:i:s';
|
||||
|
||||
public static function tz(): DateTimeZone
|
||||
{
|
||||
return new DateTimeZone((string) config('app.default_timezone', 'Asia/Shanghai'));
|
||||
}
|
||||
|
||||
public static function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable('now', self::tz());
|
||||
}
|
||||
|
||||
public static function dateOnly($value = null): DateTimeImmutable
|
||||
{
|
||||
$dt = self::toDateTime($value);
|
||||
return $dt->setTime(0, 0, 0);
|
||||
}
|
||||
|
||||
public static function parseDate(?string $value, string $field = 'date'): ?DateTimeImmutable
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dt = DateTimeImmutable::createFromFormat(self::DATE_LAYOUT, $text, self::tz());
|
||||
if (!$dt || $dt->format(self::DATE_LAYOUT) !== $text) {
|
||||
throw new \RuntimeException(sprintf('%s 格式错误,应为 YYYY-MM-DD', $field), 400);
|
||||
}
|
||||
|
||||
return $dt->setTime(0, 0, 0);
|
||||
}
|
||||
|
||||
public static function parseDateTime(?string $value, string $field = 'datetime'): ?DateTimeImmutable
|
||||
{
|
||||
$text = trim((string) $value);
|
||||
if ($text === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dt = DateTimeImmutable::createFromFormat(self::DATETIME_LAYOUT, $text, self::tz());
|
||||
if ($dt && $dt->format(self::DATETIME_LAYOUT) === $text) {
|
||||
return $dt;
|
||||
}
|
||||
|
||||
try {
|
||||
return (new DateTimeImmutable($text))->setTimezone(self::tz());
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException(sprintf('%s 格式错误,应为 YYYY-MM-DD HH:MM:SS 或 RFC3339', $field), 400);
|
||||
}
|
||||
}
|
||||
|
||||
public static function toDateTime($value): DateTimeImmutable
|
||||
{
|
||||
if ($value instanceof DateTimeImmutable) {
|
||||
return $value->setTimezone(self::tz());
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return (new DateTimeImmutable($value->format(DateTimeInterface::ATOM)))->setTimezone(self::tz());
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return (new DateTimeImmutable('@' . $value))->setTimezone(self::tz());
|
||||
}
|
||||
|
||||
if (is_numeric($value) && (string) (int) $value === (string) $value) {
|
||||
return (new DateTimeImmutable('@' . (int) $value))->setTimezone(self::tz());
|
||||
}
|
||||
|
||||
$text = trim((string) $value);
|
||||
if ($text === '') {
|
||||
return self::now();
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $text)) {
|
||||
return new DateTimeImmutable($text . ' 00:00:00', self::tz());
|
||||
}
|
||||
|
||||
return (new DateTimeImmutable($text, self::tz()))->setTimezone(self::tz());
|
||||
}
|
||||
|
||||
public static function toDbDate($value): ?string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::dateOnly($value)->format(self::DATE_LAYOUT);
|
||||
}
|
||||
|
||||
public static function toDbDateTime($value): ?string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::toDateTime($value)->format(self::DATETIME_LAYOUT);
|
||||
}
|
||||
|
||||
public static function formatRfc3339($value): string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return self::toDateTime($value)->format(DateTimeInterface::RFC3339);
|
||||
}
|
||||
|
||||
public static function formatClock($value): string
|
||||
{
|
||||
if ($value === null || trim((string) $value) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return self::toDateTime($value)->format('H:i');
|
||||
}
|
||||
|
||||
public static function parseHHMM(string $value): int
|
||||
{
|
||||
$text = trim($value);
|
||||
if (!preg_match('/^\d{2}:\d{2}$/', $text)) {
|
||||
throw new \RuntimeException('作息时间格式错误,应为 HH:MM', 400);
|
||||
}
|
||||
|
||||
[$hour, $minute] = array_map('intval', explode(':', $text));
|
||||
if ($hour < 0 || $hour > 23 || $minute < 0 || $minute > 59) {
|
||||
throw new \RuntimeException('作息时间格式错误,应为 HH:MM', 400);
|
||||
}
|
||||
|
||||
return $hour * 60 + $minute;
|
||||
}
|
||||
|
||||
public static function awakeMinutes(string $wakeUpTime, string $sleepTime): int
|
||||
{
|
||||
$wake = trim($wakeUpTime);
|
||||
$sleep = trim($sleepTime);
|
||||
if ($wake === '' || $sleep === '') {
|
||||
return 16 * 60;
|
||||
}
|
||||
|
||||
$wakeMin = self::parseHHMM($wake);
|
||||
$sleepMin = self::parseHHMM($sleep);
|
||||
if ($wakeMin === $sleepMin) {
|
||||
return 24 * 60;
|
||||
}
|
||||
|
||||
if ($sleepMin > $wakeMin) {
|
||||
return $sleepMin - $wakeMin;
|
||||
}
|
||||
|
||||
return (24 * 60 - $wakeMin) + $sleepMin;
|
||||
}
|
||||
|
||||
public static function baselineIntervalMinutes(int $awakeMinutes, int $baselineCigsPerDay): int
|
||||
{
|
||||
if ($awakeMinutes <= 0 || $baselineCigsPerDay <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(1, intdiv($awakeMinutes, $baselineCigsPerDay));
|
||||
}
|
||||
|
||||
public static function normalizedMode(?string $mode): string
|
||||
{
|
||||
$value = trim((string) $mode);
|
||||
return $value === 'quit' ? 'quit' : 'record';
|
||||
}
|
||||
|
||||
public static function jsonArray($value): array
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return array_values(array_filter(array_map(function ($item) {
|
||||
return trim((string) $item);
|
||||
}, $value), static function ($item) {
|
||||
return $item !== '';
|
||||
}));
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $value, true);
|
||||
return is_array($decoded) ? self::jsonArray($decoded) : [];
|
||||
}
|
||||
|
||||
public static function jsonEncodeArray(array $items): string
|
||||
{
|
||||
return json_encode(self::jsonArray($items), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
public static function formatLog(array $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'smoke_time' => self::formatRfc3339($row['smoke_time'] ?? null),
|
||||
'smoke_at' => self::formatRfc3339($row['smoke_at'] ?? null),
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
'reason_tags' => self::jsonArray($row['reason_tags'] ?? []),
|
||||
'createtime' => isset($row['createtime']) ? (int) $row['createtime'] : 0,
|
||||
'updatetime' => isset($row['updatetime']) ? (int) $row['updatetime'] : 0,
|
||||
'deletetime' => isset($row['deletetime']) && $row['deletetime'] !== null ? (int) $row['deletetime'] : null,
|
||||
'level' => (int) ($row['level'] ?? 1),
|
||||
'num' => (int) ($row['num'] ?? 1),
|
||||
];
|
||||
}
|
||||
|
||||
public static function logEventAt(array $row): ?DateTimeImmutable
|
||||
{
|
||||
if (!empty($row['smoke_at'])) {
|
||||
return self::toDateTime($row['smoke_at']);
|
||||
}
|
||||
|
||||
if (!empty($row['createtime'])) {
|
||||
return self::toDateTime((int) $row['createtime']);
|
||||
}
|
||||
|
||||
if (!empty($row['smoke_time'])) {
|
||||
return self::dateOnly($row['smoke_time']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function weekRange(DateTimeImmutable $anchor): array
|
||||
{
|
||||
$weekday = (int) $anchor->format('N');
|
||||
$start = self::dateOnly($anchor)->sub(new DateInterval('P' . ($weekday - 1) . 'D'));
|
||||
return [$start, $start->add(new DateInterval('P6D'))];
|
||||
}
|
||||
|
||||
public static function daysBetweenInclusive(DateTimeImmutable $start, DateTimeImmutable $end): int
|
||||
{
|
||||
$start = self::dateOnly($start);
|
||||
$end = self::dateOnly($end);
|
||||
if ($end < $start) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) $start->diff($end)->days + 1;
|
||||
}
|
||||
|
||||
public static function maskNickname(string $value): string
|
||||
{
|
||||
$text = trim($value);
|
||||
if ($text === '') {
|
||||
return '戒烟用户';
|
||||
}
|
||||
|
||||
$length = mb_strlen($text);
|
||||
if ($length <= 1) {
|
||||
return '戒烟用户';
|
||||
}
|
||||
if ($length === 2) {
|
||||
return mb_substr($text, 0, 1) . '*';
|
||||
}
|
||||
|
||||
return mb_substr($text, 0, 1) . str_repeat('*', $length - 2) . mb_substr($text, -1);
|
||||
}
|
||||
|
||||
public static function deriveUserSegment(int $baselineCigsPerDay, float $smokingYears): string
|
||||
{
|
||||
if ($baselineCigsPerDay >= 20 || $smokingYears >= 10) {
|
||||
return 'heavy';
|
||||
}
|
||||
if ($baselineCigsPerDay >= 10 || $smokingYears >= 3) {
|
||||
return 'moderate';
|
||||
}
|
||||
|
||||
return 'newbie';
|
||||
}
|
||||
|
||||
public static function truncate(string $value, int $max): string
|
||||
{
|
||||
if ($max <= 0 || mb_strlen($value) <= $max) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return mb_substr($value, 0, $max);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@ return [
|
||||
// 是否启用路由
|
||||
'with_route' => true,
|
||||
// 默认应用
|
||||
'default_app' => 'api',
|
||||
'default_app' => 'smt',
|
||||
// 默认时区
|
||||
'default_timezone' => 'Asia/Shanghai',
|
||||
|
||||
|
||||
+24
-148
@@ -1,8 +1,28 @@
|
||||
<?php
|
||||
|
||||
$mysql = [
|
||||
'type' => env('DB_TYPE', 'mysql'),
|
||||
'hostname' => env('DB_HOST', '127.0.0.1'),
|
||||
'database' => env('DB_NAME', 'wx_service'),
|
||||
'username' => env('DB_USER', 'root'),
|
||||
'password' => env('DB_PASS', ''),
|
||||
'hostport' => env('DB_PORT', '3306'),
|
||||
'params' => [],
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'prefix' => '',
|
||||
'deploy' => 0,
|
||||
'rw_separate' => false,
|
||||
'master_num' => 1,
|
||||
'slave_no' => '',
|
||||
'fields_strict' => false,
|
||||
'break_reconnect' => false,
|
||||
'trigger_sql' => env('APP_DEBUG', true),
|
||||
'fields_cache' => false,
|
||||
];
|
||||
|
||||
return [
|
||||
// 默认使用的数据库连接配置
|
||||
'default' => 'dbmember',
|
||||
// 默认使用当前唯一数据库连接
|
||||
'default' => 'mysql',
|
||||
|
||||
// 自定义时间查询规则
|
||||
'time_query_rule' => [],
|
||||
@@ -20,151 +40,7 @@ return [
|
||||
|
||||
// 数据库连接配置信息
|
||||
'connections' => [
|
||||
// 会员数据库 (member) - 主连接
|
||||
'dbmember' => [
|
||||
'type' => 'mysql',
|
||||
'hostname' => env('DB_HOSTNAME', 'rm-m5e6936bb24oj5272co.mysql.rds.aliyuncs.com'),
|
||||
'database' => env('DATABASE', 'aicaigoupmw'),
|
||||
'username' => env('USERNAME', 'contenttpl'),
|
||||
'password' => env('PASSWORD', 'QXYxgg123!@#q'),
|
||||
'hostport' => env('HOSTPORT', '3306'),
|
||||
'params' => [],
|
||||
'charset' => env('CHARSET', 'utf8mb4'),
|
||||
'prefix' => '',
|
||||
'deploy' => 0,
|
||||
'rw_separate' => false,
|
||||
'master_num' => 1,
|
||||
'slave_no' => '',
|
||||
'fields_strict' => false,
|
||||
'break_reconnect' => false,
|
||||
'trigger_sql' => env('APP_DEBUG', true),
|
||||
'fields_cache' => false,
|
||||
],
|
||||
|
||||
// 业务数据库 (aicaigoupmw) - 主库
|
||||
'dbbiz' => [
|
||||
'type' => 'mysql',
|
||||
'hostname' => env('DB_BIZ_HOSTNAME', 'rm-m5e6936bb24oj5272co.mysql.rds.aliyuncs.com'),
|
||||
'database' => env('DB_BIZ_DATABASE', 'aicaigoupmw'),
|
||||
'username' => env('DB_BIZ_USERNAME', 'contenttpl'),
|
||||
'password' => env('DB_BIZ_PASSWORD', 'QXYxgg123!@#q'),
|
||||
'hostport' => env('DB_BIZ_HOSTPORT', '3306'),
|
||||
'params' => [],
|
||||
'charset' => env('CHARSET', 'utf8mb4'),
|
||||
'prefix' => '',
|
||||
'deploy' => 0,
|
||||
'rw_separate' => false,
|
||||
'master_num' => 1,
|
||||
'slave_no' => '',
|
||||
'fields_strict' => false,
|
||||
'break_reconnect' => false,
|
||||
'trigger_sql' => env('APP_DEBUG', true),
|
||||
'fields_cache' => false,
|
||||
],
|
||||
|
||||
// 笔记数据库(独立 note 模块)
|
||||
'dbnote' => [
|
||||
'type' => 'mysql',
|
||||
'hostname' => env('DB_NOTE_HOSTNAME', '127.0.0.1'),
|
||||
'database' => env('DB_NOTE_DATABASE', 'tp'),
|
||||
'username' => env('DB_NOTE_USERNAME', 'tp'),
|
||||
'password' => env('DB_NOTE_PASSWORD', ''),
|
||||
'hostport' => env('DB_NOTE_HOSTPORT', '3306'),
|
||||
'params' => [],
|
||||
'charset' => env('DB_NOTE_CHARSET', 'utf8mb4'),
|
||||
'prefix' => '',
|
||||
'deploy' => 0,
|
||||
'rw_separate' => false,
|
||||
'master_num' => 1,
|
||||
'slave_no' => '',
|
||||
'fields_strict' => false,
|
||||
'break_reconnect' => false,
|
||||
'trigger_sql' => env('APP_DEBUG', true),
|
||||
'fields_cache' => false,
|
||||
],
|
||||
|
||||
// 抖音业务数据库(用于首页发布作品统计分表)
|
||||
'dbdouying' => [
|
||||
'type' => 'mysql',
|
||||
'hostname' => env('DB_DOUYING_HOSTNAME', 'rm-m5e6936bb24oj5272co.mysql.rds.aliyuncs.com'),
|
||||
'database' => env('DB_DOUYING_DATABASE', 'douying'),
|
||||
'username' => env('DB_DOUYING_USERNAME', 'contenttpl'),
|
||||
'password' => env('DB_DOUYING_PASSWORD', 'QXYxgg123!@#q'),
|
||||
'hostport' => env('DB_DOUYING_HOSTPORT', '3306'),
|
||||
'params' => [],
|
||||
'charset' => env('DB_DOUYING_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'deploy' => 0,
|
||||
'rw_separate' => false,
|
||||
'master_num' => 1,
|
||||
'slave_no' => '',
|
||||
'fields_strict' => false,
|
||||
'break_reconnect' => false,
|
||||
'trigger_sql' => env('APP_DEBUG', true),
|
||||
'fields_cache' => false,
|
||||
],
|
||||
|
||||
// 后台管理数据库
|
||||
'dbxgg' => [
|
||||
'type' => 'mysql',
|
||||
'hostname' => env('DB_HOSTNAME', 'rm-m5e6936bb24oj5272co.mysql.rds.aliyuncs.com'),
|
||||
'database' => env('DB_XGG_DATABASE', 'member'),
|
||||
'username' => env('DB_XGG_USERNAME', 'newsystemrds'),
|
||||
'password' => env('DB_XGG_PASSWORD', 'Xx123456'),
|
||||
'hostport' => env('HOSTPORT', '3306'),
|
||||
'params' => [],
|
||||
'charset' => env('CHARSET', 'utf8mb4'),
|
||||
'prefix' => '',
|
||||
'deploy' => 0,
|
||||
'rw_separate' => false,
|
||||
'master_num' => 1,
|
||||
'slave_no' => '',
|
||||
'fields_strict' => false,
|
||||
'break_reconnect' => false,
|
||||
'trigger_sql' => env('APP_DEBUG', true),
|
||||
'fields_cache' => false,
|
||||
],
|
||||
|
||||
// 积分数据库
|
||||
'dbintegral' => [
|
||||
'type' => 'mysql',
|
||||
'hostname' => env('DB_HOSTNAME', 'rm-m5e6936bb24oj5272co.mysql.rds.aliyuncs.com'),
|
||||
'database' => env('DB_INTEGRAL_DATABASE', 'integral'),
|
||||
'username' => env('DB_INTEGRAL_USERNAME', 'newsystemrds'),
|
||||
'password' => env('DB_INTEGRAL_PASSWORD', 'Xx123456'),
|
||||
'hostport' => env('HOSTPORT', '3306'),
|
||||
'params' => [],
|
||||
'charset' => env('CHARSET', 'utf8mb4'),
|
||||
'prefix' => '',
|
||||
'deploy' => 0,
|
||||
'rw_separate' => false,
|
||||
'master_num' => 1,
|
||||
'slave_no' => '',
|
||||
'fields_strict' => false,
|
||||
'break_reconnect' => false,
|
||||
'trigger_sql' => env('APP_DEBUG', true),
|
||||
'fields_cache' => false,
|
||||
],
|
||||
|
||||
// 本地开发数据库 (可选)
|
||||
'mysql' => [
|
||||
'type' => 'mysql',
|
||||
'hostname' => '127.0.0.1',
|
||||
'database' => 'tp_api',
|
||||
'username' => 'root',
|
||||
'password' => '',
|
||||
'hostport' => '3306',
|
||||
'params' => [],
|
||||
'charset' => 'utf8mb4',
|
||||
'prefix' => '',
|
||||
'deploy' => 0,
|
||||
'rw_separate' => false,
|
||||
'master_num' => 1,
|
||||
'slave_no' => '',
|
||||
'fields_strict' => false,
|
||||
'break_reconnect' => false,
|
||||
'trigger_sql' => env('APP_DEBUG', true),
|
||||
'fields_cache' => false,
|
||||
],
|
||||
// 当前唯一正确数据库
|
||||
'mysql' => $mysql,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# smt 戒烟小程序 ThinkPHP 模块说明
|
||||
|
||||
本次在 `tp/app/smt` 下新增了一个 ThinkPHP 多应用模块,用于承接原 `wx_service` 中 `/api/v1/auth/*` 与 `/api/v1/smoke/*` 的主要能力。
|
||||
|
||||
## 已迁移范围
|
||||
|
||||
- 小程序登录:`POST /smt/v1/auth/login`
|
||||
- 开发登录:`POST /smt/v1/auth/dev-login`
|
||||
- 当前用户:`GET /smt/v1/auth/me`
|
||||
- 更新用户资料:`PUT|POST /smt/v1/auth/profile`
|
||||
- 戒烟资料:`GET|POST /smt/v1/smoke/profile`
|
||||
- 抽烟记录:`/smt/v1/smoke/logs*`
|
||||
- 首页、看板、统计、激励语:
|
||||
- `GET /smt/v1/smoke/home`
|
||||
- `GET /smt/v1/smoke/dashboard`
|
||||
- `GET /smt/v1/smoke/stats`
|
||||
- `GET /smt/v1/smoke/motivation`
|
||||
- 下次抽烟建议:
|
||||
- `GET /smt/v1/smoke/next_smoke_time`
|
||||
- `GET /smt/v1/smoke/ai/next_smoke_time`
|
||||
- AI 建议与每日总结:
|
||||
- `GET /smt/v1/smoke/ai/advice`
|
||||
- `POST /smt/v1/smoke/ai/advice_unlocks`
|
||||
- `GET /smt/v1/smoke/ai/daily_summary`
|
||||
- 分享:
|
||||
- `POST /smt/v1/smoke/share`
|
||||
- `GET /smt/v1/smoke/share/:token`
|
||||
- `POST /smt/v1/smoke/share/:token/revoke`
|
||||
- 戒烟计划:
|
||||
- `POST /smt/v1/smoke/quit-plan/generate`
|
||||
- `GET /smt/v1/smoke/quit-plan`
|
||||
- `GET /smt/v1/smoke/quit-plan/days`
|
||||
- `POST /smt/v1/smoke/quit-plan/reset`
|
||||
- 成就:
|
||||
- `GET /smt/v1/smoke/achievement/themes`
|
||||
- `GET /smt/v1/smoke/achievement`
|
||||
|
||||
## 当前实现说明
|
||||
|
||||
- 数据库连接复用 `dbbiz`,直接对接 `users`、`mini_programs`、`user_memberships`、`fa_smoke_*`、`fa_achievement_*` 等表。
|
||||
- 鉴权复用了原 Go 服务的语义:`Authorization: Bearer <session_key>`。
|
||||
- AI 能力优先读取环境变量:`AI_BASE_URL`、`AI_API_KEY`、`AI_MODEL`、`AI_TIMEOUT_SECONDS`。
|
||||
- 当 AI 环境变量缺失或调用失败时,已做规则版兜底,保证接口仍然可返回结果。
|
||||
- 戒烟计划当前为规则生成版本,不再依赖 Go 侧的 AI 计划生成器。
|
||||
|
||||
## 未迁移范围
|
||||
|
||||
以下能力仍然在 Go 服务里,当前 `tp/app/smt` 未接入:
|
||||
|
||||
- `/api/v2/checkin/*` 戒烟打卡系统(quitcheckin)
|
||||
- 梦想目标、监督人、提醒等 v2 业务
|
||||
- Go 侧的 Redis session 缓存
|
||||
- Go 侧更细的 AI debug log 与测试体系
|
||||
|
||||
如果后续要把 `mini-programs/smt` 完全切到 `tp`,下一步应继续迁移 `wx_service/internal/quitcheckin` 到 `tp/app/smt` 或单独的 `tp/app/checkin`。
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>恭喜,站点创建成功!</title>
|
||||
<style>
|
||||
.container {
|
||||
width: 60%;
|
||||
margin: 10% auto 0;
|
||||
background-color: #f0f0f0;
|
||||
padding: 2% 5%;
|
||||
border-radius: 10px
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
line-height: 2.3
|
||||
}
|
||||
|
||||
a {
|
||||
color: #20a53a
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>恭喜, 站点创建成功!</h1>
|
||||
<h3>这是默认index.html,本页面由系统自动生成</h3>
|
||||
<ul>
|
||||
<li>本页面在FTP根目录下的index.html</li>
|
||||
<li>您可以修改、删除或覆盖本页面</li>
|
||||
<li>FTP相关信息,请到“面板系统后台 > FTP” 查看</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+87
-68
@@ -2,76 +2,95 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use think\facade\Route;
|
||||
use app\api\controller\v1\Auth;
|
||||
use app\api\controller\v1\Platform;
|
||||
use app\api\controller\v1\PublishPlan;
|
||||
use app\api\controller\v1\VideoWork;
|
||||
use app\note\controller\v1\Ai as NoteAi;
|
||||
use app\note\controller\v1\Auth as NoteAuth;
|
||||
use app\note\controller\v1\Meta as NoteMeta;
|
||||
use app\note\controller\v1\Note as NoteItem;
|
||||
use app\note\controller\v1\Share as NoteShare;
|
||||
use app\smt\controller\v1\Auth;
|
||||
use app\smt\controller\v1\QuitCheckin;
|
||||
use app\smt\controller\v1\Smoke;
|
||||
|
||||
/**
|
||||
* 全局路由入口。
|
||||
*
|
||||
* 当前项目使用 ThinkPHP 8 的根级 `route/app.php` 自动加载机制。
|
||||
* 之前把 API 路由写在 `app/api/route/app.php`,框架不会自动扫描该位置,
|
||||
* 因此会导致 `api/v1/platform/accounts` 等接口返回 404。
|
||||
*
|
||||
* 这里统一把 API 路由注册到真正生效的位置。
|
||||
*/
|
||||
|
||||
// v1 认证接口(公开)
|
||||
Route::post('api/v1/auth/login', [Auth::class, 'login']);
|
||||
Route::post('api/v1/auth/register', [Auth::class, 'register']);
|
||||
Route::post('api/v1/auth/refresh', [Auth::class, 'refresh']);
|
||||
|
||||
// v1 认证接口(需登录)
|
||||
Route::group('api/v1/auth', function () {
|
||||
Route::get('me', [Auth::class, 'me']);
|
||||
Route::post('logout', [Auth::class, 'logout']);
|
||||
Route::post('password', [Auth::class, 'password']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
|
||||
// v1 平台账号管理接口(需登录)
|
||||
Route::group('api/v1/platform', function () {
|
||||
Route::get('accounts', [Platform::class, 'accounts']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
|
||||
// v1 发布计划接口(需登录)
|
||||
Route::group('api/v1/publish-plan', function () {
|
||||
Route::get('list', [PublishPlan::class, 'index']);
|
||||
Route::post('start/:id', [PublishPlan::class, 'start']);
|
||||
Route::post('stop/:id', [PublishPlan::class, 'stop']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
|
||||
// v1 视频作品接口(需登录)
|
||||
Route::group('api/v1/video-work', function () {
|
||||
Route::get('list', [VideoWork::class, 'index']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
|
||||
// note v1 笔记小程序模块接口规划(公开)
|
||||
Route::group('note/v1', function () {
|
||||
Route::get('meta/interfaces', [NoteMeta::class, 'interfaces']);
|
||||
Route::post('auth/wechat-login', [NoteAuth::class, 'wechatLogin']);
|
||||
Route::get('share/read/:token', [NoteShare::class, 'read']);
|
||||
// 首页健康检查,方便验证 TP 站点是否已正确接入。
|
||||
Route::get('/', function () {
|
||||
return json([
|
||||
'code' => 200,
|
||||
'msg' => 'tp ok',
|
||||
'data' => [
|
||||
'app' => 'tp',
|
||||
'default_app' => 'smt',
|
||||
'status' => 'running',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// note v1 笔记小程序模块接口(需登录)
|
||||
Route::group('note/v1', function () {
|
||||
Route::get('auth/me', [NoteAuth::class, 'me']);
|
||||
// 兼容原 Go 服务接口前缀:/api/v1/*。
|
||||
Route::post('api/v1/auth/login', [Auth::class, 'login']);
|
||||
Route::post('api/v1/auth/dev-login', [Auth::class, 'devLogin']);
|
||||
Route::get('api/v1/smoke/share/:token', [Smoke::class, 'shareRead']);
|
||||
|
||||
Route::post('item/create', [NoteItem::class, 'create']);
|
||||
Route::get('item/list', [NoteItem::class, 'index']);
|
||||
Route::get('item/:id', [NoteItem::class, 'read']);
|
||||
Route::post('item/update/:id', [NoteItem::class, 'update']);
|
||||
Route::post('item/delete/:id', [NoteItem::class, 'delete']);
|
||||
Route::post('item/transcript/:id', [NoteItem::class, 'transcript']);
|
||||
Route::post('item/audio/:id', [NoteItem::class, 'audio']);
|
||||
Route::post('item/image/:id', [NoteItem::class, 'image']);
|
||||
Route::group('api/v1', function () {
|
||||
Route::get('auth/me', [Auth::class, 'me']);
|
||||
Route::get('auth/mini-program-test-code', [Auth::class, 'miniProgramTestCode']);
|
||||
Route::put('auth/profile', [Auth::class, 'profile']);
|
||||
Route::post('auth/profile', [Auth::class, 'profile']);
|
||||
|
||||
Route::post('ai/summary/:id', [NoteAi::class, 'summary']);
|
||||
Route::get('ai/summary/:id', [NoteAi::class, 'readSummary']);
|
||||
Route::post('share/create/:id', [NoteShare::class, 'create']);
|
||||
})->middleware(\app\api\middleware\Auth::class);
|
||||
Route::get('smoke/home', [Smoke::class, 'home']);
|
||||
Route::get('smoke/profile', [Smoke::class, 'profile']);
|
||||
Route::post('smoke/profile', [Smoke::class, 'saveProfile']);
|
||||
Route::get('smoke/next_smoke_time', [Smoke::class, 'nextSmokeTime']);
|
||||
Route::get('smoke/dashboard', [Smoke::class, 'dashboard']);
|
||||
Route::get('smoke/stats', [Smoke::class, 'stats']);
|
||||
Route::post('smoke/logs', [Smoke::class, 'createLog']);
|
||||
Route::post('smoke/logs/resisted', [Smoke::class, 'createResistedLog']);
|
||||
Route::get('smoke/logs', [Smoke::class, 'logs']);
|
||||
Route::get('smoke/logs/latest', [Smoke::class, 'latestLogs']);
|
||||
Route::get('smoke/logs/:id', [Smoke::class, 'readLog']);
|
||||
Route::post('smoke/logs/:id', [Smoke::class, 'updateLog']);
|
||||
Route::delete('smoke/logs/:id', [Smoke::class, 'deleteLog']);
|
||||
Route::get('smoke/motivation', [Smoke::class, 'motivation']);
|
||||
|
||||
Route::get('smoke/ai/advice', [Smoke::class, 'aiAdvice']);
|
||||
Route::post('smoke/ai/advice_unlocks', [Smoke::class, 'unlockAiAdvice']);
|
||||
Route::get('smoke/ai/next_smoke_time', [Smoke::class, 'aiNextSmokeTime']);
|
||||
Route::get('smoke/ai/daily_summary', [Smoke::class, 'aiDailySummary']);
|
||||
|
||||
Route::post('smoke/share', [Smoke::class, 'createShare']);
|
||||
Route::post('smoke/share/:token/revoke', [Smoke::class, 'revokeShare']);
|
||||
|
||||
Route::post('smoke/quit-plan/generate', [Smoke::class, 'generateQuitPlan']);
|
||||
Route::get('smoke/quit-plan', [Smoke::class, 'quitPlan']);
|
||||
Route::get('smoke/quit-plan/days', [Smoke::class, 'quitPlanDays']);
|
||||
Route::post('smoke/quit-plan/reset', [Smoke::class, 'resetQuitPlan']);
|
||||
|
||||
Route::get('smoke/achievement/themes', [Smoke::class, 'achievementThemes']);
|
||||
Route::get('smoke/achievement', [Smoke::class, 'achievement']);
|
||||
})->middleware(\app\smt\middleware\Auth::class);
|
||||
|
||||
// 兼容原 Go 服务 V2:无烟打卡监督人相关接口。
|
||||
Route::group('api/v2', function () {
|
||||
Route::get('profile', [QuitCheckin::class, 'profile']);
|
||||
Route::post('profile', [QuitCheckin::class, 'saveProfile']);
|
||||
Route::get('checkin/home', [QuitCheckin::class, 'home']);
|
||||
Route::post('checkin/check', [QuitCheckin::class, 'checkin']);
|
||||
|
||||
Route::get('dream-presets', [QuitCheckin::class, 'dreamPresets']);
|
||||
Route::get('reward-goals', [QuitCheckin::class, 'rewardGoals']);
|
||||
Route::post('reward-goals', [QuitCheckin::class, 'createRewardGoal']);
|
||||
Route::put('reward-goals/:id', [QuitCheckin::class, 'updateRewardGoal']);
|
||||
|
||||
Route::post('supervisor/invites', [QuitCheckin::class, 'createSupervisorInvite']);
|
||||
Route::post('supervisor/bind', [QuitCheckin::class, 'bindSupervisorInvite']);
|
||||
Route::post('supervisor/revoke', [QuitCheckin::class, 'revokeSupervisorBinding']);
|
||||
Route::get('supervisor/overview', [QuitCheckin::class, 'supervisorOverview']);
|
||||
Route::get('supervisor/status', [QuitCheckin::class, 'supervisorStatus']);
|
||||
Route::get('supervisor/reminders/settings', [QuitCheckin::class, 'reminderSettings']);
|
||||
Route::put('supervisor/reminders/settings', [QuitCheckin::class, 'updateReminderSettings']);
|
||||
Route::post('supervisor/reminders/run', [QuitCheckin::class, 'runReminders']);
|
||||
})->middleware(\app\smt\middleware\Auth::class);
|
||||
|
||||
// 统一处理未命中路由,避免引用已删除的 api/note 模块。
|
||||
Route::miss(function () {
|
||||
return json([
|
||||
'code' => 404,
|
||||
'msg' => '请求的接口不存在',
|
||||
'data' => [
|
||||
'path' => request()->pathinfo(),
|
||||
],
|
||||
], 404);
|
||||
});
|
||||
|
||||
Executable → Regular
Reference in New Issue
Block a user