Api.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. <?php
  2. namespace addons\aicontent\controller;
  3. use think\addons\Controller;
  4. use addons\aicontent\model\AiTask;
  5. use addons\aicontent\service\ContentGenerator;
  6. use addons\aicontent\service\ModelFactory;
  7. /**
  8. * AJAX API controller for the AI Content Assistant plugin.
  9. * All responses are JSON.
  10. * Routes under: /addons/aicontent/api/
  11. */
  12. class Api extends Controller
  13. {
  14. protected $noNeedLogin = [];
  15. protected $noNeedRight = [];
  16. public function _initialize()
  17. {
  18. parent::_initialize();
  19. // Verify MaCMS admin session — Api routes go through index.php which
  20. // does not run the built-in Begin behaviour that normally enforces login.
  21. if (session('admin_auth') !== '1' || empty(session('admin_info'))) {
  22. echo json_encode(['code' => 0, 'message' => lang('Unauthorized. Please log in to the admin panel.')]);
  23. exit;
  24. }
  25. if ($this->request->isPost()) {
  26. $token = input('_csrf_token', '');
  27. $expected = \addons\aicontent\Aicontent::generateCsrfToken();
  28. if (!hash_equals($expected, $token)) {
  29. echo json_encode(['code' => 0, 'message' => lang('Invalid request token.')]);
  30. exit;
  31. }
  32. }
  33. }
  34. /**
  35. * Generate content for a single item.
  36. *
  37. * POST /addons/aicontent/api/generate
  38. * Body params:
  39. * - content_type string video|article|topic
  40. * - content_id int (0 for ad-hoc generation without saving)
  41. * - content_name string
  42. * - provider string (optional, uses config default)
  43. * - model string (optional, uses config default)
  44. * - data array template variables (title, type, year, area, actor, director)
  45. */
  46. public function generate()
  47. {
  48. if (!$this->rateLimit('generate', 20, 60)) {
  49. return $this->json(false, lang('Rate limit exceeded. Please wait a moment.'));
  50. }
  51. $contentType = input('content_type', 'video');
  52. $contentId = (int) input('content_id', 0);
  53. $contentName = input('content_name', '');
  54. $provider = input('provider', '') ?: null;
  55. $model = input('model', '') ?: null;
  56. $data = input('data/a', []);
  57. // Ensure title is set
  58. if (empty($data['title']) && !empty($contentName)) {
  59. $data['title'] = $contentName;
  60. }
  61. if (empty($data['title'])) {
  62. return $this->json(false, lang('Title is required for content generation.'));
  63. }
  64. // Determine actual provider/model from config if not provided
  65. $config = get_addon_config('aicontent');
  66. $resolvedProvider = $provider ?? ($config['default_provider'] ?? 'claude');
  67. $resolvedModel = $model ?? ($config['default_model'] ?? 'claude-sonnet-4-6');
  68. // Create task record (only for real content items)
  69. $task = null;
  70. if ($contentId > 0) {
  71. $task = AiTask::createTask(
  72. $contentType,
  73. $contentId,
  74. $contentName,
  75. $resolvedProvider,
  76. $resolvedModel
  77. );
  78. }
  79. try {
  80. $generator = new ContentGenerator($resolvedProvider, $resolvedModel);
  81. $result = $generator->generate($data, $contentType);
  82. if ($task) {
  83. $task->markDone(json_encode($result, JSON_UNESCAPED_UNICODE));
  84. }
  85. return $this->json(true, lang('Content generated successfully.'), $result);
  86. } catch (\Throwable $e) {
  87. if ($task) {
  88. $task->markError($e->getMessage());
  89. }
  90. return $this->json(false, $this->safeError($e, lang('Content generation failed. Please check your AI provider configuration.')));
  91. }
  92. }
  93. /**
  94. * Batch generate content for multiple content IDs.
  95. *
  96. * POST /addons/aicontent/api/batch
  97. * Body params:
  98. * - content_type string
  99. * - ids array content IDs to process
  100. * - provider string (optional)
  101. * - model string (optional)
  102. */
  103. public function batch()
  104. {
  105. if (!$this->rateLimit('batch', 5, 60)) {
  106. return $this->json(false, lang('Rate limit exceeded. Please wait a moment.'));
  107. }
  108. $contentType = input('content_type', 'video');
  109. $ids = input('ids/a', []);
  110. $provider = input('provider', '') ?: null;
  111. $model = input('model', '') ?: null;
  112. if (empty($ids)) {
  113. return $this->json(false, lang('No content IDs provided.'));
  114. }
  115. $config = get_addon_config('aicontent');
  116. $maxSize = (int) ($config['batch_size'] ?? 10);
  117. $ids = array_slice((array) $ids, 0, $maxSize);
  118. // Resolve provider/model
  119. $resolvedProvider = $provider ?? ($config['default_provider'] ?? 'claude');
  120. $resolvedModel = $model ?? ($config['default_model'] ?? 'claude-sonnet-4-6');
  121. // Load content records from MaCMS database
  122. $tableMap = [
  123. 'video' => 'mac_vod',
  124. 'article' => 'mac_art',
  125. 'topic' => 'mac_topic',
  126. ];
  127. $table = $tableMap[$contentType] ?? 'mac_vod';
  128. // Field maps per content type
  129. $fieldMap = [
  130. 'video' => ['id' => 'vod_id', 'title' => 'vod_name', 'type' => 'type_name', 'year' => 'vod_year', 'area' => 'vod_area', 'actor' => 'vod_actor', 'director' => 'vod_director'],
  131. 'article' => ['id' => 'art_id', 'title' => 'art_name', 'type' => 'type_name'],
  132. 'topic' => ['id' => 'topic_id', 'title' => 'topic_name', 'type' => 'type_name'],
  133. ];
  134. $fields = $fieldMap[$contentType] ?? $fieldMap['video'];
  135. $idCol = $fields['id'];
  136. try {
  137. $rows = \think\Db::table($table)
  138. ->whereIn($idCol, $ids)
  139. ->select();
  140. } catch (\Throwable $e) {
  141. return $this->json(false, $this->safeError($e, lang('Failed to load content records.')));
  142. }
  143. $results = [];
  144. $generator = new ContentGenerator($resolvedProvider, $resolvedModel);
  145. foreach ($rows as $row) {
  146. $contentId = $row[$idCol] ?? 0;
  147. $contentName = $row[$fields['title']] ?? '';
  148. // Map DB row to template variables
  149. $data = [];
  150. foreach ($fields as $tplKey => $dbCol) {
  151. if ($tplKey === 'id') continue;
  152. $data[$tplKey] = $row[$dbCol] ?? '';
  153. }
  154. $data['title'] = $contentName;
  155. $task = AiTask::createTask(
  156. $contentType,
  157. (int) $contentId,
  158. $contentName,
  159. $resolvedProvider,
  160. $resolvedModel
  161. );
  162. try {
  163. $result = $generator->generate($data, $contentType);
  164. $task->markDone(json_encode($result, JSON_UNESCAPED_UNICODE));
  165. $results[] = [
  166. 'id' => $contentId,
  167. 'name' => $contentName,
  168. 'success' => true,
  169. 'result' => $result,
  170. ];
  171. } catch (\Throwable $e) {
  172. $task->markError($e->getMessage());
  173. $results[] = [
  174. 'id' => $contentId,
  175. 'name' => $contentName,
  176. 'success' => false,
  177. 'error' => $this->safeError($e, lang('Generation failed for this item.')),
  178. ];
  179. }
  180. }
  181. $successCount = count(array_filter($results, fn($r) => $r['success']));
  182. $msg = sprintf(lang('Processed %d items, %d succeeded.'), count($results), $successCount);
  183. return $this->json(true, $msg, $results);
  184. }
  185. /**
  186. * Enhance an existing draft text for a specific field.
  187. *
  188. * POST /addons/aicontent/api/enhance
  189. * Body:
  190. * - draft string The user's current draft text
  191. * - field string blurb | content (which field is being enhanced)
  192. * - title string Content title (for context)
  193. * - content_type string video | article
  194. * - provider string (optional)
  195. * - model string (optional)
  196. */
  197. public function enhance()
  198. {
  199. if (!$this->rateLimit('enhance', 30, 60)) {
  200. return $this->json(false, lang('Rate limit exceeded. Please wait a moment.'));
  201. }
  202. $draft = trim(input('draft', ''));
  203. $field = input('field', 'blurb');
  204. $title = input('title', '');
  205. $contentType = input('content_type', 'video');
  206. $provider = input('provider', '') ?: null;
  207. $model = input('model', '') ?: null;
  208. if (empty($draft)) {
  209. return $this->json(false, lang('Please write something first before enhancing.'));
  210. }
  211. $config = get_addon_config('aicontent');
  212. $resolvedProvider = $provider ?? ($config['default_provider'] ?? 'claude');
  213. $resolvedModel = $model ?? ($config['default_model'] ?? 'claude-sonnet-4-6');
  214. try {
  215. $generator = new ContentGenerator($resolvedProvider, $resolvedModel);
  216. $enhanced = $generator->enhance($draft, $field, ['title' => $title], $contentType);
  217. return $this->json(true, lang('Enhanced successfully.'), ['text' => $enhanced]);
  218. } catch (\Throwable $e) {
  219. return $this->json(false, $this->safeError($e, lang('Enhancement failed. Please check your AI provider configuration.')));
  220. }
  221. }
  222. /**
  223. * Test that the configured API key for a provider works.
  224. *
  225. * POST /addons/aicontent/api/testkey
  226. * Body: provider (string)
  227. */
  228. public function testkey()
  229. {
  230. if (!$this->rateLimit('testkey', 3, 60)) {
  231. return $this->json(false, lang('Rate limit exceeded. Please wait a moment.'));
  232. }
  233. $provider = input('provider', '');
  234. if (empty($provider)) {
  235. return $this->json(false, lang('Provider is required.'));
  236. }
  237. try {
  238. $model = ModelFactory::create($provider);
  239. $ok = $model->testConnection();
  240. return $this->json($ok, $ok ? lang('Connection successful.') : lang('Connection failed.'));
  241. } catch (\Throwable $e) {
  242. return $this->json(false, $this->safeError($e, lang('Connection test failed. Please verify your API key.')));
  243. }
  244. }
  245. /**
  246. * Return available models for a given provider.
  247. *
  248. * GET /addons/aicontent/api/models?provider=claude
  249. */
  250. public function models()
  251. {
  252. $provider = input('provider', '');
  253. $models = ModelFactory::getModelsForProvider($provider);
  254. return $this->json(true, '', $models);
  255. }
  256. // -----------------------------------------------------------------------
  257. // Helpers
  258. // -----------------------------------------------------------------------
  259. /**
  260. * Return a safe, client-facing error message and log the full exception.
  261. * Prevents API keys, file paths, SQL fragments, and stack traces from
  262. * leaking to the browser.
  263. */
  264. private function safeError(\Throwable $e, string $fallback = ''): string
  265. {
  266. if ($fallback === '') {
  267. $fallback = lang('An unexpected error occurred. Please try again.');
  268. }
  269. \think\Log::error('AiContent API error: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
  270. $msg = $e->getMessage();
  271. // Database errors — never expose SQL or schema details
  272. if (stripos($msg, 'SQLSTATE') !== false
  273. || stripos($msg, 'mysql') !== false
  274. || stripos($msg, 'sqlite') !== false) {
  275. return lang('A database error occurred. Please try again later.');
  276. }
  277. // Potentially sensitive patterns — fall back to generic message
  278. $sensitivePatterns = [
  279. '/sk-[a-zA-Z0-9]+/', // OpenAI / Anthropic key fragments
  280. '/key[=:\s]+\S+/i', // key=... or key: ...
  281. '/https?:\/\/\S+/', // URLs (may include keys as query params)
  282. '/\/[a-z\/]+\.[a-z]{2,4}/i', // file paths
  283. ];
  284. foreach ($sensitivePatterns as $pattern) {
  285. if (preg_match($pattern, $msg)) {
  286. return $fallback;
  287. }
  288. }
  289. // Safe to surface — truncate to avoid oversized responses
  290. return mb_substr($msg, 0, 200);
  291. }
  292. /**
  293. * Simple rate limiter keyed by action + session ID.
  294. * Uses ThinkPHP cache (Redis when available, otherwise file cache).
  295. *
  296. * @param string $action Unique action identifier
  297. * @param int $limit Max requests allowed within $window seconds
  298. * @param int $window Time window in seconds
  299. * @return bool true = allowed, false = rate limit exceeded
  300. */
  301. private function rateLimit(string $action, int $limit = 10, int $window = 60): bool
  302. {
  303. $sessionId = session_id() ?: md5(request()->ip());
  304. $key = 'ai_rate_' . $action . '_' . $sessionId;
  305. $count = (int) cache($key);
  306. if ($count >= $limit) {
  307. return false;
  308. }
  309. if ($count === 0) {
  310. cache($key, 1, $window);
  311. } else {
  312. // Increment without resetting the existing TTL is not portable across
  313. // all cache drivers, so we just overwrite. The window resets on first
  314. // request — acceptable for a lightweight abuse guard.
  315. cache($key, $count + 1, $window);
  316. }
  317. return true;
  318. }
  319. private function json(bool $success, string $message = '', $data = null): \think\response\Json
  320. {
  321. return json([
  322. 'code' => $success ? 1 : 0,
  323. 'message' => $message,
  324. 'data' => $data,
  325. ]);
  326. }
  327. }