From 613e4a58a9795795f577a133ab0c415d7e097dc7 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Sun, 26 Apr 2026 09:24:08 +0800 Subject: [PATCH] feat: add smt module --- .htaccess | 1 + 404.html | 7 + app/api/common/Jwt.php | 137 ---- app/api/common/Response.php | 68 -- app/api/controller/BaseController.php | 145 ---- app/api/controller/v1/Auth.php | 173 ----- app/api/controller/v1/Platform.php | 63 -- app/api/controller/v1/PublishPlan.php | 106 --- app/api/controller/v1/VideoWork.php | 63 -- app/api/middleware.php | 9 - app/api/middleware/Auth.php | 44 -- app/api/middleware/CrossDomain.php | 44 -- app/api/model/DySpecialBinding.php | 36 - app/api/model/DySpecialPlatforms.php | 36 - app/api/model/DyVideoCron.php | 62 -- app/api/model/DyVideoUser.php | 105 --- app/api/model/DyVideoUserTitk.php | 33 - app/api/model/DysVideoLog.php | 74 -- app/api/model/Member.php | 297 -------- app/api/model/MemberLoginLog.php | 32 - app/api/model/ProductList.php | 34 - app/api/route/app.php | 41 -- app/api/service/AuthService.php | 252 ------- app/api/service/PlatformService.php | 325 --------- app/api/service/PublishPlanService.php | 713 ------------------- app/api/service/VideoWorkService.php | 662 ----------------- app/note/controller/BaseController.php | 30 - app/note/controller/v1/Ai.php | 81 --- app/note/controller/v1/Auth.php | 70 -- app/note/controller/v1/Meta.php | 35 - app/note/controller/v1/Note.php | 204 ------ app/note/controller/v1/Share.php | 51 -- app/note/model/NoteAiSummary.php | 33 - app/note/model/NoteAudio.php | 27 - app/note/model/NoteItem.php | 47 -- app/note/model/NoteShare.php | 36 - app/note/model/NoteTranscript.php | 34 - app/note/model/NoteUser.php | 46 -- app/note/route/app.php | 41 -- app/note/service/AiService.php | 228 ------ app/note/service/AuthService.php | 164 ----- app/note/service/NoteService.php | 550 --------------- app/note/service/PlanningService.php | 225 ------ app/smt/common/Response.php | 29 + app/smt/controller/BaseController.php | 57 ++ app/smt/controller/v1/Auth.php | 77 ++ app/smt/controller/v1/QuitCheckin.php | 164 +++++ app/smt/controller/v1/Smoke.php | 484 +++++++++++++ app/smt/middleware/Auth.php | 56 ++ app/smt/model/AchievementLevel.php | 11 + app/smt/model/AchievementTheme.php | 11 + app/smt/model/BaseBizModel.php | 13 + app/smt/model/MiniProgram.php | 16 + app/smt/model/SmokeAIAdvice.php | 11 + app/smt/model/SmokeAIAdviceUnlock.php | 11 + app/smt/model/SmokeAINextSmoke.php | 11 + app/smt/model/SmokeLog.php | 11 + app/smt/model/SmokeMotivationQuote.php | 11 + app/smt/model/SmokeQuitPlan.php | 11 + app/smt/model/SmokeQuitPlanDay.php | 11 + app/smt/model/SmokeShare.php | 11 + app/smt/model/SmokeUserProfile.php | 16 + app/smt/model/User.php | 29 + app/smt/model/UserMembership.php | 11 + app/smt/route/app.php | 74 ++ app/smt/service/AchievementService.php | 116 +++ app/smt/service/AuthService.php | 331 +++++++++ app/smt/service/QuitCheckinService.php | 548 +++++++++++++++ app/smt/service/QuitPlanService.php | 248 +++++++ app/smt/service/SmokeAiService.php | 806 +++++++++++++++++++++ app/smt/service/SmokeService.php | 935 +++++++++++++++++++++++++ app/smt/service/Support.php | 295 ++++++++ config/app.php | 2 +- config/database.php | 172 +---- docs/smt_api.md | 55 ++ index.html | 39 ++ route/app.php | 155 ++-- test_api.sh | 0 78 files changed, 4629 insertions(+), 5673 deletions(-) create mode 100644 .htaccess create mode 100644 404.html delete mode 100644 app/api/common/Jwt.php delete mode 100644 app/api/common/Response.php delete mode 100644 app/api/controller/BaseController.php delete mode 100644 app/api/controller/v1/Auth.php delete mode 100644 app/api/controller/v1/Platform.php delete mode 100644 app/api/controller/v1/PublishPlan.php delete mode 100644 app/api/controller/v1/VideoWork.php delete mode 100644 app/api/middleware.php delete mode 100644 app/api/middleware/Auth.php delete mode 100644 app/api/middleware/CrossDomain.php delete mode 100644 app/api/model/DySpecialBinding.php delete mode 100644 app/api/model/DySpecialPlatforms.php delete mode 100644 app/api/model/DyVideoCron.php delete mode 100644 app/api/model/DyVideoUser.php delete mode 100644 app/api/model/DyVideoUserTitk.php delete mode 100644 app/api/model/DysVideoLog.php delete mode 100644 app/api/model/Member.php delete mode 100644 app/api/model/MemberLoginLog.php delete mode 100644 app/api/model/ProductList.php delete mode 100644 app/api/route/app.php delete mode 100644 app/api/service/AuthService.php delete mode 100644 app/api/service/PlatformService.php delete mode 100644 app/api/service/PublishPlanService.php delete mode 100644 app/api/service/VideoWorkService.php delete mode 100644 app/note/controller/BaseController.php delete mode 100644 app/note/controller/v1/Ai.php delete mode 100644 app/note/controller/v1/Auth.php delete mode 100644 app/note/controller/v1/Meta.php delete mode 100644 app/note/controller/v1/Note.php delete mode 100644 app/note/controller/v1/Share.php delete mode 100644 app/note/model/NoteAiSummary.php delete mode 100644 app/note/model/NoteAudio.php delete mode 100644 app/note/model/NoteItem.php delete mode 100644 app/note/model/NoteShare.php delete mode 100644 app/note/model/NoteTranscript.php delete mode 100644 app/note/model/NoteUser.php delete mode 100644 app/note/route/app.php delete mode 100644 app/note/service/AiService.php delete mode 100644 app/note/service/AuthService.php delete mode 100644 app/note/service/NoteService.php delete mode 100644 app/note/service/PlanningService.php create mode 100644 app/smt/common/Response.php create mode 100644 app/smt/controller/BaseController.php create mode 100644 app/smt/controller/v1/Auth.php create mode 100644 app/smt/controller/v1/QuitCheckin.php create mode 100644 app/smt/controller/v1/Smoke.php create mode 100644 app/smt/middleware/Auth.php create mode 100644 app/smt/model/AchievementLevel.php create mode 100644 app/smt/model/AchievementTheme.php create mode 100644 app/smt/model/BaseBizModel.php create mode 100644 app/smt/model/MiniProgram.php create mode 100644 app/smt/model/SmokeAIAdvice.php create mode 100644 app/smt/model/SmokeAIAdviceUnlock.php create mode 100644 app/smt/model/SmokeAINextSmoke.php create mode 100644 app/smt/model/SmokeLog.php create mode 100644 app/smt/model/SmokeMotivationQuote.php create mode 100644 app/smt/model/SmokeQuitPlan.php create mode 100644 app/smt/model/SmokeQuitPlanDay.php create mode 100644 app/smt/model/SmokeShare.php create mode 100644 app/smt/model/SmokeUserProfile.php create mode 100644 app/smt/model/User.php create mode 100644 app/smt/model/UserMembership.php create mode 100644 app/smt/route/app.php create mode 100644 app/smt/service/AchievementService.php create mode 100644 app/smt/service/AuthService.php create mode 100644 app/smt/service/QuitCheckinService.php create mode 100644 app/smt/service/QuitPlanService.php create mode 100644 app/smt/service/SmokeAiService.php create mode 100644 app/smt/service/SmokeService.php create mode 100644 app/smt/service/Support.php create mode 100644 docs/smt_api.md create mode 100644 index.html mode change 100755 => 100644 test_api.sh diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/.htaccess @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/404.html b/404.html new file mode 100644 index 0000000..6f17eaf --- /dev/null +++ b/404.html @@ -0,0 +1,7 @@ + +404 Not Found + +

404 Not Found

+
nginx
+ + \ No newline at end of file diff --git a/app/api/common/Jwt.php b/app/api/common/Jwt.php deleted file mode 100644 index 41e4621..0000000 --- a/app/api/common/Jwt.php +++ /dev/null @@ -1,137 +0,0 @@ - '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)); - } -} diff --git a/app/api/common/Response.php b/app/api/common/Response.php deleted file mode 100644 index 19d024f..0000000 --- a/app/api/common/Response.php +++ /dev/null @@ -1,68 +0,0 @@ - $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(), - ]); - } -} diff --git a/app/api/controller/BaseController.php b/app/api/controller/BaseController.php deleted file mode 100644 index c4be72e..0000000 --- a/app/api/controller/BaseController.php +++ /dev/null @@ -1,145 +0,0 @@ -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); - } -} diff --git a/app/api/controller/v1/Auth.php b/app/api/controller/v1/Auth.php deleted file mode 100644 index 092a89e..0000000 --- a/app/api/controller/v1/Auth.php +++ /dev/null @@ -1,173 +0,0 @@ -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); - } - } -} diff --git a/app/api/controller/v1/Platform.php b/app/api/controller/v1/Platform.php deleted file mode 100644 index 9104c41..0000000 --- a/app/api/controller/v1/Platform.php +++ /dev/null @@ -1,63 +0,0 @@ -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); - } - } -} diff --git a/app/api/controller/v1/PublishPlan.php b/app/api/controller/v1/PublishPlan.php deleted file mode 100644 index f0f47c7..0000000 --- a/app/api/controller/v1/PublishPlan.php +++ /dev/null @@ -1,106 +0,0 @@ -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); - } - } -} diff --git a/app/api/controller/v1/VideoWork.php b/app/api/controller/v1/VideoWork.php deleted file mode 100644 index efeb354..0000000 --- a/app/api/controller/v1/VideoWork.php +++ /dev/null @@ -1,63 +0,0 @@ -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); - } - } -} diff --git a/app/api/middleware.php b/app/api/middleware.php deleted file mode 100644 index 50270f5..0000000 --- a/app/api/middleware.php +++ /dev/null @@ -1,9 +0,0 @@ -withMiddleware([ - 'payload' => $payload, - 'userid' => (int) ($payload['userid'] ?? 0), - ]); - - return $next($request); - } -} diff --git a/app/api/middleware/CrossDomain.php b/app/api/middleware/CrossDomain.php deleted file mode 100644 index 0a76b8e..0000000 --- a/app/api/middleware/CrossDomain.php +++ /dev/null @@ -1,44 +0,0 @@ -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; - } -} diff --git a/app/api/model/DySpecialBinding.php b/app/api/model/DySpecialBinding.php deleted file mode 100644 index 0770642..0000000 --- a/app/api/model/DySpecialBinding.php +++ /dev/null @@ -1,36 +0,0 @@ -where('disabled', 0) - ->find(); - } -} diff --git a/app/api/model/DySpecialPlatforms.php b/app/api/model/DySpecialPlatforms.php deleted file mode 100644 index a3cda4f..0000000 --- a/app/api/model/DySpecialPlatforms.php +++ /dev/null @@ -1,36 +0,0 @@ -where('disabled', 0) - ->find(); - } -} diff --git a/app/api/model/DyVideoCron.php b/app/api/model/DyVideoCron.php deleted file mode 100644 index 37e9044..0000000 --- a/app/api/model/DyVideoCron.php +++ /dev/null @@ -1,62 +0,0 @@ -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); - }); - }); - } -} diff --git a/app/api/model/DyVideoUser.php b/app/api/model/DyVideoUser.php deleted file mode 100644 index 9f55e53..0000000 --- a/app/api/model/DyVideoUser.php +++ /dev/null @@ -1,105 +0,0 @@ -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(); - } -} diff --git a/app/api/model/DyVideoUserTitk.php b/app/api/model/DyVideoUserTitk.php deleted file mode 100644 index 0be1518..0000000 --- a/app/api/model/DyVideoUserTitk.php +++ /dev/null @@ -1,33 +0,0 @@ -find(); - } -} diff --git a/app/api/model/DysVideoLog.php b/app/api/model/DysVideoLog.php deleted file mode 100644 index ba7b3b3..0000000 --- a/app/api/model/DysVideoLog.php +++ /dev/null @@ -1,74 +0,0 @@ -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); - } -} diff --git a/app/api/model/Member.php b/app/api/model/Member.php deleted file mode 100644 index 7bc03f8..0000000 --- a/app/api/model/Member.php +++ /dev/null @@ -1,297 +0,0 @@ -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) . ','; - } -} diff --git a/app/api/model/MemberLoginLog.php b/app/api/model/MemberLoginLog.php deleted file mode 100644 index 9e34b31..0000000 --- a/app/api/model/MemberLoginLog.php +++ /dev/null @@ -1,32 +0,0 @@ -save($payload); - } -} diff --git a/app/api/model/ProductList.php b/app/api/model/ProductList.php deleted file mode 100644 index 8d0331f..0000000 --- a/app/api/model/ProductList.php +++ /dev/null @@ -1,34 +0,0 @@ -find(); - } -} diff --git a/app/api/route/app.php b/app/api/route/app.php deleted file mode 100644 index 6402977..0000000 --- a/app/api/route/app.php +++ /dev/null @@ -1,41 +0,0 @@ -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); diff --git a/app/api/service/AuthService.php b/app/api/service/AuthService.php deleted file mode 100644 index f217bdb..0000000 --- a/app/api/service/AuthService.php +++ /dev/null @@ -1,252 +0,0 @@ -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(); - } -} diff --git a/app/api/service/PlatformService.php b/app/api/service/PlatformService.php deleted file mode 100644 index b88dfaa..0000000 --- a/app/api/service/PlatformService.php +++ /dev/null @@ -1,325 +0,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 - */ - 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 $platforms 当前用户可见平台 - * @param int $userid 当前用户ID - * @return array> - */ - 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 - */ - 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 - */ - 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 - */ - 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> $items 列表项 - * @return array - */ - 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 - */ - 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)) - : []; - } -} diff --git a/app/api/service/PublishPlanService.php b/app/api/service/PublishPlanService.php deleted file mode 100644 index a76abdb..0000000 --- a/app/api/service/PublishPlanService.php +++ /dev/null @@ -1,713 +0,0 @@ - '抖音', - 1 => '快手', - 2 => '百家号', - 3 => '小红书', - 4 => '视频号', - 5 => 'B站', - 6 => '公众号', - 10 => 'TikTok', - ]; - - /** - * 获取发布计划列表。 - * - * @param int $userid 当前登录用户ID - * @param array $params 前端筛选参数 - * @return array - * @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 - * @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 - * @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 $records 当前页记录 - * @return array - */ - 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 $projectMap */ - $projectMap = Db::connect('dbmember') - ->name('dy_ai_project') - ->where('userid', $userid) - ->whereIn('id', $projectIds) - ->column('name', 'id'); - - return $projectMap; - } - - /** - * 加载当前页计划中用到的账号信息。 - * - * @param Collection $records 当前页记录 - * @return array> - */ - 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 $record 原始记录 - * @param array $projectNameMap 项目名称映射 - * @param array> $accountInfoMap 账号信息映射 - * @return array - */ - 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 - */ - 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 - */ - 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 - */ - private function getStatusFilterMap(): array - { - return [ - 'all' => '全部', - 'running' => '进行中', - 'stopped' => '已停止', - 'finished' => '已完成', - ]; - } - - /** - * 构建计划状态块。 - * - * @param int $status 原始状态码 - * @param int $jrstop 今日暂停标记 - * @return array - */ - 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 $statusInfo 状态块 - * @return array|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 $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 $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 $publishAccounts 发布账号名 - * @param array $atAccounts @账号名 - * @param array $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 $materialIds 素材ID集合 - * @param array $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 - */ - 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 $accountIds 账号ID集合 - * @param array> $accountInfoMap 账号信息映射 - * @return array - */ - 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); - } -} diff --git a/app/api/service/VideoWorkService.php b/app/api/service/VideoWorkService.php deleted file mode 100644 index bc134a5..0000000 --- a/app/api/service/VideoWorkService.php +++ /dev/null @@ -1,662 +0,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 $params 前端查询参数 - * @return array - * @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 $availablePlatforms 可见平台 - * @return array{items: array>, map: array>} - */ - 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 $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 $availablePlatforms 可见平台 - * @param int|null $vuid 当前账号筛选 - * @param int|null $currentPlatform 当前平台筛选 - * @return array> - */ - 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 - */ - 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 $record 原始作品记录 - * @param array> $accountMap 账号信息映射 - * @return array - */ - 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 $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 $record 原始作品记录 - * @return array - */ - 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 $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 $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 $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|null $productInfo 套餐信息 - * @return array - */ - 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 - */ - 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); - } -} diff --git a/app/note/controller/BaseController.php b/app/note/controller/BaseController.php deleted file mode 100644 index 8018c3c..0000000 --- a/app/note/controller/BaseController.php +++ /dev/null @@ -1,30 +0,0 @@ -getLoginPayload(); - - if (($payload['guard'] ?? '') !== 'note') { - throw new \RuntimeException('note 模块登录态无效', 401); - } - - return (int) ($payload['userid'] ?? 0); - } -} diff --git a/app/note/controller/v1/Ai.php b/app/note/controller/v1/Ai.php deleted file mode 100644 index 3fbf02f..0000000 --- a/app/note/controller/v1/Ai.php +++ /dev/null @@ -1,81 +0,0 @@ -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); - } - } -} diff --git a/app/note/controller/v1/Auth.php b/app/note/controller/v1/Auth.php deleted file mode 100644 index a3abd80..0000000 --- a/app/note/controller/v1/Auth.php +++ /dev/null @@ -1,70 +0,0 @@ -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); - } - } -} diff --git a/app/note/controller/v1/Meta.php b/app/note/controller/v1/Meta.php deleted file mode 100644 index 5afdb63..0000000 --- a/app/note/controller/v1/Meta.php +++ /dev/null @@ -1,35 +0,0 @@ -planningService = new PlanningService(); - } - - /** - * 获取 note 模块接口规划概览 - * GET /note/v1/meta/interfaces - */ - public function interfaces() - { - return Response::success($this->planningService->getModuleOverview()); - } -} diff --git a/app/note/controller/v1/Note.php b/app/note/controller/v1/Note.php deleted file mode 100644 index e66bab8..0000000 --- a/app/note/controller/v1/Note.php +++ /dev/null @@ -1,204 +0,0 @@ -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); - } - } -} diff --git a/app/note/controller/v1/Share.php b/app/note/controller/v1/Share.php deleted file mode 100644 index c34cbd9..0000000 --- a/app/note/controller/v1/Share.php +++ /dev/null @@ -1,51 +0,0 @@ -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); - } - } -} diff --git a/app/note/model/NoteAiSummary.php b/app/note/model/NoteAiSummary.php deleted file mode 100644 index 189cbda..0000000 --- a/app/note/model/NoteAiSummary.php +++ /dev/null @@ -1,33 +0,0 @@ -order('id', 'desc') - ->find(); - } -} diff --git a/app/note/model/NoteAudio.php b/app/note/model/NoteAudio.php deleted file mode 100644 index ea1759b..0000000 --- a/app/note/model/NoteAudio.php +++ /dev/null @@ -1,27 +0,0 @@ -order('id', 'desc') - ->find(); - } -} diff --git a/app/note/model/NoteItem.php b/app/note/model/NoteItem.php deleted file mode 100644 index 49e18e5..0000000 --- a/app/note/model/NoteItem.php +++ /dev/null @@ -1,47 +0,0 @@ -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(); - } -} diff --git a/app/note/model/NoteShare.php b/app/note/model/NoteShare.php deleted file mode 100644 index 522b65d..0000000 --- a/app/note/model/NoteShare.php +++ /dev/null @@ -1,36 +0,0 @@ -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(); - } -} diff --git a/app/note/model/NoteTranscript.php b/app/note/model/NoteTranscript.php deleted file mode 100644 index 853d243..0000000 --- a/app/note/model/NoteTranscript.php +++ /dev/null @@ -1,34 +0,0 @@ -where('segment_no', $segmentNo) - ->find(); - } -} diff --git a/app/note/model/NoteUser.php b/app/note/model/NoteUser.php deleted file mode 100644 index 385a7bb..0000000 --- a/app/note/model/NoteUser.php +++ /dev/null @@ -1,46 +0,0 @@ -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(); - } -} diff --git a/app/note/route/app.php b/app/note/route/app.php deleted file mode 100644 index b237869..0000000 --- a/app/note/route/app.php +++ /dev/null @@ -1,41 +0,0 @@ -middleware(\app\api\middleware\Auth::class); diff --git a/app/note/service/AiService.php b/app/note/service/AiService.php deleted file mode 100644 index d109973..0000000 --- a/app/note/service/AiService.php +++ /dev/null @@ -1,228 +0,0 @@ -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 : []; - } -} diff --git a/app/note/service/AuthService.php b/app/note/service/AuthService.php deleted file mode 100644 index 0f92510..0000000 --- a/app/note/service/AuthService.php +++ /dev/null @@ -1,164 +0,0 @@ -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; - } -} diff --git a/app/note/service/NoteService.php b/app/note/service/NoteService.php deleted file mode 100644 index c8a5894..0000000 --- a/app/note/service/NoteService.php +++ /dev/null @@ -1,550 +0,0 @@ -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, '/'); - } -} diff --git a/app/note/service/PlanningService.php b/app/note/service/PlanningService.php deleted file mode 100644 index 5d3945e..0000000 --- a/app/note/service/PlanningService.php +++ /dev/null @@ -1,225 +0,0 @@ - '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, - ]; - } -} diff --git a/app/smt/common/Response.php b/app/smt/common/Response.php new file mode 100644 index 0000000..fad1e68 --- /dev/null +++ b/app/smt/common/Response.php @@ -0,0 +1,29 @@ + $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); + } +} diff --git a/app/smt/controller/BaseController.php b/app/smt/controller/BaseController.php new file mode 100644 index 0000000..006dfd6 --- /dev/null +++ b/app/smt/controller/BaseController.php @@ -0,0 +1,57 @@ +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; + } +} diff --git a/app/smt/controller/v1/Auth.php b/app/smt/controller/v1/Auth.php new file mode 100644 index 0000000..f34bfbe --- /dev/null +++ b/app/smt/controller/v1/Auth.php @@ -0,0 +1,77 @@ +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); + } + } +} diff --git a/app/smt/controller/v1/QuitCheckin.php b/app/smt/controller/v1/QuitCheckin.php new file mode 100644 index 0000000..2ae12c1 --- /dev/null +++ b/app/smt/controller/v1/QuitCheckin.php @@ -0,0 +1,164 @@ +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); + } + } +} diff --git a/app/smt/controller/v1/Smoke.php b/app/smt/controller/v1/Smoke.php new file mode 100644 index 0000000..3d73050 --- /dev/null +++ b/app/smt/controller/v1/Smoke.php @@ -0,0 +1,484 @@ +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, + ]; + } +} diff --git a/app/smt/middleware/Auth.php b/app/smt/middleware/Auth.php new file mode 100644 index 0000000..9032426 --- /dev/null +++ b/app/smt/middleware/Auth.php @@ -0,0 +1,56 @@ +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] ?? '')); + } +} diff --git a/app/smt/model/AchievementLevel.php b/app/smt/model/AchievementLevel.php new file mode 100644 index 0000000..c9790ea --- /dev/null +++ b/app/smt/model/AchievementLevel.php @@ -0,0 +1,11 @@ +whereNull('deleted_at')->find(); + } +} diff --git a/app/smt/model/SmokeAIAdvice.php b/app/smt/model/SmokeAIAdvice.php new file mode 100644 index 0000000..4777494 --- /dev/null +++ b/app/smt/model/SmokeAIAdvice.php @@ -0,0 +1,11 @@ +whereNull('deleted_at')->find(); + } +} diff --git a/app/smt/model/User.php b/app/smt/model/User.php new file mode 100644 index 0000000..369a978 --- /dev/null +++ b/app/smt/model/User.php @@ -0,0 +1,29 @@ +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(); + } +} diff --git a/app/smt/model/UserMembership.php b/app/smt/model/UserMembership.php new file mode 100644 index 0000000..e894143 --- /dev/null +++ b/app/smt/model/UserMembership.php @@ -0,0 +1,11 @@ +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); diff --git a/app/smt/service/AchievementService.php b/app/smt/service/AchievementService.php new file mode 100644 index 0000000..0bf8050 --- /dev/null +++ b/app/smt/service/AchievementService.php @@ -0,0 +1,116 @@ +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, + ]; + } +} diff --git a/app/smt/service/AuthService.php b/app/smt/service/AuthService.php new file mode 100644 index 0000000..79b045c --- /dev/null +++ b/app/smt/service/AuthService.php @@ -0,0 +1,331 @@ +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; + } +} diff --git a/app/smt/service/QuitCheckinService.php b/app/smt/service/QuitCheckinService.php new file mode 100644 index 0000000..05ad6a7 --- /dev/null +++ b/app/smt/service/QuitCheckinService.php @@ -0,0 +1,548 @@ +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; + } +} diff --git a/app/smt/service/QuitPlanService.php b/app/smt/service/QuitPlanService.php new file mode 100644 index 0000000..1258a61 --- /dev/null +++ b/app/smt/service/QuitPlanService.php @@ -0,0 +1,248 @@ +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; + } +} diff --git a/app/smt/service/SmokeAiService.php b/app/smt/service/SmokeAiService.php new file mode 100644 index 0000000..0c5873f --- /dev/null +++ b/app/smt/service/SmokeAiService.php @@ -0,0 +1,806 @@ +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); + } +} diff --git a/app/smt/service/SmokeService.php b/app/smt/service/SmokeService.php new file mode 100644 index 0000000..a820511 --- /dev/null +++ b/app/smt/service/SmokeService.php @@ -0,0 +1,935 @@ +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), + ]); + } +} diff --git a/app/smt/service/Support.php b/app/smt/service/Support.php new file mode 100644 index 0000000..9474179 --- /dev/null +++ b/app/smt/service/Support.php @@ -0,0 +1,295 @@ +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); + } +} diff --git a/config/app.php b/config/app.php index 08a035a..5e16adc 100644 --- a/config/app.php +++ b/config/app.php @@ -9,7 +9,7 @@ return [ // 是否启用路由 'with_route' => true, // 默认应用 - 'default_app' => 'api', + 'default_app' => 'smt', // 默认时区 'default_timezone' => 'Asia/Shanghai', diff --git a/config/database.php b/config/database.php index 7b31dae..81fe0f6 100644 --- a/config/database.php +++ b/config/database.php @@ -1,8 +1,28 @@ 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, ], ]; diff --git a/docs/smt_api.md b/docs/smt_api.md new file mode 100644 index 0000000..aa6def3 --- /dev/null +++ b/docs/smt_api.md @@ -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 `。 +- 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`。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..86aeca2 --- /dev/null +++ b/index.html @@ -0,0 +1,39 @@ + + + + + 恭喜,站点创建成功! + + + +
+

恭喜, 站点创建成功!

+

这是默认index.html,本页面由系统自动生成

+
    +
  • 本页面在FTP根目录下的index.html
  • +
  • 您可以修改、删除或覆盖本页面
  • +
  • FTP相关信息,请到“面板系统后台 > FTP” 查看
  • +
+
+ + \ No newline at end of file diff --git a/route/app.php b/route/app.php index 2dda3fb..e119efa 100644 --- a/route/app.php +++ b/route/app.php @@ -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); +}); diff --git a/test_api.sh b/test_api.sh old mode 100755 new mode 100644