visuddhinanda пре 2 месеци
родитељ
комит
13edfb2348
100 измењених фајлова са 18260 додато и 0 уклоњено
  1. 125 0
      api-v12/app/Http/Controllers/AccessTokenController.php
  2. 103 0
      api-v12/app/Http/Controllers/AiAssistantController.php
  3. 163 0
      api-v12/app/Http/Controllers/AiModelController.php
  4. 107 0
      api-v12/app/Http/Controllers/AiTranslateController.php
  5. 92 0
      api-v12/app/Http/Controllers/AnalysisController.php
  6. 104 0
      api-v12/app/Http/Controllers/ApiController.php
  7. 736 0
      api-v12/app/Http/Controllers/ArticleController.php
  8. 165 0
      api-v12/app/Http/Controllers/ArticleFtsController.php
  9. 260 0
      api-v12/app/Http/Controllers/ArticleMapController.php
  10. 119 0
      api-v12/app/Http/Controllers/ArticleNavController.php
  11. 130 0
      api-v12/app/Http/Controllers/ArticleProgressController.php
  12. 76 0
      api-v12/app/Http/Controllers/AssetsController.php
  13. 267 0
      api-v12/app/Http/Controllers/AttachmentController.php
  14. 65 0
      api-v12/app/Http/Controllers/AttachmentMapController.php
  15. 158 0
      api-v12/app/Http/Controllers/AuthController.php
  16. 209 0
      api-v12/app/Http/Controllers/BlogController.php
  17. 366 0
      api-v12/app/Http/Controllers/BookController.php
  18. 67 0
      api-v12/app/Http/Controllers/BookTitleController.php
  19. 76 0
      api-v12/app/Http/Controllers/CaseController.php
  20. 178 0
      api-v12/app/Http/Controllers/CategoryController.php
  21. 726 0
      api-v12/app/Http/Controllers/ChannelController.php
  22. 82 0
      api-v12/app/Http/Controllers/ChannelIOController.php
  23. 93 0
      api-v12/app/Http/Controllers/ChapterController.php
  24. 84 0
      api-v12/app/Http/Controllers/ChapterIOController.php
  25. 88 0
      api-v12/app/Http/Controllers/ChapterIndexController.php
  26. 115 0
      api-v12/app/Http/Controllers/ChatController.php
  27. 106 0
      api-v12/app/Http/Controllers/ChatMessageController.php
  28. 291 0
      api-v12/app/Http/Controllers/CollectionController.php
  29. 76 0
      api-v12/app/Http/Controllers/CommandController.php
  30. 85 0
      api-v12/app/Http/Controllers/CommentaryController.php
  31. 133 0
      api-v12/app/Http/Controllers/CompoundController.php
  32. 1127 0
      api-v12/app/Http/Controllers/CorpusController.php
  33. 297 0
      api-v12/app/Http/Controllers/CourseController.php
  34. 359 0
      api-v12/app/Http/Controllers/CourseMemberController.php
  35. 420 0
      api-v12/app/Http/Controllers/DhammaTermController.php
  36. 311 0
      api-v12/app/Http/Controllers/DictController.php
  37. 87 0
      api-v12/app/Http/Controllers/DictInfoController.php
  38. 128 0
      api-v12/app/Http/Controllers/DictMeaningController.php
  39. 124 0
      api-v12/app/Http/Controllers/DictPreferenceController.php
  40. 85 0
      api-v12/app/Http/Controllers/DictStatisticController.php
  41. 105 0
      api-v12/app/Http/Controllers/DictVocabularyController.php
  42. 488 0
      api-v12/app/Http/Controllers/DiscussionController.php
  43. 235 0
      api-v12/app/Http/Controllers/DiscussionCountController.php
  44. 75 0
      api-v12/app/Http/Controllers/EditableSentenceController.php
  45. 100 0
      api-v12/app/Http/Controllers/EmailCertificationController.php
  46. 171 0
      api-v12/app/Http/Controllers/ExerciseController.php
  47. 124 0
      api-v12/app/Http/Controllers/ExportController.php
  48. 143 0
      api-v12/app/Http/Controllers/ExportWbwController.php
  49. 84 0
      api-v12/app/Http/Controllers/ForgotPasswordController.php
  50. 83 0
      api-v12/app/Http/Controllers/GrammarGuideController.php
  51. 239 0
      api-v12/app/Http/Controllers/GroupController.php
  52. 154 0
      api-v12/app/Http/Controllers/GroupMemberController.php
  53. 73 0
      api-v12/app/Http/Controllers/HealthCheckController.php
  54. 117 0
      api-v12/app/Http/Controllers/InteractiveController.php
  55. 160 0
      api-v12/app/Http/Controllers/InviteController.php
  56. 174 0
      api-v12/app/Http/Controllers/LikeController.php
  57. 71 0
      api-v12/app/Http/Controllers/MilestoneController.php
  58. 258 0
      api-v12/app/Http/Controllers/MockOpenAIController.php
  59. 107 0
      api-v12/app/Http/Controllers/ModelLogController.php
  60. 93 0
      api-v12/app/Http/Controllers/NavArticleController.php
  61. 119 0
      api-v12/app/Http/Controllers/NavCSParaController.php
  62. 96 0
      api-v12/app/Http/Controllers/NavPageController.php
  63. 223 0
      api-v12/app/Http/Controllers/NissayaCardController.php
  64. 66 0
      api-v12/app/Http/Controllers/NissayaCoverController.php
  65. 280 0
      api-v12/app/Http/Controllers/NissayaEndingController.php
  66. 155 0
      api-v12/app/Http/Controllers/NotificationController.php
  67. 117 0
      api-v12/app/Http/Controllers/OfflineIndexController.php
  68. 76 0
      api-v12/app/Http/Controllers/PaliBookCategoryController.php
  69. 310 0
      api-v12/app/Http/Controllers/PaliTextController.php
  70. 73 0
      api-v12/app/Http/Controllers/PgPaliDictDownloadController.php
  71. 511 0
      api-v12/app/Http/Controllers/ProgressChapterController.php
  72. 92 0
      api-v12/app/Http/Controllers/ProgressImgController.php
  73. 203 0
      api-v12/app/Http/Controllers/ProjectController.php
  74. 149 0
      api-v12/app/Http/Controllers/ProjectTreeController.php
  75. 104 0
      api-v12/app/Http/Controllers/RecentController.php
  76. 113 0
      api-v12/app/Http/Controllers/RelatedParagraphController.php
  77. 293 0
      api-v12/app/Http/Controllers/RelationController.php
  78. 88 0
      api-v12/app/Http/Controllers/ResetPasswordController.php
  79. 419 0
      api-v12/app/Http/Controllers/SearchController.php
  80. 80 0
      api-v12/app/Http/Controllers/SearchPageNumberController.php
  81. 148 0
      api-v12/app/Http/Controllers/SearchPaliDataController.php
  82. 141 0
      api-v12/app/Http/Controllers/SearchPaliWbwController.php
  83. 154 0
      api-v12/app/Http/Controllers/SearchPlusController.php
  84. 194 0
      api-v12/app/Http/Controllers/SearchSuggestController.php
  85. 78 0
      api-v12/app/Http/Controllers/SearchTitleController.php
  86. 71 0
      api-v12/app/Http/Controllers/SearchWordSliceController.php
  87. 112 0
      api-v12/app/Http/Controllers/SentHistoryController.php
  88. 96 0
      api-v12/app/Http/Controllers/SentInChannelController.php
  89. 272 0
      api-v12/app/Http/Controllers/SentPrController.php
  90. 111 0
      api-v12/app/Http/Controllers/SentSimController.php
  91. 85 0
      api-v12/app/Http/Controllers/SentenceAttachmentController.php
  92. 630 0
      api-v12/app/Http/Controllers/SentenceController.php
  93. 88 0
      api-v12/app/Http/Controllers/SentenceIOController.php
  94. 394 0
      api-v12/app/Http/Controllers/SentenceInfoController.php
  95. 118 0
      api-v12/app/Http/Controllers/SentencesInChapterController.php
  96. 191 0
      api-v12/app/Http/Controllers/ShareController.php
  97. 121 0
      api-v12/app/Http/Controllers/SignUpController.php
  98. 96 0
      api-v12/app/Http/Controllers/SiteInfoController.php
  99. 68 0
      api-v12/app/Http/Controllers/SnowFlakeIdController.php
  100. 88 0
      api-v12/app/Http/Controllers/StudioController.php

+ 125 - 0
api-v12/app/Http/Controllers/AccessTokenController.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\AccessToken;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\AuthApi;
+use App\Http\Api\ChannelApi;
+
+class AccessTokenController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('未登录');
+            return $this->error(__('auth.failed'), [], 401);
+        }
+        $payload = $request->get('payload');
+        $result = array();
+        foreach ($payload as $key => $value) {
+            //鉴权
+            switch ($value['res_type']) {
+                case 'channel':
+                    if (!isset($value['power']) || !isset($value['res_id'])) {
+                        continue 2;
+                    }
+                    if ($value['power'] === 'edit') {
+                        if (!ChannelApi::userCanEdit($user['user_uid'], $value['res_id'])) {
+                            continue 2;
+                        }
+                    } else {
+                        if (!ChannelApi::userCanRead($user['user_uid'], $value['res_id'])) {
+                            continue 2;
+                        }
+                    }
+                    break;
+                default:
+                    continue;
+                    break;
+            }
+            //获取token
+            $token = AccessToken::firstOrNew(
+                [
+                    'res_type' => $value['res_type'],
+                    'res_id' => $value['res_id']
+                ],
+                [
+                    'token' => (string)Str::uuid()
+                ]
+            );
+            if (!$token->exists) {
+                $token->save();
+            }
+
+            try {
+                $jwt = JWT::encode($value, $token->token, 'HS512');
+            } catch (\Exception $e) {
+                Log::error('jwt', ['error' => $e]);
+                continue;
+            }
+            $result[] = [
+                'payload' => $value,
+                'token' => $jwt
+            ];
+        }
+        return $this->ok(['rows' => $result, 'count' => count($result)]);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\AccessToken  $accessToken
+     * @return \Illuminate\Http\Response
+     */
+    public function show(AccessToken $accessToken)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\AccessToken  $accessToken
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, AccessToken $accessToken)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\AccessToken  $accessToken
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(AccessToken $accessToken)
+    {
+        //
+    }
+}

+ 103 - 0
api-v12/app/Http/Controllers/AiAssistantController.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\AiModel;
+use Illuminate\Http\Request;
+use App\Http\Resources\AiAssistantResource;
+use App\Http\Api\AuthApi;
+use App\Http\Api\ShareApi;
+
+use Illuminate\Support\Facades\Log;
+
+class AiAssistantController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('notification auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $resList = ShareApi::getResList($user['user_uid'], 8);
+        $resId = [];
+        foreach ($resList as $res) {
+            $resId[] = $res['res_id'];
+        }
+        $table = AiModel::where('owner_id', $user['user_uid'])
+            ->orWhere('privacy', 'public')
+            ->orWhereIn('uid', $resId);
+        if ($request->has('keyword')) {
+            $table = $table->where('name', 'like', '%' . $request->get('keyword') . '%');
+        }
+        $count = $table->count();
+
+        $table = $table->orderBy(
+            $request->get('order', 'created_at'),
+            $request->get('dir', 'asc')
+        );
+
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+
+        return $this->ok(
+            [
+                "rows" => AiAssistantResource::collection(resource: $result),
+                "count" => $count,
+            ]
+        );
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function show(AiModel $aiModel)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, AiModel $aiModel)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(AiModel $aiModel)
+    {
+        //
+    }
+}

+ 163 - 0
api-v12/app/Http/Controllers/AiModelController.php

@@ -0,0 +1,163 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreAiModelRequest;
+use App\Http\Requests\UpdateAiModelRequest;
+use App\Models\AiModel;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\StudioApi;
+use App\Http\Resources\AiModelResource;
+
+
+class AiModelController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('notification auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        switch ($request->get('view')) {
+            case 'all':
+                $table = AiModel::whereNotNull('owner_id');
+                break;
+            case 'studio':
+                $studioId = StudioApi::getIdByName($request->get('name'));
+                $table = AiModel::where('owner_id', $studioId);
+                break;
+            case 'usable':
+                $table = AiModel::where('owner_id', $request->get('user_id'))
+                    ->orWhere('privacy', 'public');
+                break;
+            case 'chat':
+                $table = AiModel::where('owner_id', config("mint.admin.root_uuid"));
+                break;
+        }
+        if ($request->has('keyword')) {
+            $table = $table->where('name', 'like', '%' . $request->get('keyword') . '%');
+        }
+        $count = $table->count();
+
+        $table = $table->orderBy(
+            $request->get('order', 'created_at'),
+            $request->get('dir', 'asc')
+        );
+
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+
+        return $this->ok(
+            [
+                "rows" => AiModelResource::collection(resource: $result),
+                "count" => $count,
+            ]
+        );
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \App\Http\Requests\StoreAiModelRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(StoreAiModelRequest $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $studioId = StudioApi::getIdByName($request->get('studio_name'));
+        Log::debug('store', ['studioId' => $studioId, 'user' => $user]);
+        if (!self::canEdit($user['user_uid'], $studioId)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $new = new AiModel();
+        $new->name = $request->get('name');
+        $new->uid = Str::uuid();
+        $new->real_name = Str::uuid();
+        $new->owner_id = $studioId;
+        $new->editor_id = $user['user_uid'];
+        $new->save();
+        return $this->ok(new AiModelResource($new));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function show(AiModel $aiModel)
+    {
+        //
+        return $this->ok(new AiModelResource($aiModel));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \App\Http\Requests\UpdateAiModelRequest  $request
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function update(UpdateAiModelRequest $request, AiModel $aiModel)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (!self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $aiModel->name = $request->get('name');
+        $aiModel->description = $request->get('description');
+        $aiModel->system_prompt = $request->get('system_prompt');
+        $aiModel->url = $request->get('url');
+        $aiModel->model = $request->get('model');
+        $aiModel->key = $request->get('key');
+        $aiModel->privacy = $request->get('privacy');
+        $aiModel->editor_id = $user['user_uid'];
+        $aiModel->save();
+        return $this->ok(new AiModelResource($aiModel));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\AiModel  $aiModel
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, AiModel $aiModel)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (!self::canEdit($user['user_uid'], $aiModel->owner_id)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $del = $aiModel->delete();
+        return $this->ok($del);
+    }
+
+    public static function canEdit($user_uid, $owner_uid)
+    {
+        return $user_uid === $owner_uid;
+    }
+}

+ 107 - 0
api-v12/app/Http/Controllers/AiTranslateController.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use App\Models\PaliText;
+
+class AiTranslateController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index() {}
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        return $this->fetch(strip_tags($request->get('origin')));
+    }
+
+    private function fetch($origin, $engin = 'kimi', $prompt_pre = '', $prompt_suf = '请翻译上述巴利文。')
+    {
+        $api = config('mint.ai.accounts');
+        $selected = array_filter($api, function ($value) use ($engin) {
+            return $value['name'] === $engin;
+        });
+        if (!is_array($selected) || count($selected) === 0) {
+            return $this->error('no engin name', 200, 200);
+        }
+
+        $url = $selected[0]['api_url'];
+        $param = [
+            "model" => $selected[0]['model'],
+            "messages" => [
+                ["role" => "system", "content" => "你是翻译人工智能助手,bhikkhu 为专有名词,不可翻译成其他语言。"],
+                ["role" => "user", "content" => "{$prompt_pre}{$origin}\n{$prompt_suf}"],
+            ],
+            "temperature" => 0.3,
+        ];
+        $response = Http::withToken($selected[0]['token'])
+            ->post($url, $param);
+        if ($response->failed()) {
+            $this->error('http request error' . $response->json('message'));
+            Log::error('http request error', ['data' => $response->json()]);
+            return $this->error($response->json(), 200, 200);
+        } else {
+            return $this->ok($response->json());
+        }
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, $id)
+    {
+        //
+        $para = explode('-', $id);
+        if (count($para) >= 2) {
+            $content = PaliText::where('book', $para[0])
+                ->where('paragraph', $para[1])
+                ->value('text');
+            if (!empty($content)) {
+                return $this->fetch($content, $request->get('engin', config('mint.ai.default')));
+            } else {
+                return $this->error('no content', 200, 200);
+            }
+        } else {
+            return $this->error('参数错误', 403, 403);
+        }
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 92 - 0
api-v12/app/Http/Controllers/AnalysisController.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\WbwAnalysis;
+use Illuminate\Http\Request;
+
+class AnalysisController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+        $result = WbwAnalysis::selectRaw('d1, data ,count(*) as ct')
+                             ->where('type',9)
+                             ->groupby('d1')
+                             ->groupby('data')
+                             ->orderbyRaw('d1,ct desc')
+                             ->get();
+        return view('wbwanalyses',['data'=>$result]);
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function create()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\WbwAnalysis  $wbwAnalysis
+     * @return \Illuminate\Http\Response
+     */
+    public function show(WbwAnalysis $wbwAnalysis)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\WbwAnalysis  $wbwAnalysis
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(WbwAnalysis $wbwAnalysis)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\WbwAnalysis  $wbwAnalysis
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, WbwAnalysis $wbwAnalysis)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\WbwAnalysis  $wbwAnalysis
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(WbwAnalysis $wbwAnalysis)
+    {
+        //
+    }
+}

+ 104 - 0
api-v12/app/Http/Controllers/ApiController.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use App\Tools\RedisClusters;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Log;
+
+class ApiController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request,$id)
+    {
+        //
+        $times = $id;
+        $currTime = time();
+        $key= "pref-s/";
+        $begin = $currTime - $times - 1;
+        $value = 0;
+        for ($i=$begin; $i <= $currTime; $i++) {
+            $keyApi = $key.$request->get('api','all')."/".$i;
+            if(!empty(Redis::get($keyApi.'/delay'))){
+                if($request->get('item') === 'average'){
+                    $value += intval(Redis::get($keyApi.'/delay') / Redis::get($keyApi.'/count'));
+                }else{
+                    $value += (int)Redis::get($keyApi.'/'.$request->get('item'));
+                }
+            }
+        }
+        $value = $value/$times;
+        return $this->ok($value);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+        $currMinute = intval(time()/60);
+        $key= "pref-m/";
+        $begin = $currMinute - 60;
+        $output = [];
+        for ($i=$begin; $i <= $currMinute; $i++) {
+            $value = 0;
+            $keyApi = $key.$request->get('api','all')."/".$i;
+            if(!empty(Redis::get($keyApi.'/delay'))){
+                if($request->get('item') === 'average'){
+                    $value += intval(Redis::get($keyApi.'/delay') / Redis::get($keyApi.'/count'));
+                }else{
+                    $value += (int)Redis::get($keyApi.'/'.$request->get('item'));
+                }
+            }else{
+                $value = 0;
+            }
+            $time = date("H:i:s",$i);
+            $output[] = ['date'=>$time,'value'=>$value];
+        }
+        return $this->ok($output);
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 736 - 0
api-v12/app/Http/Controllers/ArticleController.php

@@ -0,0 +1,736 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+use App\Models\Article;
+use App\Models\ArticleCollection;
+use App\Models\Collection;
+use App\Models\CustomBook;
+use App\Models\CustomBookId;
+use App\Models\Sentence;
+
+use App\Http\Resources\ArticleResource;
+use App\Http\Api\AuthApi;
+use App\Http\Api\ShareApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\SentenceApi;
+use App\Tools\OpsLog;
+
+class ArticleController extends Controller
+{
+    public static function userCanRead($user_uid,Article $article){
+        if($article->status === 30 ){
+            return true;
+        }
+        if(empty($user_uid)){
+            return false;
+        }
+            //私有文章,判断是否为所有者
+        if($user_uid === $article->owner){
+            return true;
+        }
+        //非所有者
+        //判断是否为文章协作者
+        $power = ShareApi::getResPower($user_uid,$article->uid);
+        if($power >= 10 ){
+            return true;
+        }
+        //无读取权限
+        //判断文集是否有读取权限
+        $inCollection = ArticleCollection::where('article_id',$article->uid)
+                                        ->select('collect_id')
+                                        ->groupBy('collect_id')->get();
+        if(!$inCollection){
+            return false;
+        }
+        //查找与文章同主人的文集
+        $collections = Collection::whereIn('uid',$inCollection)
+                                    ->where('owner',$article->owner)
+                                    ->select('uid')
+                                    ->get();
+        if(!$collections){
+            return false;
+        }
+        //查找与文章同主人的文集是否是共享的
+        $power = 0;
+        foreach ($collections as $collection) {
+            # code...
+            $currPower = ShareApi::getResPower($user_uid,$collection->uid);
+            if($currPower >= 10){
+                return true;
+            }
+        }
+        return false;
+    }
+    public static function userCanEditId($user_uid,$articleId){
+        $article = Article::find($articleId);
+        if($article){
+            return ArticleController::userCanEdit($user_uid,$article);
+        }else{
+            return false;
+        }
+    }
+    public static function userCanEdit($user_uid,$article){
+        if(empty($user_uid)){
+            return false;
+        }
+        //私有文章,判断是否为所有者
+        if($user_uid === $article->owner){
+            return true;
+        }
+        //非所有者
+        //判断是否为文章协作者
+        $power = ShareApi::getResPower($user_uid,$article->uid);
+        if($power >= 20 ){
+            return true;
+        }
+        //无读取权限
+        //判断文集是否有读取权限
+        $inCollection = ArticleCollection::where('article_id',$article->uid)
+                                        ->select('collect_id')
+                                        ->groupBy('collect_id')->get();
+        if(!$inCollection){
+            return false;
+        }
+        //查找与文章同主人的文集
+        $collections = Collection::whereIn('uid',$inCollection)
+                                    ->where('owner',$article->owner)
+                                    ->select('uid')
+                                    ->get();
+        if(!$collections){
+            return false;
+        }
+        //查找与文章同主人的文集是否是共享的
+        $power = 0;
+        foreach ($collections as $collection) {
+            # code...
+            $currPower = ShareApi::getResPower($user_uid,$collection->uid);
+            if($currPower >= 20){
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public static function userCanManage($user_uid,$studioName){
+        if(empty($user_uid)){
+            return false;
+        }
+        //判断是否为所有者
+        if($user_uid === StudioApi::getIdByName($studioName)){
+            return true;
+        }else{
+            return false;
+        }
+    }
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $field = ['uid','title','subtitle',
+                                'summary','owner','lang',
+                                'status','editor_id','updated_at','created_at'];
+        if($request->get('content')==="true"){
+            $field[] = 'content';
+            $field[] = 'content_type';
+        }
+        $table = Article::select($field);
+        switch ($request->get('view')) {
+            case 'template':
+                $studioId = StudioApi::getIdByName($request->get('studio_name'));
+                $table = $table->where('owner', $studioId);
+                break;
+            case 'studio':
+				# 获取studio内所有 article
+                $user = AuthApi::current($request);
+                if(!$user){
+                    return $this->error(__('auth.failed'),[],401);
+                }
+                //判断当前用户是否有指定的studio的权限
+                $studioId = StudioApi::getIdByName($request->get('name'));
+                if($user['user_uid'] !== $studioId){
+                    return $this->error(__('auth.failed'),[],403);
+                }
+
+                if($request->get('view2','my')==='my'){
+                    $table = $table->where('owner', $studioId);
+                }else{
+                    //协作
+                    $resList = ShareApi::getResList($studioId,3);
+                    $resId=[];
+                    foreach ($resList as $res) {
+                        $resId[] = $res['res_id'];
+                    }
+                    $table = $table->whereIn('uid', $resId)->where('owner','<>', $studioId);
+                }
+
+                //根据anthology过滤
+                if($request->has('anthology')){
+                    switch ($request->get('anthology')) {
+                        case 'all':
+                            break;
+                        case 'none':
+                            # 我的文集
+                            $myCollection = Collection::where('owner',$studioId)->select('uid')->get();
+                            //收录在我的文集里面的文章
+                            $articles = ArticleCollection::whereIn('collect_id',$myCollection)
+                                                         ->select('article_id')->groupBy('article_id')->get();
+                            //不在这些范围之内的文章
+                            $table =  $table->whereNotIn('uid',$articles);
+                            break;
+                        default:
+                            $articles = ArticleCollection::where('collect_id',$request->get('anthology'))
+                                                         ->select('article_id')->get();
+                            $table =  $table->whereIn('uid',$articles);
+                            break;
+                    }
+                }
+				break;
+            case 'public':
+                $table = $table->where('status',30);
+                break;
+            default:
+                $this->error("view error");
+                break;
+        }
+        //处理搜索
+        if($request->has("search") && !empty($request->get("search"))){
+            $table = $table->where('title', 'like', "%".$request->get("search")."%");
+        }
+        if($request->has("subtitle") && !empty($request->get("subtitle"))){
+            $table = $table->where('subtitle', 'like', $request->get("subtitle"));
+        }
+        //获取记录总条数
+        $count = $table->count();
+        //处理排序
+        $table = $table->orderBy($request->get("order",'updated_at'),
+                                 $request->get("dir",'desc'));
+        //处理分页
+        $table = $table->skip($request->get("offset",0))
+                       ->take($request->get("limit",1000));
+        //获取数据
+        $result = $table->get();
+		return $this->ok(["rows"=>ArticleResource::collection($result),"count"=>$count]);
+    }
+
+        /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function showMyNumber(Request $request){
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        $studioId = StudioApi::getIdByName($request->get('studio'));
+        if($user['user_uid'] !== $studioId){
+            return $this->error(__('auth.failed'));
+        }
+        //我的
+        $my = Article::where('owner', $studioId)->count();
+        //协作
+        $resList = ShareApi::getResList($studioId,3);
+        $resId=[];
+        foreach ($resList as $res) {
+            $resId[] = $res['res_id'];
+        }
+        $collaboration = Article::whereIn('uid', $resId)->where('owner','<>', $studioId)->count();
+
+        return $this->ok(['my'=>$my,'collaboration'=>$collaboration]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //判断权限
+        $user = AuthApi::current($request);
+        if(!$user){
+            Log::error('未登录');
+            return $this->error(__('auth.failed'),[],401);
+        }else{
+            $user_uid=$user['user_uid'];
+        }
+
+        $canManage = ArticleController::userCanManage($user_uid,$request->get('studio'));
+        if(!$canManage){
+            Log::error('userCanManage 失败');
+            //判断是否有文集权限
+            if($request->has('anthologyId')){
+                $currPower = ShareApi::getResPower($user_uid,$request->get('anthologyId'));
+                if($currPower <= 10){
+                    Log::error('没有文集编辑权限');
+                    return $this->error(__('auth.failed'),[],403);
+                }
+            }else{
+                Log::error('没有文集id');
+                return $this->error(__('auth.failed'),[],403);
+            }
+        }
+        //权限判断结束
+
+        //查询标题是否重复
+        /*
+        if(Article::where('title',$request->get('title'))->where('owner',$studioUuid)->exists()){
+            return $this->error(__('validation.exists'));
+        }*/
+        Log::debug('开始新建'.$request->get('title'));
+
+        $newArticle = new Article;
+        DB::transaction(function() use($user,$request,$newArticle){
+            $studioUuid = StudioApi::getIdByName($request->get('studio'));
+            //新建文章,加入文集必须都成功。否则回滚
+            $newArticle->id = app('snowflake')->id();
+            $newArticle->uid = Str::uuid();
+            $newArticle->title = mb_substr($request->get('title'),0,128,'UTF-8');
+            $newArticle->lang = $request->get('lang');
+            if(!empty($request->get('status'))){
+                $newArticle->status = $request->get('status');
+            }
+            $newArticle->owner = $studioUuid;
+            $newArticle->owner_id = $user['user_id'];
+            $newArticle->editor_id = $user['user_id'];
+            $newArticle->parent = $request->get('parentId');
+            $newArticle->create_time = time()*1000;
+            $newArticle->modify_time = time()*1000;
+            $newArticle->save();
+            OpsLog::debug($user['user_uid'],$newArticle);
+
+            Log::debug('开始挂接 id='.$newArticle->uid);
+            $anthologyId = $request->get('anthologyId');
+            if(Str::isUuid($anthologyId)){
+                $parentNode = $request->get('parentNode');
+                if(Str::isUuid($parentNode)){
+                    Log::debug('有挂接点'.$parentNode);
+                    $map = ArticleCollection::where('collect_id',$anthologyId)
+                                        ->orderBy('id')->get();
+                    Log::debug('查询到原map数据'.count($map));
+                    $newMap = array();
+                    $parentNodeLevel = -1;
+                    $appended = false;
+                    foreach ($map as $key => $row) {
+                        $orgNode = $row;
+                        if(!$appended){
+                            if($parentNodeLevel>0){
+                                if($row->level <= $parentNodeLevel ){
+                                    //parent node 末尾
+                                    $newNode = array();
+                                    $newNode['collect_id'] = $anthologyId;
+                                    $newNode['article_id'] = $newArticle->uid;
+                                    $newNode['level'] = $parentNodeLevel+1;
+                                    $newNode['title'] = $newArticle->title;
+                                    $newNode['children'] = 0;
+                                    $newMap[] = $newNode;
+                                    Log::debug('新增节点',['node'=>$newNode]);
+                                    $appended = true;
+                                }
+                            }else{
+                                if($row->article_id === $parentNode){
+                                    $parentNodeLevel = $row->level;
+                                    $orgNode['children'] = $orgNode['children']+1;
+                                }
+                            }
+                        }
+                        $newMap[] = $orgNode;
+                    }
+                    if($parentNodeLevel>0){
+                        if($appended===false){
+                        //
+                            Log::debug('没挂上 挂到结尾');
+                            $newNode = array();
+                            $newNode['collect_id'] = $anthologyId;
+                            $newNode['article_id'] = $newArticle->uid;
+                            $newNode['level'] = $parentNodeLevel+1;
+                            $newNode['title'] = $newArticle->title;
+                            $newNode['children'] = 0;
+                            $newMap[] = $newNode;
+                        }
+                    }else{
+                        Log::error('没找到挂接点'.$parentNode);
+                    }
+                    Log::debug('新map数据'.count($newMap));
+
+                    $delete = ArticleCollection::where('collect_id',$anthologyId)->delete();
+                    Log::debug('删除旧map数据'.$delete);
+                    $count=0;
+                    foreach ($newMap as $key => $row) {
+                        $new = new ArticleCollection;
+                        $new->id = app('snowflake')->id();
+                        $new->article_id = $row["article_id"];
+                        $new->collect_id = $row["collect_id"];
+                        $new->title = $row["title"];
+                        $new->level = $row["level"];
+                        $new->children = $row["children"];
+                        $new->editor_id = $user["user_id"];
+                        if(isset($row["deleted_at"])){
+                            $new->deleted_at = $row["deleted_at"];
+                        }
+                        $new->save();
+                        $count++;
+                    }
+                    Log::debug('新map数据'.$count);
+                    ArticleMapController::updateCollection($anthologyId);
+                }else{
+                    $articleMap = new ArticleCollection();
+                    $articleMap->id = app('snowflake')->id();
+                    $articleMap->article_id = $newArticle->uid;
+                    $articleMap->collect_id = $request->get('anthologyId');
+                    $articleMap->title = Article::find($newArticle->uid)->title;
+                    $articleMap->level = 1;
+                    $articleMap->save();
+                }
+
+            }
+        });
+        if(Str::isUuid($newArticle->uid)){
+            return $this->ok(new ArticleResource($newArticle));
+        }else{
+            return $this->error('fail');
+        }
+
+    }
+
+    /**
+     * Display the specified resource.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Article  $article
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request  $request,Article $article)
+    {
+        //
+        if(!$article){
+            return $this->error("no recorder");
+        }
+        //判断权限
+        $user = AuthApi::current($request);
+        if(!$user){
+            $user_uid="";
+        }else{
+            $user_uid=$user['user_uid'];
+        }
+
+        $canRead = ArticleController::userCanRead($user_uid,$article);
+        if(!$canRead){
+            return $this->error(__('auth.failed'),403,403);
+        }
+        return $this->ok(new ArticleResource($article));
+    }
+    /**
+     * Display the specified resource.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $article
+     * @return \Illuminate\Http\Response
+     */
+    public function preview(Request  $request,string $articleId)
+    {
+        //
+        $article = Article::find($articleId);
+        if(!$article){
+            return $this->error("no recorder");
+        }
+        //判断权限
+        $user = AuthApi::current($request);
+        if(!$user){
+            $user_uid="";
+        }else{
+            $user_uid=$user['user_uid'];
+        }
+
+        $canRead = ArticleController::userCanRead($user_uid,$article);
+        if(!$canRead){
+            return $this->error(__('auth.failed'),[],401);
+        }
+        if($request->has('content')){
+            $article->content = $request->get('content');
+            return $this->ok(new ArticleResource($article));
+        }else{
+            return $this->error('no content',[],200);
+        }
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Article  $article
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Article $article)
+    {
+        //
+        if(!$article){
+            return $this->error("no recorder");
+        }
+        //鉴权
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),401,401);
+        }else{
+            $user_uid=$user['user_uid'];
+        }
+
+        $canEdit = ArticleController::userCanEdit($user_uid,$article);
+        if(!$canEdit){
+            return $this->error(__('auth.failed'),401,401);
+        }
+
+        /*
+        //查询标题是否重复
+        if(Article::where('title',$request->get('title'))
+                  ->where('owner',$article->owner)
+                  ->where('uid',"<>",$article->uid)
+                  ->exists()){
+            return $this->error(__('validation.exists'));
+        }*/
+
+        $content = $request->get('content');
+        if($request->get('to_tpl')===true){
+            /**
+             * 转化为模版
+             */
+            $tplContent = $this->toTpl($content,
+                                       $request->get('anthology_id'),
+                                       $user);
+            $content = $tplContent;
+        }
+
+        $article->title = mb_substr($request->get('title'),0,128,'UTF-8') ;
+        $article->subtitle = mb_substr($request->get('subtitle'),0,128,'UTF-8') ;
+        $article->summary = mb_substr($request->get('summary'),0,1024,'UTF-8') ;
+        $article->content = $content;
+        $article->lang = $request->get('lang');
+        $article->status = $request->get('status',10);
+        $article->editor_id = $user['user_id'];
+        $article->modify_time = time()*1000;
+        $article->save();
+
+        OpsLog::debug($user_uid,$article);
+        return $this->ok(new ArticleResource($article));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Article  $article
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request,Article $article)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        if($user['user_uid'] !== $article->owner){
+            return $this->error(__('auth.failed'));
+        }
+        $delete = 0;
+        DB::transaction(function() use($article,$delete){
+            //TODO 删除文集中的文章
+            $delete = $article->delete();
+            ArticleMapController::deleteArticle($article->uid);
+        });
+
+        return $this->ok($delete);
+    }
+
+    public function toTpl($content,$anthologyId,$user){
+        //查询书号
+        if(!Str::isUuid($anthologyId)){
+            throw new \Exception('anthology Id not uuid');
+        }
+
+        $bookId = $this->getBookId($anthologyId,$user);
+
+        $tpl = $this->convertToTpl($content,$bookId['book'],$bookId['paragraph']);
+
+        //保存原文到句子表
+        $customBook = $this->getCustomBookByBookId($bookId['book']);
+        $sentenceSave = new SentenceApi;
+        $auth = $sentenceSave->auth($customBook->channel_id,$user['user_uid']);
+        if(!$auth){
+            throw new \Exception('auth fail');
+        }
+        foreach ($tpl['sentences'] as $key => $sentence) {
+            $sentenceSave->store($sentence,$user);
+        }
+        return $tpl['content'];
+    }
+
+    private function getCustomBookByBookId($bookId){
+        return CustomBook::where('book_id',$bookId)->first();
+    }
+
+    private function getBookId($anthologyId,$user){
+        $anthology = Collection::where('uid',$anthologyId)->first();
+        if(!$anthology){
+            throw new \Exception('anthology not exists id='.$anthologyId);
+        }
+        $bookId = $anthology->book_id;
+        if(empty($bookId)){
+            //生成 book id
+            $newBookId = CustomBook::max('book_id') + 1;
+
+            $newBook = new CustomBook;
+            $newBook->id = app('snowflake')->id();
+            $newBook->book_id = $newBookId;
+            $newBook->title = $anthology->title;
+            $newBook->owner = $anthology->owner;
+            $newBook->editor_id = $user['user_id'];
+            $newBook->lang = $anthology->lang;
+            $newBook->status = $anthology->status;
+            //查询anthology所在的studio有没有符合要求的channel 没有的话,建立
+            $channelId = ChannelApi::userBookGetOrCreate($anthology->owner,$anthology->lang,$anthology->status);
+            if($channelId === false){
+                throw new \Exception('user book get fail studio='.$anthology->owner.' language='.$anthology->lang);
+            }
+            $newBook->channel_id = $channelId;
+            $ok = $newBook->save();
+            if(!$ok){
+                throw new \Exception('user book create fail studio='.$anthology->owner.' language='.$anthology->lang);
+            }
+            CustomBookId::where('key','max_book_number')->update(['value'=>$newBookId]);
+            $bookId = $newBookId;
+            $anthology->book_id = $newBookId;
+            $anthology->save();
+        }else{
+            $channelId = CustomBook::where('book_id',$bookId)->value('channel_id');
+        }
+        $maxPara = Sentence::where('channel_uid',$channelId)
+                           ->where('book_id',$bookId)->max('paragraph');
+        if(!$maxPara){
+            $maxPara = 0;
+        }
+        return ['book'=>$bookId,'paragraph'=>$maxPara+1];
+    }
+
+    public function convertToTpl($content,$bookId,$paraStart){
+        $newSentence = array();
+        $para = $paraStart;
+		$sentNum = 1;
+		$newText =  "";
+		$isTable=false;
+		$isList=false;
+		$newSent="";
+        $sentences = explode("\n",$content);
+		foreach ($sentences as $row) {
+			//$data 为一行文本
+            $listHead= "";
+            $isList = false;
+
+            $heading = false;
+            $title = false;
+
+			$trimData = trim($row);
+
+            # 判断是否为list
+			$listLeft =strstr($row,"- ",true);
+			if($listLeft !== FALSE){
+                if(ctype_space($listLeft) || empty($listLeft)){
+                    # - 左侧是空,判定为list
+                    $isList=true;
+                    $iListPos = mb_strpos($row,'- ',0,"UTF-8");
+                    $listHead = mb_substr($row,0,$iListPos+2,"UTF-8");
+                    $listBody = mb_substr($row,$iListPos+2,mb_strlen($row,"UTF-8")-$iListPos+2,"UTF-8");
+                }
+			}
+
+            # TODO 判断是否为标题
+			$headingStart =mb_strpos($row,"# ",0,'UTF-8');
+			if($headingStart !== false){
+                $headingLeft = mb_substr($row,0,$headingStart+2,'UTF-8');
+                $title = mb_substr($row,$headingStart+2,null,'UTF-8');
+                if(str_replace('#','', trim($headingLeft)) === ''){
+                    # 除了#没有其他东西,那么是标题
+                    $heading = $headingLeft;
+                    $newText .= $headingLeft;
+                    $newText .='{{'."{$bookId}-{$para}-{$sentNum}-{$sentNum}"."}}\n";
+                    $newSentence[] = $this->newSent($bookId,$para,$sentNum,$sentNum,$title);
+                    $newSent="";
+                    $para++;
+                    $sentNum = 1;
+                    continue;
+                }
+			}
+
+			//判断是否为表格开始
+			if(mb_substr($trimData,0,1,"UTF-8") == "|"){
+				$isTable=true;
+			}
+			if($trimData!="" && $isTable == true){
+				//如果是表格 不新增句子
+				$newSent .= "{$row}\n";
+				continue;
+			}
+            if($isList == true){
+                $newSent .= $listBody;
+            }else{
+                $newSent .= $trimData;
+            }
+
+			#生成句子编号
+			if($trimData==""){
+				#空行
+				if(strlen($newSent)>0){
+					//之前有内容
+					$newText .='{{'."{$bookId}-{$para}-{$sentNum}-{$sentNum}"."}}\n";
+                    $newSentence[] = $this->newSent($bookId,$para,$sentNum,$sentNum,$newSent);
+					$newSent="";
+				}
+				#新的段落 不插入数据库
+				$para++;
+				$sentNum = 1;
+				$newText .="\n";
+				$isTable = false; //表格开始标记
+				$isList = false;
+				continue;
+			}else{
+				$sentNum=$sentNum+10;
+			}
+
+			if(mb_substr($trimData,0,2,"UTF-8")=="{{"){
+				#已经有的句子链接不处理
+				$newText .= $trimData."\n";
+			}else{
+                $newText .= $listHead;
+				$newText .='{{'."{$bookId}-{$para}-{$sentNum}-{$sentNum}"."}}\n";
+                $newSentence[] = $this->newSent($bookId,$para,$sentNum,$sentNum,$newSent);
+				$newSent="";
+			}
+		}
+
+        return [
+            'content' =>$newText,
+            'sentences' =>$newSentence,
+        ];
+    }
+
+    private function newSent($book,$para,$start,$end,$content){
+        return array(
+            'book_id'=>$book,
+            'paragraph'=>$para,
+            'word_start'=>$start,
+            'word_end'=>$end,
+            'content'=>$content,
+        );
+    }
+}

+ 165 - 0
api-v12/app/Http/Controllers/ArticleFtsController.php

@@ -0,0 +1,165 @@
+<?php
+/**
+ * 文章全文搜索
+ */
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+use App\Models\ArticleCollection;
+use App\Models\Article;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Http;
+
+
+class ArticleFtsController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     * http://127.0.0.1:8000/api/v2/article-fts?id=df6c6609-6fc1-42d0-9ef1-535ef3e702c9&anthology=697c9169-cb9d-4a60-8848-92745e467bab&channesl=7fea264d-7a26-40f8-bef7-bc95102760fb
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $pageSize = 10;
+        $pageCurrent = $request->get('from',0);
+
+        $articlesId = [];
+        if(!empty($request->get('anthology'))){
+            //子节点
+            $node = ArticleCollection::where('article_id',$request->get('id'))
+                        ->where('collect_id',$request->get('anthology'))->first();
+            if($node){
+                $nodeList = ArticleCollection::where('collect_id',$request->get('anthology'))
+                                ->where('id','>=',(int)$node->id)
+                                ->orderBy('id')
+                                ->skip($request->get('from',0))
+                                ->get();
+                $result = [];
+                $count = 0;
+                foreach ($nodeList as $curr) {
+                    if($count>0 && $curr->level <= $node->level){
+                        break;
+                    }
+                    $result[] = $curr;
+                }
+                foreach ($result as $key => $value) {
+                    $articlesId[] = $value->article_id;
+                }
+            }
+        }else{
+            $articlesId[] = $request->get('id');
+        }
+        $total = count($articlesId);
+        $channels = explode(',',$request->get('channels'));
+        $output = [];
+        for ($i=$pageCurrent; $i <$pageCurrent+$pageSize ; $i++) {
+            if($i>=$total){
+                break;
+            }
+            $curr = $articlesId[$i];
+            foreach ($channels as $channel) {
+                # code...
+                $article = $this->fetch($curr,$channel);
+                if ($article === false) {
+                    Log::error('fetch fail');
+                }else{
+                    # code...
+                    $content = $article['html'];
+                    if(!empty($request->get('key'))){
+                        if(strpos($content,$request->get('key')) !== false){
+                            $output[] = $article;
+                        }
+                    }
+                }
+            }
+        }
+
+        return $this->ok(['rows'=>$output,
+            'page'=>[
+                'size' => $pageSize,
+                'current' => $pageCurrent,
+                'total' => $total
+            ],]);
+    }
+
+    private function fetch($articleId,$channel,$token=null){
+        try {
+            $api = config('mint.server.api.bamboo');
+            $basicUrl = $api . '/v2/article/';
+            $url =  $basicUrl . $articleId;;
+
+            $urlParam = [
+                    'mode' => 'read',
+                    'format' => 'text',
+                    'channel' => $channel,
+            ];
+            Log::debug('http request',['url'=>$url,'param'=>$urlParam]);
+            if($token){
+                $response = Http::withToken($this->option('token'))->get($url,$urlParam);
+            }else{
+                $response = Http::get($url,$urlParam);
+            }
+
+            if($response->failed()){
+                Log::error('http request error'.$response->json('message'));
+                return false;
+            }
+            if(!$response->json('ok')){
+                return false;
+            }
+            $article = $response->json('data');
+            return $article;
+        }catch (\Throwable $th) {
+            // 处理请求过程中抛出的异常
+            Log::error('fetch',['error'=>$th]);
+            return false;
+        }
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 260 - 0
api-v12/app/Http/Controllers/ArticleMapController.php

@@ -0,0 +1,260 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\ArticleCollection;
+use App\Models\Article;
+use App\Models\Collection;
+use App\Http\Api\ShareApi;
+use App\Http\Api\AuthApi;
+use Illuminate\Http\Request;
+use App\Http\Resources\ArticleMapResource;
+use Illuminate\Support\Facades\Log;
+
+class ArticleMapController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'anthology':
+                $table = ArticleCollection::where('collect_id',$request->get('id'))
+                            ->leftJoin('articles','articles.uid','=','article_collections.article_id');
+                break;
+            case 'article':
+                $table = ArticleCollection::where('article_id',$request->get('id'))
+                            ->leftJoin('articles','articles.uid','=','article_collections.article_id');
+                break;
+        }
+        $count = $table->count();
+        $result = [];
+        if(!empty($request->get('parent'))){
+            //输出某节点的子节点
+            $node = $table->where('article_id',$request->get('parent'))->first();
+            if($node){
+                $nodeList = ArticleCollection::where('collect_id',$request->get('id'))
+                                            ->where('id','>',(int)$node->id)->orderBy('id')->get();
+                foreach ($nodeList as $key => $curr) {
+                    if($curr->level <= $node->level){
+                        break;
+                    }
+                    if($request->has('lazy')){
+                        if($curr->level === $node->level+1){
+                            $result[] = $curr;
+                        }
+                    }else{
+                        $result[] = $curr;
+                    }
+                }
+            }
+        }else{
+            if($request->has('lazy') && $count > 300){
+                $table = $table->where('level',1);
+            }
+            $result = $table->select([
+                'article_collections.id',
+                'collect_id','article_id',
+                'level',
+                'article_collections.title',
+                'children',
+                'article_collections.editor_id',
+                'article_collections.deleted_at',
+                'articles.status'
+                ])->orderBy('id')->get();
+        }
+
+        return $this->ok(["rows"=>ArticleMapResource::collection($result),"count"=>$count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+
+        $validated = $request->validate([
+                'anthology_id' => 'required',
+                'operation' => 'required'
+            ]);
+        $collection  = Collection::find($request->get('anthology_id'));
+        if(!$collection){
+            return $this->error("no recorder");
+        }
+        //鉴权
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        if(!CollectionController::UserCanEdit($user["user_uid"],$collection)){
+            Log::error($user["user_uid"].'无文集编辑权限'.$collection->uid);
+            return $this->error(__('auth.failed'));
+        }
+        switch ($validated['operation']) {
+            case 'add':
+                # 添加多个文章到文集
+                $count=0;
+                foreach ($request->get('article_id') as $key => $article) {
+                    # code...
+
+                    if(!ArticleCollection::where('article_id',$article)
+                                        ->where('collect_id',$request->get('anthology_id'))
+                                        ->exists())
+                    {
+                        $new = new ArticleCollection;
+                        $new->id = app('snowflake')->id();
+                        $new->article_id = $article;
+                        $new->collect_id = $request->get('anthology_id');
+                        $new->title = Article::find($article)->title;
+                        $new->level = 1;
+                        $new->editor_id = $user["user_id"];
+                        $new->save();
+                        $count++;
+                    }
+                }
+                return $this->ok($count);
+                break;
+            default:
+                return $this->error('unknown operation');
+                break;
+        }
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\ArticleCollection  $articleCollection
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $articleCollection)
+    {
+        //
+        $id = explode('_',$articleCollection);
+        $result = ArticleCollection::where('article_id',$id[0])
+                    ->where('collect_id',$id[1])
+                    ->first();
+        if($result){
+            return $this->ok(new ArticleMapResource($result));
+        }else{
+            return $this->error('no');
+        }
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, string $id)
+    {
+        //
+        $validated = $request->validate([
+            'operation' => 'required'
+        ]);
+
+        $collection  = Collection::find($id);
+        if(!$collection){
+            return $this->error("no recorder");
+        }
+        //鉴权
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        if(!CollectionController::UserCanEdit($user["user_uid"],$collection)){
+            return $this->error(__('auth.failed'));
+        }
+
+        switch ($validated['operation']) {
+            case 'anthology':
+                $delete = ArticleCollection::where('collect_id',$id)->delete();
+                $count=0;
+                foreach ($request->get('data') as $key => $row) {
+                    # code...
+                    $new = new ArticleCollection;
+                    $new->id = app('snowflake')->id();
+                    $new->article_id = $row["article_id"];
+                    $new->collect_id = $id;
+                    $new->title = $row["title"];
+                    $new->level = $row["level"];
+                    $new->children = $row["children"];
+                    $new->editor_id = $user["user_id"];
+                    if(isset($row["deleted_at"])){
+                        $new->deleted_at = $row["deleted_at"];
+                    }
+                    $new->save();
+                    $count++;
+                }
+                ArticleMapController::updateCollection($id);
+                return $this->ok($count);
+                break;
+        }
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\ArticleCollection  $articleCollection
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(ArticleCollection $articleCollection)
+    {
+        //
+    }
+
+    public static function deleteArticle(string $articleId){
+        //查找有这个文章的文集
+        $collections = ArticleCollection::where('article_id',$articleId)
+                                        ->select('collect_id')
+                                        ->groupBy('collect_id')
+                                        ->get();
+        //设置为删除
+        ArticleCollection::where('article_id',$articleId)
+                         ->update(['deleted_at'=>now()]);
+        //查找没有下级文章的文集
+        $updateCollections = ArticleCollection::where('article_id',$articleId)
+                                            ->where('children',0)
+                                            ->select('collect_id')
+                                            ->groupBy('collect_id')
+                                            ->get();
+        //真的删除没有下级文章的文集中的文章
+        $count = ArticleCollection::where('article_id',$articleId)
+                                  ->where('children',0)
+                                  ->delete();
+        //更新改动的文集
+        foreach ($updateCollections as  $collection) {
+            # code...
+            ArticleMapController::updateCollection($collection->collect_id);
+        }
+        return [count($collections),$count];
+    }
+
+    public static function deleteCollection(string $collectionId){
+        $count = ArticleCollection::where('collect_id',$collectionId)
+                                  ->delete();
+        return $count;
+    }
+
+    /**
+     * 用表中的数据生成json,更新collection 表中的字段
+     */
+    public static function updateCollection(string $collectionId){
+        $result = ArticleCollection::where('collect_id',$collectionId)
+                        ->select(['article_id','level','title'])
+                        ->orderBy('id')->get();
+        Collection::where('uid',$collectionId)
+                  ->update(['article_list'=>json_encode($result,JSON_UNESCAPED_UNICODE)]);
+        return count($result);
+    }
+}

+ 119 - 0
api-v12/app/Http/Controllers/ArticleNavController.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Http\Resources\ArticleNavResource;
+use App\Models\PaliText;
+
+class ArticleNavController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('type')) {
+            case 'chapter':
+                $para = explode('-', $request->get('id'));
+                $prev = PaliText::where('book', $para[0])
+                    ->where('paragraph', '<', $para[1])
+                    ->where('level', '<', 8)
+                    ->orderBy('paragraph', 'desc')
+                    ->first();
+                $next = PaliText::where('book', $para[0])
+                    ->where('paragraph', '>', $para[1])
+                    ->where('level', '<', 8)
+                    ->orderBy('paragraph', 'asc')
+                    ->first();
+                if ($prev) {
+                    $nav['prev']['id'] = $prev->book . '-' . $prev->paragraph;
+                    $nav['prev']['title'] = $prev->toc;
+                    $nav['prev']['subtitle'] = $prev->toc;
+                }
+                if ($next) {
+                    $nav['next']['id'] = $next->book . '-' . $next->paragraph;
+                    $nav['next']['title'] = $next->toc;
+                    $nav['next']['subtitle'] = $next->toc;
+                }
+                break;
+            case 'para':
+                $para = explode('-', $request->get('id'));
+                $prev = PaliText::where('book', $para[0])
+                    ->where('paragraph', '<', $para[1])
+                    ->orderBy('paragraph', 'desc')
+                    ->first();
+                $next = PaliText::where('book', $para[0])
+                    ->where('paragraph', '>', $para[1])
+                    ->orderBy('paragraph', 'asc')
+                    ->first();
+                if ($prev) {
+                    $nav['prev']['id'] = $prev->book . '-' . $prev->paragraph;
+                    $nav['prev']['title'] = $prev->text;
+                    $nav['prev']['subtitle'] = $prev->text;
+                }
+                if ($next) {
+                    $nav['next']['id'] = $next->book . '-' . $next->paragraph;
+                    $nav['next']['title'] = $next->text;
+                    $nav['next']['subtitle'] = $next->text;
+                }
+                break;
+            default:
+                return $this->error('type?');
+                break;
+        }
+        if (isset($nav)) {
+            return $this->ok(new ArticleNavResource($nav));
+        } else {
+            return $this->error('no nav data', 200, 200);
+        }
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 130 - 0
api-v12/app/Http/Controllers/ArticleProgressController.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Channel;
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use App\Http\Api\PaliTextApi;
+use Illuminate\Support\Arr;
+
+use Illuminate\Http\Request;
+
+class ArticleProgressController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'chapter':
+                $chapter = PaliTextApi::getChapterStartEnd($request->get('book'),$request->get('para'));
+                $channels = Sentence::where('book_id',$request->get('book'))
+                                    ->whereBetween('paragraph',$chapter)
+                                    ->where('strlen','>',0)
+                                    ->groupBy('channel_uid')
+                                    ->select('channel_uid')
+                                    ->get();
+                //获取单句长度
+                $sentLen = PaliSentence::where('book',$request->get('book'))
+                            ->whereBetween('paragraph',$chapter)
+                            ->orderBy('word_begin')
+                            ->select(['book','paragraph','word_begin','word_end','length'])
+                            ->get();
+                //获取每个channel的完成度
+                foreach ($channels as $key => $value) {
+                    # code...
+                    $finished = Sentence::where('book_id',$request->get('book'))
+                    ->whereBetween('paragraph',$chapter)
+                    ->where('channel_uid',$value->channel_uid)
+                    ->where('strlen','>',0)
+                    ->select(['strlen','book_id','paragraph','word_start','word_end'])
+                    ->get();
+                    $final=[];
+                    foreach ($sentLen as  $sent) {
+                        # code...
+                        $first = Arr::first($finished, function ($value, $key) use($sent) {
+                            return ($value->book_id==$sent->book &&
+                                    $value->paragraph==$sent->paragraph &&
+                                    $value->word_start==$sent->word_begin &&
+                                    $value->word_end==$sent->word_end);
+                        });
+                        $final[] = [$sent->length,$first?true:false];
+                    }
+                    $value['final'] = $final;
+                }
+                return $this->ok($channels);
+                break;
+        }
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function create()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Channel $channel)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(Channel $channel)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Channel $channel)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Channel $channel)
+    {
+        //
+    }
+}

+ 76 - 0
api-v12/app/Http/Controllers/AssetsController.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\App;
+
+class AssetsController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $bucket $name
+     * @return \Illuminate\Http\Response
+     */
+    public function show($bucket,$name)
+    {
+        //
+        $filename = $bucket.'/'.$name;
+        if(Storage::missing($filename)){
+            return $this->error('404',404,404);
+        }
+        //header("Content-Type: {$type1}/{$type1}");
+        if (App::environment('local')) {
+            $url = Storage::url($filename);
+        }else{
+            $url = Storage::temporaryUrl($filename, now()->addDays(2));
+        }
+        return redirect($url);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 267 - 0
api-v12/app/Http/Controllers/AttachmentController.php

@@ -0,0 +1,267 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\File;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\App;
+
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Resources\AttachmentResource;
+use App\Models\Attachment;
+
+use Intervention\Image\ImageManagerStatic as Image;
+use FFMpeg\FFMpeg;
+
+
+
+class AttachmentController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+		switch ($request->get('view')) {
+            case 'studio':
+                $user = AuthApi::current($request);
+                if(!$user){
+                    return $this->error(__('auth.failed'));
+                }
+                //判断当前用户是否有指定的studio的权限
+                if($user['user_uid'] !== StudioApi::getIdByName($request->get('studio'))){
+                    return $this->error(__('auth.failed'));
+                }
+                $table = Attachment::where('owner_uid', $user["user_uid"]);
+                break;
+            default:
+                return $this->error("error view",[],200);
+            break;
+        }
+        if($request->has('search')){
+            $table = $table->where('title', 'like', $request->get('search')."%");
+        }
+        if($request->has('content_type')){
+            $table = $table->where('content_type', 'like', $request->get('content_type')."%");
+        }
+        $count = $table->count();
+        $table = $table->orderBy($request->get('order','updated_at'),
+                                 $request->get('dir','desc'));
+
+        $table = $table->skip($request->get('offset',0))
+                       ->take($request->get('limit',1000));
+
+        $result = $table->get();
+
+        return $this->ok(["rows"=>AttachmentResource::collection($result),"count"=>$count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),401,401);
+        }
+
+        $request->validate([
+            'file' => 'required',
+        ]);
+
+        $isCreate = true;
+        if(Str::isUuid($request->get('id'))){
+            $attachment = Attachment::find($request->get('id'));
+            if(!$attachment){
+                return $this->error('no res');
+            }
+            $fileId = $attachment->id;
+            $isCreate = false;
+        }else{
+            $fileId = Str::uuid();
+        }
+
+        $file = $request->file('file');
+        $bucket = config('mint.attachments.bucket_name.permanent');
+
+        $ext = $file->getClientOriginalExtension();
+
+        if($request->get('type') === 'avatar'){
+            $resize = Image::make($file)->fit(512);
+            Storage::put($bucket.'/'.$fileId.'.jpg',$resize->stream());
+            $resize = Image::make($file)->fit(256);
+            Storage::put($bucket.'/'.$fileId.'_m.jpg',$resize->stream());
+            $resize = Image::make($file)->fit(128);
+            Storage::put($bucket.'/'.$fileId.'_s.jpg',$resize->stream());
+            $name = $fileId.'.jpg';
+        }else{
+            //Move Uploaded File
+            $name = $fileId.'.'.$ext;
+            if(!$isCreate){
+                //替换模式,先删除旧文件
+                Storage::delete($bucket.'/'.$name);
+            }
+            $filename = $file->storeAs($bucket,$name);
+        }
+
+        if($isCreate){
+            $attachment = new Attachment;
+            $attachment->id = $fileId;
+            $attachment->bucket = $bucket;
+            if($request->has('studio')){
+                $owner_uid = StudioApi::getIdByName($request->get('studio'));
+            }else{
+                $owner_uid = $user['user_uid'];
+            }
+            if($owner_uid){
+                $attachment->owner_uid = $owner_uid;
+            }
+            $attachment->status = 'public';
+            $path_parts = pathinfo($file->getClientOriginalName());
+            $attachment->title = $path_parts['filename'];
+        }
+
+        $attachment->user_uid = $user['user_uid'];
+        $attachment->name = $name;
+        $attachment->filename = $file->getClientOriginalName();
+        $attachment->size = $file->getSize();
+        $attachment->content_type = $file->getMimeType();
+        $attachment->save();
+
+        $type = explode('/',$file->getMimeType());
+        switch ($type[0]) {
+            case 'image':
+                $thumbnail = Image::make($file);
+                break;
+            case 'video':
+                $tmpFile = $file->storeAs($bucket,$name,'local');
+                $path = storage_path('app/'.$tmpFile);
+                if (App::environment('local')) {
+                    $ffmpeg = FFMpeg::create();
+                }else{
+                    $ffmpeg = FFMpeg::create(array(
+                        'ffmpeg.binaries' => '/usr/bin/ffmpeg',
+                        'ffprobe.binaries' => '/usr/bin/ffprobe',
+                        'timeout' => 3600,
+                        'ffmpeg.threads' => 1,
+                    ));
+                }
+
+                $video = $ffmpeg->open($path);
+                $frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1));
+                $screenShot = storage_path("app/tmp/{$fileId}.jpg");
+                $frame->save($screenShot);
+                $thumbnail = Image::make($screenShot);
+                break;
+            default:
+                # code...
+                break;
+        }
+        if(isset($thumbnail)){
+            //生成缩略图
+            $thumbnail->resize(256, 256, function ($constraint) {
+                $constraint->aspectRatio();
+            });
+            Storage::put($bucket.'/'.$fileId.'_m.jpg',$thumbnail->stream());
+            $thumbnail->resize(128, 128, function ($constraint) {
+                $constraint->aspectRatio();
+            });
+            Storage::put($bucket.'/'.$fileId.'_s.jpg',$thumbnail->stream());
+            //销毁图片资源
+            $thumbnail->destroy();
+        }
+
+
+        return $this->ok(new AttachmentResource($attachment));
+
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Attachment  $attachment
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Attachment $attachment)
+    {
+        //
+        return $this->ok(new AttachmentResource($attachment));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Attachment  $attachment
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Attachment $attachment)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),401,401);
+        }
+
+        $attachment->title = $request->get('title');
+        $attachment->save();
+        return $this->ok(new AttachmentResource($attachment));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request,string $id)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),401,401);
+        }
+        if(Str::isUuid($id)){
+            $res = Attachment::where('id',$id)->first();
+        }else{
+            /**
+             * 从文件名获取bucket和name
+             */
+            $pos = mb_strrpos($request->get('name'),'/',0,"UTF-8");
+            if($pos === false){
+                return $this->error('无效的文件名',500,500);
+            }
+            $bucket = mb_substr($request->get('name'),0,$pos,'UTF-8');
+            $name = mb_substr($request->get('name'),$pos+1,NULL,'UTF-8');
+            $res = Attachment::where('bucket',$bucket)
+                            ->where('name',$name)
+                            ->first();
+        }
+        if(!$res){
+            return $this->error('no res');
+        }
+        if($user['user_uid'] !== $res->user_uid){
+            return $this->error(__('auth.failed'),403,403);
+        }
+
+        //删除文件
+        $filename = $res->bucket . '/' . $res->name;
+        $path_parts = pathinfo($res->name);
+        Storage::delete($filename);
+        Storage::delete($res->bucket.'/'.$path_parts['filename'].'_m.jpg');
+        Storage::delete($res->bucket.'/'.$path_parts['filename'].'_s.jpg');
+
+        $del = $res->delete();
+        return $this->ok($del);
+    }
+}

+ 65 - 0
api-v12/app/Http/Controllers/AttachmentMapController.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreAttachmentMapRequest;
+use App\Http\Requests\UpdateAttachmentMapRequest;
+use App\Models\AttachmentMap;
+
+class AttachmentMapController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \App\Http\Requests\StoreAttachmentMapRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(StoreAttachmentMapRequest $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\AttachmentMap  $attachmentMap
+     * @return \Illuminate\Http\Response
+     */
+    public function show(AttachmentMap $attachmentMap)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \App\Http\Requests\UpdateAttachmentMapRequest  $request
+     * @param  \App\Models\AttachmentMap  $attachmentMap
+     * @return \Illuminate\Http\Response
+     */
+    public function update(UpdateAttachmentMapRequest $request, AttachmentMap $attachmentMap)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\AttachmentMap  $attachmentMap
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(AttachmentMap $attachmentMap)
+    {
+        //
+    }
+}

+ 158 - 0
api-v12/app/Http/Controllers/AuthController.php

@@ -0,0 +1,158 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\UserInfo;
+use Firebase\JWT\JWT;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\App;
+use App\Http\Api\UserApi;
+use App\Http\Api\AiAssistantApi;
+
+class AuthController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+    public function signIn(Request $request)
+    {
+
+        $query = UserInfo::where(function ($query) use ($request) {
+            $query->where('username', $request->get('username'))
+                ->where('password', md5($request->get('password')));
+        })
+            ->orWhere(function ($query) use ($request) {
+                $query->where('email', $request->get('username'))
+                    ->where('password', md5($request->get('password')));
+            });
+        //Log::info($query->toSql());
+        $user = $query->first();
+        if ($user) {
+            $ExpTime = time() + 60 * 60 * 24 * 365;
+            $key = config('app.key');
+            $payload = [
+                'nbf' => time(),
+                'exp' => $ExpTime,
+                'uid' => $user->userid,
+                'id' => $user->id,
+            ];
+            $jwt = JWT::encode($payload, $key, 'HS512');
+            return $this->ok($jwt);
+        } else {
+            return $this->error('invalid token');
+        }
+    }
+
+    public static function getUserToken($userUid)
+    {
+        $user = UserApi::getByUuid($userUid);
+        if (!$user) {
+            $user = AiAssistantApi::getByUuid($userUid);
+        }
+        if ($user) {
+            $ExpTime = time() + 60 * 60 * 24 * 365;
+            $key = config('app.key');
+            $payload = [
+                'nbf' => time(),
+                'exp' => $ExpTime,
+                'uid' => $user['id'],
+                'id' => $user['sn'],
+            ];
+            $jwt = JWT::encode($payload, $key, 'HS512');
+            return $jwt;
+        }
+        return null;
+    }
+
+    public function getUserInfoByToken(Request $request)
+    {
+        $curr = AuthApi::current($request);
+        if (!$curr) {
+            return $this->error('invalid token', 401, 401);
+        }
+        $userInfo = UserInfo::where('userid', $curr['user_uid'])
+            ->first();
+        $user = [
+            "id" => $curr['user_uid'],
+            "nickName" => $userInfo->nickname,
+            "realName" => $userInfo->username,
+            "avatar" => "",
+            "token" => \substr($request->header('Authorization'), 7),
+        ];
+
+        //role为空 返回[]
+        $user['roles'] = [];
+        if (!empty($userInfo->role)) {
+            $roles = json_decode($userInfo->role);
+            if (is_array($roles)) {
+                $user['roles'] = $roles;
+            }
+        }
+
+        if ($curr['user_uid'] === config('mint.admin.root_uuid')) {
+            $user['roles'] = ['root'];
+        }
+        if ($userInfo->avatar) {
+            $img = str_replace('.jpg', '_s.jpg', $userInfo->avatar);
+            if (App::environment('local')) {
+                $user['avatar'] = Storage::url($img);
+            } else {
+                $user['avatar'] = Storage::temporaryUrl($img, now()->addDays(6));
+            }
+        }
+        return $this->ok($user);
+    }
+}

+ 209 - 0
api-v12/app/Http/Controllers/BlogController.php

@@ -0,0 +1,209 @@
+<?php
+// app/Http/Controllers/BlogController.php
+namespace App\Http\Controllers;
+
+use App\Models\Post;
+use App\Models\Tag;
+use App\Models\ProgressChapter;
+
+use App\Http\Api\UserApi;
+use Illuminate\Support\Facades\Log;
+
+use App\Services\ProgressChapterService;
+
+class BlogController extends Controller
+{
+    protected         $categories = [
+        ['id' => 'sutta', 'label' => 'suttapiṭaka'],
+        ['id' => 'vinaya', 'label' => 'vinayapiṭaka'],
+        ['id' => 'abhidhamma', 'label' => 'abhidhammapiṭaka'],
+        ['id' => 'añña', 'label' => 'añña'],
+        ['id' => 'mūla', 'label' => 'mūla'],
+        ['id' => 'aṭṭhakathā', 'label' => 'aṭṭhakathā'],
+        ['id' => 'ṭīkā', 'label' => 'ṭīkā'],
+    ];
+    // 首页 - 最新博文列表
+    public function index($user)
+    {
+        $user = UserApi::getByName($user);
+        $posts = ProgressChapter::with('channel')
+            ->where('progress', '>', 0.9)
+            ->whereHas('channel', function ($query) use ($user) {
+                $query->where('status', 30)->where('owner_uid', $user['id']);
+            })
+            ->latest()
+            ->paginate(10);
+
+        $categories = $this->categories;
+        /*
+        $posts = Post::published()
+            ->with(['category', 'tags'])
+            ->latest()
+            ->paginate(10);
+
+        $categories = Category::withCount('posts')->get();
+        $popularPosts = Post::published()
+            ->orderBy('views_count', 'desc')
+            ->take(5)
+            ->get();
+*/
+        //return view('blog.index', compact('posts', 'categories', 'popularPosts'));
+        return view('blog.index', compact('user', 'posts', 'categories'));
+    }
+
+    /*
+    // 博文详情页
+    public function show(Post $post)
+    {
+        if (!$post->is_published) {
+            abort(404);
+        }
+
+        $post->incrementViews();
+        $post->load(['category', 'tags']);
+
+        // 相关文章
+        $relatedPosts = Post::published()
+            ->where('category_id', $post->category_id)
+            ->where('id', '!=', $post->id)
+            ->take(3)
+            ->get();
+
+        // 上一篇和下一篇
+        $prevPost = Post::published()
+            ->where('published_at', '<', $post->published_at)
+            ->latest()
+            ->first();
+
+        $nextPost = Post::published()
+            ->where('published_at', '>', $post->published_at)
+            ->oldest()
+            ->first();
+
+        return view('blog.show', compact('post', 'relatedPosts', 'prevPost', 'nextPost'));
+    }
+
+    // 分类列表
+    public function categories()
+    {
+        $categories = Category::withCount('posts')
+            ->having('posts_count', '>', 0)
+            ->orderBy('posts_count', 'desc')
+            ->get();
+
+        return view('blog.categories', compact('categories'));
+    }
+*/
+    // 分类下的文章
+    public function category(
+        $user,
+        $category1,
+        $category2 = null,
+        $category3 = null,
+        $category4 = null,
+        $category5 = null
+    ) {
+        $user = UserApi::getByName($user);
+        $categories = $this->categories;
+        $chapterService = new ProgressChapterService();
+
+        $tags = $this->getCategories($category1, $category2, $category3, $category4, $category5);
+        $posts = $chapterService->setProgress(0.9)->setChannelOwnerId($user['id'])
+            ->setTags($tags)
+            ->get();
+        $count = count($posts);
+        $current = array_map(function ($item) {
+            return ['id' => $item, 'label' => $item];
+        }, $tags);
+
+        $tagOptions = $chapterService->setProgress(0.9)->setChannelOwnerId($user['id'])
+            ->setTags($tags)
+            ->getTags();
+        return view('blog.category', compact('user', 'categories', 'posts', 'current', 'tagOptions', 'count'));
+    }
+
+    private function getCategories($category1, $category2, $category3, $category4, $category5)
+    {
+        $category = [];
+        if ($category1) {
+            $category[] = $category1;
+        }
+        if ($category2) {
+            $category[] = $category2;
+        }
+        if ($category3) {
+            $category[] = $category3;
+        }
+        if ($category4) {
+            $category[] = $category4;
+        }
+        if ($category5) {
+            $category[] = $category5;
+        }
+        return $category;
+    }
+    /*
+    // 年度归档
+    public function archives()
+    {
+        $archives = Post::published()
+            ->selectRaw('YEAR(published_at) as year, COUNT(*) as count')
+            ->groupBy('year')
+            ->orderBy('year', 'desc')
+            ->get();
+
+        return view('blog.archives', compact('archives'));
+    }
+
+    // 指定年份的文章
+    public function archivesByYear($year)
+    {
+        $posts = Post::published()
+            ->whereYear('published_at', $year)
+            ->with(['category', 'tags'])
+            ->latest()
+            ->paginate(15);
+
+        // 按月分组
+        $postsByMonth = $posts->getCollection()->groupBy(function ($post) {
+            return $post->published_at->format('Y-m');
+        });
+
+        return view('blog.archives-year', compact('posts', 'postsByMonth', 'year'));
+    }
+
+    // 标签页面
+    public function tag(Tag $tag)
+    {
+        $posts = $tag->posts()
+            ->published()
+            ->with(['category', 'tags'])
+            ->latest()
+            ->paginate(10);
+
+        return view('blog.tag', compact('tag', 'posts'));
+    }
+
+    // 搜索
+    public function search(Request $request)
+    {
+        $query = $request->get('q');
+
+        if (empty($query)) {
+            return redirect()->route('blog.index');
+        }
+
+        $posts = Post::published()
+            ->where(function ($q) use ($query) {
+                $q->where('title', 'LIKE', "%{$query}%")
+                    ->orWhere('content', 'LIKE', "%{$query}%")
+                    ->orWhere('excerpt', 'LIKE', "%{$query}%");
+            })
+            ->with(['category', 'tags'])
+            ->latest()
+            ->paginate(10);
+
+        return view('blog.search', compact('posts', 'query'));
+    }
+        */
+}

+ 366 - 0
api-v12/app/Http/Controllers/BookController.php

@@ -0,0 +1,366 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use App\Models\ProgressChapter;
+use App\Models\PaliText;
+use App\Models\Sentence;
+
+class BookController extends Controller
+{
+    protected $maxChapterLen = 50000;
+    protected $minChapterLen = 100;
+    public function show($id)
+    {
+        $bookRaw = $this->loadBook($id);
+
+        if (!$bookRaw) {
+            abort(404);
+        }
+
+        //查询章节
+        $channelId = $bookRaw->channel_id; // 替换为具体的 channel_id 值
+
+        $book = $this->getBookInfo($bookRaw);
+        $book['contents'] = $this->getBookToc($bookRaw->book, $bookRaw->para, $channelId);
+
+        // 获取其他版本
+        $others = ProgressChapter::with('channel.owner')
+            ->where('book', $bookRaw->book)
+            ->where('para', $bookRaw->para)
+            ->whereHas('channel', function ($query) {
+                $query->where('status', 30);
+            })
+            ->where('progress', '>', 0.2)
+            ->get();
+
+        $otherVersions = [];
+        $others->each(function ($book) use (&$otherVersions, $id) {
+            $otherVersions[] = $this->getBookInfo($book);
+        });
+
+        return view('library.book.show', compact('book', 'otherVersions'));
+    }
+
+
+    public function read($id)
+    {
+        $bookRaw = $this->loadBook($id);
+
+        if (!$bookRaw) {
+            abort(404);
+        }
+        $channelId = $bookRaw->channel_id; // 替换为具体的 channel_id 值
+
+        $book = $this->getBookInfo($bookRaw);
+        $book['toc'] = $this->getBookToc($bookRaw->book, $bookRaw->para, $channelId, 2, 7);
+        $book['categories'] = $this->getBookCategory($bookRaw->book, $bookRaw->para);
+        $book['tags'] = [];
+        $book['pagination'] = $this->pagination($bookRaw);
+        $book['content'] = $this->getBookContent($id);
+        return view('library.book.read', compact('book'));
+    }
+
+    private function loadBook($id)
+    {
+        $book = ProgressChapter::with('channel.owner')->find($id);
+        return $book;
+    }
+
+    public function toggleTheme(Request $request)
+    {
+        $theme = $request->input('theme', 'light');
+        session(['theme' => $theme]);
+        return response()->json(['status' => 'success']);
+    }
+
+    private function getBookInfo($book)
+    {
+        $title = $book->title;
+        if (empty($title)) {
+            $title = PaliText::where('book', $book->book)
+                ->where('paragraph', $book->para)->first()->toc;
+        }
+        return [
+            "id" => $book->uid,
+            "title" => $title,
+            "author" => $book->channel->name,
+            "publisher" => $book->channel->owner,
+            "type" => __('label.' . $book->channel->type),
+            "category_id" => 11,
+            "cover" => "/assets/images/cover/1/214.jpg",
+            "description" => $book->summary ?? "",
+            "language" => __('language.' . $book->channel->lang),
+        ];
+    }
+
+    private function getBookToc(int $book, int $paragraph, string $channelId, $minLevel = 2, $maxLevel = 2)
+    {
+        //先找到书的起始(书名)章节
+        //一个book 里面可以有多本书
+        $currBook = $this->bookStart($book, $paragraph);
+        $start = $currBook->paragraph;
+        $end = $currBook->paragraph + $currBook->chapter_len - 1;
+        $paliTexts = PaliText::where('book', $book)
+            ->whereBetween('paragraph',  [$start, $end])
+            ->whereBetween('level', [$minLevel, $maxLevel])->orderBy('paragraph')->get();
+
+        $chapters = ProgressChapter::where('book', $book)
+            ->whereBetween('para', [$start, $end])
+            ->where('channel_id', $channelId)->orderBy('para')->get();
+
+        $toc = $paliTexts->map(function ($paliText) use ($chapters) {
+            $title = $paliText->toc;
+            if (count($chapters) > 0) {
+                $found = array_filter($chapters->toArray(), function ($chapter) use ($paliText) {
+                    return $chapter['book'] == $paliText->book && $chapter['para'] == $paliText->paragraph;
+                });
+                if (count($found) > 0) {
+                    $chapter = array_shift($found);
+                    if (!empty($chapter['title'])) {
+                        $title = $chapter['title'];
+                    }
+                    if (!empty($chapter['summary'])) {
+                        $summary = $chapter['summary'];
+                    }
+                    $progress = (int)($chapter['progress'] * 100);
+                    $id = $chapter['uid'];
+                }
+            }
+            return [
+                "id" => $id ?? '',
+                "title" => $title,
+                "summary" => $summary ?? "",
+                "progress" => $progress ?? 0,
+                "level" => (int)$paliText->level,
+                "disabled" => !isset($progress),
+            ];
+        })->toArray();
+        return $toc;
+    }
+
+    public function getBookCategory($book, $paragraph)
+    {
+        $tags = PaliText::with('tagMaps.tags')
+            ->where('book', $book)
+            ->where('paragraph', $paragraph)
+            ->first()->tagMaps->map(function ($tagMap) {
+                return $tagMap->tags;
+            })->toArray();
+        return $tags;
+    }
+
+    private function bookStart($book, $paragraph)
+    {
+        $currBook = PaliText::where('book', $book)
+            ->where('paragraph', '<=', $paragraph)
+            ->where('level', 1)
+            ->orderBy('paragraph', 'desc')
+            ->first();
+        return $currBook;
+    }
+    public function getBookContent($id)
+    {
+        //查询book信息
+        $book = $this->loadBook($id);
+        $currBook = $this->bookStart($book->book, $book->para);
+        $start = $currBook->paragraph;
+        $end = $currBook->paragraph + $currBook->chapter_len - 1;
+        // 查询起始段落
+        $paragraphs = PaliText::where('book', $book->book)
+            ->whereBetween('paragraph', [$start, $end])
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')
+            ->get();
+        $curr = $paragraphs->firstWhere('paragraph', $book->para);
+        $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
+        if ($curr->chapter_strlen > $this->maxChapterLen) {
+            //太大了,修改结束位置 找到下一级
+            foreach ($paragraphs as $key => $paragraph) {
+                if ($paragraph->paragraph > $curr->paragraph) {
+                    if ($paragraph->chapter_strlen <= $this->maxChapterLen) {
+                        $endParagraph = $paragraph->paragraph + $paragraph->chapter_len - 1;
+                        break;
+                    }
+                    if ($paragraph->level <= $curr->level) {
+                        //不能往下走了,就是它了
+                        $endParagraph = $paragraphs[$key - 1]->paragraph + $paragraphs[$key - 1]->chapter_len - 1;
+                        break;
+                    }
+                }
+            }
+        }
+
+        //获取句子数据
+        $sentences = Sentence::where('book_id', $book->book)
+            ->whereBetween('paragraph', [$curr->paragraph, $endParagraph])
+            ->where('channel_uid', $book->channel_id)
+            ->orderBy('paragraph')
+            ->orderBy('word_start')
+            ->get();
+        $pali = PaliText::where('book', $book->book)
+            ->whereBetween('paragraph', [$curr->paragraph, $endParagraph])
+            ->orderBy('paragraph')
+            ->get();
+        $result = [];
+        for ($i = $curr->paragraph; $i <= $endParagraph; $i++) {
+            $texts = array_filter($sentences->toArray(), function ($sentence) use ($i) {
+                return $sentence['paragraph'] == $i;
+            });
+            $contents = array_map(function ($text) {
+                return $text['content'];
+            }, $texts);
+            $currPali = $pali->firstWhere('paragraph', $i);
+            $paragraph = [
+                'id' => $i,
+                'level' => $currPali->level,
+                'text' => [[implode('', $contents)]],
+            ];
+            $result[] = $paragraph;
+        }
+
+        return $result;
+    }
+
+    public function pagination($book)
+    {
+        $currBook = $this->bookStart($book->book, $book->para);
+        $start = $currBook->paragraph;
+        $end = $currBook->paragraph + $currBook->chapter_len - 1;
+        // 查询起始段落
+        $paragraphs = PaliText::where('book', $book->book)
+            ->whereBetween('paragraph', [$start, $end])
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')
+            ->get();
+        $curr = $paragraphs->firstWhere('paragraph', $book->para);
+        $current = $curr; //实际显示的段落
+        $endParagraph = $curr->paragraph + $curr->chapter_len - 1;
+        if ($curr->chapter_strlen > $this->maxChapterLen) {
+            //太大了,修改结束位置 找到下一级
+            foreach ($paragraphs as $key => $paragraph) {
+                if ($paragraph->paragraph > $curr->paragraph) {
+                    if ($paragraph->chapter_strlen <= $this->maxChapterLen) {
+                        $endParagraph = $paragraph->paragraph + $paragraph->chapter_len - 1;
+                        $current = $paragraph;
+                        break;
+                    }
+                    if ($paragraph->level <= $curr->level) {
+                        //不能往下走了,就是它了
+                        $endParagraph = $paragraphs[$key - 1]->paragraph + $paragraphs[$key - 1]->chapter_len - 1;
+                        $current = $paragraph;
+                        break;
+                    }
+                }
+            }
+        }
+        $start = $curr->paragraph;
+        $end = $endParagraph;
+        $nextPali = $this->next($current->book, $current->paragraph, $current->level);
+        $prevPali = $this->prev($current->book, $current->paragraph, $current->level);
+
+        $next = null;
+        if ($nextPali) {
+            $nextTranslation = ProgressChapter::with('channel.owner')
+                ->where('book', $nextPali->book)
+                ->where('para', $nextPali->paragraph)
+                ->where('channel_id', $book->channel_id)
+                ->first();
+            if ($nextTranslation) {
+                if (!empty($nextTranslation->title)) {
+                    $next['title'] = $nextTranslation->title;
+                } else {
+                    $next['title'] = $nextPali->toc;
+                }
+                $next['id'] = $nextTranslation->uid;
+            }
+        }
+
+        $prev = null;
+        if ($prevPali) {
+            $prevTranslation = ProgressChapter::with('channel.owner')
+                ->where('book', $prevPali->book)
+                ->where('para', $prevPali->paragraph)
+                ->where('channel_id', $book->channel_id)
+                ->first();
+            if ($prevTranslation) {
+                if (!empty($prevTranslation->title)) {
+                    $prev['title'] = $prevTranslation->title;
+                } else {
+                    $prev['title'] = $prevPali->toc;
+                }
+                $prev['id'] = $prevTranslation->uid;
+            }
+        }
+
+        return compact('start', 'end', 'next', 'prev');
+    }
+
+    public function next($book, $paragraph, $level)
+    {
+        $next = PaliText::where('book', $book)
+            ->where('paragraph', '>', $paragraph)
+            ->where('level', $level)
+            ->orderBy('paragraph')
+            ->first();
+        return $next ?? null;
+    }
+    public function prev($book, $paragraph, $level)
+    {
+        $prev = PaliText::where('book', $book)
+            ->where('paragraph', '<', $paragraph)
+            ->where('level', $level)
+            ->orderBy('paragraph', 'desc')
+            ->first();
+
+        return $prev ?? null;
+    }
+    public function show2($id)
+    {
+        // Sample book data (replace with database query)
+        $book = [
+            'title' => 'Sample Book Title',
+            'author' => 'John Doe',
+            'category' => 'Fiction',
+            'tags' => ['Adventure', 'Mystery', 'Bestseller'],
+            'toc' => ['Introduction', 'Chapter 1', 'Chapter 2', 'Conclusion'],
+            'content' => [
+                'This is the introduction to the book...',
+                'Chapter 1 content goes here...',
+                'Chapter 2 content goes here...',
+                'Conclusion of the book...',
+            ],
+            'downloads' => [
+                ['format' => 'PDF', 'url' => '#'],
+                ['format' => 'EPUB', 'url' => '#'],
+                ['format' => 'MOBI', 'url' => '#'],
+            ],
+        ];
+
+        // Sample related books (replace with database query)
+        $relatedBooks = [
+            [
+                'title' => 'Related Book 1',
+                'description' => 'A thrilling adventure...',
+                'image' => 'https://via.placeholder.com/300x200',
+                'link' => '#',
+            ],
+            [
+                'title' => 'Related Book 2',
+                'description' => 'A mystery novel...',
+                'image' => 'https://via.placeholder.com/300x200',
+                'link' => '#',
+            ],
+            [
+                'title' => 'Related Book 3',
+                'description' => 'A bestseller...',
+                'image' => 'https://via.placeholder.com/300x200',
+                'link' => '#',
+            ],
+        ];
+
+        return view('library.book.read2', compact('book', 'relatedBooks'));
+    }
+}

+ 67 - 0
api-v12/app/Http/Controllers/BookTitleController.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\BookTitle;
+use Illuminate\Http\Request;
+use App\Http\Resources\BookTitleResource;
+
+class BookTitleController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+        $result = BookTitle::orderBy('sn')->get();
+        return $this->ok(["rows"=>BookTitleResource::collection($result),"count"=>count($result)]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\BookTitle  $bookTitle
+     * @return \Illuminate\Http\Response
+     */
+    public function show(BookTitle $bookTitle)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\BookTitle  $bookTitle
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, BookTitle $bookTitle)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\BookTitle  $bookTitle
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(BookTitle $bookTitle)
+    {
+        //
+    }
+}

+ 76 - 0
api-v12/app/Http/Controllers/CaseController.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Tools\CaseMan;
+
+class CaseController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * 输入一个单词,输出三藏中所有可能的变形
+     *
+     * @param  string  $word
+     * @return \Illuminate\Http\Response
+     */
+    public function show($word)
+    {
+        //
+        $output = array();
+        $case  = new CaseMan();
+        $result = $case->BaseToWord($word,0.2);
+        $output[] = ['word'=>$word, 'case'=>$result,'count'=>count($result)];
+        $parent = $case->WordToBase($word,1,false);
+        foreach ($parent as $key => $base) {
+            $result = $case->BaseToWord($key,0.2);
+            if(count($result)>0){
+                $output[] = ['word'=>$key, 'case'=>$result,'count'=>count($result)];
+            }
+        }
+        return $this->ok(['rows'=>$output,'count'=>count($output)]);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 178 - 0
api-v12/app/Http/Controllers/CategoryController.php

@@ -0,0 +1,178 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\DB;
+use App\Models\PaliText;
+use App\Models\ProgressChapter;
+use App\Models\Tag;
+use App\Models\TagMap;
+
+class CategoryController extends Controller
+{
+    protected static int $nextId = 1;
+    public function index()
+    {
+        $categories = $this->loadCategories();
+
+        // 获取一级分类和对应的书籍
+        $categoryData = [];
+        foreach ($categories as $category) {
+            if ($category['level'] == 1) {
+                $categoryBooks = $this->getBooks($categories, $category['id']);
+                $categoryData[] = [
+                    'category' => $category,
+                    'books' => array_slice(array_values($categoryBooks), 0, 3)
+                ];
+            }
+        }
+
+        return view('library.index', compact('categoryData', 'categories'));
+    }
+
+    public function show($id)
+    {
+        $categories = $this->loadCategories();
+
+        $currentCategory = collect($categories)->firstWhere('id', $id);
+        if (!$currentCategory) {
+            abort(404);
+        }
+
+        // 获取子分类
+        $subCategories = array_filter($categories, function ($cat) use ($id) {
+            return $cat['parent_id'] == $id;
+        });
+
+        // 获取该分类下的书籍
+        $categoryBooks = $this->getBooks($categories, $id);
+        // 获取面包屑
+        $breadcrumbs = $this->getBreadcrumbs($currentCategory, $categories);
+
+        return view('library.category', compact('currentCategory', 'subCategories', 'categoryBooks', 'breadcrumbs'));
+    }
+
+    private function getBooks($categories, $id)
+    {
+        $currentCategory = collect($categories)->firstWhere('id', $id);
+        if (!$currentCategory) {
+            abort(404);
+        }
+
+        // 标签查章节
+        $tagNames = $currentCategory['tag'];
+        $tm = (new TagMap)->getTable();
+        $tg = (new Tag)->getTable();
+        $pt = (new PaliText)->getTable();
+        $where1 = " where co = " . count($tagNames);
+        $a = implode(",", array_fill(0, count($tagNames), '?'));
+        $in1 = "and t.name in ({$a})";
+        $param = $tagNames;
+        $where2 = "where level = 1";
+        $query = "
+                        select uid as id,book,paragraph,level,toc as title,chapter_strlen,parent,path from (
+                            select anchor_id as cid from (
+                                select tm.anchor_id , count(*) as co
+                                    from $tm as  tm
+                                    left join $tg as t on tm.tag_id = t.id
+                                    where tm.table_name  = 'pali_texts'
+                                    $in1
+                                    group by tm.anchor_id
+                            ) T
+                                $where1
+                        ) CID
+                        left join $pt as pt on CID.cid = pt.uid
+                        $where2
+                        order by book,paragraph";
+
+        $chapters = DB::select($query, $param);
+        $chaptersParam = [];
+        foreach ($chapters as $key => $chapter) {
+            $chaptersParam[] = [$chapter->book, $chapter->paragraph];
+        }
+        // 获取该分类下的章节
+        $books = ProgressChapter::with('channel.owner')
+            ->leftJoin('pali_texts', function ($join) {
+                $join->on('progress_chapters.book', '=', 'pali_texts.book')
+                    ->on('progress_chapters.para', '=', 'pali_texts.paragraph');
+            })
+            ->whereIns(['progress_chapters.book', 'progress_chapters.para'], $chaptersParam)
+            ->whereHas('channel', function ($query) {
+                $query->where('status', 30);
+            })
+            ->where('progress', '>', config('mint.library.list_min_progress'))
+            ->get();
+
+        $pali = PaliText::where('level', 1)->get();
+        // 获取该分类下的书籍
+        $categoryBooks = [];
+        $books->each(function ($book) use (&$categoryBooks, $id, $pali) {
+            $title = $book->title;
+            if (empty($title)) {
+                $title = $pali->firstWhere('book', $book->book)->toc;
+            }
+            $categoryBooks[] = [
+                "id" => $book->uid,
+                "title" => $title,
+                "author" => $book->channel->name,
+                "publisher" => $book->channel->owner,
+                "type" => __('labels.' . $book->channel->type),
+                "category_id" => $id,
+                "cover" => "/assets/images/cover/zh-hans/1/{$book->pcd_book_id}.png",
+                "description" => $book->summary ?? "比库戒律的详细说明",
+                "language" => __('language.' . $book->channel->lang),
+            ];
+        });
+        return $categoryBooks;
+    }
+    private function loadCategories()
+    {
+        $json = file_get_contents(public_path("app/palicanon/category/default.json"));
+        $tree = json_decode($json, true);
+        $flat = self::flattenWithIds($tree);
+        return $flat;
+    }
+
+    public static function flattenWithIds(array $tree, int $parentId = 0, int $level = 1): array
+    {
+
+        $flat = [];
+
+        foreach ($tree as $node) {
+            $currentId = self::$nextId++;
+
+            $item = [
+                'id' => $currentId,
+                'parent_id' => $parentId,
+                'name' => $node['name'] ?? null,
+                'tag' => $node['tag'] ?? [],
+                "description" => "佛教戒律经典",
+                'level' => $level,
+            ];
+
+            $flat[] = $item;
+
+            if (isset($node['children']) && is_array($node['children'])) {
+                $childrenLevel = $level + 1;
+                $flat = array_merge($flat, self::flattenWithIds($node['children'], $currentId, $childrenLevel));
+            }
+        }
+
+        return $flat;
+    }
+
+    private function getBreadcrumbs($category, $categories)
+    {
+        $breadcrumbs = [];
+        $current = $category;
+
+        while ($current) {
+            array_unshift($breadcrumbs, $current);
+            $current = collect($categories)->firstWhere('id', $current['parent_id']);
+        }
+
+        return $breadcrumbs;
+    }
+}

+ 726 - 0
api-v12/app/Http/Controllers/ChannelController.php

@@ -0,0 +1,726 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+
+use App\Models\Channel;
+use App\Models\Sentence;
+use App\Models\DhammaTerm;
+use App\Models\WbwBlock;
+use App\Models\PaliSentence;
+use App\Models\CustomBook;
+
+use App\Http\Controllers\AuthController;
+use App\Http\Resources\ChannelResource;
+
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\ShareApi;
+use App\Http\Api\PaliTextApi;
+use App\Http\Api\ChannelApi;
+
+
+class ChannelController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $result = false;
+        $indexCol = [
+            'channels.uid',
+            'name',
+            'channels.summary',
+            'type',
+            'owner_uid',
+            'channels.lang',
+            'status',
+            'is_system',
+            'channels.updated_at',
+            'channels.created_at'
+        ];
+        if ($request->has("book")) {
+            $indexCol[] = 'progress_chapters.progress';
+        }
+        switch ($request->get('view')) {
+            case 'public':
+                $table = Channel::select($indexCol)
+                    ->where('status', 30);
+                /*
+                if ($request->has("book")) {
+                    $table = $table->leftJoin('progress_chapters', 'channels.uid', '=', 'progress_chapters.channel_id',)
+                        ->where('progress_chapters.book', $request->get("book"))
+                        ->where('progress_chapters.para', $request->get("paragraph"));
+                }*/
+                break;
+            case 'studio':
+                # 获取studio内所有channel
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                //判断当前用户是否有指定的studio的权限
+                $studioId = StudioApi::getIdByName($request->get('name'));
+                if (!StudioApi::userCanList($user['user_uid'], $studioId)) {
+                    return $this->error(__('auth.failed'), 403, 403);
+                }
+
+                $table = Channel::select($indexCol);
+                if ($request->get('view2', 'my') === 'my') {
+                    $table = $table->where('owner_uid', $studioId);
+                } else {
+                    //协作
+                    $resList = ShareApi::getResList($studioId, 2);
+                    $resId = [];
+                    foreach ($resList as $res) {
+                        $resId[] = $res['res_id'];
+                    }
+                    $table = $table->whereIn('channels.uid', $resId);
+                    if ($request->get('collaborator', 'all') !== 'all') {
+                        $table = $table->where('owner_uid', $request->get('collaborator'));
+                    } else {
+                        $table = $table->where('owner_uid', '<>', $studioId);
+                    }
+                }
+                break;
+            case 'studio-all':
+                /**
+                 * studio 的和协作的
+                 */
+                #获取user所有有权限的channel列表
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                //判断当前用户是否有指定的studio的权限
+                if ($user['user_uid'] !== StudioApi::getIdByName($request->get('name'))) {
+                    return $this->error(__('auth.failed'));
+                }
+                $channelById = [];
+                $channelId = [];
+                //获取共享channel
+                $allSharedChannels = ShareApi::getResList($user['user_uid'], 2);
+                foreach ($allSharedChannels as $key => $value) {
+                    # code...
+                    $channelId[] = $value['res_id'];
+                    $channelById[$value['res_id']] = $value;
+                }
+                $table = Channel::select($indexCol)
+                    ->whereIn('uid', $channelId)
+                    ->orWhere('owner_uid', $user['user_uid']);
+                break;
+            case 'user-edit':
+                /**
+                 * 某用户有编辑权限的
+                 */
+                #获取user所有有权限的channel列表
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                $channelById = [];
+                $channelId = [];
+                //获取共享channel
+                $allSharedChannels = ShareApi::getResList($user['user_uid'], 2);
+                foreach ($allSharedChannels as $key => $value) {
+                    # code...
+                    if ($value['power'] >= 20) {
+                        $channelId[] = $value['res_id'];
+                        $channelById[$value['res_id']] = $value;
+                    }
+                }
+                $table = Channel::select($indexCol)
+                    ->whereIn('uid', $channelId)
+                    ->orWhere('owner_uid', $user['user_uid']);
+                break;
+            case 'user-in-chapter':
+                #获取user 在某章节 所有有权限的channel列表
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                $channelById = [];
+                $channelId = [];
+                //获取共享channel
+                $allSharedChannels = ShareApi::getResList($user['user_uid'], 2);
+                foreach ($allSharedChannels as $key => $value) {
+                    # code...
+                    $channelId[] = $value['res_id'];
+                    $channelById[$value['res_id']] = $value;
+                }
+                //获取全网公开channel
+                $chapter = PaliTextApi::getChapterStartEnd($request->get('book'), $request->get('para'));
+                $publicChannelsWithContent = Sentence::where('book_id', $request->get('book'))
+                    ->whereBetween('paragraph', $chapter)
+                    ->where('strlen', '>', 0)
+                    ->where('status', 30)
+                    ->groupBy('channel_uid')
+                    ->select('channel_uid')
+                    ->get();
+                foreach ($publicChannelsWithContent as $key => $value) {
+                    # code...
+                    $value['res_id'] = $value->channel_uid;
+                    $value['power'] = 10;
+                    $value['type'] = 2;
+                    if (!isset($channelById[$value['res_id']])) {
+                        $channelId[] = $value['res_id'];
+                        $channelById[$value['res_id']] = $value;
+                    }
+                }
+                $table = Channel::select($indexCol)
+                    ->whereIn('uid', $channelId)
+                    ->orWhere('owner_uid', $user['user_uid']);
+                break;
+            case 'system':
+                $table = Channel::select($indexCol)
+                    ->where('owner_uid', config("mint.admin.root_uuid"));
+                break;
+            case 'id':
+                $table = Channel::select($indexCol)
+                    ->whereIn('uid', explode(',', $request->get("id")));
+        }
+
+        if ($request->has("book")) {
+            if ($request->get("view") === "public") {
+                $table = $table->leftJoin('progress_chapters', 'channels.uid', '=', 'progress_chapters.channel_id',)
+                    ->where('progress_chapters.book', $request->get("book"))
+                    ->where('progress_chapters.para', $request->get("paragraph"));
+            } else {
+                $table = $table->leftJoin('progress_chapters', function ($join) use ($request) {
+                    $join->on('channels.uid', '=', 'progress_chapters.channel_id')
+                        ->where('progress_chapters.book', $request->get("book"))
+                        ->where('progress_chapters.para', $request->get("paragraph")); // 条件写在这里!
+                });
+            }
+
+            /* leftJoin('progress_chapters', 'channels.uid', '=', 'progress_chapters.channel_id',)
+                ->where('progress_chapters.book', $request->get("book"))
+                ->where('progress_chapters.para', $request->get("paragraph"));*/
+        }
+        //处理搜索
+        if (!empty($request->get("search"))) {
+            $table = $table->where('name', 'like', "%" . $request->get("search") . "%");
+        }
+        if ($request->has("type")) {
+            $table = $table->where('type', $request->get("type"));
+        }
+        if ($request->has("updated_at")) {
+            $table = $table->where('updated_at', '>', $request->get("updated_at"));
+        }
+        if ($request->has("created_at")) {
+            $table = $table->where('created_at', '>', $request->get("created_at"));
+        }
+        //获取记录总条数
+        $count = $table->count();
+        //处理排序
+        $table = $table->orderBy(
+            $request->get("order", 'created_at'),
+            $request->get("dir", 'desc')
+        );
+        //处理分页
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get("limit", 200));
+        Log::debug('channel sql ' . $table->toSql());
+        //获取数据
+        $result = $table->get();
+        //TODO 将下面代码转移到resource
+        if ($result) {
+            if ($request->has('progress')) {
+                //获取进度
+                //获取单句长度
+                $sentLen = PaliSentence::where('book', $request->get('book'))
+                    ->whereBetween('paragraph', $chapter)
+                    ->orderBy('word_begin')
+                    ->select(['book', 'paragraph', 'word_begin', 'word_end', 'length'])
+                    ->get();
+            }
+            foreach ($result as $key => $value) {
+                if ($request->has('progress')) {
+                    //获取进度
+                    $finalTable = Sentence::where('book_id', $request->get('book'))
+                        ->whereBetween('paragraph', $chapter)
+                        ->where('channel_uid', $value->uid)
+                        ->where('strlen', '>', 0)
+                        ->select(['strlen', 'book_id', 'paragraph', 'word_start', 'word_end']);
+                    if ($finalTable->count() > 0) {
+                        $finished = $finalTable->get();
+                        $final = [];
+                        foreach ($sentLen as  $sent) {
+                            # code...
+                            $first = Arr::first($finished, function ($value, $key) use ($sent) {
+                                return ($value->book_id == $sent->book &&
+                                    $value->paragraph == $sent->paragraph &&
+                                    $value->word_start == $sent->word_begin &&
+                                    $value->word_end == $sent->word_end);
+                            });
+                            $final[] = [$sent->length, $first ? true : false];
+                        }
+                        $value['final'] = $final;
+                    }
+                }
+                //角色
+                if (isset($user['user_uid'])) {
+                    if ($value->owner_uid === $user['user_uid']) {
+                        $value['role'] = 'owner';
+                    } else {
+                        if (isset($channelById[$value->uid])) {
+                            switch ($channelById[$value->uid]['power']) {
+                                case 10:
+                                    # code...
+                                    $value['role'] = 'member';
+                                    break;
+                                case 20:
+                                    $value['role'] = 'editor';
+                                    break;
+                                case 30:
+                                    $value['role'] = 'owner';
+                                    break;
+                                default:
+                                    # code...
+                                    $value['role'] = $channelById[$value->uid]['power'];
+                                    break;
+                            }
+                        }
+                    }
+                }
+                # 获取studio信息
+                $value->studio = StudioApi::getById($value->owner_uid);
+            }
+            return $this->ok(["rows" => $result, "count" => $count]);
+        } else {
+            return $this->ok(["rows" => [], "count" => 0]);
+        }
+    }
+
+    /**
+     * 获取我的,和协作channel数量
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function showMyNumber(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        $studioId = StudioApi::getIdByName($request->get('studio'));
+        if ($user['user_uid'] !== $studioId) {
+            return $this->error(__('auth.failed'));
+        }
+        //我的
+        $my = Channel::where('owner_uid', $studioId)->count();
+        //协作
+        $resList = ShareApi::getResList($studioId, 2);
+        $resId = [];
+        foreach ($resList as $res) {
+            $resId[] = $res['res_id'];
+        }
+        $collaboration = Channel::whereIn('uid', $resId)->where('owner_uid', '<>', $studioId)->count();
+
+        return $this->ok(['my' => $my, 'collaboration' => $collaboration]);
+    }
+    /**
+     * 获取章节的进度
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function progress(Request $request)
+    {
+        $indexCol = ['uid', 'name', 'summary', 'type', 'owner_uid', 'lang', 'status', 'updated_at', 'created_at'];
+
+        $sent = $request->get('sentence');
+        $query = [];
+        $queryWithChannel = [];
+        $sentContainer = [];
+        $sentLenContainer = [];
+
+        $paliChannel = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        $customBookChannel = array();
+
+        foreach ($sent as $value) {
+            $ids = explode('-', $value);
+            $idWithChannel = $ids;
+            if (count($ids) === 4) {
+                if ($ids[0] < 1000) {
+                    $idWithChannel[] = $paliChannel;
+                } else {
+                    if (!isset($customBookChannel[$ids[0]])) {
+                        $cbChannel = CustomBook::where('book_id', $ids[0])->value('channel_id');
+                        if ($cbChannel) {
+                            $customBookChannel[$ids[0]] = $cbChannel;
+                        } else {
+                            $customBookChannel[$ids[0]] = $paliChannel;
+                        }
+                    }
+                    $idWithChannel[] = $customBookChannel[$ids[0]];
+                }
+                $sentContainer[$value] = false;
+                $query[] = $ids;
+                $queryWithChannel[] = $idWithChannel;
+            }
+        }
+        //获取单句长度
+        if (count($query) > 0) {
+            $table = Sentence::whereIns(['book_id', 'paragraph', 'word_start', 'word_end', 'channel_uid'], $queryWithChannel)
+                ->select(['book_id', 'paragraph', 'word_start', 'word_end', 'strlen']);
+            $sentLen = $table->get();
+
+            foreach ($sentLen as $value) {
+                $strlen = $value->strlen;
+                if (empty($strlen)) {
+                    $strlen = 0;
+                }
+                $sentId = "{$value->book_id}-{$value->paragraph}-{$value->word_start}-{$value->word_end}";
+                $sentLenContainer[$sentId] = $strlen;
+            }
+        }
+
+        $channelById = [];
+        $channelId = [];
+
+        //获取全网公开的有译文的channel
+        if ($request->get('owner') === 'all' || $request->get('owner') === 'public') {
+            if (count($query) > 0) {
+                $publicChannelsWithContent = Sentence::whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query)
+                    ->where('strlen', '>', 0)
+                    ->where('status', 30)
+                    ->groupBy('channel_uid')
+                    ->select('channel_uid')
+                    ->get();
+                foreach ($publicChannelsWithContent as $key => $value) {
+                    # code...
+                    $value['res_id'] = $value->channel_uid;
+                    $value['power'] = 10;
+                    $value['type'] = 2;
+                    if (!isset($channelById[$value['res_id']])) {
+                        $channelId[] = $value['res_id'];
+                        $channelById[$value['res_id']] = $value;
+                    }
+                }
+            }
+        }
+
+        #获取 user 在某章节 所有有权限的 channel 列表
+        $user = AuthApi::current($request);
+        if ($user !== false) {
+            //我自己的
+            if ($request->get('owner') === 'all' || $request->get('owner') === 'my') {
+                $my = Channel::select($indexCol)->where('owner_uid', $user['user_uid'])->get();
+                foreach ($my as $key => $value) {
+                    $channelId[] = $value->uid;
+                    $channelById[$value->uid] = [
+                        'res_id' => $value->uid,
+                        'power' => 30,
+                        'type' => 2,
+                    ];
+                }
+            }
+
+            //获取共享channel
+            if ($request->get('owner') === 'all' || $request->get('owner') === 'collaborator') {
+                $allSharedChannels = ShareApi::getResList($user['user_uid'], 2);
+                foreach ($allSharedChannels as $key => $value) {
+                    # code...
+                    if (!in_array($value['res_id'], $channelId)) {
+                        $channelId[] = $value['res_id'];
+                        $channelById[$value['res_id']] = $value;
+                    }
+                }
+            }
+        }
+
+        //所有有这些句子译文的channel
+        if (count($query) > 0) {
+            $allChannels = Sentence::whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query)
+                ->where('strlen', '>', 0)
+                ->groupBy('channel_uid')
+                ->select('channel_uid')
+                ->get();
+        }
+
+        //所有需要查询的channel
+        $table = Channel::select(['uid', 'name', 'summary', 'type', 'owner_uid', 'lang', 'status', 'updated_at', 'created_at'])
+            ->whereIn('uid', $channelId);
+        if ($user !== false) {
+            $table->orWhere('owner_uid', $user['user_uid']);
+        }
+        $result = $table->get();
+
+        foreach ($result as $key => $value) {
+            //角色
+            if ($user !== false && $value->owner_uid === $user['user_uid']) {
+                $value['role'] = 'owner';
+            } else {
+                if (isset($channelById[$value->uid])) {
+                    switch ($channelById[$value->uid]['power']) {
+                        case 10:
+                            # code...
+                            $value['role'] = 'member';
+                            break;
+                        case 20:
+                            $value['role'] = 'editor';
+                            break;
+                        case 30:
+                            $value['role'] = 'owner';
+                            break;
+                        default:
+                            # code...
+                            $value['role'] = $channelById[$value->uid]['power'];
+                            break;
+                    }
+                }
+            }
+            # 获取studio信息
+            $result[$key]["studio"] = \App\Http\Api\StudioApi::getById($value->owner_uid);
+
+            //获取进度
+            if (count($query) > 0) {
+                $currChannelId = $value->uid;
+                $hasContent = Arr::first($allChannels, function ($value, $key) use ($currChannelId) {
+                    return ($value->channel_uid === $currChannelId);
+                });
+                if ($hasContent && count($query) > 0) {
+                    $finalTable = Sentence::whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query)
+                        ->where('channel_uid', $currChannelId)
+                        ->where('strlen', '>', 0)
+                        ->select(['strlen', 'book_id', 'paragraph', 'word_start', 'word_end', 'created_at', 'updated_at']);
+                    $created_at = time();
+                    $edit_at = 0;
+                    if ($finalTable->count() > 0) {
+                        $finished = $finalTable->get();
+                        $currChannel = [];
+                        foreach ($finished as $rowFinish) {
+                            $createTime = strtotime($rowFinish->created_at);
+                            $updateTime = strtotime($rowFinish->updated_at);
+                            if ($createTime < $created_at) {
+                                $created_at = $createTime;
+                            }
+                            if ($updateTime > $edit_at) {
+                                $edit_at = $updateTime;
+                            }
+                            $currChannel["{$rowFinish->book_id}-{$rowFinish->paragraph}-{$rowFinish->word_start}-{$rowFinish->word_end}"] = 1;
+                        }
+                        $final = [];
+                        foreach ($sentContainer as $sentId => $rowSent) {
+                            # code...
+                            if (isset($currChannel[$sentId])) {
+                                $final[] = [$sentLenContainer[$sentId], true];
+                            } else {
+                                $final[] = [$sentLenContainer[$sentId], false];
+                            }
+                        }
+                        $result[$key]['final'] = $final;
+                        $result[$key]['content_created_at'] = date('Y-m-d H:i:s', $created_at);
+                        $result[$key]['content_updated_at'] = date('Y-m-d H:i:s', $edit_at);
+                    }
+                }
+            }
+        }
+        return $this->ok(["rows" => $result, count($result)]);
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        //判断当前用户是否有指定的studio的权限
+        $studioId = StudioApi::getIdByName($request->get('studio'));
+        if (!StudioApi::userCanManage($user['user_uid'], $studioId)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $studio = StudioApi::getById($studioId);
+        //查询是否重复
+        if (Channel::where('name', $request->get('name'))
+            ->where('owner_uid', $studioId)
+            ->exists()
+        ) {
+            return $this->error(__('validation.exists', ['name']), 200, 200);
+        }
+
+        $channel = new Channel;
+        $channel->id = app('snowflake')->id();
+        $channel->name = $request->get('name');
+        $channel->owner_uid = $studioId;
+        $channel->type = $request->get('type');
+        $channel->lang = $request->get('lang');
+        $channel->editor_id = $user['user_id'];
+        if (isset($studio['roles'])) {
+            if (in_array('basic', $studio['roles'])) {
+                $channel->status = 5;
+            }
+        }
+        $channel->create_time = time() * 1000;
+        $channel->modify_time = time() * 1000;
+        $channel->save();
+        return $this->ok($channel);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+        $indexCol = ['uid', 'name', 'summary', 'type', 'owner_uid', 'lang', 'is_system', 'status', 'updated_at', 'created_at'];
+        $channel = Channel::where("uid", $id)->select($indexCol)->first();
+        if (!$channel) {
+            return $this->error('no res');
+        }
+        $studio = StudioApi::getById($channel->owner_uid);
+        $channel->studio = $studio;
+        $channel->owner_info = ['nickname' => $studio['nickName'], 'username' => $studio['realName']];
+        return $this->ok($channel);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $name
+     * @return \Illuminate\Http\Response
+     */
+    public function showByName(string $name)
+    {
+        //
+        $indexCol = ['uid', 'name', 'summary', 'type', 'owner_uid', 'lang', 'is_system', 'status', 'updated_at', 'created_at'];
+        $channel = Channel::where("name", $name)->select($indexCol)->first();
+        if ($channel) {
+            return $this->ok(new ChannelResource($channel));
+        } else {
+            return $this->error('no channel');
+        }
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Channel $channel)
+    {
+        //鉴权
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if ($channel->is_system) {
+            return $this->error('system channel', 403, 403);
+        }
+        if ($channel->owner_uid !== $user["user_uid"]) {
+            //判断是否为协作
+            $power = ShareApi::getResPower($user["user_uid"], $request->get('id'));
+            if ($power < 30) {
+                return $this->error(__('auth.failed'), 403, 403);
+            }
+        }
+        $channel->name = $request->get('name');
+        $channel->type = $request->get('type');
+        $channel->summary = $request->get('summary');
+        $channel->lang = $request->get('lang');
+        $channel->status = $request->get('status');
+        $channel->save();
+        return $this->ok($channel);
+    }
+    /**
+     * patch the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function patch(Request $request, Channel $channel)
+    {
+        //鉴权
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [], 401);
+        }
+        if ($channel->is_system) {
+            return $this->error('system channel', 403, 403);
+        }
+        if ($channel->owner_uid !== $user["user_uid"]) {
+            //判断是否为协作
+            $power = ShareApi::getResPower($user["user_uid"], $request->get('id'));
+            if ($power < 30) {
+                return $this->error(__('auth.failed'), [], 403);
+            }
+        }
+        if ($request->has('name')) {
+            $channel->name = $request->get('name');
+        }
+        if ($request->has('type')) {
+            $channel->type = $request->get('type');
+        }
+        if ($request->has('summary')) {
+            $channel->summary = $request->get('summary');
+        }
+        if ($request->has('lang')) {
+            $channel->lang = $request->get('lang');
+        }
+        if ($request->has('status')) {
+            $channel->status = $request->get('status');
+        }
+        if ($request->has('config')) {
+            $channel->status = $request->get('config');
+        }
+        $channel->save();
+        return $this->ok($channel);
+    }
+    /**
+     * Remove the specified resource from storage.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, Channel $channel)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        if ($user['user_uid'] !== $channel->owner_uid) {
+            return $this->error(__('auth.failed'));
+        }
+        //查询其他资源
+        if (Sentence::where("channel_uid", $channel->uid)->exists()) {
+            return $this->error("译文有数据无法删除");
+        }
+        if (DhammaTerm::where("channal", $channel->uid)->exists()) {
+            return $this->error("术语有数据无法删除");
+        }
+        if (WbwBlock::where("channel_uid", $channel->uid)->exists()) {
+            return $this->error("逐词解析有数据无法删除");
+        }
+        $delete = 0;
+        DB::transaction(function () use ($channel, $delete) {
+            //TODO 删除相关资源
+            $delete = $channel->delete();
+        });
+
+        return $this->ok($delete);
+    }
+}

+ 82 - 0
api-v12/app/Http/Controllers/ChannelIOController.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Channel;
+use Illuminate\Http\Request;
+
+class ChannelIOController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $table = Channel::select(['uid','name','summary',
+                                'type','owner_uid','lang',
+                                'status','updated_at','created_at']);
+        switch ($request->get('view')) {
+            case 'public':
+                $table->where('status',30)
+                      ->where('updated_at','>',$request->get('updated_at','2000-1-1'));
+                break;
+        }
+        $count = $table->count();
+        //处理排序
+        $table->orderBy('updated_at','asc');
+        //处理分页
+        $table->skip($request->get("offset",0))
+              ->take($request->get("limit",200));
+        //获取数据
+        $result = $table->get();
+        return $this->ok(["rows"=>$result,"count"=>$count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Channel $channel)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Channel $channel)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Channel  $channel
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Channel $channel)
+    {
+        //
+    }
+}

+ 93 - 0
api-v12/app/Http/Controllers/ChapterController.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\PaliText;
+use Illuminate\Http\Request;
+use App\Http\Resources\ChapterResource;
+
+class ChapterController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'toc':
+                $chapter = PaliText::where('book', $request->get('book'))
+                    ->where('paragraph', $request->get('para'))
+                    ->first();
+                $start = $request->get('para');
+                $end = $request->get('para') + $chapter->chapter_len - 1;
+                $table = PaliText::where('book', $request->get('book'))
+                    ->whereBetween('paragraph', [$start, $end])
+                    ->where('level', '<', 100)
+                    ->select(['book', 'paragraph', 'level', 'text', 'chapter_len', 'chapter_strlen', 'parent']);
+                break;
+        }
+        //获取记录总条数
+        $count = $table->count();
+        //处理排序
+        $table = $table->orderBy(
+            $request->get("order", 'paragraph'),
+            $request->get("dir", 'asc')
+        );
+        //处理分页
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get("limit", 1000));
+        $result = $table->get();
+        return $this->ok([
+            "rows" => ChapterResource::collection($result),
+            "count" => $count
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function show(PaliText $paliText)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, PaliText $paliText)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(PaliText $paliText)
+    {
+        //
+    }
+}

+ 84 - 0
api-v12/app/Http/Controllers/ChapterIOController.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\ProgressChapter;
+use App\Models\Channel;
+use Illuminate\Http\Request;
+
+class ChapterIOController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $table = ProgressChapter::select(['uid','book','para',
+                                    'channel_id','progress','lang',
+                                    'title','summary','updated_at','created_at']);
+        switch ($request->get('view')) {
+            case 'public':
+                $channels = Channel::where('status',30)->select('uid')->get();
+                $table->whereIn('channel_id',$channels)
+                      ->where('updated_at','>',$request->get('updated_at','2000-1-1'));
+            break;
+        }
+        $count = $table->count();
+        //处理排序
+        $table->orderBy('updated_at','asc');
+        //处理分页
+        $table->skip($request->get("offset",0))
+              ->take($request->get("limit",200));
+        //获取数据
+        $result = $table->get();
+        return $this->ok(["rows"=>$result,"count"=>$count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function show(ProgressChapter $progressChapter)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, ProgressChapter $progressChapter)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(ProgressChapter $progressChapter)
+    {
+        //
+    }
+}

+ 88 - 0
api-v12/app/Http/Controllers/ChapterIndexController.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\ProgressChapter;
+use App\Models\Channel;
+use Illuminate\Http\Request;
+
+class ChapterIndexController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'public':
+                $channels = Channel::where('status',30)->select('uid');
+                $table = ProgressChapter::whereIn('channel_id',$channels);
+            break;
+        }
+        if($request->has("updated_at")){
+            $table = $table->where('updated_at','>', $request->get("updated_at"));
+        }
+        if($request->has("created_at")){
+            $table = $table->where('created_at','>', $request->get("created_at"));
+        }
+        //获取记录总条数
+        $count = $table->count();
+        //处理排序
+        $table = $table->orderBy($request->get("order",'created_at'),
+                                 $request->get("dir",'desc'));
+        //处理分页
+        $table = $table->skip($request->get("offset",0))
+                       ->take($request->get("limit",200));
+        //获取数据
+        $result = $table->get();
+        return $this->ok(["rows"=>$result,"count"=>$count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function show(ProgressChapter $progressChapter)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, ProgressChapter $progressChapter)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(ProgressChapter $progressChapter)
+    {
+        //
+    }
+}

+ 115 - 0
api-v12/app/Http/Controllers/ChatController.php

@@ -0,0 +1,115 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Http\Requests\StoreChatRequest;
+use App\Http\Requests\UpdateChatRequest;
+use App\Models\Chat;
+use App\Http\Resources\ChatResource;
+
+class ChatController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        $query = Chat::query();
+
+        if ($request->has('user_id')) {
+            $query->where('user_id', $request->user_id);
+        }
+
+        $total = $query->count();
+
+        $chats = $query->orderBy('updated_at', 'desc')
+            ->paginate($request->get('limit', 20));
+
+        return $this->ok([
+            'rows' => ChatResource::collection($chats),
+            'total' => $total
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \App\Http\Requests\StoreChatRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(StoreChatRequest $request)
+    {
+        $chat = Chat::create($request->validated());
+
+        return $this->ok(new ChatResource($chat));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Chat  $chat
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Chat $chat)
+    {
+        return $this->ok(new ChatResource($chat));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \App\Http\Requests\UpdateChatRequest  $request
+     * @param  \App\Models\Chat  $chat
+     * @return \Illuminate\Http\Response
+     */
+    public function update(UpdateChatRequest $request, Chat $chat)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Chat  $chat
+     * @return \Illuminate\Http\Response
+     */
+    /**
+     * 单个软删除
+     */
+    public function destroy(Chat $chat)
+    {
+        $chat->delete(); // 软删除
+        return $this->ok('Chat deleted successfully.');
+    }
+
+    /**
+     * 批量软删除
+     */
+    public function batchDelete(Request $request)
+    {
+        $chatIds = $request->input('uids', []); // 前端传入数组
+        $count = Chat::batchSoftDelete($chatIds);
+
+        return $this->ok([
+            'message' => "Chats soft deleted successfully.",
+            'deleted_count' => $count
+        ]);
+    }
+
+    /**
+     * 批量恢复
+     */
+    public function batchRestore(Request $request)
+    {
+        $chatIds = $request->input('uids', []); // 前端传入数组
+        $count = Chat::batchRestore($chatIds);
+
+        return $this->ok([
+            'message' => "Chats restored successfully.",
+            'restored_count' => $count
+        ]);
+    }
+}

+ 106 - 0
api-v12/app/Http/Controllers/ChatMessageController.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreChatMessageRequest;
+use App\Http\Requests\UpdateChatMessageRequest;
+use App\Models\ChatMessage;
+use App\Http\Resources\ChatMessageResource;
+use App\Models\Chat;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+
+class ChatMessageController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $query = ChatMessage::where('chat_id', $request->get('chat'));
+
+        $total = $query->count();
+
+        $messages = $query->orderBy('id')->paginate($request->get('limit', 500));
+
+        return $this->ok([
+            'data' => ChatMessageResource::collection($messages),
+            'total' => $total
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \App\Http\Requests\StoreChatMessageRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(StoreChatMessageRequest $request)
+    {
+
+        $messagesData = $request->validated()['messages'];
+        $chatId = $request->validated()['chat_id'];
+
+        $created = [];
+        foreach ($messagesData as $key => $data) {
+            $data['chat_id'] = $chatId;
+            $data['uid'] = (string) Str::uuid();
+
+            // 如果是新消息且没有指定session_id,创建新的session
+            if (empty($data['session_id']) && empty($data['parent_id'])) {
+                $data['session_id'] = (string) Str::uuid();
+            }
+            // 如果有parent_id但没有session_id,继承父消息的session_id
+            elseif (empty($data['session_id']) && !empty($data['parent_id'])) {
+                $parent = ChatMessage::where('uid', $data['parent_id'])->first();
+                if ($parent) {
+                    $data['session_id'] = $parent->session_id;
+                }
+            }
+
+            $created[] = ChatMessage::create($data);
+        }
+
+        return $this->ok([
+            'data' => ChatMessageResource::collection($created),
+            'total' => count($created),
+        ]);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\ChatMessage  $chatMessage
+     * @return \Illuminate\Http\Response
+     */
+    public function show(ChatMessage $chatMessage)
+    {
+        return $this->ok(new ChatMessageResource($chatMessage));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \App\Http\Requests\UpdateChatMessageRequest  $request
+     * @param  \App\Models\ChatMessage  $chatMessage
+     * @return \Illuminate\Http\Response
+     */
+    public function update(UpdateChatMessageRequest $request, ChatMessage $chatMessage)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\ChatMessage  $chatMessage
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(ChatMessage $chatMessage)
+    {
+        //
+    }
+}

+ 291 - 0
api-v12/app/Http/Controllers/CollectionController.php

@@ -0,0 +1,291 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Collection;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\ShareApi;
+use App\Http\Resources\CollectionResource;
+use Illuminate\Support\Facades\DB;
+
+class CollectionController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+
+        $result = false;
+        $indexCol = [
+            'uid',
+            'title',
+            'subtitle',
+            'summary',
+            'article_list',
+            'owner',
+            'status',
+            'default_channel',
+            'lang',
+            'updated_at',
+            'created_at'
+        ];
+        switch ($request->get('view')) {
+            case 'studio_list':
+                $indexCol = ['owner'];
+                //TODO ?
+                $table = Collection::select($indexCol)
+                    ->selectRaw('count(*) as count')
+                    ->where('status', 30)
+                    ->groupBy('owner');
+                break;
+            case 'studio':
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                $studioId = StudioApi::getIdByName($request->get('name'));
+                //判断当前用户是否有指定的studio的权限
+                if ($user['user_uid'] !== $studioId) {
+                    return $this->error(__('auth.failed'));
+                }
+                $table = Collection::select($indexCol);
+                if ($request->get('view2', 'my') === 'my') {
+                    $table = $table->where('owner', $studioId);
+                } else {
+                    //协作
+                    $resList = ShareApi::getResList($studioId, 4);
+                    $resId = [];
+                    foreach ($resList as $res) {
+                        $resId[] = $res['res_id'];
+                    }
+                    $table = $table->whereIn('uid', $resId)->where('owner', '<>', $studioId);
+                }
+                break;
+            case 'public':
+                //全网公开
+                $table = Collection::select($indexCol)->where('status', 30);
+                if ($request->has('studio')) {
+                    $studioId = StudioApi::getIdByName($request->get('studio'));
+                    $table = $table->where('owner', $studioId);
+                }
+                break;
+            default:
+                # code...
+                return $this->error("无法识别的view参数", 200, 200);
+                break;
+        }
+        if ($request->has("search") && !empty($request->has("search"))) {
+            $table = $table->where('title', 'like', "%" . $request->get("search") . "%");
+        }
+        $count = $table->count();
+        if ($request->has("order") && $request->has("dir")) {
+            $table = $table->orderBy($request->get("order"), $request->get("dir"));
+        } else {
+            if ($request->get('view') === 'studio_list') {
+                $table = $table->orderBy('count', 'desc');
+            } else {
+                $table = $table->orderBy('updated_at', 'desc');
+            }
+        }
+
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get("limit", 1000));
+
+        $result = $table->get();
+        return $this->ok(["rows" => CollectionResource::collection($result), "count" => $count]);
+    }
+
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function showMyNumber(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        $studioId = StudioApi::getIdByName($request->get('studio'));
+        if ($user['user_uid'] !== $studioId) {
+            return $this->error(__('auth.failed'));
+        }
+        //我的
+        $my = Collection::where('owner', $studioId)->count();
+        //协作
+        $resList = ShareApi::getResList($studioId, 4);
+        $resId = [];
+        foreach ($resList as $res) {
+            $resId[] = $res['res_id'];
+        }
+        $collaboration = Collection::whereIn('uid', $resId)->where('owner', '<>', $studioId)->count();
+
+        return $this->ok(['my' => $my, 'collaboration' => $collaboration]);
+    }
+
+    public static function UserCanEdit($user_uid, $collection)
+    {
+        if ($collection->owner === $user_uid) {
+            return true;
+        }
+        //查协作
+        $currPower = ShareApi::getResPower($user_uid, $collection->uid);
+        if ($currPower >= 20) {
+            return true;
+        }
+        return false;
+    }
+    public static function UserCanRead($user_uid, $collection)
+    {
+        if ($collection->owner === $user_uid) {
+            return true;
+        }
+        //查协作
+        $currPower = ShareApi::getResPower($user_uid, $collection->uid);
+        if ($currPower >= 10) {
+            return true;
+        }
+        return false;
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        $user = \App\Http\Api\AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        //判断当前用户是否有指定的studio的权限
+        if ($user['user_uid'] !== \App\Http\Api\StudioApi::getIdByName($request->get('studio'))) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        //查询是否重复
+        if (Collection::where('title', $request->get('title'))->where('owner', $user['user_uid'])->exists()) {
+            return $this->error(__('validation.exists'), 200, 200);
+        } else {
+            $newOne = new Collection;
+            $newOne->id = app('snowflake')->id();
+            $newOne->uid = Str::uuid();
+            $newOne->title = $request->get('title');
+            $newOne->lang = $request->get('lang');
+            $newOne->article_list = "[]";
+            $newOne->owner = $user['user_uid'];
+            $newOne->owner_id = $user['user_id'];
+            $newOne->editor_id = $user['user_id'];
+            $newOne->create_time = time() * 1000;
+            $newOne->modify_time = time() * 1000;
+            $newOne->save();
+            return $this->ok(new CollectionResource($newOne));
+        }
+    }
+
+    /**
+     * Display the specified resource.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request  $request, $id)
+    {
+        $result  = Collection::where('uid', $id)->first();
+        if (!$result) {
+            Log::warning("没有查询到数据 id={$id}");
+            return $this->error("没有查询到数据 id={$id}");
+        }
+        if ($result->status < 30) {
+            //私有文章,判断权限
+            Log::info('私有文章,判断权限' . $id);
+            $user = \App\Http\Api\AuthApi::current($request);
+            if (!$user) {
+                Log::warning('未登录');
+                return $this->error(__('auth.failed'), 403, 403);
+            }
+            //判断当前用户是否有指定的studio的权限
+            if ($user['user_uid'] !== $result->owner) {
+                Log::info($user["user_uid"] . '私有文章,判断权限' . $id);
+                //非所有者
+                if (CollectionController::UserCanRead($user['user_uid'], $result) === false) {
+                    Log::warning($user["user_uid"] . '没有读取权限');
+                    return $this->error(__('auth.failed'), 403, 403);
+                }
+            }
+        }
+        $result->fullArticleList = true;
+        return $this->ok(new CollectionResource($result));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, string $id)
+    {
+        //
+        $collection  = Collection::find($id);
+        if (!$collection) {
+            return $this->error("no recorder");
+        }
+        //鉴权
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (!CollectionController::UserCanEdit($user["user_uid"], $collection)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $collection->title = $request->get('title');
+        $collection->subtitle = $request->get('subtitle');
+        $collection->summary = $request->get('summary');
+        if ($request->has('aritcle_list')) {
+            $collection->article_list = \json_encode($request->get('aritcle_list'));
+        }
+        $collection->lang = $request->get('lang');
+        $collection->status = $request->get('status');
+        $collection->default_channel = $request->get('default_channel');
+        $collection->modify_time = time() * 1000;
+        $collection->save();
+        return $this->ok(new CollectionResource($collection));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, string $id)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        $collection = Collection::find($id);
+        if ($user['user_uid'] !== $collection['owner']) {
+            return $this->error(__('auth.failed'));
+        }
+        $delete = 0;
+        DB::transaction(function () use ($collection, $delete) {
+            //TODO 删除文集中的文章
+            $delete = $collection->delete();
+        });
+
+        return $this->ok($delete);
+    }
+}

+ 76 - 0
api-v12/app/Http/Controllers/CommandController.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\Mq;
+
+class CommandController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+        return $this->ok('ok');
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user || $user['user_uid'] !== 'ba5463f3-72d1-4410-858e-eadd10884713'){
+            return $this->error(__('auth.failed'),403,403);
+        }
+
+        Mq::publish('task',[
+            'name'=>$request->get('name'),
+            'param'=>$request->get('param'),
+        ]);
+        return $this->ok('ok');
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 85 - 0
api-v12/app/Http/Controllers/CommentaryController.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Commentary;
+use Illuminate\Http\Request;
+
+class CommentaryController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function create()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Commentary  $commentary
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Commentary $commentary)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\Commentary  $commentary
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(Commentary $commentary)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Commentary  $commentary
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Commentary $commentary)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Commentary  $commentary
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Commentary $commentary)
+    {
+        //
+    }
+}

+ 133 - 0
api-v12/app/Http/Controllers/CompoundController.php

@@ -0,0 +1,133 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserDict;
+use Illuminate\Http\Request;
+use App\Http\Api\DictApi;
+use App\Tools\TurboSplit;
+use App\Http\Resources\CompoundResource;
+
+class CompoundController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        $dict_id = DictApi::getSysDict('robot_compound');
+        if (!$dict_id) {
+            return $this->error('没有找到 robot_compound 字典');
+        }
+        switch ($request->get('view')) {
+            case 'only-word':
+                $result = UserDict::where('dict_id', $dict_id)
+                    ->groupBy('word')->select('word')->get();
+                $count = count($result);
+                break;
+
+            default:
+                # code...
+                break;
+        }
+        return $this->ok([
+            "rows" => CompoundResource::collection($result),
+            "count" => $count
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        /**
+         *
+         */
+        $dict_id = DictApi::getSysDict('robot_compound');
+        if (!$dict_id) {
+            return $this->error('没有找到 robot_compound 字典');
+        }
+        //删除旧数据
+        $del = UserDict::where('dict_id', $dict_id)
+            ->whereIn('word', $request->get('index'))
+            ->delete();
+        foreach ($request->get('words') as $key => $word) {
+            $new = new UserDict;
+            $new->id = app('snowflake')->id();
+            $new->word = $word['word'];
+            $new->factors = $word['factors'];
+            $new->dict_id = $dict_id;
+            $new->source = '_ROBOT_';
+            $new->create_time = (int)(microtime(true) * 1000);
+            $new->type = $word['type'];
+            $new->grammar = $word['grammar'];
+            $new->parent = $word['parent'];
+            $new->confidence = $word['confidence'];
+            $new->note = $word['confidence'];
+            $new->language = 'cm';
+            $new->creator_id = 1;
+            $new->flag = 0; //标记为维护状态
+            $new->save();
+        }
+        return $this->ok(count($request->get('words')));
+    }
+
+    /**
+     * Display the specified resource.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\DhammaTerm  $dhammaTerm
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, string $word)
+    {
+        //
+        $start = microtime(true);
+        $dict_id = DictApi::getSysDict('robot_compound');
+        if (!$dict_id) {
+            return $this->error('没有找到 robot_compound 字典');
+        }
+        $result = UserDict::where('dict_id', $dict_id)
+            ->where('word', $word)
+            ->orderBy('confidence', 'desc')
+            ->get();
+        if (count($result) > 0) {
+            return $this->ok(['rows' => $result, 'count' => count($result), 'mode' => 'dict']);
+        } else if (mb_strlen($word, 'UTF-8') < 60) {
+            $ts = new TurboSplit();
+            $parts = $ts->splitA($word);
+            $time = microtime(true) - $start;
+            return $this->ok(['rows' => $parts, 'count' => count($parts), 'mode' => 'realtime', 'time' => $time]);
+        } else {
+            return $this->ok(['rows' => [], 'count' => 0]);
+        }
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\UserDict  $word
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, UserDict $word)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserDict  $word
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserDict $word)
+    {
+        //
+    }
+}

+ 1127 - 0
api-v12/app/Http/Controllers/CorpusController.php

@@ -0,0 +1,1127 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Carbon\Carbon;
+
+use App\Models\Sentence;
+use App\Models\Channel;
+use App\Models\PaliText;
+use App\Models\WbwTemplate;
+use App\Models\WbwBlock;
+use App\Models\Wbw;
+use App\Models\Discussion;
+use App\Models\PaliSentence;
+use App\Models\SentSimIndex;
+use App\Models\CustomBookSentence;
+use App\Models\CustomBook;
+
+use Illuminate\Support\Str;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use App\Tools\RedisClusters;
+use App\Http\Api\MdRender;
+use App\Http\Api\SuggestionApi;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\UserApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Arr;
+use App\Http\Resources\TocResource;
+use Illuminate\Support\Facades\Redis;
+
+class CorpusController extends Controller
+{
+    protected $result = [
+        "uid" => '',
+        "title" => '',
+        "path" => [],
+        "sub_title" => '',
+        "summary" => '',
+        "content" => '',
+        "content_type" => "html",
+        "toc" => [],
+        "status" => 30,
+        "lang" => "",
+        "created_at" => "",
+        "updated_at" => "",
+    ];
+    protected $wbwChannels = [];
+    //句子需要查询的列
+    protected $selectCol = [
+        'uid',
+        'book_id',
+        'paragraph',
+        'word_start',
+        "word_end",
+        'channel_uid',
+        'content',
+        'content_type',
+        'editor_uid',
+        'acceptor_uid',
+        'pr_edit_at',
+        'fork_at',
+        'create_time',
+        'modify_time',
+        'created_at',
+        'updated_at',
+    ];
+
+    protected $userUuid = null;
+
+    protected $debug = [];
+
+    public function __construct() {}
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'para':
+                return $this->showPara($request);
+                break;
+            default:
+                # code...
+                break;
+        }
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Sentence $sentence)
+    {
+        //
+    }
+    public function getSentTpl($id, $inputChannels, $mode = 'edit', $onlyProps = false, $format = 'react')
+    {
+        $sent = [];
+        $channels = $inputChannels;
+        $sentId = \explode('-', $id);
+        if (count($sentId) !== 4) {
+            return false;
+        }
+        $bookId = (int)$sentId[0];
+        if ($bookId < 1000) {
+            if ($mode === 'read') {
+                $originalChannelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+            } else {
+                $originalChannelId = ChannelApi::getSysChannel('_System_Wbw_VRI_');
+            }
+        } else {
+            $originalChannelId = CustomBook::where('book_id', $bookId)->value('channel_id');
+        }
+
+
+        if (isset($originalChannelId) && $originalChannelId) {
+            array_push($channels, $originalChannelId);
+        }
+        $record = Sentence::select($this->selectCol)
+            ->where('book_id', $sentId[0])
+            ->where('paragraph', $sentId[1])
+            ->where('word_start', (int)$sentId[2])
+            ->where('word_end', (int)$sentId[3])
+            ->whereIn('channel_uid', $channels)
+            ->get();
+
+        $channelIndex = $this->getChannelIndex($channels);
+
+        if (isset($toSentFormat)) {
+            foreach ($toSentFormat as $key => $org) {
+                $record[] = $org;
+            }
+        }
+
+        //获取wbw channel
+        //目前默认的 wbw channel 是第一个translation channel
+        foreach ($channels as  $channel) {
+            # code...
+            if ($channelIndex[$channel]->type === 'translation') {
+                $this->wbwChannels[] = $channel;
+                break;
+            }
+        }
+        return $this->makeContent($record, $mode, $channelIndex, [], $onlyProps, false, $format);
+    }
+    /**
+     * Display the specified resource.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function showSent(Request  $request, string $id)
+    {
+        $user = AuthApi::current($request);
+        if ($user) {
+            $this->userUuid = $user['user_uid'];
+        }
+        $channels = \explode('_', $request->get('channels'));
+
+        $this->result['uid'] = "";
+        $this->result['title'] = "";
+        $this->result['subtitle'] = "";
+        $this->result['summary'] = "";
+        $this->result['lang'] = "";
+        $this->result['status'] = 30;
+        $this->result['content'] = $this->getSentTpl($id, $channels, $request->get('mode', 'edit'));
+        return $this->ok($this->result);
+    }
+    /**
+     * 获取某句子的全部译文
+
+     * @param  \Illuminate\Http\Request  $request
+     * @param string $type
+     * @param string $id
+     * @return \Illuminate\Http\Response
+     */
+    public function showSentences(Request $request, string $type, string $id)
+    {
+        $user = AuthApi::current($request);
+        if ($user) {
+            $this->userUuid = $user['user_uid'];
+        }
+
+        $param = \explode('_', $id);
+        $sentId = \explode('-', $param[0]);
+        $channels = [];
+
+        #获取channel类型
+        $sentChannel = Sentence::select('channel_uid')
+            ->where('book_id', $sentId[0])
+            ->where('paragraph', $sentId[1])
+            ->where('word_start', $sentId[2])
+            ->where('word_end', $sentId[3])
+            ->get();
+        foreach ($sentChannel as $key => $value) {
+            # code...
+            $channels[] = $value->channel_uid;
+        }
+        $channelInfo = Channel::whereIn("uid", $channels)->select(['uid', 'type', 'lang', 'name'])->get();
+        $indexChannel = [];
+        $channels = [];
+        foreach ($channelInfo as $key => $value) {
+            # code...
+            if ($value->type === $type) {
+                $indexChannel[$value->uid] = $value;
+                $channels[] = $value->uid;
+            }
+        }
+        //获取句子数据
+        $record = Sentence::select($this->selectCol)
+            ->where('book_id', $sentId[0])
+            ->where('paragraph', $sentId[1])
+            ->where('word_start', $sentId[2])
+            ->where('word_end', $sentId[3])
+            ->whereIn('channel_uid', $channels)
+            ->orderBy('paragraph')
+            ->orderBy('word_start')
+            ->get();
+        if (count($record) === 0) {
+            return $this->error("no data");
+        }
+
+        $this->result['uid'] = "";
+        $this->result['title'] = "";
+        $this->result['subtitle'] = "";
+        $this->result['summary'] = "";
+        $this->result['lang'] = "";
+        $this->result['status'] = 30;
+        $this->result['content'] = $this->makeContent($record, 'read', $indexChannel);
+        //TODO 检查一下这个read为什么要写死
+        return $this->ok($this->result);
+    }
+    /**
+     * Store a newly created resource in storage.
+
+     * @param  \Illuminate\Http\Request  $request
+     * @param string $id
+     * @param string $mode
+     * @return \Illuminate\Http\Response
+     */
+    public function showPara(Request $request)
+    {
+        if ($request->has('debug')) {
+            $this->debug = explode(',', $request->get('debug'));
+        }
+        $user = AuthApi::current($request);
+        if ($user) {
+            $this->userUuid = $user['user_uid'];
+        }
+        //
+        $channels = [];
+        if ($request->get('mode') === 'edit') {
+            //翻译模式加载json格式原文
+            $channels[] = ChannelApi::getSysChannel('_System_Wbw_VRI_');
+        } else {
+            //阅读模式加载html格式原文
+            $channels[] = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        }
+
+        if ($request->has('channels')) {
+            if (strpos($request->get('channels'), ',') === FALSE) {
+                $getChannel = explode('_', $request->get('channels'));
+            } else {
+                $getChannel = explode(',', $request->get('channels'));
+            }
+            $channels = array_merge($channels, $getChannel);
+        }
+        $para = explode(",", $request->get('par'));
+
+        //段落所在章节
+        $parent = PaliText::where('book', $request->get('book'))
+            ->where('paragraph', $para[0])->first();
+        $chapter = PaliText::where('book', $request->get('book'))
+            ->where('paragraph', $parent->parent)->first();
+        if ($chapter) {
+            if (empty($chapter->toc)) {
+                $this->result['title'] = "unknown";
+            } else {
+                $this->result['title'] = $chapter->toc;
+                $this->result['sub_title'] = $chapter->toc;
+                $this->result['path'] = json_decode($parent->path);
+            }
+        }
+        $paraFrom = $para[0];
+        $paraTo = end($para);
+
+        $indexedHeading = [];
+
+        #获取channel索引表
+        $tranChannels = [];
+        $channelInfo = Channel::whereIn("uid", $channels)
+            ->select(['uid', 'type', 'lang', 'name'])->get();
+        foreach ($channelInfo as $key => $value) {
+            # code...
+            if ($value->type === "translation") {
+                $tranChannels[] = $value->uid;
+            }
+        }
+        $indexChannel = [];
+        $indexChannel = $this->getChannelIndex($channels);
+        //获取wbw channel
+        //目前默认的 wbw channel 是第一个translation channel
+        foreach ($channels as $key => $value) {
+            # code...
+            if (
+                isset($indexChannel[$value]) &&
+                $indexChannel[$value]->type === 'translation'
+            ) {
+                $this->wbwChannels[] = $value;
+                break;
+            }
+        }
+        //章节译文标题
+        $title = Sentence::select($this->selectCol)
+            ->where('book_id', $parent->book)
+            ->where('paragraph', $parent->parent)
+            ->whereIn('channel_uid', $tranChannels)
+            ->first();
+        if ($title) {
+            $this->result['title'] = MdRender::render($title->content, [$title->channel_uid]);
+        }
+
+        /**
+         * 获取句子数据
+         */
+        $record = Sentence::select($this->selectCol)
+            ->where('book_id', $request->get('book'))
+            ->whereIn('paragraph', $para)
+            ->whereIn('channel_uid', $channels)
+            ->orderBy('paragraph')
+            ->orderBy('word_start')
+            ->get();
+        if (count($record) === 0) {
+            $this->result['content'] = "<span>No Data</span>";
+        } else {
+            $this->result['content'] = $this->makeContent($record, $request->get('mode', 'read'), $indexChannel, $indexedHeading, false, true);
+        }
+
+        return $this->ok($this->result);
+    }
+    /**
+     * Store a newly created resource in storage.
+
+     * @param  \Illuminate\Http\Request  $request
+     * @param string $id
+     * @return \Illuminate\Http\Response
+     */
+    public function showChapter(Request $request, string $id)
+    {
+        if ($request->has('debug')) {
+            $this->debug = explode(',', $request->get('debug'));
+        }
+        $user = AuthApi::current($request);
+        if ($user) {
+            $this->userUuid = $user['user_uid'];
+        }
+        //
+        $sentId = \explode('-', $id);
+        $channels = [];
+        if ($request->has('channels')) {
+            if (strpos($request->get('channels'), ',') === FALSE) {
+                $_channels = explode('_', $request->get('channels'));
+            } else {
+                $_channels = explode(',', $request->get('channels'));
+            }
+            foreach ($_channels as $key => $channel) {
+                if (Str::isUuid($channel)) {
+                    $channels[] = $channel;
+                }
+            }
+        }
+
+        $mode = $request->get('mode', 'read');
+        if ($mode === 'read') {
+            //阅读模式加载html格式原文
+            $channelId = ChannelApi::getSysChannel('_System_Pali_VRI_');
+        } else {
+            //翻译模式加载json格式原文
+            $channelId = ChannelApi::getSysChannel('_System_Wbw_VRI_');
+        }
+
+        if ($channelId !== false) {
+            $channels[] = $channelId;
+        }
+
+        $chapter = PaliText::where('book', $sentId[0])->where('paragraph', $sentId[1])->first();
+        if (!$chapter) {
+            return $this->error("no data");
+        }
+        $paraFrom = $sentId[1];
+        $paraTo = $sentId[1] + $chapter->chapter_len - 1;
+
+        if (empty($chapter->toc)) {
+            $this->result['title'] = "unknown";
+        } else {
+            $this->result['title'] = $chapter->toc;
+            $this->result['sub_title'] = $chapter->toc;
+            $this->result['path'] = json_decode($chapter->path);
+        }
+
+        //获取标题
+        $heading = PaliText::select(["book", "paragraph", "level"])
+            ->where('book', $sentId[0])
+            ->whereBetween('paragraph', [$paraFrom, $paraTo])
+            ->where('level', '<', 8)
+            ->get();
+        //将标题段落转成索引数组 以便输出标题层级
+        $indexedHeading = [];
+        foreach ($heading as $key => $value) {
+            # code...
+            $indexedHeading["{$value->book}-{$value->paragraph}"] = $value->level;
+        }
+        #获取channel索引表
+        $tranChannels = [];
+        $channelInfo = Channel::whereIn("uid", $channels)
+            ->select(['uid', 'type', 'lang', 'name'])->get();
+        foreach ($channelInfo as $key => $value) {
+            # code...
+            if ($value->type === "translation") {
+                $tranChannels[] = $value->uid;
+            }
+        }
+        $indexChannel = [];
+        $indexChannel = $this->getChannelIndex($channels);
+        //获取wbw channel
+        //目前默认的 wbw channel 是第一个translation channel
+        //TODO 处理不存在的channel id
+        foreach ($channels as $key => $value) {
+            # code...
+            if (
+                isset($indexChannel[$value]) &&
+                $indexChannel[$value]->type === 'translation'
+            ) {
+                $this->wbwChannels[] = $value;
+                break;
+            }
+        }
+        $title = Sentence::select($this->selectCol)
+            ->where('book_id', $sentId[0])
+            ->where('paragraph', $sentId[1])
+            ->whereIn('channel_uid', $tranChannels)
+            ->first();
+        if ($title) {
+            $this->result['title'] = MdRender::render($title->content, [$title->channel_uid]);
+            $mdRender = new MdRender(['format' => 'simple']);
+            $this->result['title_text'] = $mdRender->convert($title->content, [$title->channel_uid]);
+        }
+
+        /**
+         * 获取句子数据
+         * 算法:
+         * 1. 如果标题和下一级第一个标题之间有段落。只输出这些段落和子目录
+         * 2. 如果标题和下一级第一个标题之间没有间隔 且 chapter 长度大于10000个字符 且有子目录,只输出子目录
+         * 3. 如果二者都不是,lazy load
+         */
+        //1. 计算 标题和下一级第一个标题之间 是否有间隔
+        $nextChapter =  PaliText::where('book', $sentId[0])
+            ->where('paragraph', ">", $sentId[1])
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')
+            ->value('paragraph');
+        $between = $nextChapter - $sentId[1];
+        //查找子目录
+        $chapterLen = $chapter->chapter_len;
+        $toc = PaliText::where('book', $sentId[0])
+            ->whereBetween('paragraph', [$paraFrom + 1, $paraFrom + $chapterLen - 1])
+            ->where('level', '<', 8)
+            ->orderBy('paragraph')
+            ->select(['book', 'paragraph', 'level', 'toc'])
+            ->get();
+        $maxLen = 3000;
+        if ($between > 1) {
+            //有间隔
+            $paraTo = $nextChapter - 1;
+        } else {
+            if ($chapter->chapter_strlen > $maxLen) {
+                if (count($toc) > 0) {
+                    //有子目录只输出标题和目录
+                    $paraTo = $paraFrom;
+                } else {
+                    //没有子目录 全部输出
+                }
+            } else {
+                //章节小。全部输出 不输出子目录
+                $toc = [];
+            }
+        }
+
+        $pFrom = $request->get('from', $paraFrom);
+        $pTo = $request->get('to', $paraTo);
+        //根据句子的长度找到这次应该加载的段落
+
+        $paliText = PaliText::select(['paragraph', 'lenght'])
+            ->where('book', $sentId[0])
+            ->whereBetween('paragraph', [$pFrom, $pTo])
+            ->orderBy('paragraph')
+            ->get();
+        $sumLen = 0;
+        $currTo = $pTo;
+        foreach ($paliText as $para) {
+            $sumLen += $para->lenght;
+            if ($sumLen > $maxLen) {
+                $currTo = $para->paragraph;
+                break;
+            }
+        }
+        $record = Sentence::select($this->selectCol)
+            ->where('book_id', $sentId[0])
+            ->whereBetween('paragraph', [$pFrom, $currTo])
+            ->whereIn('channel_uid', $channels)
+            ->orderBy('paragraph')
+            ->orderBy('word_start')
+            ->get();
+        if (count($record) === 0) {
+            return $this->error("no data");
+        }
+        $this->result['content'] = $this->makeContent($record, $mode, $indexChannel, $indexedHeading, false, true);
+        if (!$request->has('from')) {
+            //第一次才显示toc
+            $this->result['toc'] = TocResource::collection($toc);
+        }
+        if ($currTo < $pTo) {
+            $this->result['from'] = $currTo + 1;
+            $this->result['to'] = $pTo;
+            $this->result['paraId'] = $id;
+            $this->result['channels'] = $request->get('channels');
+            $this->result['mode'] = $request->get('mode');
+        }
+
+        return $this->ok($this->result);
+    }
+
+    private function getChannelIndex($channels, $type = null)
+    {
+        #获取channel索引表
+        $channelInfo = Channel::whereIn("uid", $channels)
+            ->select(['uid', 'type', 'name', 'lang', 'owner_uid'])
+            ->get();
+        $indexChannel = [];
+        foreach ($channels as $key => $channelId) {
+            $channelInfo = Channel::where("uid", $channelId)
+                ->select(['uid', 'type', 'name', 'lang', 'owner_uid'])->first();
+            if (!$channelInfo) {
+                Log::error('no channel id' . $channelId);
+                continue;
+            }
+            if ($type !== null && $channelInfo->type !== $type) {
+                continue;
+            }
+            $indexChannel[$channelId] = $channelInfo;
+            $indexChannel[$channelId]->studio = StudioApi::getById($channelInfo->owner_uid);
+        }
+        return $indexChannel;
+    }
+    /**
+     * 根据句子库数据生成文章内容
+     * $record 句子数据
+     * $mode read | edit | wbw
+     * $indexChannel channel索引
+     * $indexedHeading 标题索引 用于给段落加标题标签 <h1> ect.
+     */
+    private function makeContent($record, $mode, $indexChannel, $indexedHeading = [], $onlyProps = false, $paraMark = false, $format = 'react')
+    {
+        $content = [];
+        $lastSent = "0-0";
+        $sentCount = 0;
+        $sent = [];
+        $sent["origin"] = [];
+        $sent["translation"] = [];
+        $sent["commentaries"] = [];
+
+        //获取句子编号列表
+        $sentList = [];
+        foreach ($record as $key => $value) {
+            $currSentId = "{$value->book_id}-{$value->paragraph}-{$value->word_start}-{$value->word_end}";
+            $sentList[$currSentId] = [$value->book_id, $value->paragraph, $value->word_start, $value->word_end];
+            $value->sid = "{$currSentId}_{$value->channel_uid}";
+        }
+        $channelsId = array();
+        foreach ($indexChannel as $channelId => $info) {
+            $channelsId[] = $channelId;
+        }
+        array_pop($channelsId);
+        //遍历列表查找每个句子的所有channel的数据,并填充
+        $currPara = "";
+        foreach ($sentList as $currSentId => $arrSentId) {
+            $para = $arrSentId[0] . "-" . $arrSentId[1];
+            if ($currPara !== $para) {
+                $currPara = $para;
+                //输出段落标记
+
+                if ($paraMark) {
+                    $sentInPara = array();
+                    foreach ($sentList as $sentId => $sentParam) {
+                        if (
+                            $sentParam[0] === $arrSentId[0] &&
+                            $sentParam[1] === $arrSentId[1]
+                        ) {
+                            $sentInPara[] = $sentId;
+                        }
+                    }
+
+                    //输出段落起始
+                    if (!empty($currPara)) {
+                        $content[] = '</MdTpl>';
+                    }
+                    $markProps = base64_encode(\json_encode([
+                        'book' => $arrSentId[0],
+                        'para' => $arrSentId[1],
+                        'channels' => $channelsId,
+                        'sentences' => $sentInPara,
+                        'mode' => $mode,
+                    ]));
+                    $content[] = "<MdTpl tpl='para-shell' props='{$markProps}' >";
+                }
+            }
+            $sent = $this->newSent($arrSentId[0], $arrSentId[1], $arrSentId[2], $arrSentId[3]);
+            foreach ($indexChannel as $channelId => $info) {
+                # code...
+                $sid = "{$currSentId}_{$channelId}";
+                if (isset($info->studio)) {
+                    $studioInfo = $info->studio;
+                } else {
+                    $studioInfo = null;
+                }
+                $newSent = [
+                    "content" => "",
+                    "html" => "",
+                    "book" => $arrSentId[0],
+                    "para" => $arrSentId[1],
+                    "wordStart" => $arrSentId[2],
+                    "wordEnd" => $arrSentId[3],
+                    "channel" => [
+                        "name" => $info->name,
+                        "type" => $info->type,
+                        "id" => $info->uid,
+                        'lang' => $info->lang,
+                    ],
+                    "studio" => $studioInfo,
+                    "updateAt" => "",
+                    "suggestionCount" => SuggestionApi::getCountBySent($arrSentId[0], $arrSentId[1], $arrSentId[2], $arrSentId[3], $channelId),
+                ];
+
+                $row = Arr::first($record, function ($value, $key) use ($sid) {
+                    return $value->sid === $sid;
+                });
+                if ($row) {
+                    $newSent['id'] = $row->uid;
+                    $newSent['content'] = $row->content;
+                    $newSent['contentType'] = $row->content_type;
+                    $newSent['html'] = '';
+                    $newSent["editor"] = UserApi::getByUuid($row->editor_uid);
+                    /**
+                     * TODO 刷库改数据
+                     * 旧版api没有更新updated_at所以造成旧版的数据updated_at数据比modify_time 要晚
+                     */
+                    $newSent['forkAt'] =  $row->fork_at; //
+                    $newSent['updateAt'] =  $row->updated_at; //
+                    $newSent['updateAt'] = date("Y-m-d H:i:s.", $row->modify_time / 1000) . ($row->modify_time % 1000) . " UTC";
+
+                    $newSent['createdAt'] = $row->created_at;
+                    if ($mode !== "read") {
+                        if (isset($row->acceptor_uid) && !empty($row->acceptor_uid)) {
+                            $newSent["acceptor"] = UserApi::getByUuid($row->acceptor_uid);
+                            $newSent["prEditAt"] = $row->pr_edit_at;
+                        }
+                    }
+                    switch ($info->type) {
+                        case 'wbw':
+                        case 'original':
+                            //
+                            // 在编辑模式下。
+                            // 如果是原文,查看是否有逐词解析数据,
+                            // 有的话优先显示。
+                            // 阅读模式直接显示html原文
+                            // 传过来的数据一定有一个原文channel
+                            //
+                            if ($mode === "read") {
+                                $newSent['content'] = "";
+                                $newSent['html'] = MdRender::render(
+                                    $row->content,
+                                    [$row->channel_uid],
+                                    null,
+                                    $mode,
+                                    "translation",
+                                    $row->content_type,
+                                    $format
+                                );
+                            } else {
+                                if ($row->content_type === 'json') {
+                                    $newSent['channel']['type'] = "wbw";
+                                    if (isset($this->wbwChannels[0])) {
+                                        $newSent['channel']['name'] = $indexChannel[$this->wbwChannels[0]]->name;
+                                        $newSent['channel']['lang'] = $indexChannel[$this->wbwChannels[0]]->lang;
+                                        $newSent['channel']['id'] = $this->wbwChannels[0];
+                                        //存在一个translation channel
+                                        //尝试查找逐词解析数据。找到,替换现有数据
+                                        $wbwData = $this->getWbw(
+                                            $arrSentId[0],
+                                            $arrSentId[1],
+                                            $arrSentId[2],
+                                            $arrSentId[3],
+                                            $this->wbwChannels[0]
+                                        );
+                                        if ($wbwData) {
+                                            $newSent['content'] = $wbwData;
+                                            $newSent['contentType'] = 'json';
+                                            $newSent['html'] = "";
+                                            $newSent['studio'] = $indexChannel[$this->wbwChannels[0]]->studio;
+                                        }
+                                    }
+                                } else {
+                                    $newSent['content'] = $row->content;
+                                    $newSent['html'] = MdRender::render(
+                                        $row->content,
+                                        [$row->channel_uid],
+                                        null,
+                                        $mode,
+                                        "translation",
+                                        $row->content_type,
+                                        $format
+                                    );
+                                }
+                            }
+
+                            break;
+                        case 'nissaya':
+                            $newSent['html'] = RedisClusters::remember(
+                                "/sent/{$channelId}/{$currSentId}/{$format}",
+                                config('mint.cache.expire'),
+                                function () use ($row, $mode, $format) {
+                                    return MdRender::render(
+                                        $row->content,
+                                        [$row->channel_uid],
+                                        null,
+                                        $mode,
+                                        "nissaya",
+                                        $row->content_type,
+                                        $format
+                                    );
+                                }
+                            );
+                            break;
+                        case 'commentary':
+                            $options = [
+                                'debug' => $this->debug,
+                                'format' => $format,
+                                'mode' => $mode,
+                                'channelType' => 'translation',
+                                'contentType' => $row->content_type,
+                            ];
+                            $mdRender = new MdRender($options);
+                            $newSent['html'] = $mdRender->convert($row->content, $channelsId);
+                            break;
+                        default:
+                            $options = [
+                                'debug' => $this->debug,
+                                'format' => $format,
+                                'mode' => $mode,
+                                'channelType' => 'translation',
+                                'contentType' => $row->content_type,
+                            ];
+                            $mdRender = new MdRender($options);
+                            $newSent['html'] = $mdRender->convert($row->content, [$row->channel_uid]);
+                            //Log::debug('md render', ['content' => $row->content, 'options' => $options, 'render' => $newSent['html']]);
+                            break;
+                    }
+                }
+                switch ($info->type) {
+                    case 'wbw':
+                    case 'original':
+                        array_push($sent["origin"], $newSent);
+                        break;
+                    case 'commentary':
+                        array_push($sent["commentaries"], $newSent);
+                        break;
+                    default:
+                        array_push($sent["translation"], $newSent);
+                        break;
+                }
+            }
+            if ($onlyProps) {
+                return $sent;
+            }
+            $content = $this->pushSent($content, $sent, 0, $mode);
+        }
+        if ($paraMark) {
+            $content[] = '</MdTpl>';
+        }
+        $output = \implode("", $content);
+        return "<div>{$output}</div>";
+    }
+    public function getWbw($book, $para, $start, $end, $channel)
+    {
+        /**
+         * 非阅读模式下。原文使用逐词解析数据。
+         * 优先加载第一个translation channel 如果没有。加载默认逐词解析。
+         */
+
+        //获取逐词解析数据
+        $wbwBlock = WbwBlock::where('channel_uid', $channel)
+            ->where('book_id', $book)
+            ->where('paragraph', $para)
+            ->select('uid')
+            ->first();
+        if (!$wbwBlock) {
+            return false;
+        }
+        //找到逐词解析数据
+        $wbwData = Wbw::where('block_uid', $wbwBlock->uid)
+            ->whereBetween('wid', [$start, $end])
+            ->select(['book_id', 'paragraph', 'wid', 'data', 'uid', 'editor_id', 'created_at', 'updated_at'])
+            ->orderBy('wid')
+            ->get();
+        $wbwContent = [];
+        foreach ($wbwData as $wbwrow) {
+            $wbw = str_replace("&nbsp;", ' ', $wbwrow->data);
+            $wbw = str_replace("<br>", ' ', $wbw);
+
+            $xmlString = "<root>" . $wbw . "</root>";
+            try {
+                $xmlWord = simplexml_load_string($xmlString);
+            } catch (\Exception $e) {
+                Log::error('corpus', ['error' => $e]);
+                continue;
+            }
+            $wordsList = $xmlWord->xpath('//word');
+            foreach ($wordsList as $word) {
+                $case = \str_replace(['#', '.'], ['$', ''], $word->case->__toString());
+                $case = \str_replace('$$', '$', $case);
+                $case = trim($case);
+                $case = trim($case, "$");
+                $wbwId = explode('-', $word->id->__toString());
+
+                $wbwData = [
+                    'uid' => $wbwrow->uid,
+                    'book' => $wbwrow->book_id,
+                    'para' => $wbwrow->paragraph,
+                    'sn' => array_slice($wbwId, 2),
+                    'word' => ['value' => $word->pali->__toString(), 'status' => 0],
+                    'real' => ['value' => $word->real->__toString(), 'status' => 0],
+                    'meaning' => ['value' => $word->mean->__toString(), 'status' => 0],
+                    'type' => ['value' => $word->type->__toString(), 'status' => 0],
+                    'grammar' => ['value' => $word->gramma->__toString(), 'status' => 0],
+                    'case' => ['value' => $word->case->__toString(), 'status' => 0],
+                    'parent' => ['value' => $word->parent->__toString(), 'status' => 0],
+                    'style' => ['value' => $word->style->__toString(), 'status' => 0],
+                    'factors' => ['value' => $word->org->__toString(), 'status' => 0],
+                    'factorMeaning' => ['value' => $word->om->__toString(), 'status' => 0],
+                    'confidence' => $word->cf->__toString(),
+                    'created_at' => $wbwrow->created_at,
+                    'updated_at' => $wbwrow->updated_at,
+                    'hasComment' => Discussion::where('res_id', $wbwrow->uid)->exists(),
+                ];
+                if (isset($word->parent2)) {
+                    $wbwData['parent2']['value'] = $word->parent2->__toString();
+                    if (isset($word->parent2['status'])) {
+                        $wbwData['parent2']['status'] = (int)$word->parent2['status'];
+                    } else {
+                        $wbwData['parent2']['status'] = 0;
+                    }
+                }
+                if (isset($word->pg)) {
+                    $wbwData['grammar2']['value'] = $word->pg->__toString();
+                    if (isset($word->pg['status'])) {
+                        $wbwData['grammar2']['status'] = (int)$word->pg['status'];
+                    } else {
+                        $wbwData['grammar2']['status'] = 0;
+                    }
+                }
+                if (isset($word->rela)) {
+                    $wbwData['relation']['value'] = $word->rela->__toString();
+                    if (isset($word->rela['status'])) {
+                        $wbwData['relation']['status'] = (int)$word->rela['status'];
+                    } else {
+                        $wbwData['relation']['status'] = 7;
+                    }
+                }
+                if (isset($word->bmt)) {
+                    $wbwData['bookMarkText']['value'] = $word->bmt->__toString();
+                    if (isset($word->bmt['status'])) {
+                        $wbwData['bookMarkText']['status'] = (int)$word->bmt['status'];
+                    } else {
+                        $wbwData['bookMarkText']['status'] = 7;
+                    }
+                }
+                if (isset($word->bmc)) {
+                    $wbwData['bookMarkColor']['value'] = $word->bmc->__toString();
+                    if (isset($word->bmc['status'])) {
+                        $wbwData['bookMarkColor']['status'] = (int)$word->bmc['status'];
+                    } else {
+                        $wbwData['bookMarkColor']['status'] = 7;
+                    }
+                }
+                if (isset($word->note)) {
+                    $wbwData['note']['value'] = $word->note->__toString();
+                    if (isset($word->note['status'])) {
+                        $wbwData['note']['status'] = (int)$word->note['status'];
+                    } else {
+                        $wbwData['note']['status'] = 7;
+                    }
+                }
+                if (isset($word->cf)) {
+                    $wbwData['confidence'] = (float)$word->cf->__toString();
+                }
+                if (isset($word->attachments)) {
+                    $wbwData['attachments'] = json_decode($word->attachments->__toString());
+                }
+                if (isset($word->pali['status'])) {
+                    $wbwData['word']['status'] = (int)$word->pali['status'];
+                }
+                if (isset($word->real['status'])) {
+                    $wbwData['real']['status'] = (int)$word->real['status'];
+                }
+                if (isset($word->mean['status'])) {
+                    $wbwData['meaning']['status'] = (int)$word->mean['status'];
+                }
+                if (isset($word->type['status'])) {
+                    $wbwData['type']['status'] = (int)$word->type['status'];
+                }
+                if (isset($word->gramma['status'])) {
+                    $wbwData['grammar']['status'] = (int)$word->gramma['status'];
+                }
+                if (isset($word->case['status'])) {
+                    $wbwData['case']['status'] = (int)$word->case['status'];
+                }
+                if (isset($word->parent['status'])) {
+                    $wbwData['parent']['status'] = (int)$word->parent['status'];
+                }
+                if (isset($word->org['status'])) {
+                    $wbwData['factors']['status'] = (int)$word->org['status'];
+                }
+                if (isset($word->om['status'])) {
+                    $wbwData['factorMeaning']['status'] = (int)$word->om['status'];
+                }
+
+                $wbwContent[] = $wbwData;
+            }
+        }
+        if (count($wbwContent) === 0) {
+            return false;
+        }
+        return \json_encode($wbwContent, JSON_UNESCAPED_UNICODE);
+    }
+    /**
+     * 将句子放进结果列表
+     */
+    private function pushSent($result, $sent, $level = 0, $mode = 'read')
+    {
+
+        $sentProps = base64_encode(\json_encode($sent));
+        if ($mode === 'read') {
+            $sentWidget = "<MdTpl tpl='sentread' props='{$sentProps}' ></MdTpl>";
+        } else {
+            $sentWidget = "<MdTpl tpl='sentedit' props='{$sentProps}' ></MdTpl>";
+        }
+        //增加标题的html标记
+        if ($level > 0) {
+            $sentWidget = "<h{$level}>" . $sentWidget . "</h{$level}>";
+        }
+        array_push($result, $sentWidget);
+        return $result;
+    }
+    private function newSent($book, $para, $word_start, $word_end)
+    {
+        $sent = [
+            "id" => "{$book}-{$para}-{$word_start}-{$word_end}",
+            "book" => $book,
+            "para" => $para,
+            "wordStart" => $word_start,
+            "wordEnd" => $word_end,
+            "origin" => [],
+            "translation" => [],
+            "commentaries" => [],
+        ];
+
+        if ($book < 1000) {
+            #生成channel 数量列表
+            $sentId = "{$book}-{$para}-{$word_start}-{$word_end}";
+            $channelCount = CorpusController::_sentCanReadCount($book, $para, $word_start, $word_end, $this->userUuid);
+            $path = json_decode(PaliText::where('book', $book)->where('paragraph', $para)->value("path"), true);
+            $sent["path"] = [];
+            foreach ($path as $key => $value) {
+                # code...
+                $value['paliTitle'] = $value['title'];
+                $sent["path"][] = $value;
+            }
+            $sent["tranNum"] = $channelCount['tranNum'];
+            $sent["nissayaNum"] = $channelCount['nissayaNum'];
+            $sent["commNum"] = $channelCount['commNum'];
+            $sent["originNum"] = $channelCount['originNum'];
+            $sent["simNum"] = $channelCount['simNum'];
+        }
+
+        return $sent;
+    }
+
+    public static function _sentCanReadCount($book, $para, $start, $end, $userUuid = null)
+    {
+        $keyCanRead = "/channel/can-read/";
+        if ($userUuid) {
+            $keyCanRead .= $userUuid;
+        } else {
+            $keyCanRead .= 'guest';
+        }
+        $channelCanRead = RedisClusters::remember(
+            $keyCanRead,
+            config('mint.cache.expire'),
+            function () use ($userUuid) {
+                return ChannelApi::getCanReadByUser($userUuid);
+            }
+        );
+        $channels =  Sentence::where('book_id', $book)
+            ->where('paragraph', $para)
+            ->where('word_start', $start)
+            ->where('word_end', $end)
+            ->where('strlen', '<>', 0)
+            ->whereIn('channel_uid', $channelCanRead)
+            ->select('channel_uid')
+            ->groupBy('channel_uid')
+            ->get();
+        $channelList = [];
+        foreach ($channels as $key => $value) {
+            # code...
+            if (Str::isUuid($value->channel_uid)) {
+                $channelList[] = $value->channel_uid;
+            }
+        }
+        $simId = PaliSentence::where('book', $book)
+            ->where('paragraph', $para)
+            ->where('word_begin', $start)
+            ->where('word_end', $end)
+            ->value('id');
+        if ($simId) {
+            $output["simNum"] = SentSimIndex::where('sent_id', $simId)->value('count');
+        } else {
+            $output["simNum"] = 0;
+        }
+        $channelInfo = Channel::whereIn("uid", $channelList)->select('type')->get();
+        $output["tranNum"] = 0;
+        $output["nissayaNum"] = 0;
+        $output["commNum"] = 0;
+        $output["originNum"] = 0;
+
+        foreach ($channelInfo as $key => $value) {
+            # code...
+            switch ($value->type) {
+                case "translation":
+                    $output["tranNum"]++;
+                    break;
+                case "nissaya":
+                    $output["nissayaNum"]++;
+                    break;
+                case "commentary":
+                    $output["commNum"]++;
+                    break;
+                case "original":
+                    $output["originNum"]++;
+                    break;
+            }
+        }
+        return $output;
+    }
+    /**
+     * 获取某个句子的相关资源的句子数量
+     */
+    public static function sentCanReadCount($book, $para, $start, $end, $userUuid = null)
+    {
+        $sentId = "{$book}-{$para}-{$start}-{$end}";
+        $hKey = "/sentence/res-count/{$sentId}/";
+        if ($userUuid) {
+            $key = $userUuid;
+        } else {
+            $key = 'guest';
+        }
+        if (Redis::hExists($hKey, $key)) {
+            return json_decode(Redis::hGet($hKey, $key), true);
+        } else {
+            $channelCount = CorpusController::_sentCanReadCount($book, $para, $start, $end, $userUuid);
+            Redis::hSet($hKey, $key, json_encode($channelCount));
+            return $channelCount;
+        }
+    }
+
+    private function markdownRender($input) {}
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Sentence $sentence)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Sentence $sentence)
+    {
+        //
+    }
+}

+ 297 - 0
api-v12/app/Http/Controllers/CourseController.php

@@ -0,0 +1,297 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Course;
+use App\Models\CourseMember;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Resources\CourseResource;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+class CourseController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+		$result=false;
+		$indexCol = ['id','title','subtitle',
+                     'cover','content','content_type',
+                     'teacher','start_at','end_at',
+                     'sign_up_start_at','sign_up_end_at',
+                     'join','publicity','number',
+                     'updated_at','created_at'];
+		switch ($request->get('view')) {
+            case 'new':
+                //最新公开课程列表
+                $table = Course::where('publicity', 30);
+                break;
+            case 'open':
+                /**
+                 * 开放课程列表
+                 * 开放规则:
+                 * 1. 公开
+                 * 2. 课程开始时间比现在时间晚
+                 */
+                $table = Course::where('publicity', 30)
+                            ->whereDate('start_at',">",date("Y-m-d",strtotime("today")));
+                break;
+            case 'close':
+                /**
+                 * 已经关闭课程列表
+                 * 判定规则:
+                 * 1. 公开
+                 * 2. 课程开始时间比现在时间早
+                 */
+                $table = Course::where('publicity', 30)
+                        ->whereDate('start_at',"<=",date("Y-m-d",strtotime("today")));
+                break;
+            case 'create':
+	            # 获取 studio 建立的所有 course
+                $user = AuthApi::current($request);
+                if(!$user){
+                    return $this->error(__('auth.failed'));
+                }
+                //判断当前用户是否有指定的studio的权限
+                if($user['user_uid'] !== StudioApi::getIdByName($request->get('studio'))){
+                    return $this->error(__('auth.failed'));
+                }
+
+                $table = Course::where('studio_id', $user["user_uid"]);
+				break;
+            case 'study':
+                $user = AuthApi::current($request);
+                if(!$user){
+                    return $this->error(__('auth.failed'));
+                }
+                //我学习的课程
+                $course = CourseMember::where('user_id',$user["user_uid"])
+                                      ->where('role','student')
+                                      ->where('is_current',true)
+                                      ->select('course_id')
+                                      ->get();
+                $courseId = [];
+                foreach ($course as $key => $value) {
+                    # code...
+                    $courseId[] = $value->course_id;
+                }
+                $table = Course::whereIn('id', $courseId);
+                break;
+            case 'teach':
+                //我任教的课程
+                $user = AuthApi::current($request);
+                if(!$user){
+                    return $this->error(__('auth.failed'));
+                }
+                $course = CourseMember::where('user_id',$user["user_uid"])
+                                    ->whereIn('role',['assistant','manager','teacher'])
+                                      ->where('is_current',true)
+                                      ->select('course_id')
+                                    ->get();
+                $courseId = [];
+                foreach ($course as $key => $value) {
+                    # code...
+                    $courseId[] = $value->course_id;
+                }
+                $table = Course::whereIn('id', $courseId);
+                break;
+        }
+        $table = $table->select($indexCol);
+        if($request->has('search')){
+            $table = $table->where('title', 'like', $request->get('search')."%");
+        }
+        $count = $table->count();
+        $table = $table->orderBy($request->get('order','updated_at'),
+                                 $request->get('dir','desc'));
+
+        $table = $table->skip($request->get('offset',0))
+                       ->take($request->get('limit',1000));
+
+        $result = $table->get();
+
+		return $this->ok(["rows"=>CourseResource::collection($result),"count"=>$count]);
+
+    }
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function showMyCourseNumber(Request $request){
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        //我建立的课程
+        $create = Course::where('studio_id', $user["user_uid"])->count();
+        //我学习的课程
+        $study = CourseMember::where('user_id',$user["user_uid"])
+                            ->where('role','student')
+                            ->where('is_current',true)
+                            ->count();
+        //我任教的课程
+        $teach = CourseMember::where('user_id',$user["user_uid"])
+                            ->where('is_current',true)
+                            ->whereIn('role',['assistant','manager','teacher'])
+                            ->count();
+        return $this->ok(['create'=>$create,'teach'=>$teach,'study'=>$study]);
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        $studio_id = StudioApi::getIdByName($request->get('studio'));
+        if($user['user_uid'] !== $studio_id){
+            return $this->error(__('auth.failed'));
+        }
+        //查询是否重复
+        if(Course::where('title',$request->get('title'))
+                ->where('studio_id',$user['user_uid'])
+                ->exists()){
+            return $this->error(__('validation.exists',['name']));
+        }
+
+        try {
+            $course = new Course;
+            DB::transaction(function () use($course,$request,$studio_id,$user) {
+                $saveCourse = false;
+                $saveCourseMember = false;
+
+                $course->id = Str::uuid();
+                $course->title = $request->get('title');
+                $course->studio_id = $studio_id;
+                $saveCourse = $course->save();
+
+                //添加owner
+                $newMember = new CourseMember();
+                $newMember->user_id = $user['user_uid'];
+                $newMember->course_id = $course->id;
+                $newMember->role = 'owner';
+                $saveCourseMember = $newMember->save();
+            });
+
+        } catch(\Exception $e) {
+            return $this->error('course create fail',500,500);
+        }
+
+        return $this->ok(new CourseResource($course));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Course  $course
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Course $course)
+    {
+        //
+        return $this->ok(new CourseResource($course));
+
+    }
+
+    private function userCanManage($courseId,$userUid){
+                    //判断是否是manager
+        $role = CourseMember::where('course_id',$courseId)
+                    ->where('is_current',true)
+                    ->where('user_id',$userUid)
+                    ->value('role');
+        $manager = ['owner','teacher','manager'];
+        if(in_array($role,$manager)){
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Course  $course
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Course $course)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        $canManage = $this->userCanManage($course->id,$user['user_uid']);
+        if(!$canManage){
+            return $this->error(__('auth.failed'),403,403);
+        }
+
+        //查询标题是否重复
+        if(Course::where('title',$request->get('title'))
+                ->where('studio_id',$user['user_uid'])
+                ->exists()){
+            if($course->title !== $request->get('title')){
+                return $this->error(__('validation.exists',['name']));
+            }
+        }
+        $course->title = $request->get('title');
+        $course->subtitle = $request->get('subtitle');
+        $course->summary = $request->get('summary');
+        $course->number = $request->get('number',0);
+        if($request->has('cover')) {$course->cover = $request->get('cover');}
+        $course->content = $request->get('content');
+        $course->sign_up_message = $request->get('sign_up_message');
+        if($request->has('teacher_id')) {$course->teacher = $request->get('teacher_id');}
+        if($request->has('anthology_id')) {$course->anthology_id = $request->get('anthology_id');}
+        $course->channel_id = $request->get('channel_id');
+        if($request->has('publicity')) {$course->publicity = $request->get('publicity');}
+        $course->start_at = $request->get('start_at');
+        $course->end_at = $request->get('end_at');
+        $course->sign_up_start_at = $request->get('sign_up_start_at');
+        $course->sign_up_end_at = $request->get('sign_up_end_at');
+        $course->join = $request->get('join');
+        $course->save();
+        return $this->ok($course);
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Course  $course
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request,Course $course)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        if($user['user_uid'] !== $course->studio_id){
+            return $this->error(__('auth.failed'));
+        }
+        $delete = 0;
+        DB::transaction(function() use($delete,$course){
+            //删除group member
+            $memberDelete = CourseMember::where('course_id',$course->id)->delete();
+            $delete = $course->delete();
+        });
+
+        return $this->ok($delete);
+    }
+}

+ 359 - 0
api-v12/app/Http/Controllers/CourseMemberController.php

@@ -0,0 +1,359 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\CourseMember;
+use App\Models\Course;
+use App\Models\UserInfo;
+
+use Illuminate\Http\Request;
+use App\Http\Resources\CourseMemberResource;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\UserApi;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+class CourseMemberController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed',[403],403));
+        }
+        //判断当前用户是否有指定的 course 的权限
+        $role = CourseMember::where('course_id', $request->get('id',$request->get('course')))
+                            ->where('user_id',$user['user_uid'])
+                            ->value('role');
+        if(empty($role)){
+            return $this->error(__('auth.failed',[403],403));
+        }
+
+        $result=false;
+		$indexCol = ['id','user_id','course_id',
+                    'channel_id','role','editor_uid',
+                    'updated_at','created_at'];
+		switch ($request->get('view')) {
+            case 'course':
+	            # 获取 course 内所有 成员
+                $table = CourseMember::where('course_id', $request->get('id'))
+                                    ->where('is_current',true);
+				break;
+            case 'timeline':
+                /**
+                 * 编辑时间线
+                 */
+                $table = CourseMember::where('user_id',$request->get('userId'));
+                if($request->get('timeline','current')==='current'){
+                    $table = $table->where('course_id', $request->get('course'));
+                }
+
+                break;
+            default:
+                return $this->error('无法识别的参数view',400,400);
+            break;
+        }
+        if(!empty($request->get("role")) && $request->get("role") !=='all'){
+            $table = $table->where('role', $request->get("role"));
+        }
+        if(!empty($request->get("status"))){
+            $table = $table->whereIn('status', explode(',',$request->get("status")) );
+        }
+        if(!empty($request->get("search"))){
+            $usersId = UserInfo::where('nickname','like', '%'.$request->get("search")."%")
+                            ->select('userid')
+                            ->get();
+            $table = $table->whereIn('user_id', $usersId);
+        }
+
+        $count = $table->count();
+
+        $table = $table->orderBy($request->get('order','created_at'),
+                                $request->get('dir','asc'));
+
+        $table = $table->skip($request->get('offset',0))
+              ->take($request->get('limit',1000));
+
+        $result = $table->get();
+
+        //获取当前用户角色
+        $role = CourseMember::where('course_id', $request->get('id'))
+                            ->where('user_id', $user['user_uid'])
+                            ->where('is_current',true)
+                            ->value('role');
+
+		return $this->ok(["rows"=>CourseMemberResource::collection($result),'role'=>$role,"count"=>$count]);
+
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed',[403],403));
+        }
+        $validated = $request->validate([
+            'user_id' => 'required',
+            'course_id' => 'required',
+            'role' => 'required',
+            'status' => 'required',
+        ]);
+        //查找重复的
+        if($validated['status'] !== 'invited'){
+            if(CourseMember::where('course_id', $validated['course_id'])
+                        ->where('user_id',$validated['user_id'])
+                        ->exists()){
+                return $this->error('member exists',[200],200);
+            }
+        }
+
+        if($validated['status'] === 'invited'){
+            $userId = $validated['user_id'];
+        }else{
+            $userId = $user['user_uid'];
+        }
+
+        CourseMember::where('course_id',$validated['course_id'])
+            ->where('user_id',$userId)
+            ->update(['is_current'=>false]);
+
+        $newMember = new CourseMember();
+        $newMember->course_id = $validated['course_id'];
+        $newMember->role = $validated['role'];
+        $newMember->editor_uid = $user['user_uid'];
+        $newMember->status = $validated['status'];
+        $newMember->user_id = $userId;
+
+        /**
+         * 查找course 信息,根据加入方式设置状态
+         * open : accepted
+         * manual: progressing
+         */
+        $course  = Course::find($validated['course_id']);
+        if(!$course){
+            return $this->error('invalid course');
+        }
+        switch ($course->join) {
+            case 'open': //开放学习课程
+                if($validated['status']!=='joined' &&
+                    $validated['status']!=='invited'
+                    ){
+                    return $this->error('invalid course',[200],200);
+                    }
+                break;
+            case 'manual': //人工审核课程
+                if($validated['status']!=='applied' &&
+                    $validated['status']!=='invited'
+                    ){
+                    return $this->error('invalid course',[200],200);
+                    }
+                break;
+        }
+        $newMember->save();
+
+        return $this->ok(new CourseMemberResource($newMember));
+
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $courseId
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request,string $courseId)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        $userId = $user['user_uid'];
+        if(!empty($request->get('user_uid'))){
+            $userId = $request->get('user_uid');
+        }
+        $member = CourseMember::where('course_id',$courseId)
+                                ->where('user_id',$userId)
+                                ->where('is_current',true)
+                                ->first();
+        if($member){
+            return $this->ok(new CourseMemberResource($member));
+        }else{
+            return $this->error('no result',200,200);
+        }
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\CourseMember  $courseMember
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, CourseMember $courseMember)
+    {
+        /**
+         * 保留原有记录
+         * 增加一条新纪录
+         * 原有记录变为历史记录
+         */
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+
+        $newMember = new CourseMember();
+        $newMember->user_id = $courseMember->user_id;
+        $newMember->course_id = $courseMember->course_id;
+        $newMember->role = $courseMember->role;
+        $newMember->status = $courseMember->status;
+        $newMember->channel_id = $courseMember->channel_id;
+        $newMember->editor_uid = $user['user_uid'];
+
+        $courseMember->is_current = false;
+        $courseMember->save();
+
+        if($request->has('channel_id')) {
+            if($newMember->user_id !== $user['user_uid']){
+                return $this->error(__('auth.failed'));
+            }
+            $newMember->channel_id = $request->get('channel_id');
+        }
+        if($request->has('status')) {
+            $newMember->status = $request->get('status');
+        }
+        $newMember->save();
+        return $this->ok(new CourseMemberResource($newMember));
+
+    }
+    public function set_channel(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+
+        if($request->has('channel_id')) {
+            $courseMember = CourseMember::where('course_id',$request->get('course_id'))
+                                        ->where('user_id',$user['user_uid'])
+                                        ->where('is_current',true)
+                                        ->first();
+            if($courseMember){
+                $courseMember->channel_id = $request->get('channel_id');
+                $courseMember->save();
+                return $this->ok(new CourseMemberResource($courseMember));
+            }else{
+                return $this->error(__('auth.failed'));
+            }
+        }else{
+            return $this->error(__('auth.failed'));
+        }
+
+
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\CourseMember  $courseMember
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request,CourseMember $courseMember)
+    {
+        //查看删除者有没有删除权限
+        //查询删除者的权限
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+
+        $isOwner = Course::where('id',$courseMember->course_id)->where('studio_id',$user["user_uid"])->exists();
+        if(!$isOwner){
+            $courseUser = CourseMember::where('course_id',$courseMember->course_id)
+                ->where('user_id',$user["user_uid"])
+                ->select('role')->first();
+            //open 课程 可以删除自己
+
+            if(!$courseUser){
+                //被删除的不是自己
+                if($courseUser->role ==="student"){
+                    //普通成员没有删除权限
+                    return $this->error(__('auth.failed'));
+                }
+            }
+        }
+
+        $delete = $courseMember->delete();
+        return $this->ok($delete);
+    }
+
+    /**
+     * 获取当前用户权限
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function curr(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        $courseUser = CourseMember::where('course_id',$request->get("course_id"))
+                                ->where('user_id',$user["user_uid"])
+                                ->where('is_current',true)
+                                ->select(['role','channel_id'])->first();
+        if($courseUser){
+            return $this->ok($courseUser);
+        }else{
+            return $this->error("not member");
+        }
+    }
+
+    public function export(Request $request){
+
+        $courseUser = CourseMember::where('course_id',$request->get("course_id"))
+                                    ->where('is_current',true)
+                                    ->get();
+
+        $spreadsheet = new Spreadsheet();
+        $activeWorksheet = $spreadsheet->getActiveSheet();
+        $activeWorksheet->setCellValue('A1', 'nickname');
+        $activeWorksheet->setCellValue('B1', 'username');
+        $activeWorksheet->setCellValue('C1', 'role');
+        $activeWorksheet->setCellValue('D1', 'status');
+        $activeWorksheet->setCellValue('E1', 'created_at');
+
+        $currLine = 2;
+        foreach ($courseUser as $key => $row) {
+            $user = UserApi::getByUuid($row->user_id);
+            $activeWorksheet->setCellValue("A{$currLine}", $user['nickName']);
+            $activeWorksheet->setCellValue("B{$currLine}", $user['userName']);
+            $activeWorksheet->setCellValue("C{$currLine}", $row->role);
+            $activeWorksheet->setCellValue("D{$currLine}", $row->status);
+            $activeWorksheet->setCellValue("E{$currLine}", $row->created_at);
+            $currLine++;
+        }
+        $writer = new Xlsx($spreadsheet);
+        header('Content-Type: application/vnd.ms-excel');
+        header('Content-Disposition: attachment; filename="course_member.xlsx"');
+        $writer->save("php://output");
+    }
+}

+ 420 - 0
api-v12/app/Http/Controllers/DhammaTermController.php

@@ -0,0 +1,420 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Log;
+
+use App\Models\DhammaTerm;
+use App\Models\Channel;
+use App\Http\Resources\TermResource;
+
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\ShareApi;
+use App\Tools\Tools;
+use App\Tools\RedisClusters;
+
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+
+
+class DhammaTermController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        $result = false;
+        $indexCol = [
+            'id',
+            'guid',
+            'word',
+            'meaning',
+            'other_meaning',
+            'note',
+            'tag',
+            'language',
+            'channal',
+            'owner',
+            'editor_id',
+            'created_at',
+            'updated_at'
+        ];
+
+        switch ($request->get('view')) {
+            case 'create-by-channel':
+                # 新建术语时。根据术语所在channel 给出新建术语所需数据。如语言,备选意思等。
+                #获取channel信息
+                $currChannel = Channel::where('uid', $request->get('channel'))->first();
+                if (!$currChannel) {
+                    return $this->error(__('auth.failed'));
+                }
+                #TODO 查询studio信息
+                #获取同studio的channel列表
+                $studioChannels = Channel::where('owner_uid', $currChannel->owner_uid)
+                    ->select(['name', 'uid'])
+                    ->get();
+                #获取全网意思列表
+                $meanings = DhammaTerm::where('word', $request->get('word'))
+                    ->where('language', $currChannel->lang)
+                    ->select(['meaning', 'other_meaning'])
+                    ->get();
+                $meaningList = [];
+                foreach ($meanings as $key => $value) {
+                    # code...
+                    $meaning1 = [$value->meaning];
+
+                    if (!empty($value->other_meaning)) {
+                        $meaning2 = \explode(',', $value->other_meaning);
+                        $meaning1 = array_merge($meaning1, $meaning2);
+                    }
+                    foreach ($meaning1 as $key => $value) {
+                        # code...
+                        if (isset($meaningList[$value])) {
+                            $meaningList[$value]++;
+                        } else {
+                            $meaningList[$value] = 1;
+                        }
+                    }
+                }
+                $meaningCount = [];
+                foreach ($meaningList as $key => $value) {
+                    # code...
+                    $meaningCount[] = ['meaning' => $key, 'count' => $value];
+                }
+                return $this->ok([
+                    "word" => $request->get('word'),
+                    "meaningCount" => $meaningCount,
+                    "studioChannels" => $studioChannels,
+                    "language" => $currChannel->lang,
+                    'studio' => StudioApi::getById($currChannel->owner_uid),
+                ]);
+                break;
+            case 'studio':
+                # 获取 studio 内所有 term
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'), [], 401);
+                }
+                //判断当前用户是否有指定的studio的权限
+                if ($user['user_uid'] !== StudioApi::getIdByName($request->get('name'))) {
+                    return $this->error(__('auth.failed'), [], 403);
+                }
+                $table = DhammaTerm::select($indexCol)
+                    ->where('owner', $user["user_uid"]);
+                break;
+            case 'channel':
+                # 获取 studio 内所有 term
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                //判断当前用户是否有指定的 channel 的权限
+                $channel = Channel::find($request->get('id'));
+                if ($user['user_uid'] !== $channel->owner_uid) {
+                    //看是否为协作
+                    $power = ShareApi::getResPower($user['user_uid'], $request->get('id'));
+                    if ($power === 0) {
+                        return $this->error(__('auth.failed'), [], 403);
+                    }
+                }
+                $table = DhammaTerm::select($indexCol)
+                    ->where('channal', $request->get('id'));
+                break;
+            case 'show':
+                return $this->ok(DhammaTerm::find($request->get('id')));
+                break;
+            case 'user':
+                # code...
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                $userUid = $user['user_uid'];
+                $search = $request->get('search');
+                $table = DhammaTerm::select($indexCol)
+                    ->where('owner', $userUid);
+                break;
+            case 'word':
+                $table = DhammaTerm::select($indexCol)
+                    ->whereIn('word', explode(',', $request->get("word")))
+                    ->orWhereIn('meaning', explode(',', $request->get("word")));
+                break;
+            case 'tag':
+                $table = DhammaTerm::select($indexCol)
+                    ->whereIn('tag', explode(',', $request->get("tag")));
+                break;
+            case 'hot-meaning':
+                $key = 'term/hot_meaning';
+                $value = RedisClusters::get($key, function () use ($request) {
+                    $hotMeaning = [];
+                    $words = DhammaTerm::select('word')
+                        ->where('language', $request->get("language"))
+                        ->groupby('word')
+                        ->get();
+
+                    foreach ($words as $key => $word) {
+                        # code...
+                        $result = DhammaTerm::select(DB::raw('count(*) as word_count, meaning'))
+                            ->where('language', $request->get("language"))
+                            ->where('word', $word['word'])
+                            ->groupby('meaning')
+                            ->orderby('word_count', 'desc')
+                            ->first();
+                        if ($result) {
+                            $hotMeaning[] = [
+                                'word' => $word['word'],
+                                'meaning' => $result['meaning'],
+                                'language' => $request->get("language"),
+                                'owner' => '',
+                            ];
+                        }
+                    }
+                    RedisClusters::put($key, $hotMeaning, 3600);
+                    return $hotMeaning;
+                }, config('mint.cache.expire'));
+                return $this->ok(["rows" => $value, "count" => count($value)]);
+                break;
+            default:
+                # code...
+                break;
+        }
+
+        $search = $request->get('search');
+        if (!empty($search)) {
+            $table = $table->where(function ($query) use ($search) {
+                $query->where('word', 'like', $search . "%")
+                    ->orWhere('word_en', 'like', $search . "%")
+                    ->orWhere('meaning', 'like', "%" . $search . "%");
+            });
+        }
+        $count = $table->count();
+        $table = $table->orderBy($request->get('order', 'updated_at'), $request->get('dir', 'desc'));
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
+        $result = $table->get();
+
+        return $this->ok(["rows" => TermResource::collection($result), "count" => $count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        $validated = $request->validate([
+            'word' => 'required',
+            'meaning' => 'required',
+        ]);
+
+
+        /**
+         * 查询重复的
+         * 一个channel下面word+tag+language 唯一
+         */
+        $table = DhammaTerm::where('owner', $user["user_uid"])
+            ->where('word', $request->get("word"))
+            ->where('tag', $request->get("tag"));
+        if (!empty($request->get("channel"))) {
+            $isDoesntExist = $table->where('channal', $request->get("channel"))
+                ->doesntExist();
+        } else {
+            $isDoesntExist = $table->whereNull('channal')->where('language', $request->get("language"))
+                ->doesntExist();
+        }
+
+        if ($isDoesntExist) {
+            #没有重复的 插入数据
+            $term = new DhammaTerm;
+            $term->id = app('snowflake')->id();
+            $term->guid = Str::uuid();
+            $term->word = $request->get("word");
+            $term->word_en = Tools::getWordEn($request->get("word"));
+            $term->meaning = $request->get("meaning");
+            $term->other_meaning = $request->get("other_meaning");
+            $term->note = $request->get("note");
+            $term->tag = $request->get("tag");
+            $term->channal = $request->get("channel");
+            $term->language = $request->get("language");
+            if (!empty($request->get("channel"))) {
+                $channelInfo = ChannelApi::getById($request->get("channel"));
+                if (!$channelInfo) {
+                    return $this->error("channel id failed");
+                } else {
+                    //查看有没有channel权限
+                    $power = ShareApi::getResPower($user["user_uid"], $request->get("channel"), 2);
+                    if ($power < 20) {
+                        return $this->error(__('auth.failed'));
+                    }
+                    $term->owner = $channelInfo['studio_id'];
+                    $term->language = $channelInfo['lang'];
+                }
+            } else {
+                if ($request->has("studioId")) {
+                    $studioId = $request->get("studioId");
+                } else if ($request->has("studioName")) {
+                    $studioId = StudioApi::getIdByName($request->get("studioName"));
+                }
+                if (Str::isUuid($studioId)) {
+                    $term->owner = $studioId;
+                } else {
+                    return $this->error('not valid studioId');
+                }
+            }
+            $term->editor_id = $user["user_id"];
+            $term->create_time = time() * 1000;
+            $term->modify_time = time() * 1000;
+            $term->save();
+            //删除cache
+            $this->deleteCache($term);
+            return $this->ok(new TermResource($term));
+        } else {
+            return $this->error("word existed", [], 200);
+        }
+    }
+
+    private function deleteCache($term)
+    {
+        if (empty($term->channal)) {
+            //通用 查询studio所有channel
+            $channels = Channel::where('owner_uid', $term->owner)->select('uid')->get();
+            foreach ($channels as $channel) {
+                RedisClusters::forget("/term/{$channel}/{$term->word}");
+            }
+        } else {
+            RedisClusters::forget("/term/{$term->channal}/{$term->word}");
+        }
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request  $request, $id)
+    {
+        //
+        $result  = DhammaTerm::where('guid', $id)->first();
+        if ($result) {
+            return $this->ok(new TermResource($result));
+        } else {
+            return $this->error("没有查询到数据");
+        }
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\DhammaTerm  $dhammaTerm
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, string $id)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [], 401);
+        }
+        $dhammaTerm = DhammaTerm::find($id);
+        if (!$dhammaTerm) {
+            return $this->error('404');
+        }
+
+        if (empty($dhammaTerm->channal)) {
+            //查看有没有studio权限
+            if ($user['user_uid'] !== $dhammaTerm->owner) {
+                return $this->error(__('auth.failed'), [], 403);
+            }
+        } else {
+            //查看有没有channel权限
+            $power = ShareApi::getResPower($user["user_uid"], $dhammaTerm->channal, 2);
+            if ($power < 20) {
+                return $this->error(__('auth.failed'), [], 403);
+            }
+        }
+
+        $dhammaTerm->word = $request->get("word");
+        $dhammaTerm->word_en = Tools::getWordEn($request->get("word"));
+        $dhammaTerm->meaning = $request->get("meaning");
+        $dhammaTerm->other_meaning = $request->get("other_meaning");
+        $dhammaTerm->note = $request->get("note");
+        $dhammaTerm->tag = $request->get("tag");
+        $dhammaTerm->language = $request->get("language");
+        $dhammaTerm->editor_id = $user["user_id"];
+        $dhammaTerm->create_time = time() * 1000;
+        $dhammaTerm->modify_time = time() * 1000;
+        $dhammaTerm->save();
+        //删除cache
+        $this->deleteCache($dhammaTerm);
+        return $this->ok(new TermResource($dhammaTerm));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\DhammaTerm  $dhammaTerm
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(DhammaTerm $dhammaTerm, Request $request)
+    {
+        /**
+         * 一次删除多个单词
+         */
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        $count = 0;
+        if ($request->has("uuid")) {
+            //查看是否有删除权限
+            foreach ($request->get("id") as $key => $uuid) {
+                $term = DhammaTerm::find($uuid);
+                if ($term->owner !== $user['user_uid']) {
+                    if (!empty($term->channal)) {
+                        //看是否为协作
+                        $power = ShareApi::getResPower($user['user_uid'], $term->channal);
+                        if ($power < 20) {
+                            continue;
+                        }
+                    } else {
+                        continue;
+                    }
+                }
+                $count += $term->delete();
+                //删除cache
+                $this->deleteCache($term);
+            }
+        } else {
+            $arrId = json_decode($request->get("id"), true);
+            foreach ($arrId as $key => $id) {
+                # code...
+                $term = DhammaTerm::where('id', $id)
+                    ->where('owner', $user['user_uid']);
+                $result = $term->delete();
+                $this->deleteCache($term);
+                if ($result) {
+                    $count++;
+                }
+            }
+        }
+
+        return $this->ok($count);
+    }
+}

+ 311 - 0
api-v12/app/Http/Controllers/DictController.php

@@ -0,0 +1,311 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Facades\App;
+use App\Models\UserDict;
+use App\Models\DictInfo;
+use App\Models\GroupMember;
+use Illuminate\Http\Request;
+use App\Tools\CaseMan;
+use Illuminate\Support\Facades\Log;
+use App\Http\Api\DictApi;
+use App\Http\Api\AuthApi;
+
+require_once __DIR__ . "/../../../public/app/dict/grm_abbr.php";
+
+
+class DictController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $startAt = microtime(true);
+
+        $output = [];
+        $wordDataOutput = [];
+        $dictListOutput = [];
+        $caseListOutput = [];
+        $wordDataPass = [];
+        $indexCol = ['word', 'note', 'dict_id'];
+        $words = [];
+        $word_base = [];
+        $searched = [];
+        $words[$request->get('word')] = [];
+        $userLang = $request->get('lang', "zh");
+
+        /**
+         * 临时代码判断是否在缅汉字典群里面。在群里的用户可以产看缅汉字典pdf
+         */
+        $user = AuthApi::current($request);
+        if ($user) {
+            $inMyHanGroup = GroupMember::where('group_id', '905af467-1bde-4d2c-8dc7-49cfb74e0b09')
+                ->where('user_id', $user['user_uid'])->exists();
+        } else {
+            $inMyHanGroup = false;
+        }
+
+        if (App::environment('local')) {
+            // The environment is local
+            $inMyHanGroup = true;
+        }
+        $resultCount = 0;
+        $MAX_LOOP = 2;
+        for ($i = 0; $i < $MAX_LOOP; $i++) {
+            # code...
+            $word_base = [];
+            $wordDataOutput = [];
+            foreach ($words as $word => $case) {
+                # code...
+                $searched[] = $word;
+                $table = UserDict::select($indexCol)
+                    ->where('word', $word)
+                    ->where('source', '_PAPER_');
+                if (!$inMyHanGroup) {
+                    $table = $table->where('dict_id', '<>', '8ae6e0f5-f04c-49fc-a355-4885cc08b4b3');
+                    //测试代码
+                    //$table = $table->where('dict_id','<>','ac9b7b73-b9c0-4d31-a5c9-7c6dc5a2c187');
+                }
+                $result = $table->get();
+                $resultCount += count($result);
+                $anchor = $word;
+                $wordData = [
+                    'word' => $word,
+                    'factors' => "",
+                    'parents' => "",
+                    'case' => [],
+                    'grammar' => $case,
+                    'anchor' => $anchor,
+                    'dict' => [],
+                ];
+                /**
+                 * 按照语言调整词典顺序
+                 * 算法:准备理想的词典顺序容器。
+                 * 将查询的结果放置在对应的容器中。
+                 * 最后将结果扁平化
+                 * 准备字典容器
+                 * $wordDict = [
+                 *    "zh"=>[
+                 *        "0d79e8e8-1430-4c99-a0f1-b74f2b4b26d8"=>[];
+                 *    ]
+                 * ]
+                 */
+
+                foreach (DictApi::langOrder($userLang) as  $langId) {
+                    # code...
+                    $dictContainer = [];
+                    foreach (DictApi::dictOrder($langId) as $dictId) {
+                        $dictContainer[$dictId] = [];
+                    }
+                    $wordDict[$langId] = $dictContainer;
+                }
+                $dictList = [
+                    'href' => '#' . $anchor,
+                    'title' => $word,
+                    'children' => [],
+                ];
+                foreach ($result as $key => $value) {
+                    # code...
+                    $dictInfo = DictInfo::find($value->dict_id);
+                    $dict_lang = explode('-', $dictInfo->dest_lang);
+                    $anchor = "{$word}-{$dictInfo->shortname}";
+                    $currData = [
+                        'dictname' => $dictInfo->name,
+                        'shortname' => $dictInfo->shortname,
+                        'description' => $dictInfo->description,
+                        'meta' => json_decode($dictInfo->meta),
+                        'dict_id' => $value->dict_id,
+                        'lang' => $dict_lang[0],
+                        'word' => $word,
+                        'note' => $this->GrmAbbr($value->note, 0),
+                        'anchor' => $anchor,
+                    ];
+                    if (isset($wordDict[$dict_lang[0]])) {
+                        if (isset($wordDict[$dict_lang[0]][$value->dict_id])) {
+                            array_push($wordDict[$dict_lang[0]][$value->dict_id], $currData);
+                        } else {
+                            array_push($wordDict[$dict_lang[0]]["others"], $currData);
+                        }
+                    } else {
+                        array_push($wordDict['others']['others'], $currData);
+                    }
+                }
+                /**
+                 * 把树状数据变为扁平数据
+                 */
+                foreach ($wordDict as $oneLang) {
+                    # code...
+                    foreach ($oneLang as $langId => $dictId) {
+                        # code...
+                        foreach ($dictId as $oneData) {
+                            # code...
+                            $wordData['dict'][] = $oneData;
+                            if (isset($dictList['children']) && count($dictList['children']) > 0) {
+                                $lastHref = end($dictList['children'])['href'];
+                            } else {
+                                $lastHref = '';
+                            }
+                            $currHref = '#' . $oneData['anchor'];
+                            if ($lastHref !== $currHref) {
+                                $dictList['children'][] = [
+                                    'href' => $currHref,
+                                    'title' => $oneData['shortname'],
+                                ];
+                            }
+                        }
+                    }
+                }
+
+                if ($i < $MAX_LOOP - 1 || count($wordData['dict']) > 0) {
+                    $wordDataOutput[] = $wordData;
+                    $dictListOutput[] = $dictList;
+                }
+
+
+                //TODO 加变格查询
+                $case = new CaseMan();
+                $parent = $case->WordToBase($word);
+                foreach ($parent as $base => $case) {
+                    # code...
+                    if (!in_array($base, $searched)) {
+                        $word_base[$base] = $case;
+                    }
+                }
+            }
+
+            usort($wordDataOutput, function ($a, $b) {
+                return count($b['dict']) - count($a['dict']);
+            });
+            $wordDataPass[] = ['pass' => $i + 1, 'words' => $wordDataOutput];
+
+            if (count($word_base) === 0) {
+                break;
+            } else {
+                $words = $word_base;
+            }
+        }
+
+        if ($resultCount < 2 && $request->has('content')) {
+            //查询内文
+            $wordDataOutput = [];
+            $table = UserDict::select($indexCol)
+                ->where('note', 'like', '%' . $word . '%')
+                ->where('language', '<>', 'my')
+                ->take(5)
+                ->get();
+            $resultCount += count($table);
+            $wordData = [
+                'word' => $word,
+                'factors' => "",
+                'parents' => "",
+                'case' => [],
+                'grammar' => [],
+                'anchor' => $anchor,
+                'dict' => [],
+            ];
+            foreach ($table as $key => $value) {
+                $dictInfo = DictInfo::find($value->dict_id);
+                $dict_lang = explode('-', $dictInfo->dest_lang);
+                $anchor = "{$word}-{$dictInfo->shortname}";
+                $currData = [
+                    'dictname' => $dictInfo->name,
+                    'shortname' => $dictInfo->shortname,
+                    'description' => $dictInfo->description,
+                    'dict_id' => $value->dict_id,
+                    'lang' => $dict_lang[0],
+                    'word' => $word,
+                    'note' => $this->GrmAbbr($value->note, 0),
+                    'anchor' => $anchor,
+                ];
+                $wordData['dict'][] = $currData;
+            }
+            $wordDataOutput[] = $wordData;
+            $wordDataPass[] = ['pass' => 0, 'words' => $wordDataOutput];
+        }
+
+
+        $output['words'] = $wordDataPass;
+        $output['dictlist'] = $dictListOutput;
+        $output['caselist'] = $caseListOutput;
+
+        $output['time'] = microtime(true) - $startAt;
+        $output['count'] = $resultCount;
+
+        return $this->ok($output);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function show(UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserDict $userDict)
+    {
+        //
+    }
+
+    private function GrmAbbr($input, $dictid)
+    {
+        $mean = $input;
+        $replaced = array();
+        foreach (GRM_ABBR as $key => $value) {
+            if (in_array($value["abbr"], $replaced)) {
+                continue;
+            } else {
+                $replaced[] = $value["abbr"];
+            }
+            if ($dictid !== 0) {
+                if ($value["dictid"] === $dictid && strpos($input, $value["abbr"] . "|") == false) {
+                    $mean = str_ireplace($value["abbr"], "|@{$value["abbr"]}-{$value["replace"]}", $mean);
+                }
+            } else {
+                if (strpos($mean, "|@" . $value["abbr"]) == false) {
+                    $props = base64_encode(\json_encode(['text' => $value["abbr"], 'gid' => $value["replace"]]));
+                    $tpl = "<MdTpl name='grammar-pop' tpl='grammar-pop' props='{$props}'></MdTpl>";
+                    $mean = str_ireplace($value["abbr"], $tpl, $mean);
+                }
+            }
+        }
+        return $mean;
+    }
+}

+ 87 - 0
api-v12/app/Http/Controllers/DictInfoController.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\DictInfo;
+use Illuminate\Http\Request;
+use App\Http\Resources\DictInfoResource;
+
+class DictInfoController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'name':
+                $table = DictInfo::where('name',$request->get('name'));
+                break;
+
+            default:
+                # code...
+                break;
+        }
+
+        $table = $table->orderBy($request->get('order','updated_at'),
+                                $request->get('dir','desc'));
+
+        $table = $table->skip($request->get('offset',0))
+                       ->take($request->get('limit',100));
+
+        $result = $table->get();
+        $count = count($result);
+        return $this->ok([
+                            "rows"=>DictInfoResource::collection($result),
+                            "count"=>$count
+                        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\DictInfo  $dictInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function show(DictInfo $dictInfo)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\DictInfo  $dictInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, DictInfo $dictInfo)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\DictInfo  $dictInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(DictInfo $dictInfo)
+    {
+        //
+    }
+}

+ 128 - 0
api-v12/app/Http/Controllers/DictMeaningController.php

@@ -0,0 +1,128 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserDict;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use App\Tools\RedisClusters;
+
+class DictMeaningController extends Controller
+{
+    protected $langOrder = [
+        "zh-Hans"=>[
+            "zh-Hans","zh-Hant","jp","en","my","vi"
+        ],
+        "zh-Hant"=>[
+            "zh-Hant","zh-Hans","jp","en","my","vi"
+        ],
+        "en"=>[
+            "en","my","zh-Hant","zh-Hans","jp","vi"
+        ],
+        "jp"=>[
+            "jp","en","my","zh-Hant","zh-Hans","vi"
+        ],
+    ];
+
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $words = explode("-",$request->get('word'));
+        $lang = $request->get('lang');
+        $key = "dict_first_mean/";
+        $meaning = [];
+        foreach ($words as $key => $word) {
+            # code...
+            $meaning[] = ['word'=>$word,'meaning'=>$this->get($word,$lang)];
+        }
+
+        return $this->ok($meaning);
+    }
+
+    public function get(string $word,string $lang){
+        $currMeaning = "";
+        if(isset($this->langOrder[$lang])){
+            foreach ($this->langOrder[$lang] as $key => $value) {
+                # 遍历每种语言。找到返回
+                $cacheKey = "dict_first_mean/{$value}/{$word}";
+                $meaning = RedisClusters::get($cacheKey);
+                if(!empty($meaning)){
+                    $currMeaning = $meaning;
+                    break;
+                }
+            }
+        }
+        return $currMeaning;
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function create()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function show(UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserDict $userDict)
+    {
+        //
+    }
+}

+ 124 - 0
api-v12/app/Http/Controllers/DictPreferenceController.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Facades\Log;
+use Illuminate\Http\Request;
+
+use App\Models\UserDict;
+use App\Models\WordIndex;
+use App\Http\Resources\DictPreferenceResource;
+use App\Http\Api\DictApi;
+use App\Http\Api\AuthApi;
+
+class DictPreferenceController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $dict_id = DictApi::getSysDict('system_preference');
+        if (!$dict_id) {
+            return $this->error('没有找到 system_preference 字典', 200, 200);
+        }
+        $table = WordIndex::where('user_dicts.dict_id', $dict_id)
+            ->leftJoin('user_dicts', 'word_indices.word', '=', 'user_dicts.word')
+            ->select([
+                'user_dicts.id',
+                'word_indices.word',
+                'word_indices.count',
+                'user_dicts.factors',
+                'user_dicts.parent',
+                'user_dicts.note',
+                'user_dicts.confidence',
+                'user_dicts.editor_id',
+            ]);
+        //处理搜索
+        if (!empty($request->get("keyword"))) {
+            $table = $table->where('word_indices.word', 'like', "%" . $request->get("keyword") . "%");
+        }
+
+        //获取记录总条数
+        $count = $table->count();
+        //处理排序
+        $table = $table->orderBy(
+            $request->get("order", 'word_indices.count'),
+            $request->get("dir", 'desc')
+        );
+        //处理分页
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get("limit", 200));
+        //获取数据
+        $result = $table->get();
+        return $this->ok([
+            "rows" => DictPreferenceResource::collection($result),
+            "count" => $count
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function show(UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request,  $id)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [], 401);
+        }
+        $newData = $request->all();
+        $word = UserDict::findOrFail($id);
+        if (isset($newData['factors'])) {
+            $word->factors = $newData['factors'];
+        }
+        if (isset($newData['parent'])) {
+            $word->parent = $newData['parent'];
+        }
+        if (isset($newData['confidence'])) {
+            $word->confidence = $newData['confidence'];
+        }
+        $word->editor_id = $user['user_uid'];
+        $word->save();
+        return $this->ok(new DictPreferenceResource($word));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserDict $userDict)
+    {
+        //
+    }
+}

+ 85 - 0
api-v12/app/Http/Controllers/DictStatisticController.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\UserDict;
+use App\Models\DictInfo;
+use Illuminate\Support\Facades\DB;
+
+class DictStatisticController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $items = array();
+        $all = UserDict::count();
+        $query = "SELECT count(*) from (SELECT word from user_dicts ud group by word) as t;";
+		$allVocabulary = DB::select($query);
+        $query = "SELECT count(*) from (SELECT parent from user_dicts ud group by parent) as t;";
+		$allParent = DB::select($query);
+        $items[] = ['key'=>'all','title'=>'all','count'=>$all,'vocabulary'=>$allVocabulary[0]->count,'parent'=>$allParent[0]->count];
+
+        $dictName = ['robot_compound','system_regular','community_extract','community'];
+        foreach ($dictName as $key => $name) {
+            $dict = DictInfo::where('name',$name)->first();
+            $all = UserDict::where('dict_id',$dict->id)->count();
+            $query = "SELECT count(*) from (SELECT word from user_dicts ud where dict_id = ? group by word) as t;";
+            $vocabulary = DB::select($query,[$dict->id]);
+            $query = "SELECT count(*) from (SELECT parent from user_dicts ud where dict_id = ? group by parent) as t;";
+            $parent = DB::select($query,[$dict->id]);
+            $items[] = ['key'=>$dict->shortname,'title'=>$dict->shortname,'count'=>$all,'vocabulary'=>$vocabulary[0]->count,'parent'=>$parent[0]->count];
+        }
+        return $this->ok($items);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 105 - 0
api-v12/app/Http/Controllers/DictVocabularyController.php

@@ -0,0 +1,105 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserDict;
+use App\Models\DictInfo;
+use Illuminate\Http\Request;
+use App\Http\Resources\DictVocabularyResource;
+
+
+class DictVocabularyController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get("view")) {
+            case 'dict_name':
+                $id = DictInfo::where('name',$request->get("name"))->value('id');
+                if(!$id){
+                    return $this->error('name:'.$request->get("name").' can not found.',200,200);
+                }
+                $table = UserDict::where('dict_id',$id)
+                                ->groupBy('word')
+                                ->selectRaw('word,count(*)');
+                break;
+            case 'dict_short_name':
+                    $id = DictInfo::where('shortname',$request->get("name"))->value('id');
+                    if(!$id){
+                        return $this->error('name:'.$request->get("name").' can not found.',200,200);
+                    }
+                    $table = UserDict::where('dict_id',$id)
+                                    ->groupBy('word')
+                                    ->selectRaw('word,count(*)');
+                    break;
+
+        }
+        if($request->get("stream") === 'true'){
+            return response()->streamDownload(function () use ($table) {
+                $result = $table->get();
+                echo json_encode($result);
+            },'dict.txt');
+        }
+        $count = 2;
+        $table = $table->orderBy('word',$request->get('dir','asc'));
+
+        $table = $table->skip($request->get('offset',0))
+                       ->take($request->get('limit',1000));
+
+        $result = $table->get();
+        return $this->ok([
+                            "rows"=>DictVocabularyResource::collection($result),
+                            "count"=>$count
+                        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function show(UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserDict $userDict)
+    {
+        //
+    }
+}

+ 488 - 0
api-v12/app/Http/Controllers/DiscussionController.php

@@ -0,0 +1,488 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Facades\Log;
+use Illuminate\Http\Request;
+
+use App\Models\Discussion;
+use App\Models\Wbw;
+use App\Models\WbwBlock;
+use App\Models\PaliSentence;
+use App\Models\Sentence;
+use App\Models\Channel;
+use App\Http\Controllers\ArticleController;
+use App\Http\Controllers\WbwSentenceController;
+use App\Http\Resources\DiscussionResource;
+use App\Http\Api\MdRender;
+use App\Http\Api\AuthApi;
+use App\Http\Api\Mq;
+use App\Http\Api\UserApi;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\CourseApi;
+
+class DiscussionController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if ($user) {
+            $userInfo = UserApi::getByUuid($user['user_uid']);
+        }
+        switch ($request->get('view')) {
+            case 'question-by-topic':
+                $topic = Discussion::where('id', $request->get('id'));
+                $topic->where('status', $request->get('status', 'active'))
+                    ->select('res_id')->first();
+                if (!$topic) {
+                    return $this->error("无效的id");
+                }
+                $table = Discussion::where('res_id', $topic->res_id);
+                $activeNumber = Discussion::where('res_id', $topic->res_id)
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('res_id', $topic->res_id)
+                    ->where('status', 'close')->count();
+                $table->where('status', $request->get('status', 'active'))
+                    ->where('parent', null);
+                break;
+            case 'question':
+                /**
+                 * 禁止:
+                 * 未注册用户看到任何人发表的discussion
+                 * basic用户看到别人在别人channel发表的discussion
+                 *
+                 */
+                if (!$user && $request->get('type') === 'discussion') {
+                    return $this->ok([
+                        "rows" => [],
+                        "count" => 0,
+                        'active' => 0,
+                        'close' => 0,
+                        'can_create' => false,
+                        'can_reply' => false,
+                    ]);
+                }
+                $resType = $request->get('res_type');
+                if ($user) {
+                    switch ($resType) {
+                        case 'sentence':
+                            # code...
+                            break;
+                        case 'wbw':
+                            $block_uid = Wbw::where('uid', $request->get('id'))->value('block_uid');
+                            if ($block_uid) {
+                                $channelId = WbwBlock::where('uid', $block_uid)->value('channel_uid');
+                                if ($channelId) {
+                                    $canEdit = ChannelApi::userCanEdit($user['user_uid'], $channelId);
+                                }
+                            }
+                            break;
+                        default:
+                            # code...
+                            break;
+                    }
+                }
+
+
+                $resId = [$request->get('id')];
+                if (!empty($request->get('course'))) {
+                    //
+                    /**
+                     * 如果res id 是答案,获取学员提问
+                     * 如果是学员
+                     */
+                    //获取学员提问
+                    //获取学员channel
+                    if ($request->get('show_student') === 'true') {
+                        $channelsId = CourseApi::getStudentChannels($request->get('course'));
+                        switch ($resType) {
+                            case 'wbw':
+                                //获取答案单词编号
+                                $wbwWord = Wbw::where('uid', $request->get('id'))
+                                    ->first();
+                                $wbwId = WbwSentenceController::getWbwIdByChannels(
+                                    $channelsId,
+                                    $wbwWord->book_id,
+                                    $wbwWord->paragraph,
+                                    $wbwWord->wid
+                                );
+                                $resId = array_merge($resId, $wbwId);
+                                break;
+                            case 'sentence':
+                                break;
+                        }
+                    }
+                }
+                $table = Discussion::whereIn('res_id', $resId)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', $request->get('status', 'active'))
+                    ->where('parent', null);
+                if ($request->get('type') === 'discussion') {
+                    if (
+                        isset($userInfo) &&
+                        isset($userInfo['roles']) &&
+                        in_array('basic', $userInfo['roles'])
+                    ) {
+                        if (isset($canEdit) && $canEdit === true) {
+                        } else {
+                            $table = $table->where('editor_uid', $userInfo['id']);
+                        }
+                    }
+                }
+                $activeNumber = Discussion::whereIn('res_id', $resId)
+                    ->where('parent', null)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::whereIn('res_id', $resId)
+                    ->where('parent', null)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'close')->count();
+                break;
+            case 'answer':
+                $table = Discussion::where('parent', $request->get('id'));
+                $activeNumber = Discussion::where('parent', $request->get('id'))
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('parent', $request->get('id'))
+                    ->where('status', 'close')->count();
+                break;
+            case 'res_id':
+                /**
+                 * 先获取顶级节点
+                 * 需要确定用户身份,manager查看全部topic 普通用户只显示自己提交的topic
+                 */
+                $roots = Discussion::where('res_id', $request->get('id'))
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->whereIn('status', explode(',', $request->get('status', 'active')))
+                    ->where('parent', null)
+                    ->select('id')
+                    ->get();
+
+                $table = Discussion::where(function ($query) use ($roots) {
+                    $query->whereIn('id', $roots)
+                        ->orWhereIn('parent', $roots);
+                });
+                $activeNumber = Discussion::where('res_id', $request->get('id'))
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('res_id', $request->get('id'))
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'close')->count();
+                break;
+            case 'topic-by-user':
+                /**
+                 * 某用户发表的全部topic
+                 *
+                 */
+                if (!$user) {
+                    return $this->error('', 403, 403);
+                }
+                $table = Discussion::where('editor_uid', $user['user_uid'])
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->whereIn('status', explode(',', $request->get('status', 'active')))
+                    ->where('parent', null);
+                $activeNumber = Discussion::where('editor_uid', $user['user_uid'])
+                    ->where('parent', null)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('editor_uid', $user['user_uid'])
+                    ->where('parent', null)
+                    ->where('type', $request->get('type', 'discussion'))
+                    ->where('status', 'close')->count();
+                break;
+            case 'all':
+                $table = Discussion::where('parent', null);
+                $activeNumber = Discussion::where('parent', null)
+                    ->where('status', 'active')->count();
+                $closeNumber = Discussion::where('parent', null)
+                    ->where('status', 'close')->count();
+                break;
+        }
+        if (!empty($search)) {
+            $table = $table->where('title', 'like', $search . "%");
+        }
+        $count = $table->count();
+
+        $table = $table->orderBy($request->get('order', 'created_at'), $request->get('dir', 'desc'));
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 100));
+
+        $result = $table->get();
+
+        $can_create = false;
+        $can_reply = false;
+        $user = AuthApi::current($request);
+
+        switch ($request->get('type', 'discussion')) {
+            case 'qa':
+                switch ($request->get('res_type')) {
+                    case 'article':
+                        if ($user && ArticleController::userCanEditId($user['user_uid'], $request->get('id'))) {
+                            $can_create = true;
+                            $can_reply = true;
+                        }
+                        break;
+                }
+                break;
+            case 'help':
+                switch ($request->get('res_type')) {
+                    case 'article':
+                        if ($user) {
+                            $can_reply = true;
+                            if (ArticleController::userCanEditId($user['user_uid'], $request->get('id'))) {
+                                $can_create = true;
+                            }
+                        }
+                        break;
+                }
+                break;
+            case 'discussion':
+                if ($user) {
+                    $can_create = true;
+                    $can_reply = true;
+                }
+                break;
+        }
+
+        return $this->ok([
+            "rows" => DiscussionResource::collection($result),
+            "count" => $count,
+            'active' => $activeNumber,
+            'close' => $closeNumber,
+            'can_create' => $can_create,
+            'can_reply' => $can_reply,
+        ]);
+    }
+
+    public function discussion_tree(Request $request)
+    {
+        $output = [];
+        $sentences = $request->get("data");
+        foreach ($sentences as $key => $sentence) {
+            # 先查句子信息
+            $sentInfo = Sentence::where('book_id', $sentence['book'])
+                ->where('paragraph', $sentence['paragraph'])
+                ->where('word_start', $sentence['word_start'])
+                ->where('word_end', $sentence['word_end'])
+                ->where('channel_uid', $sentence['channel_id'])
+                ->first();
+            if ($sentInfo) {
+                $sentPr = Discussion::where('res_id', $sentInfo['uid'])
+                    ->whereNull('parent')
+                    ->select('title', 'children_count', 'editor_uid')
+                    ->orderBy('created_at', 'desc')->get();
+                if (count($sentPr) > 0) {
+                    $output[] = [
+                        'sentence' => [
+                            'book' => $sentInfo->book_id,
+                            'paragraph' => $sentInfo->paragraph,
+                            'word_start' => $sentInfo->word_start,
+                            'word_end' => $sentInfo->word_end,
+                            'channel_id' => $sentInfo->channel_uid,
+                            'content' => $sentInfo->content,
+                            'pr_count' => count($sentPr),
+                        ],
+                        'pr' => $sentPr,
+                    ];
+                }
+            }
+        }
+        return $this->ok(['rows' => $output, 'count' => count($output)]);
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('discussion store auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), [401], 401);
+        }
+        //
+        // validate
+        // read more on validation at http://laravel.com/docs/validation
+
+        if ($request->has('parent')) {
+            $rules = [];
+            $parentInfo = Discussion::find($request->get('parent'));
+            if (!$parentInfo) {
+                return $this->error('no record');
+            }
+        } else {
+            $rules = array(
+                'res_id' => 'required',
+                'res_type' => 'required',
+                'title' => 'required',
+            );
+        }
+
+        $validated = $request->validate($rules);
+
+        $discussion = new Discussion;
+        if ($request->has('parent')) {
+            $discussion->res_id = $parentInfo->res_id;
+            $discussion->res_type = $parentInfo->res_type;
+        } else {
+            $discussion->res_id = $request->get('res_id');
+            $discussion->res_type = $request->get('res_type');
+        }
+        $discussion->type = $request->get('type', 'discussion');
+        $discussion->tpl_id = $request->get('tpl_id');
+        $discussion->title = $request->get('title', null);
+        $discussion->content = $request->get('content', null);
+        $discussion->content_type = $request->get('content_type', "markdown");
+        $discussion->parent = $request->get('parent', null);
+        $discussion->editor_uid = $user['user_uid'];
+        $discussion->save();
+        //更新parent children_count
+        if ($request->has('parent')) {
+            $parentInfo->increment('children_count', 1);
+            $parentInfo->save();
+        }
+        if ($request->get('notification', true)) {
+            Mq::publish('discussion', new DiscussionResource($discussion));
+        }
+
+        return $this->ok(new DiscussionResource($discussion));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Discussion  $discussion
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Discussion $discussion)
+    {
+        //
+        return $this->ok(new DiscussionResource($discussion));
+    }
+
+    /**
+     * 获取discussion 锚点的数据。以句子为最小单位,逐词解析也要显示单词所在的句子
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function anchor($id)
+    {
+        //
+        $discussion = Discussion::find($id);
+        $content = '';
+        switch ($discussion->res_type) {
+            case 'wbw':
+                # 从逐词解析表获取逐词解析数据
+                $wbw = Wbw::where('uid', $discussion->res_id)->first();
+                if (!$wbw) {
+                    return $this->error('no wbw data');
+                }
+                $wbwBlock = WbwBlock::where('uid', $wbw->block_uid)->first();
+                if (!$wbwBlock) {
+                    return $this->error('no wbwBlock data');
+                }
+                $sent = PaliSentence::where('book', $wbw->book_id)
+                    ->where('paragraph', $wbw->paragraph)
+                    ->where('word_begin', '<=', $wbw->wid)
+                    ->where('word_end', '>=', $wbw->wid)
+                    ->first();
+                if (!$sent) {
+                    return $this->error('no sent data');
+                }
+                $sentId = "{$sent['book']}-{$sent['paragraph']}-{$sent['word_begin']}-{$sent['word_end']}";
+                $channel = $wbwBlock->channel_uid;
+                $content = MdRender::render("{{" . $sentId . "}}", [$channel]);
+                break;
+
+            default:
+                # code...
+                break;
+        }
+        return $this->ok($content);
+    }
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Discussion  $discussion
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Discussion $discussion)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [403], 403);
+        }
+        //
+        $isManager = false;
+        $isResManager = false;
+        if ($discussion->editor_uid === $user['user_uid']) {
+            $isManager = true;
+        } else {
+            //查看是否是资源拥有者
+            if ($discussion->res_type === 'sentence') {
+                $res = Sentence::find($discussion->res_id);
+                if ($res) {
+                    $channelId = $res->channel_uid;
+                }
+            } else if ($discussion->res_type === 'wbw') {
+                $res = Wbw::where('uid', $discussion->res_id)->first();
+                if ($res) {
+                    $block = WbwBlock::where('uid', $res->block_uid)->first();
+                    if ($block) {
+                        $channelId = $block->channel_uid;
+                    }
+                }
+            }
+            if (isset($channelId)) {
+                $channel = Channel::find($channelId);
+                if ($channel) {
+                    $isResManager = ChannelApi::userCanEdit($user['user_uid'], $channelId);
+                }
+            }
+        }
+        if (!$isManager && !$isResManager) {
+            return $this->error(__('auth.failed'), [403], 403);
+        }
+
+        $discussion->title = $request->get('title', null);
+        $discussion->content = $request->get('content', null);
+        $discussion->status = $request->get('status', 'active');
+        if ($request->has('type')) {
+            $discussion->type = $request->get('type');
+        }
+        //$discussion->editor_uid = $user['user_uid'];
+        $discussion->save();
+        return $this->ok(new DiscussionResource($discussion));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Discussion  $discussion
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, Discussion $discussion)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), [401], 401);
+        }
+        //TODO 其他有权限的人也可以删除
+        if ($discussion->editor_uid !== $user['user_uid']) {
+            return $this->error(__('auth.failed'), [403], 403);
+        }
+        $delete = $discussion->delete();
+        return $this->ok($delete);
+    }
+}

+ 235 - 0
api-v12/app/Http/Controllers/DiscussionCountController.php

@@ -0,0 +1,235 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\ChannelApi;
+use App\Http\Resources\DiscussionCountResource;
+use App\Http\Resources\TagMapResource;
+use Illuminate\Support\Facades\Log;
+use App\Models\Discussion;
+use App\Models\CourseMember;
+use App\Models\Course;
+use App\Models\Sentence;
+use App\Models\WbwBlock;
+use App\Models\Wbw;
+use App\Models\TagMap;
+
+
+
+class DiscussionCountController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * 课程模式业务逻辑
+     * 标准答案channel:学生提问
+     * 学生channel: 老师批改作业
+     * 老师:
+     *   标准答案channel: 本期学生,老师(区分已经回复,未回复)
+     *   学生channel: 本期学生,老师
+     * 学生:
+     *   标准答案channel:我自己topic(区分已经回复,未回复)
+     *   学生自己channel: 我自己,本期老师
+     *
+     * 输入:
+     *   句子列表
+     *   courseId
+     * 返回数据
+     *  resId
+     *  type:'discussion':
+     *     my:number
+     *     myReplied:number
+     *     all:number
+     *     allReplied:number
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        /**
+         * 课程
+         * 1. 获取用户角色
+         * 2. 获取成员列表
+         * 3. 计算答案channel的结果
+         * 4. 计算作业channel的结果
+         */
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error('auth.failed',401,401);
+        }
+        $studioIdForTag = $user["user_uid"];
+        if($request->has('course_id')){
+            //判断我的角色
+            $my = CourseMember::where('user_id',$user["user_uid"])
+                                ->where('is_current',true)
+                                ->where('course_id',$request->get('course_id'))
+                                ->first();
+            if(!$my){
+                return $this->error('auth.failed',403,403);
+            }
+            //获取全部成员列表
+            $allMembers = CourseMember::where('is_current',true)
+                                ->where('course_id',$request->get('course_id'))
+                                ->select('user_id')
+                                ->get();
+            Log::debug('allMembers',['members'=>$allMembers]);
+            //找到全部相关channel
+            $channels = array();
+            //获取答案 channel
+            $answerChannel = Course::where('id',$request->get('course_id'))
+                            ->value('channel_id');
+            $exerciseChannels = CourseMember::where('is_current',true)
+                                    ->where('course_id',$request->get('course_id'))
+                                    ->select('channel_id')
+                                    ->get();
+            if($answerChannel){
+                array_push($channels,$answerChannel);
+            }
+            $users = array();
+            if($my->role === 'student'){
+                //自己的channel + 答案
+                if($my->channel_id){
+                    array_push($channels,$my->channel_id);
+                }
+            }else{
+                //找到全部学员channel + 答案
+
+                foreach ($exerciseChannels as $key => $value) {
+                    array_push($channels,$value->channel_id);
+                }
+                //找到
+                $courseStudioId = Course::where('id',$request->get('course_id'))
+                            ->value('studio_id');
+                if($courseStudioId){
+                    $studioIdForTag = $courseStudioId;
+                }
+
+            }
+        }
+
+        //获取全部资源列表
+        $resId = array();
+        $querySentId = $request->get('sentences');
+        //译文
+        $table = Sentence::select('uid')
+                        ->whereIns(['book_id','paragraph','word_start','word_end'],$querySentId);
+        if(isset($channels)){
+            $table = $table->whereIn('channel_uid',$channels);
+        }
+        $sentUid = $table->get();
+
+        foreach ($sentUid as $key => $value) {
+            $resId[] = $value->uid;
+        }
+        //wbw
+        $wbwBlockParagraphs = [];
+        foreach ($querySentId as $key => $value) {
+            $wbwBlockParagraphs[] = [$value[0],$value[1]];
+        }
+        $table = WbwBlock::select('uid')
+                          ->whereIns(['book_id','paragraph'],$wbwBlockParagraphs);
+        if(isset($channels)){
+            $table = $table->whereIn('channel_uid',$channels);
+        }
+        $wbwBlock = $table->get();
+        if($wbwBlock){
+            //找到逐词解析数据
+            foreach ($querySentId as $key => $value) {
+                $wbwData = Wbw::whereIn('block_uid',$wbwBlock)
+                                ->whereBetween('wid',[$value[2],$value[3]])
+                                ->select('uid')
+                                ->get();
+                foreach ($wbwData as $key => $value) {
+                    $resId[] = $value->uid;
+                }
+            }
+        }
+        Log::debug('res id',['res'=>$resId]);
+        //全部资源id获取完毕
+        //获取discussion
+        $table = Discussion::select(['id','res_id','res_type','type','editor_uid'])
+                            ->where('status','active')
+                            ->whereNull('parent')
+                            ->whereIn('res_id',$resId);
+        if(isset($allMembers)){
+            $table = $table->whereIn('editor_uid',$allMembers);
+        }
+
+        $allDiscussions = $table->get();
+        $discussions = DiscussionCountResource::collection($allDiscussions);
+
+        //获取 tag
+        $tags = TagMap::select(['tag_maps.id','anchor_id','table_name','tag_id','editor_uid','tags.name','tags.color'])
+                            ->whereIn('anchor_id',$resId)
+                            ->where('owner_uid',$studioIdForTag)
+                            ->leftJoin('tags','tags.id', '=', 'tag_maps.tag_id')
+                            ->get();
+        Log::debug('response',['data'=>$discussions]);
+        return $this->ok([
+            'discussions'=>$discussions,
+            'tags' => $tags,
+        ]);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $resId
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string  $resId)
+    {
+        //
+        $allDiscussions = Discussion::where('status','active')
+                                    ->whereNull('parent')
+                                    ->where('res_id',$resId)
+                                    ->select(['id','res_id','res_type','type','editor_uid'])
+                                    ->get();
+        $discussions = DiscussionCountResource::collection($allDiscussions);
+
+        //获取 tag
+        $table = TagMap::select(['id','anchor_id','table_name','tag_id','editor_uid'])
+                       ->where('anchor_id',$resId);
+
+        $allTags = $table->get();
+        $tags = TagMapResource::collection($allTags);
+        Log::debug('response',['discussions'=>$discussions]);
+        return $this->ok([
+            'discussions'=>$discussions,
+            'tags' => $tags,
+        ]);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Discussion  $discussion
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Discussion $discussion)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Discussion  $discussion
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Discussion $discussion)
+    {
+        //
+    }
+}

+ 75 - 0
api-v12/app/Http/Controllers/EditableSentenceController.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Sentence;
+use Illuminate\Http\Request;
+
+class EditableSentenceController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $sentenceId)
+    {
+        //
+        $sentence = Sentence::find($sentenceId);
+        $sentId = $sentence->book_id . '-'.
+                    $sentence->paragraph .'-'.
+                    $sentence->word_start .'-'.
+                    $sentence->word_end;
+        $corpus = new CorpusController;
+        $props = $corpus->getSentTpl($sentId,[$sentence->channel_uid],
+                    'edit',true,
+                    'react');
+        return $this->ok($props);
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Sentence $sentence)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Sentence $sentence)
+    {
+        //
+    }
+}

+ 100 - 0
api-v12/app/Http/Controllers/EmailCertificationController.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\Invite;
+use App\Http\Resources\InviteResource;
+use Illuminate\Support\Str;
+use App\Mail\EmailCertif;
+use Illuminate\Support\Facades\Mail;
+use App\Tools\RedisClusters;
+use App\Models\UserInfo;
+
+class EmailCertificationController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //查询是否重复
+        if (UserInfo::where('email', $request->get('email'))->exists()) {
+            return $this->error('email.exists', 'err.email.exists', 200);
+        }
+        $sender = config("mint.admin.root_uuid");
+
+        $uuid = Str::uuid();
+        $invite = Invite::firstOrNew(
+            ['email' => $request->get('email')],
+            ['id' => $uuid]
+        );
+        $invite->user_uid = $sender;
+        $invite->status = 'invited';
+        $invite->save();
+
+        Mail::to($request->get('email'))
+            ->send(new EmailCertif(
+                $invite->id,
+                $request->get('subject', 'sign up wikipali'),
+                $request->get('lang'),
+            ));
+        if (Mail::failures()) {
+            return $this->error('send email fail', '', 200);
+        }
+
+        return $this->ok(new InviteResource($invite));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $id)
+    {
+        //
+        $code = RedisClusters::get("/email/certification/" . $id);
+        if (empty($code)) {
+            return $this->error('Certification is avalide', 200, 200);
+        }
+        return $this->ok($code);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 171 - 0
api-v12/app/Http/Controllers/ExerciseController.php

@@ -0,0 +1,171 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Course;
+use App\Models\CourseMember;
+use App\Models\Article;
+use App\Models\WbwBlock;
+use App\Models\Wbw;
+use App\Models\Discussion;
+use App\Models\Sentence;
+use Illuminate\Http\Request;
+use App\Http\Api\MDRender;
+use App\Http\Api\UserApi;
+
+class ExerciseController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        /**
+         * 列出某个练习所有人的提交情况
+         * 情况包括
+         * 1.作业填充百分比
+         * 2.问题数量
+         */
+        $validated = $request->validate([
+            'course_id' => 'required',
+            'article_id' => 'required',
+            'exercise_id' => 'required',
+        ]);
+        $output = [];
+        //课程信息
+        $course = Course::findOrFail($validated['course_id']);
+
+        //查询练习句子编号
+        $article = Article::where('uid',$validated['article_id'])->value('content');
+
+        $wiki = MdRender::markdown2wiki($article);
+        $xml = MdRender::wiki2xml($wiki);
+        $html = MdRender::xmlQueryId($xml, $validated['exercise_id']);
+        $sentences = MdRender::take_sentence($html);
+
+        //获取课程答案逐词解析列表
+        $answerWbw = [];
+        foreach ($sentences as  $sent) {
+            # code...wbw
+            $sentId = explode('-',$sent);
+            if(count($sentId)<4){
+                break;
+            }
+            $courseWb = WbwBlock::where('book_id',$sentId[0])
+                            ->where('paragraph',$sentId[1])
+                            ->where('channel_uid',$course->channel_id)
+                            ->value('uid');
+            if($courseWb){
+                $wbwId = Wbw::where('block_uid',$courseWb)
+                    ->whereBetween('wid',[$sentId[2],$sentId[3]])
+                    ->select('uid')->get();
+                foreach ($wbwId as $id) {
+                    # code...
+                    $answerWbw[] = $id->uid;
+                }
+            }
+        }
+        $members = CourseMember::where('course_id',$validated['course_id'])
+                            ->where('role','student')
+                            ->select(['user_id','channel_id'])
+                            ->get();
+        foreach ($members as  $member) {
+            # code...
+            $data = [
+                'user' => UserApi::getByUuid($member->user_id),
+                'wbw' => 0,
+                'translation' => 0,
+                'question' => 0,
+                'html' => ""
+            ];
+            if(!empty($member->channel_id)){
+                //
+                foreach ($sentences as  $sent) {
+                    # code...wbw
+                    $sentId = explode('-',$sent);
+                    if(count($sentId)<4){
+                        break;
+                    }
+                    $wb = WbwBlock::where('book_id',$sentId[0])
+                            ->where('paragraph',$sentId[1])
+                            ->where('channel_uid',$member->channel_id)
+                            ->value('uid');
+                    if($wb){
+                        $wbwCount = Wbw::where('block_uid',$wb)
+                            ->whereBetween('wid',[$sentId[2],$sentId[3]])
+                            ->where('status','>',4)
+                            ->count();
+                        $data['wbw'] += $wbwCount;
+                    }
+                    //translation
+                    $sentCount = Sentence::where('book_id',$sentId[0])
+                            ->where('paragraph',$sentId[1])
+                            ->where('word_start',$sentId[2])
+                            ->where('word_end',$sentId[3])
+                            ->where('channel_uid',$member->channel_id)
+                            ->count();
+                    $data['translation'] += $sentCount;
+                    //discussion
+                    //查找答案的wbw 对应的discussion
+                    $discussionCount = Discussion::whereIn('res_id',$answerWbw)
+                            ->where('editor_uid',$member->user_id)
+                            ->whereNull('parent')
+                            ->count();
+                    $data['question'] += $discussionCount;
+
+                    $tpl = MdRender::xml2tpl($html,$member->channel_id);
+                    $data['html'] .= $tpl;
+                }
+            }
+            $output[] = $data;
+        }
+        return $this->ok(["rows"=>$output,"count"=>count($output)]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Course  $course
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Course $course)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Course  $course
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Course $course)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Course  $course
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Course $course)
+    {
+        //
+    }
+}

+ 124 - 0
api-v12/app/Http/Controllers/ExportController.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Storage;
+
+use App\Http\Api\AuthApi;
+use App\Http\Api\Mq;
+use App\Tools\RedisClusters;
+use App\Tools\ExportDownload;
+
+class ExportController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        $queryId = Str::uuid();
+        $token = AuthApi::getToken($request);
+        switch ($request->get('type','chapter')) {
+            case 'chapter':
+                $data = [
+                    'book'=>$request->get('book'),
+                    'para'=>$request->get('par'),
+                    'channel'=>$request->get('channel'),
+                    'format'=>$request->get('format'),
+                    'origin'=>$request->get('origin'),
+                    'translation'=>$request->get('translation'),
+                    'queryId'=>$queryId,
+                ];
+                if($token){
+                    $data['token'] = $token;
+                }
+                Mq::publish('export_pali_chapter',$data);
+                break;
+            case 'article':
+                $data = [
+                    'id'=>$request->get('id'),
+                    'channel'=>$request->get('channel'),
+                    'format'=>$request->get('format'),
+                    'origin'=>$request->get('origin'),
+                    'translation'=>$request->get('translation'),
+                    'queryId'=>$queryId,
+                    'anthology'=>$request->get('anthology'),
+                    'channel'=>$request->get('channel'),
+                ];
+                if($token){
+                    $data['token'] = $token;
+                }
+                Mq::publish('export_article',$data);
+                break;
+            default:
+                return $this->error('unknown type '.$request->get('type'),400,400);
+                break;
+        }
+
+        return $this->ok($queryId);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($filename)
+    {
+        //
+        $exportChapter = new ExportDownload(['queryId'=>$filename]);
+        $exportStatus = $exportChapter->getStatus();
+        if(empty($exportStatus)){
+            return $this->error('no file',200,200);
+        };
+
+        $output = array();
+        $output['status'] = $exportStatus;
+        if($exportStatus['progress']===1){
+            $output['url'] = $exportStatus['url'];
+        }
+        return $this->ok($output);
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 143 - 0
api-v12/app/Http/Controllers/ExportWbwController.php

@@ -0,0 +1,143 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Wbw;
+use App\Models\WbwBlock;
+use App\Models\PaliSentence;
+use Illuminate\Http\Request;
+
+class ExportWbwController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $sent = explode("\n",$request->get("sent"));
+        $output = [];
+        foreach ($sent as $key => $value) {
+            # code...
+            $sent = [];
+            $value = trim($value);
+            $sentId = explode("-",$value);
+            //先查wbw block 拿到block id
+            $block = WbwBlock::where('book_id',$sentId[0])
+                        ->where('paragraph',$sentId[1])
+                        ->select('uid')
+                        ->where('channel_uid',$request->get("channel"))->first();
+            if(!$block){
+                continue;
+            }
+            $wbwdata = Wbw::where('book_id',$sentId[0])
+                        ->where('paragraph',$sentId[1])
+                        ->where('wid','>=',$sentId[2])
+                        ->where('wid','<=',$sentId[3])
+                        ->where('block_uid',$block->uid)
+                        ->get();
+            $sent['sid']=$value;
+            $sent['text'] = PaliSentence::where('book',$sentId[0])
+                                        ->where('paragraph',$sentId[1])
+                                        ->where('word_begin',$sentId[2])
+                                        ->where('word_end','<=',$sentId[3])
+                                        ->value('html');
+            $sent['data']=[];
+            foreach ($wbwdata as  $wbw) {
+                # code...
+                $data = str_replace("&nbsp;",' ',$wbw->data);
+                $data = str_replace("<br>",' ',$data);
+
+                $xmlString = "<root>" . $data . "</root>";
+                try{
+                    $xmlWord = simplexml_load_string($xmlString);
+                }catch(Exception $e){
+                    continue;
+                }
+
+                $wordsList = $xmlWord->xpath('//word');
+                foreach ($wordsList as $word) {
+                    $pali = $word->real->__toString();
+                    $case = explode("#",$word->case->__toString()) ;
+                    if(isset($case[0])){
+                        $type = $case[0];
+                    }else{
+                        $type = "";
+                    }
+
+                    if(isset($case[1])){
+                        $grammar = $case[1];
+                        $grammar = str_replace("null","",$grammar);
+                    }else{
+                        $grammar = "";
+                    }
+
+                    $style = $word->style->__toString();
+                    $factormeaning = str_replace("
","",$word->om->__toString());
+                    $factormeaning = str_replace("↓↓","",$factormeaning);
+                    if($type !== '.ctl.' && $style !== 'note' && !empty($pali)){
+                        $sent['data'][]=[
+                            'pali'=>$word->real->__toString(),
+                            'mean' => str_replace("
","",$word->mean->__toString()),
+                            'type' => ltrim($type,'.'),
+                            'grammar' => ltrim(str_replace('$.',',',$grammar),'.') ,
+                            'parent' => $word->parent->__toString(),
+                            'factors' => $word->org->__toString(),
+                            'factormeaning' => $factormeaning
+                        ];
+                    }
+
+                }
+            }
+            $output[]=$sent;
+        }
+        return view('export_wbw',['sentences' => $output] );
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Wbw  $wbw
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Wbw $wbw)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Wbw  $wbw
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Wbw $wbw)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Wbw  $wbw
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Wbw $wbw)
+    {
+        //
+    }
+}

+ 84 - 0
api-v12/app/Http/Controllers/ForgotPasswordController.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserInfo;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Mail;
+use App\Mail\ForgotPassword;
+
+class ForgotPasswordController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = UserInfo::where('email',$request->get('email'))->first();
+        if(!$user){
+            return $this->error('no user',404,404);
+        }
+        $resetToken = Str::uuid();
+        $user->reset_password_token = $resetToken;
+        $ok = $user->save();
+        if(!$ok){
+            return $this->error('fail on update reset_password_token',500,500);
+        }
+
+        Mail::to($request->get('email'))
+            ->send(new ForgotPassword($resetToken,$request->get('lang'),$request->get('dashboard')));
+        if(Mail::failures()){
+            return $this->error('send email fail',[],200);
+        }
+        return $this->ok('');
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\UserInfo  $userInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function show(UserInfo $userInfo)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\UserInfo  $userInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, UserInfo $userInfo)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserInfo  $userInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserInfo $userInfo)
+    {
+        //
+    }
+}

+ 83 - 0
api-v12/app/Http/Controllers/GrammarGuideController.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\DhammaTerm;
+use App\Http\Api\ChannelApi;
+use Illuminate\Http\Request;
+
+class GrammarGuideController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\DhammaTerm  $dhammaTerm
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $id)
+    {
+        //
+        $param = explode('_',$id);
+
+        $localTermChannel = ChannelApi::getSysChannel(
+            "_System_Grammar_Term_".strtolower($param[1])."_",
+            "_System_Grammar_Term_en_"
+        );
+        if(!$localTermChannel){
+            return $this->error('no term channel');
+        }
+        $result = DhammaTerm::where('word',$param[0])
+                    ->where('channal',$localTermChannel)->first();
+
+        if($result){
+            return $this->ok("# {$result->meaning}\n {$result->note}");
+        }else{
+            return $this->ok("# {$id}\n no record");
+        }
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\DhammaTerm  $dhammaTerm
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, DhammaTerm $dhammaTerm)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\DhammaTerm  $dhammaTerm
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(DhammaTerm $dhammaTerm)
+    {
+        //
+    }
+}

+ 239 - 0
api-v12/app/Http/Controllers/GroupController.php

@@ -0,0 +1,239 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\GroupInfo;
+use App\Models\GroupMember;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\DB;
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Resources\GroupResource;
+
+
+class GroupController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $result = false;
+        $indexCol = ['uid', 'name', 'description', 'owner', 'updated_at', 'created_at'];
+        switch ($request->get('view')) {
+            case 'studio':
+                # 获取studio内所有group
+                $user = AuthApi::current($request);
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                //判断当前用户是否有指定的studio的权限
+                $studioId = StudioApi::getIdByName($request->get('name'));
+                if ($user['user_uid'] !== $studioId) {
+                    return $this->error(__('auth.failed'));
+                }
+
+                $table = GroupInfo::select($indexCol);
+                if ($request->get('view2', 'my') === 'my') {
+                    $table = $table->where('owner', $studioId);
+                } else {
+                    //我参加的group
+                    $groupId = GroupMember::where('user_id', $studioId)
+                        ->groupBy('group_id')
+                        ->select('group_id')
+                        ->get();
+                    $table = $table->whereIn('uid', $groupId);
+                    $table = $table->where('owner', '<>', $studioId);
+                }
+                break;
+            case 'all':
+                $table = GroupInfo::select($indexCol);
+                break;
+        }
+        if ($request->has("search")) {
+            $table = $table->where('name', 'like', "%" . $request->get("search") . "%");
+        }
+        $count = $table->count();
+
+        if ($request->get('view') === 'studio_list') {
+            $table = $table->orderBy('count', 'desc');
+        } else {
+            $table = $table->orderBy(
+                $request->get('order', 'updated_at'),
+                $request->get('dir', 'desc')
+            );
+        }
+        $table->skip($request->get('offset', 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+        if ($result) {
+            return $this->ok(["rows" => GroupResource::collection($result), "count" => $count]);
+        } else {
+            return $this->error("没有查询到数据");
+        }
+    }
+    /**
+     * 获取我的,和协作channel数量
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function showMyNumber(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        $studioId = StudioApi::getIdByName($request->get('studio'));
+        if ($user['user_uid'] !== $studioId) {
+            return $this->error(__('auth.failed'));
+        }
+        //我的
+        $my = GroupMember::where('user_id', $studioId)->where('power', 0)->count();
+        //协作
+        $collaboration = GroupMember::where('user_id', $studioId)->where('power', '<>', 0)->count();
+
+        return $this->ok(['my' => $my, 'collaboration' => $collaboration]);
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的studio的权限
+        if ($user['user_uid'] !== StudioApi::getIdByName($request->get('studio_name'))) {
+            return $this->error(__('auth.failed'));
+        }
+        //查询是否重复
+        if (GroupInfo::where('name', $request->get('name'))->where('owner', $user['user_uid'])->exists()) {
+            return $this->error(__('validation.exists', ['name']));
+        }
+        $studioId = StudioApi::getIdByName($request->get('studio_name'));
+        $group = new GroupInfo;
+        DB::transaction(function () use ($group, $request, $user, $studioId) {
+            $group->id = app('snowflake')->id();
+            $group->uid = Str::uuid();
+            $group->name = $request->get('name');
+            $group->owner = $studioId;
+            $group->create_time = time() * 1000;
+            $group->modify_time = time() * 1000;
+            $group->save();
+
+            $newMember = new GroupMember();
+            $newMember->id = app('snowflake')->id();
+            $newMember->user_id = $studioId;
+            $newMember->group_id = $group->uid;
+            $newMember->power = 0;
+            $newMember->group_name = $request->get('name');
+            $newMember->save();
+        });
+
+        return $this->ok($group);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request  $request, $id)
+    {
+        //
+        $indexCol = ['uid', 'name', 'description', 'owner', 'updated_at', 'created_at'];
+
+        $result  = GroupInfo::select($indexCol)->where('uid', $id)->first();
+        if (!$result) {
+            return $this->error("没有查询到数据");
+        }
+        if ($result->status < 30) {
+            //私有,判断权限
+            $user = AuthApi::current($request);
+            if (!$user) {
+                return $this->error(__('auth.failed'));
+            }
+            //判断当前用户是否有指定的group的权限
+            if ($user['user_uid'] !== $result->owner) {
+                //非所有者
+                //判断是否协作
+                $power = GroupMember::where('group_id', $id)
+                    ->where('user_id', $user['user_uid'])
+                    ->value('power');
+                if ($power === null) {
+                    return $this->error(__('auth.failed'));
+                }
+            }
+        }
+        return $this->ok(new GroupResource($result));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\GroupInfo  $group
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, GroupInfo $group)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有修改权限
+        if ($user['user_uid'] !== $group->owner) {
+            return $this->error(__('auth.failed'));
+        }
+        $group->name = $request->get('name');
+        $group->description = $request->get('description');
+        if ($request->has('status')) {
+            $group->status = $request->get('status');
+        }
+        $group->create_time = time() * 1000;
+        $group->modify_time = time() * 1000;
+        $group->save();
+        return $this->ok($group);
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\GroupInfo  $group
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, GroupInfo $group)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //判断当前用户是否有指定的 group 的删除权限
+        if ($user['user_uid'] !== $group->owner) {
+            return $this->error(__('auth.failed'));
+        }
+        $delete = 0;
+        DB::transaction(function () use ($group, $delete) {
+            //删除group member
+            $memberDelete = GroupMember::where('group_id', $group->uid)->delete();
+            $delete = $group->delete();
+        });
+
+        return $this->ok($delete);
+    }
+}

+ 154 - 0
api-v12/app/Http/Controllers/GroupMemberController.php

@@ -0,0 +1,154 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\GroupMember;
+use App\Models\GroupInfo;
+use Illuminate\Http\Request;
+use App\Http\Resources\GroupMemberResource;
+use App\Http\Api\AuthApi;
+
+class GroupMemberController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        $result = false;
+        $indexCol = ['id', 'user_id', 'group_id', 'power', 'level', 'status', 'updated_at', 'created_at'];
+        switch ($request->get('view')) {
+            case 'group':
+                # 获取 group 内所有 成员
+                //判断当前用户是否有指定的 group 的权限
+                if (GroupMember::where('group_id', $request->get('id'))
+                    ->where('user_id', $user['user_uid'])
+                    ->exists()
+                ) {
+                    $table = GroupMember::where('group_id', $request->get('id'));
+                    //当前用户角色
+                    $power = GroupMember::where('group_id', $request->get('id'))
+                        ->where('user_id', $user['user_uid'])
+                        ->value('power');
+                    $roles = ["owner", "manager", "member"];
+                } else {
+                    return $this->error(__('auth.failed'));
+                }
+                break;
+            case 'user':
+                //获取当前用户参与的group列表
+                $table = GroupMember::where('user_id', $user['user_uid']);
+                break;
+        }
+        if (isset($_GET["search"])) {
+            $table = $table->where('title', 'like', $_GET["search"] . "%");
+        }
+        $count = $table->count();
+        if (isset($_GET["order"]) && isset($_GET["dir"])) {
+            $table = $table->orderBy($_GET["order"], $_GET["dir"]);
+        } else {
+            $table = $table->orderBy('created_at');
+        }
+
+        $table->skip($request->get('offset', 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+
+        $output = [
+            "rows" => GroupMemberResource::collection($result),
+            "count" => $count,
+        ];
+        if (isset($power) && isset($roles[$power])) {
+            $output['role'] = $roles[$power];
+        }
+
+        return $this->ok($output);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $validated = $request->validate([
+            'user_id' => 'required',
+            'group_id' => 'required',
+        ]);
+        //查找重复的项目
+        if (GroupMember::where('group_id', $validated['group_id'])->where('user_id', $validated['user_id'])->exists()) {
+            return $this->error('member exists');
+        }
+        $newMember = new GroupMember();
+        $newMember->id = app('snowflake')->id();
+        $newMember->user_id = $validated['user_id'];
+        $newMember->group_id = $validated['group_id'];
+        $newMember->power = 2;
+        $newMember->group_name = GroupInfo::find($validated['group_id'])->name;
+        $newMember->save();
+        return $this->ok(new GroupMemberResource($newMember));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\GroupMember  $groupMember
+     * @return \Illuminate\Http\Response
+     */
+    public function show(GroupMember $groupMember)
+    {
+        //
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\GroupMember  $groupMember
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, GroupMember $groupMember)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *@param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\GroupMember  $groupMember
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, GroupMember $groupMember)
+    {
+        //
+        //查看删除者有没有删除权限
+        //查询删除者的权限
+        $currUser = AuthApi::current($request);
+        if (!$currUser) {
+            return $this->error(__('auth.failed'));
+        }
+
+        $power = GroupMember::where('group_id', $groupMember->group_id)
+            ->where('user_id', $currUser["user_uid"])
+            ->select('power')->first();
+        if (!$power || $power->power >= 2) {
+            //普通成员没有删除权限
+            return $this->error(__('auth.failed'));
+        }
+
+        $delete = $groupMember->delete();
+        return $this->ok($delete);
+    }
+}

+ 73 - 0
api-v12/app/Http/Controllers/HealthCheckController.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class HealthCheckController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+        if(file_exists(base_path('.stop'))){
+            $status = 503;
+        }else{
+            $status = 200;
+        }
+        return response()->json(['createdAt' => now()],
+            $status,
+            ['Content-Type' => 'application/json;charset=UTF-8',
+            'Charset' => 'utf-8'],
+            JSON_UNESCAPED_UNICODE);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 117 - 0
api-v12/app/Http/Controllers/InteractiveController.php

@@ -0,0 +1,117 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Discussion;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+
+class InteractiveController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * 获取某个资源,某个用户的权限
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $res_id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, string $res_id)
+    {
+        //
+        $user = AuthApi::current($request);
+        $data = [];
+        switch ($request->get('res_type')) {
+            case 'article':
+                /* qa */
+                $data['qa'] = [
+                    'can_create' => false,
+                    'can_reply' => false,
+                ];
+                if($user && ArticleController::userCanEditId($user['user_uid'],$res_id)){
+                    $data['qa']['can_create'] = true;
+                    $data['qa']['can_reply'] = true;
+                }
+                $data['qa']['count'] = Discussion::where('res_id',$res_id)
+                                                ->where('type','qa')
+                                                ->where('status','close')
+                                                ->count();
+                /* help */
+                $data['help'] = [
+                    'can_create' => false,
+                    'can_reply' => false,
+                ];
+                if($user){
+                    $data['help']['can_reply'] = true;
+                    if(ArticleController::userCanEditId($user['user_uid'],$res_id)){
+                        $data['help']['can_create'] = true;
+                    }
+                }
+                $data['help']['count'] = Discussion::where('res_id',$res_id)
+                                                ->where('type','help')
+                                                ->where('status','active')
+                                                ->count();
+
+
+
+                /* discussion */
+                $data['discussion'] = [
+                    'can_create' => false,
+                    'can_reply' => false,
+                ];
+                if($user){
+                    $data['discussion']['can_reply'] = true;
+                    $data['discussion']['can_create'] = true;
+                }
+                $data['discussion']['count'] = Discussion::where('res_id',$res_id)
+                                                ->where('type','discussion')
+                                                ->where('status','active')
+                                                ->count();
+                break;
+        }
+
+        return $this->ok($data);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Discussion  $discussion
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Discussion $discussion)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Discussion  $discussion
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Discussion $discussion)
+    {
+        //
+    }
+}

+ 160 - 0
api-v12/app/Http/Controllers/InviteController.php

@@ -0,0 +1,160 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Invite;
+use App\Models\UserInfo;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\UserApi;
+use App\Http\Api\StudioApi;
+use App\Http\Resources\InviteResource;
+use Illuminate\Support\Str;
+use App\Mail\InviteMail;
+use Illuminate\Support\Facades\Mail;
+
+class InviteController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        $table = Invite::select([
+            'id',
+            'user_uid',
+            'email',
+            'status',
+            'created_at',
+            'updated_at'
+        ]);
+        switch ($request->get('view')) {
+            case 'studio':
+                if (empty($request->get('studio'))) {
+                    return $this->error(__('auth.failed'));
+                }
+                //判断当前用户是否有指定的studio的权限
+                if ($user['user_uid'] !== StudioApi::getIdByName($request->get('studio'))) {
+                    return $this->error(__('auth.failed'));
+                }
+                $table = $table->where('user_uid', $user["user_uid"]);
+                break;
+            case 'all':
+                $user = UserApi::getByUuid($user['user_uid']);
+                if (!$user || !isset($user['roles']) || !in_array('administrator', $user['roles'])) {
+                    return $this->error(__('auth.failed'));
+                }
+                break;
+        }
+        if ($request->has('search')) {
+            $table = $table->where('email', 'like', '%' . $request->get('search') . "%");
+        }
+        $count = $table->count();
+        $table = $table->orderBy(
+            $request->get('order', 'updated_at'),
+            $request->get('dir', 'desc')
+        );
+
+        $table = $table->skip($request->get('offset', 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+        return $this->ok(["rows" => InviteResource::collection($result), "count" => $count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $sender = '';
+        if (!empty($request->get('studio'))) {
+            $user = AuthApi::current($request);
+            if (!$user) {
+                return $this->error(__('auth.failed'), 401, 401);
+            }
+            //判断当前用户是否有指定的studio的权限
+            $studio_id = StudioApi::getIdByName($request->get('studio'));
+            if ($user['user_uid'] !== $studio_id) {
+                return $this->error(__('auth.failed'));
+            }
+            $sender = $studio_id;
+        } else {
+            $sender = config("mint.admin.root_uuid");
+        }
+
+        //查询是否重复
+        if (
+            Invite::where('email', $request->get('email'))->exists() ||
+            UserInfo::where('email', $request->get('email'))->exists()
+        ) {
+            return $this->error('email.exists', __('validation.exists', ['email']), 200);
+        }
+
+        $uuid = Str::uuid();
+        Mail::to($request->get('email'))
+            ->send(new InviteMail(
+                $uuid,
+                $request->get('subject', 'sign up wikipali'),
+                $request->get('lang'),
+                $request->get('dashboard')
+            ));
+        if (Mail::failures()) {
+            return $this->error('send email fail', '', 200);
+        } else {
+            $invite = new Invite;
+            $invite->id = $uuid;
+            $invite->email = $request->get('email');
+            $invite->user_uid = $sender;
+            $invite->status = 'invited';
+            $invite->save();
+        }
+        return $this->ok(new InviteResource($invite));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Invite  $invite
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Invite $invite)
+    {
+        //
+        return $this->ok(new InviteResource($invite));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Invite  $invite
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Invite $invite)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Invite  $invite
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Invite $invite)
+    {
+        //
+    }
+}

+ 174 - 0
api-v12/app/Http/Controllers/LikeController.php

@@ -0,0 +1,174 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Like;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\UserApi;
+use App\Http\Resources\LikeResource;
+use Illuminate\Support\Str;
+
+class LikeController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get("view")) {
+            case 'count':
+                # code...
+                $result = Like::where("target_id",$request->get("target_id"))
+                                ->groupBy("type")
+                                ->select("type")
+                                ->selectRaw("count(*)")
+                                ->get();
+                $user = AuthApi::current($request);
+                if($user){
+                    foreach ($result as $key => $value) {
+                        $curr = Like::where(["target_id"=>$request->get("target_id"),
+                                        'type'=>$value->type,
+                                        'user_id'=>$user["user_uid"]])->first();
+                        if($curr){
+                            $result[$key]->selected = true;
+                            $result[$key]->my_id = $curr->id;
+                        }
+                    }
+                }
+                return $this->ok($result);
+                break;
+            case 'target':
+                $table = Like::where("target_id",$request->get("target_id"));
+                break;
+            default:
+                # code...
+                break;
+        }
+        if($request->has("type")){
+            $table = $table->where('type',$request->get("type"));
+        }
+        $count = $table->count();
+        $result = $table->get();
+        return $this->ok([
+            "rows"=>LikeResource::collection($result),
+            "count"=>$count
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        $param = $request->all();
+        $user_id = $request->get('user_id',$user["user_uid"]);
+        $like = Like::firstOrNew([
+            'type'=>$param['type'],
+            'target_id'=>$param['target_id'],
+            'target_type'=>$param['target_type'],
+            'user_id' =>  $user_id,
+        ],
+        [
+            'id'=>Str::uuid(),
+        ]);
+        $like->save();
+        $output = [
+            'id'=>$like->id,
+            'type'=>$param['type'],
+            'target_id'=>$param['target_id'],
+            'target_type'=>$param['target_type'],
+            'user_id' => $user_id,
+            'count'=>Like::where('target_id',$param['target_id'])
+                        ->where('type',$param['type'])->count(),
+            'selected'=>true,
+            'my_id'=>$like->id,
+            'user' => UserApi::getByUuid($user_id),
+        ];
+        return $this->ok($output);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Like  $like
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Like $like)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Like  $like
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Like $like)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Like  $like
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request,Like $like)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        if($like->user_id===$user["user_uid"]){
+            //移除自己
+            $delete = $like->delete();
+            if($delete){
+                $output = [
+                    'type'=>$like['type'],
+                    'count'=>Like::where('target_id',$like['target_id'])
+                                ->where('type',$like['type'])->count(),
+                    'selected'=>false,
+                ];
+                return $this->ok($output);
+            }else{
+                $this->error('未知错误',200,200);
+            }
+
+        }else{
+            return $this->error(_('auth.failed'),403,403);
+        }
+    }
+    public function delete(Request $request){
+        if(!isset($_COOKIE["user_uid"])){
+            return $this->error("no login");
+        }
+        $param = [
+            "id"=>$request->get('id'),
+            'user_id'=>$_COOKIE["user_uid"]
+        ];
+        $del = Like::where($param)->delete();
+        $count = Like::where('target_id',$request->get('target_id'))
+                    ->where('type',$request->get('type'))
+                    ->count();
+        return $this->ok([
+            'deleted'=>$del,
+            'count'=>$count
+            ]);
+    }
+}

+ 71 - 0
api-v12/app/Http/Controllers/MilestoneController.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Http\Api\StudioApi;
+use App\Models\UserInfo;
+use App\Models\Wbw;
+use App\Models\Sentence;
+use App\Models\DhammaTerm;
+use App\Models\Course;
+
+class MilestoneController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $studioName
+     * @return \Illuminate\Http\Response
+     */
+    public function show($studioName)
+    {
+        //
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 258 - 0
api-v12/app/Http/Controllers/MockOpenAIController.php

@@ -0,0 +1,258 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Str;
+
+class MockOpenAIController extends Controller
+{
+    /**
+     * 模拟 Chat Completions API
+     */
+    public function chatCompletions(Request $request): JsonResponse
+    {
+        // 随机延迟
+        $this->randomDelay($request->query('delay', 'h'));
+
+        // 随机返回错误
+        if ($errorResponse = $this->randomError($request->query('error', "h"))) {
+            return $errorResponse;
+        }
+
+        $model = $request->input('model', 'gpt-3.5-turbo');
+        $messages = $request->input('messages', []);
+
+        return response()->json([
+            'id' => 'chatcmpl-' . Str::random(29),
+            'object' => 'chat.completion',
+            'created' => time(),
+            'model' => $model,
+            'choices' => [
+                [
+                    'index' => 0,
+                    'message' => [
+                        'role' => 'assistant',
+                        'content' => $this->generateMockResponse($messages)
+                    ],
+                    'finish_reason' => 'stop'
+                ]
+            ],
+            'usage' => [
+                'prompt_tokens' => rand(10, 100),
+                'completion_tokens' => rand(20, 200),
+                'total_tokens' => rand(30, 300)
+            ]
+        ]);
+    }
+
+    /**
+     * 模拟 Completions API
+     */
+    public function completions(Request $request): JsonResponse
+    {
+        // 随机延迟
+        $this->randomDelay($request->query('delay', 'h'));
+
+        // 随机返回错误
+        if ($errorResponse = $this->randomError($request->query('error', "h"))) {
+            return $errorResponse;
+        }
+
+        $model = $request->input('model', 'text-davinci-003');
+        $prompt = $request->input('prompt', '');
+
+        return response()->json([
+            'id' => 'cmpl-' . Str::random(29),
+            'object' => 'text_completion',
+            'created' => time(),
+            'model' => $model,
+            'choices' => [
+                [
+                    'text' => $this->generateMockTextResponse($prompt),
+                    'index' => 0,
+                    'logprobs' => null,
+                    'finish_reason' => 'stop'
+                ]
+            ],
+            'usage' => [
+                'prompt_tokens' => rand(10, 100),
+                'completion_tokens' => rand(20, 200),
+                'total_tokens' => rand(30, 300)
+            ]
+        ]);
+    }
+
+    /**
+     * 模拟 Models API
+     */
+    public function models(Request $request): JsonResponse
+    {
+
+        return response()->json([
+            'object' => 'list',
+            'data' => [
+                [
+                    'id' => 'gpt-4',
+                    'object' => 'model',
+                    'created' => 1687882411,
+                    'owned_by' => 'openai'
+                ],
+                [
+                    'id' => 'gpt-3.5-turbo',
+                    'object' => 'model',
+                    'created' => 1677610602,
+                    'owned_by' => 'openai'
+                ],
+                [
+                    'id' => 'text-davinci-003',
+                    'object' => 'model',
+                    'created' => 1669599635,
+                    'owned_by' => 'openai-internal'
+                ]
+            ]
+        ]);
+    }
+
+    /**
+     * 随机延迟
+     */
+    private function randomDelay(string $level): void
+    {
+        switch ($level) {
+            case 'l':
+                sleep(1);
+                break;
+            case 'm':
+                sleep(rand(1, 3));
+                break;
+            case 'h':
+                // 90% 概率 1-3秒延迟
+                // 10% 概率 60-100秒延迟
+                if (rand(1, 100) <= 10) {
+                    sleep(rand(60, 100));
+                } else {
+                    sleep(rand(1, 3));
+                }
+                break;
+            default:
+                break;
+        }
+    }
+
+    /**
+     * 随机返回错误响应
+     */
+    private function randomError(string $level): ?JsonResponse
+    {
+        switch ($level) {
+            case 'l':
+                if (rand(1, 100) <= 10) {
+                    return $this->rateLimitError();
+                }
+                break;
+            case 'm':
+                if (rand(1, 100) <= 20) {
+                    return $this->rateLimitError();
+                }
+                break;
+            case 'h':
+                // 20% 概率返回三种错误
+                if (rand(1, 100) <= 20) {
+                    $errorType = rand(1, 3);
+                    switch ($errorType) {
+                        case 1:
+                            return $this->badRequestError();
+                        case 2:
+                            return $this->internalServerError();
+                        case 3:
+                            return $this->rateLimitError();
+                    }
+                }
+                break;
+            default:
+                return null;
+                break;
+        }
+        return null;
+    }
+
+    /**
+     * 400 错误响应
+     */
+    private function badRequestError(): JsonResponse
+    {
+        return response()->json([
+            'error' => [
+                'message' => 'Invalid request: missing required parameter',
+                'type' => 'invalid_request_error',
+                'param' => null,
+                'code' => null
+            ]
+        ], 400);
+    }
+
+    /**
+     * 500 错误响应
+     */
+    private function internalServerError(): JsonResponse
+    {
+        return response()->json([
+            'error' => [
+                'message' => 'The server had an error while processing your request. Sorry about that!',
+                'type' => 'server_error',
+                'param' => null,
+                'code' => null
+            ]
+        ], 500);
+    }
+
+    /**
+     * 429 限流错误响应
+     */
+    private function rateLimitError(): JsonResponse
+    {
+        return response()->json([
+            'error' => [
+                'message' => 'Rate limit reached for requests',
+                'type' => 'requests',
+                'param' => null,
+                'code' => 'rate_limit_exceeded'
+            ]
+        ], 429);
+    }
+
+    /**
+     * 生成模拟聊天响应
+     */
+    private function generateMockResponse(array $messages): string
+    {
+        $responses = [
+            "这是一个模拟的AI响应。我正在模拟OpenAI的API服务器。",
+            "感谢您的问题!这是一个测试响应,用于模拟真实的AI助手。",
+            "我是一个模拟的AI助手。您的请求已被处理,这是模拟生成的回复。",
+            "模拟Hello! This is a mock response from the simulated OpenAI API server.",
+            "模拟Thank you for your message. This is a simulated response for testing purposes.",
+            "模拟I understand your question. This is a mock reply generated by the test API server.",
+        ];
+
+        return $responses[array_rand($responses)] . " (响应时间: " . date('Y-m-d H:i:s') . ")";
+    }
+
+    /**
+     * 生成模拟文本补全响应
+     */
+    private function generateMockTextResponse(string $prompt): string
+    {
+        $responses = [
+            " 这是对您提示的模拟补全回复。",
+            " Mock completion response for your prompt.",
+            " 模拟的文本补全结果,用于测试目的。",
+            " This is a simulated text completion.",
+            " 基于您的输入生成的模拟响应。",
+        ];
+
+        return $responses[array_rand($responses)];
+    }
+}

+ 107 - 0
api-v12/app/Http/Controllers/ModelLogController.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\ModelLog;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Resources\ModelLogResource;
+use Illuminate\Support\Str;
+
+class ModelLogController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        switch ($request->get('view')) {
+            case 'model':
+                # code..
+                $table = ModelLog::where('model_id', $request->get('id'));
+                break;
+
+            default:
+                # code...
+                break;
+        }
+        if ($request->has('search')) {
+            $table = $table->where('email', 'like', '%' . $request->get('search') . "%");
+        }
+        $count = $table->count();
+        $table = $table->orderBy(
+            $request->get('order', 'updated_at'),
+            $request->get('dir', 'desc')
+        );
+
+        $table = $table->skip($request->get('offset', 0))
+            ->take($request->get('limit', 20));
+
+        $result = $table->get();
+        return $this->ok(["rows" => ModelLogResource::collection($result), "total" => $count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $modelLog = new ModelLog();
+        $modelLog->uid = Str::uuid();
+        $modelLog->model_id = $request->get('model_id');
+        $modelLog->request_at = $request->get('request_at');
+        $modelLog->request_headers = $request->get('request_headers');
+        $modelLog->request_data = $request->get('request_data');
+        $modelLog->response_headers = $request->get('response_headers');
+        $modelLog->response_data = $request->get('response_data');
+        $modelLog->status = $request->get('status');
+        $modelLog->success = $request->get('success', true);
+        $modelLog->save();
+        return $this->ok(new ModelLogResource($modelLog));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\ModelLog  $modelLog
+     * @return \Illuminate\Http\Response
+     */
+    public function show(ModelLog $modelLog)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\ModelLog  $modelLog
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, ModelLog $modelLog)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\ModelLog  $modelLog
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(ModelLog $modelLog)
+    {
+        //
+    }
+}

+ 93 - 0
api-v12/app/Http/Controllers/NavArticleController.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\ArticleCollection;
+use Illuminate\Http\Request;
+use App\Http\Resources\ArticleMapResource;
+
+class NavArticleController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     * 文章导航
+     * 文集中 某个文章的 前一个,后一个
+     * @param  \App\Models\ArticleCollection  $articleCollection
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $id)
+    {
+        //article_anthology
+        $id = explode('_',$id);
+        if(count($id) !== 2){
+            return $this->error('参数错误。参数应为 2 实际得到'.count($id),400,400);
+        }
+        $curr = ArticleCollection::where('collect_id',$id[1])
+                                ->where('article_id',$id[0])
+                                ->first();
+        if(!$curr){
+            return $this->error('article not found');
+        }
+        $data = array();
+        $data['curr'] = new ArticleMapResource($curr);
+        $prev = ArticleCollection::where('collect_id',$id[1])
+                                ->where('id','<',$curr->id)
+                                ->orderBy('id','desc')
+                                ->first();
+        if($prev){
+            $data['prev'] = new ArticleMapResource($prev);
+        }
+        $next = ArticleCollection::where('collect_id',$id[1])
+                                ->where('id','>',$curr->id)
+                                ->orderBy('id')
+                                ->first();
+        if($next){
+            $data['next'] = new ArticleMapResource($next);
+        }
+        return $this->ok($data);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\ArticleCollection  $articleCollection
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, ArticleCollection $articleCollection)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\ArticleCollection  $articleCollection
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(ArticleCollection $articleCollection)
+    {
+        //
+    }
+}

+ 119 - 0
api-v12/app/Http/Controllers/NavCSParaController.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\WbwTemplate;
+use App\Models\PaliText;
+use App\Models\RelatedParagraph;
+use App\Http\Resources\NavCSParaResource;
+use Illuminate\Http\Request;
+
+class NavCSParaController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\WbwTemplate  $wbwTemplate
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $paraNumber)
+    {
+        //99_5_37-38
+        $id = explode('_',$paraNumber);
+        if(count($id) !== 3){
+            return $this->error('参数错误。参数应为3 实际得到'.count($id),400,400);
+        }
+        //查询段落起始
+        $para = PaliText::where('book',$id[0])
+                        ->where('paragraph',$id[1])
+                        ->first();
+        if(!$para){
+            return $this->error('没有找到段落起始'.$id,404,404);
+        }
+        //cs 段落号列表
+        $csPara = explode('-',$id[2]);
+        $csList = [];
+        for ($i=(int)$csPara[0]; $i <=(int)end($csPara) ; $i++) {
+            $csList[] = $i;
+        }
+        //段落区间
+        $begin = $id[1];
+        $end = (int)$id[1] + $para->chapter_len;
+        $curr = WbwTemplate::where('book',$id[0])
+                                ->where('style','paranum')
+                                ->whereIn('word',$csList)
+                                ->whereBetween('paragraph',[$begin,$end])
+                                ->orderBy('paragraph')
+                                ->select('book','paragraph')->get();
+        if(!$curr){
+            return $this->error('没有找到段落'.$id,404,404);
+        }
+
+        $data = [];
+        $data['curr'] = new NavCSParaResource($curr[0]);
+        $next = WbwTemplate::where('book',$id[0])
+                ->where('style','paranum')
+                ->where('word',(int)end($csPara)+1)
+                ->whereBetween('paragraph',[$begin,$end])
+                ->select('book','paragraph')->first();
+        if($next){
+            $data['next'] = new NavCSParaResource($next);
+            $data['end'] = $next->paragraph -1;
+        }else{
+            $data['end'] = $end;
+        }
+        $prev = WbwTemplate::where('book',$id[0])
+                            ->where('style','paranum')
+                            ->where('word',(int)$csPara[0]-1)
+                            ->whereBetween('paragraph',[$begin,$end])
+                            ->select('book','paragraph')->first();
+        if($prev){
+            $data['prev'] = new NavCSParaResource($prev);
+        }
+        return $this->ok($data);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\WbwTemplate  $wbwTemplate
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, WbwTemplate $wbwTemplate)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\WbwTemplate  $wbwTemplate
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(WbwTemplate $wbwTemplate)
+    {
+        //
+    }
+}

+ 96 - 0
api-v12/app/Http/Controllers/NavPageController.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\PageNumber;
+use Illuminate\Http\Request;
+use App\Http\Resources\NavPageResource;
+
+class NavPageController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     * 页码导航
+     * 支持缅文版,PTS,等当前页,前一页,后一页,页面信息的获取
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $pageNumber
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $pageNumber)
+    {
+        //M-99_100_101-1-37
+        $id = explode('-',$pageNumber);
+        if(count($id) !== 4){
+            return $this->error('参数错误。参数应为4 实际得到'.count($id),400,400);
+        }
+        $books = explode('_',$id[1]);
+        $pageCurr = PageNumber::whereIn('pcd_book_id',$books)
+                            ->where('type',$id[0])
+                            ->where('volume',$id[2])
+                            ->where('page',$id[3])
+                            ->first();
+        $pagePrev = PageNumber::whereIn('pcd_book_id',$books)
+                            ->where('type',$id[0])
+                            ->where('volume',$id[2])
+                            ->where('page',(int)$id[3]-1)
+                            ->first();
+        $pageNext = PageNumber::whereIn('pcd_book_id',$books)
+                            ->where('type',$id[0])
+                            ->where('volume',$id[2])
+                            ->where('page',(int)$id[3]+1)
+                            ->first();
+        if($pageCurr){
+            return $this->ok([
+                'curr'=>$pageCurr? new NavPageResource($pageCurr):null,
+                'prev'=>$pagePrev? new NavPageResource($pagePrev):null,
+                'next'=>$pageNext? new NavPageResource($pageNext):null,
+            ]);
+        }else{
+            return $this->error('page not found');
+        }
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\PageNumber  $pageNumber
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, PageNumber $pageNumber)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\PageNumber  $pageNumber
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(PageNumber $pageNumber)
+    {
+        //
+    }
+}

+ 223 - 0
api-v12/app/Http/Controllers/NissayaCardController.php

@@ -0,0 +1,223 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\NissayaEnding;
+use Illuminate\Http\Request;
+use mustache\mustache;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\MdRender;
+use Illuminate\Support\Facades\App;
+use App\Models\DhammaTerm;
+use App\Models\Relation;
+
+class NissayaCardController extends Controller
+{
+
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $nissayaEnding
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, string $nissayaEnding)
+    {
+        //
+        $cardData = [];
+        App::setLocale($request->get('lang'));
+        $localTerm = ChannelApi::getSysChannel(
+            "_System_Grammar_Term_" . strtolower($request->get('lang')) . "_",
+            "_System_Grammar_Term_en_"
+        );
+        if (!$localTerm) {
+            return $this->error('no term channel');
+        }
+        $termTable = DhammaTerm::where('channal', $localTerm);
+
+        $cardData['ending']['word'] = $nissayaEnding;
+        $endingTerm = $termTable->where('word', $nissayaEnding)->first();
+        if ($endingTerm) {
+            $cardData['ending']['id'] = $endingTerm->guid;
+            $cardData['ending']['tag'] = $endingTerm->tag;
+            $cardData['ending']['meaning'] = $endingTerm->meaning;
+            $cardData['ending']['note'] = $endingTerm->note;
+            if (!empty($endingTerm->note)) {
+                $mdRender = new MdRender(
+                    [
+                        'mode' => 'read',
+                        'format' => 'react',
+                        'lang' => $endingTerm->lang,
+                    ]
+                );
+                $cardData['ending']['html']  = $mdRender->convert($endingTerm->note, [], null);
+            }
+        }
+
+        $myEnding = NissayaEnding::where('ending', $nissayaEnding)
+            ->select('relation')->get();
+        if (count($myEnding) === 0) {
+            if (!isset($cardData['ending']['note'])) {
+                $cardData['ending']['note'] = "no record\n";
+            }
+        }
+
+        $relations = Relation::whereIn('name', $myEnding)->get();
+
+        if (count($relations) > 0) {
+            $cardData['title_case'] = "本词";
+            $cardData['title_relation'] = "关系";
+            $cardData['title_local_relation'] = "关系";
+            $cardData['title_local_link_to'] = "目标词特征";
+            $cardData['title_content'] = "含义";
+            $cardData['title_local_ending'] = "翻译建议";
+
+            foreach ($relations as $key => $relation) {
+                $newLine = array();
+                $relationInTerm = DhammaTerm::where('channal', $localTerm)
+                    ->where('word', $relation['name'])
+                    ->first();
+                if (empty($relation->from)) {
+                    $cardData['row'][] = ["relation" => $relation->name];
+                    continue;
+                }
+                $from = json_decode($relation->from);
+                if (isset($from->case)) {
+                    $cases = $from->case;
+                    $localCase  = [];
+                    foreach ($cases as $case) {
+                        $localCase[] = [
+                            'label' => $termTable->where('word', $case)->value('meaning'),
+                            'case' => $case,
+                            'link' => config('mint.server.dashboard_base_path') . '/term/list/' . $case
+                        ];
+                    }
+                    # 格位
+                    $newLine['from']['case'] = $localCase;
+                }
+                if (isset($from->spell)) {
+                    $newLine['from']['spell'] = $from->spell;
+                }
+                //连接到
+                $linkTos = json_decode($relation->to);
+                if (isset($linkTos->case) && is_array($linkTos->case) && count($linkTos->case) > 0) {
+                    $localTo  = [];
+                    foreach ($linkTos->case as $to) {
+                        $localTo[] = [
+                            'label' => $termTable->where('word', $to)->value('meaning'),
+                            'case' => $to,
+                            'link' => config('mint.server.dashboard_base_path') . '/term/list/' . $to
+                        ];
+                    }
+
+                    # 格位
+                    $newLine['to']['case'] = $localTo;
+                }
+                if (isset($linkTos->spell)) {
+                    $newLine['to']['spell'] = $linkTos->spell;
+                }
+                //含义 用分类字段的term 数据
+                if (isset($relation['category']) && !empty($relation['category'])) {
+                    $newLine['category']['name'] = $relation['category'];
+                    $localCategory = DhammaTerm::where('channal', $localTerm)
+                        ->where('word', $relation['category'])
+                        ->first();
+
+                    if ($localCategory) {
+                        $mdRender = new MdRender(
+                            [
+                                'mode' => 'read',
+                                'format' => 'text',
+                                'lang' => $endingTerm->lang,
+                            ]
+                        );
+                        $newLine['category']['note'] = $mdRender->convert($localCategory->note, [], null);
+                        $newLine['category']['meaning'] = $localCategory->meaning;
+                    } else {
+                        $newLine['category']['note'] = $relation['category'];
+                        $newLine['category']['meaning'] = $relation['category'];
+                    }
+                }
+
+                /**
+                 * 翻译建议
+                 * relation 和 from 都匹配成功
+                 * from 为空 只匹配 relation
+                 */
+                $arrLocalEnding = array();
+                $localEndings = NissayaEnding::where('relation', $relation['name'])
+                    ->where('lang', $request->get('lang'))
+                    ->get();
+                foreach ($localEndings as $localEnding) {
+                    if (empty($localEnding->from) || $localEnding->from === $relation->from) {
+                        $arrLocalEnding[] = $localEnding->ending;
+                    }
+                }
+                $newLine['local_ending'] = implode(';', $arrLocalEnding);
+
+                //本地语言 关系名称
+                if ($relationInTerm) {
+                    $newLine['local_relation'] =  $relationInTerm->meaning;
+                }
+                //关系名称
+                $newLine['relation'] =  $relation['name'];
+                $newLine['relation_link'] =  config('mint.server.dashboard_base_path') . '/term/list/' . $relation['name'];
+                $cardData['row'][] = $newLine;
+            }
+        }
+
+        if ($request->get('content_type', 'markdown') === 'markdown') {
+            $m = new \Mustache_Engine(array('entity_flags' => ENT_QUOTES));
+            $tpl = file_get_contents(resource_path("mustache/nissaya_ending_card.tpl"));
+            $result = $m->render($tpl, $cardData);
+        } else {
+            $result = $cardData;
+        }
+
+        return $this->ok($result);
+    }
+
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\NissayaEnding  $nissayaEnding
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, NissayaEnding $nissayaEnding)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\NissayaEnding  $nissayaEnding
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(NissayaEnding $nissayaEnding)
+    {
+        //
+    }
+}

+ 66 - 0
api-v12/app/Http/Controllers/NissayaCoverController.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Tools\RedisClusters;
+
+class NissayaCoverController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+        $cover = RedisClusters::get('/statistics/nissaya/cover');
+        return $this->ok($cover);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 280 - 0
api-v12/app/Http/Controllers/NissayaEndingController.php

@@ -0,0 +1,280 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\NissayaEnding;
+use App\Models\Relation;
+use App\Models\DhammaTerm;
+use Illuminate\Http\Request;
+use App\Http\Resources\NissayaEndingResource;
+use App\Http\Api\AuthApi;
+use App\Http\Api\ChannelApi;
+use Illuminate\Support\Facades\App;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use mustache\mustache;
+
+class NissayaEndingController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $table = NissayaEnding::select([
+            'id',
+            'ending',
+            'lang',
+            'relation',
+            'case',
+            'from',
+            'count',
+            'editor_id',
+            'created_at',
+            'updated_at'
+        ]);
+
+        if (($request->has('case'))) {
+            $table->whereIn('case', explode(",", $request->get('case')));
+        }
+
+        if (($request->has('lang'))) {
+            $table->whereIn('lang', explode(",", $request->get('lang')));
+        }
+
+        if (($request->has('relation'))) {
+            $table->where('relation', $request->get('relation'));
+        }
+        if (($request->has('case'))) {
+            $table->where('case', $request->get('case'));
+        }
+
+        if (($request->has('search'))) {
+            $table->where('ending', 'like', "%" . $request->get('search') . "%");
+        }
+
+        $count = $table->count();
+
+        $table->orderBy(
+            $request->get('order', 'updated_at'),
+            $request->get('dir', 'desc')
+        );
+
+        $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
+        $result = $table->get();
+
+        return $this->ok(["rows" => NissayaEndingResource::collection($result), "count" => $count]);
+    }
+
+    public function vocabulary(Request $request)
+    {
+        $result = NissayaEnding::select(['ending'])
+            ->where('lang', $request->get('lang'))
+            ->groupBy('ending')
+            ->get();
+        return $this->ok(["rows" => $result, "count" => count($result)]);
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //TODO 判断权限
+        $validated = $request->validate([
+            'ending' => 'required',
+            'lang' => 'required',
+        ]);
+        $new = new NissayaEnding;
+        $new->ending = $validated['ending'];
+        $new->strlen = mb_strlen($validated['ending'], "UTF-8");
+        $new->lang = $validated['lang'];
+        $new->relation = $request->get('relation');
+        $new->case = $request->get('case');
+        if ($request->has('from')) {
+            $new->from = json_encode($request->get('from'), JSON_UNESCAPED_UNICODE);
+        } else {
+            $new->from = null;
+        }
+        $new->editor_id = $user['user_uid'];
+        $new->save();
+        return $this->ok(new NissayaEndingResource($new));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\NissayaEnding  $nissayaEnding
+     * @return \Illuminate\Http\Response
+     */
+    public function show(NissayaEnding $nissayaEnding)
+    {
+        //
+        return $this->ok(new NissayaEndingResource($nissayaEnding));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\NissayaEnding  $nissayaEnding
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, NissayaEnding $nissayaEnding)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //查询是否重复
+        /*
+        $table = NissayaEnding::where('ending',$request->get('ending'))
+                 ->where('lang',$request->get('lang'))
+                 ->where('relation',$request->get('relation'));
+        $from = json_encode($request->get('from'),JSON_UNESCAPED_UNICODE);
+        if(empty($from)){
+            $table = $table->whereNull('from');
+        }else{
+            $json = $request->get('from');
+            $table = $table->whereJsonContains('from',['case'=>$json['case']]);
+        }
+        if($table->exists()){
+            return $this->error(__('validation.exists',['name']));
+        }
+*/
+        $nissayaEnding->ending = $request->get('ending');
+        $nissayaEnding->strlen = mb_strlen($request->get('ending'), "UTF-8");
+        $nissayaEnding->lang = $request->get('lang');
+        $nissayaEnding->relation = $request->get('relation');
+        if ($request->has('from') && !empty($request->get('from'))) {
+            $nissayaEnding->from = json_encode($request->get('from'), JSON_UNESCAPED_UNICODE);
+        } else {
+            $nissayaEnding->from = null;
+        }
+        $nissayaEnding->editor_id = $user['user_uid'];
+        $nissayaEnding->save();
+        return $this->ok(new NissayaEndingResource($nissayaEnding));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\NissayaEnding  $nissayaEnding
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, NissayaEnding $nissayaEnding)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+        //TODO 判断当前用户是否有权限
+        $delete = 0;
+        $delete = $nissayaEnding->delete();
+
+        return $this->ok($delete);
+    }
+
+    public function export()
+    {
+        $spreadsheet = new Spreadsheet();
+        $activeWorksheet = $spreadsheet->getActiveSheet();
+        $activeWorksheet->setCellValue('A1', 'id');
+        $activeWorksheet->setCellValue('B1', 'ending');
+        $activeWorksheet->setCellValue('C1', 'lang');
+        $activeWorksheet->setCellValue('D1', 'relation');
+
+        $nissaya = NissayaEnding::cursor();
+        $currLine = 2;
+        foreach ($nissaya as $key => $row) {
+            # code...
+            $activeWorksheet->setCellValue("A{$currLine}", $row->id);
+            $activeWorksheet->setCellValue("B{$currLine}", $row->ending);
+            $activeWorksheet->setCellValue("C{$currLine}", $row->lang);
+            $activeWorksheet->setCellValue("D{$currLine}", $row->relation);
+            $activeWorksheet->setCellValue("E{$currLine}", $row->case);
+            $currLine++;
+        }
+        $writer = new Xlsx($spreadsheet);
+        header('Content-Type: application/vnd.ms-excel');
+        header('Content-Disposition: attachment; filename="nissaya-ending.xlsx"');
+        $writer->save("php://output");
+    }
+
+    public function import(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'));
+        }
+
+        $filename = $request->get('filename');
+        $reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx();
+        $reader->setReadDataOnly(true);
+        $spreadsheet = $reader->load($filename);
+        $activeWorksheet = $spreadsheet->getActiveSheet();
+        $currLine = 2;
+        $countFail = 0;
+        $error = "";
+        do {
+            # code...
+            $id = $activeWorksheet->getCell("A{$currLine}")->getValue();
+            $ending = $activeWorksheet->getCell("B{$currLine}")->getValue();
+            $lang = $activeWorksheet->getCell("C{$currLine}")->getValue();
+            $relation = $activeWorksheet->getCell("D{$currLine}")->getValue();
+            $case = $activeWorksheet->getCell("E{$currLine}")->getValue();
+            if (!empty($ending)) {
+                //查询是否有冲突数据
+                //查询此id是否有旧数据
+                if (!empty($id)) {
+                    $oldRow = NissayaEnding::find($id);
+                }
+                //查询是否跟已有数据重复
+                $row = NissayaEnding::where(['ending' => $ending, 'relation' => $relation, 'case' => $case])->first();
+                if (!$row) {
+                    //不重复
+                    if (isset($oldRow) && $oldRow) {
+                        //有旧的记录-修改旧数据
+                        $row = $oldRow;
+                    } else {
+                        //没找到旧的记录-新建
+                        $row = new NissayaEnding();
+                    }
+                } else {
+                    //重复-如果与旧的id不同旧报错
+                    if (isset($oldRow) && $oldRow && $row->id !== $id) {
+                        $error .= "重复的数据:{$id} - {$ending}\n";
+                        $currLine++;
+                        $countFail++;
+                        continue;
+                    }
+                }
+                $row->ending = $ending;
+                $row->strlen = mb_strlen($ending, "UTF-8");
+                $row->lang = $lang;
+                $row->relation = $relation;
+                $row->case = $case;
+                $row->editor_id = $user['user_uid'];
+                $row->save();
+            } else {
+                break;
+            }
+            $currLine++;
+        } while (true);
+        return $this->ok(["success" => $currLine - 2 - $countFail, 'fail' => ($countFail)], $error);
+    }
+}

+ 155 - 0
api-v12/app/Http/Controllers/NotificationController.php

@@ -0,0 +1,155 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+use App\Models\Notification;
+use App\Http\Api\AuthApi;
+use App\Http\Resources\NotificationResource;
+
+class NotificationController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('notification auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        switch ($request->get('view')) {
+            case 'to':
+                $table = Notification::where('to', $user['user_uid']);
+                $unread = Notification::where('to', $user['user_uid'])
+                    ->where('status', 'unread')->count();
+                break;
+        }
+
+        if ($request->has('status')) {
+            $table = $table->whereIn('status', explode(',', $request->get('status')));
+        }
+        $count = $table->count();
+
+        $table = $table->orderBy($request->get('order', 'created_at'), $request->get('dir', 'desc'));
+
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 10));
+
+        $result = $table->get();
+
+        return $this->ok(
+            [
+                "rows" => NotificationResource::collection($result),
+                "count" => $count,
+                'unread' => $unread,
+            ]
+        );
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('notification auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $new = new Notification;
+        $new->id = Str::uuid();
+        $new->from = $user['user_uid'];
+        $new->to = $request->get('to');
+        $new->url = $request->get('url');
+        $new->content = $request->get('content');
+        $new->res_type = $request->get('res_type');
+        $new->res_id = $request->get('res_id');
+        $new->channel = $request->get('channel');
+        $new->save();
+
+        return $this->ok(new NotificationResource($new));
+    }
+
+    public static function insert($from, $to, $res_type, $res_id, $channel)
+    {
+        foreach ($to as $key => $one) {
+            $new = new Notification;
+            $new->id = Str::uuid();
+            $new->from = $from;
+            $new->to = $one;
+            $new->url = '';
+            $new->content = '';
+            $new->res_type = $res_type;
+            $new->res_id = $res_id;
+            $new->channel = $channel;
+            $new->save();
+        }
+        return count($to);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  Notification $notification
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Notification $notification)
+    {
+        //
+        return $this->ok(new NotificationResource($notification));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  Notification $notification
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Notification $notification)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if ($notification->to === $user['user_uid']) {
+            $notification->status = $request->get('status', 'read');
+            $notification->save();
+            $unread = Notification::where('to', $notification->to)
+                ->where('status', 'unread')
+                ->count();
+            return $this->ok(['unread' => $unread]);
+        } else {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  Notification $notification
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Notification $notification)
+    {
+        //
+        $notification->delete();
+        if ($notification->trashed()) {
+            return $this->ok('ok');
+        } else {
+            return $this->error('fail', 500, 500);
+        }
+    }
+}

+ 117 - 0
api-v12/app/Http/Controllers/OfflineIndexController.php

@@ -0,0 +1,117 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use App\Tools\RedisClusters;
+use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\App;
+
+class OfflineIndexController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $key = '/offline/index';
+
+        if (!RedisClusters::has($key)) {
+            return [];
+        }
+        $fileInfo = RedisClusters::get($key);
+        $output = [];
+        foreach ($fileInfo as $key => $file) {
+            if ($request->has('file')) {
+                if ($file['id'] !== $request->get('file')) {
+                    continue;
+                }
+            }
+            $zipFile = $file['filename'];
+            $bucket = config('mint.attachments.bucket_name.temporary');
+            $tmpFile =  $bucket . '/' . $zipFile;
+            $url = array();
+            foreach (config('mint.server.cdn_urls') as $key => $cdn) {
+                $url[] = [
+                    'link' => $cdn . '/' . $zipFile,
+                    'hostname' => 'cdn-' . $key,
+                ];
+            }
+            if (App::environment('local')) {
+                $s3Link = Storage::url($tmpFile);
+            } else {
+                try {
+                    $s3Link = Storage::temporaryUrl($tmpFile, now()->addDays(2));
+                } catch (\Exception $e) {
+                    Log::error('offline-index {Exception}', ['exception' => $e]);
+                    continue;
+                }
+            }
+            //Log::info('offline-index: link=' . $s3Link);
+            $url[] = [
+                'link' => $s3Link,
+                'hostname' => 'Amazon cloud storage(Hongkong)',
+            ];
+            $file['url'] = $url;
+            Log::debug('offline-index: file info=', ['data' => $file]);
+            $output[] = $file;
+        }
+        return response()->json(
+            $output,
+            200,
+            [
+                'Content-Type' => 'application/json;charset=UTF-8',
+                'Charset' => 'utf-8'
+            ],
+            JSON_UNESCAPED_UNICODE
+        );
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $filename
+     * @return \Illuminate\Http\Response
+     */
+    public function show($filename) {}
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 76 - 0
api-v12/app/Http/Controllers/PaliBookCategoryController.php

@@ -0,0 +1,76 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class PaliBookCategoryController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $file
+     * @return \Illuminate\Http\Response
+     */
+    public function show($file)
+    {
+        $data = file_get_contents(public_path("app/palicanon/category/{$file}.json"));
+        if ($data === false) {
+            return $this->error('no file');
+        }
+        $response = json_decode($data);
+        return response()->json(
+            $response,
+            200,
+            [
+                'Content-Type' => 'application/json;charset=UTF-8',
+                'Charset' => 'utf-8'
+            ],
+            JSON_UNESCAPED_UNICODE
+        );
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 310 - 0
api-v12/app/Http/Controllers/PaliTextController.php

@@ -0,0 +1,310 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Facades\DB;
+use App\Models\PaliText;
+use App\Models\BookTitle;
+use App\Models\Tag;
+use App\Models\TagMap;
+use Illuminate\Http\Request;
+use App\Tools\RedisClusters;
+use App\Http\Resources\PaliTextResource;
+
+class PaliTextController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $all_count = 0;
+        switch ($request->get('view')) {
+            case 'chapter-tag':
+                $tm = (new TagMap)->getTable();
+                $tg = (new Tag)->getTable();
+                $pt = (new PaliText)->getTable();
+                if ($request->get('tags') && $request->get('tags') !== '') {
+                    $tags = explode(',', $request->get('tags'));
+                    foreach ($tags as $tag) {
+                        # code...
+                        if (!empty($tag)) {
+                            $tagNames[] = $tag;
+                        }
+                    }
+                }
+
+                if (isset($tagNames)) {
+                    $where1 = " where co = " . count($tagNames);
+                    $a = implode(",", array_fill(0, count($tagNames), '?'));
+                    $in1 = "and t.name in ({$a})";
+                    $param  = $tagNames;
+                } else {
+                    $where1 = " ";
+                    $in1 = " ";
+                }
+                $query = "
+                    select tags.id,tags.name,co as count
+                        from (
+                            select tm.tag_id,count(*) as co from (
+                                select anchor_id as id from (
+                                    select tm.anchor_id , count(*) as co
+                                        from $tm as  tm
+                                        left join $tg as t on tm.tag_id = t.id
+                                        left join $pt as pc on tm.anchor_id = pc.uid
+                                        where tm.table_name  = 'pali_texts'
+                                        $in1
+                                        group by tm.anchor_id
+                                ) T
+                                    $where1
+                            ) CID
+                            left join $tm as tm on tm.anchor_id = CID.id
+                            group by tm.tag_id
+                        ) tid
+                        left join $tg on $tg.id = tid.tag_id
+                        order by count desc
+                    ";
+                if (isset($param)) {
+                    $chapters = DB::select($query, $param);
+                } else {
+                    $chapters = DB::select($query);
+                }
+                $all_count = count($chapters);
+                break;
+
+            case 'chapter':
+                if ($request->get('tags') && $request->get('tags') !== '') {
+                    $tags = explode(',', $request->get('tags'));
+                    foreach ($tags as $tag) {
+                        # code...
+                        if (!empty($tag)) {
+                            $tagNames[] = $tag;
+                        }
+                    }
+                }
+
+                $tm = (new TagMap)->getTable();
+                $tg = (new Tag)->getTable();
+                $pt = (new PaliText)->getTable();
+                if (isset($tagNames)) {
+                    $where1 = " where co = " . count($tagNames);
+                    $a = implode(",", array_fill(0, count($tagNames), '?'));
+                    $in1 = "and t.name in ({$a})";
+                    $param = $tagNames;
+                    $where2 = "where level < 3";
+                } else {
+                    $where1 = " ";
+                    $in1 = " ";
+                    $where2 = "where level = 1";
+                }
+                $query = "
+                        select uid as id,book,paragraph,level,toc as title,chapter_strlen,parent,path from (
+                            select anchor_id as cid from (
+                                select tm.anchor_id , count(*) as co
+                                    from $tm as  tm
+                                    left join $tg as t on tm.tag_id = t.id
+                                    where tm.table_name  = 'pali_texts'
+                                    $in1
+                                    group by tm.anchor_id
+                            ) T
+                                $where1
+                        ) CID
+                        left join $pt as pt on CID.cid = pt.uid
+                        $where2
+                        order by book,paragraph";
+
+                if (isset($param)) {
+                    $chapters = DB::select($query, $param);
+                } else {
+                    $chapters = DB::select($query);
+                }
+
+                $all_count = count($chapters);
+                break;
+            case 'chapter_children':
+                $table = PaliText::where('book', $request->get('book'))
+                    ->where('parent', $request->get('para'))
+                    ->where('level', '<', 8);
+                $all_count = $table->count();
+                $chapters = $table->orderBy('paragraph')->get();
+                break;
+            case 'children':
+                if ($request->has('id')) {
+                    $root = PaliText::where('uid', $request->get('id'))
+                        ->first();
+                } else {
+                    $root = PaliText::where('book', $request->get('book'))
+                        ->where('paragraph', $request->get('para'))
+                        ->first();
+                }
+
+                if ($root->level >= 8) {
+                    $chapters = [];
+                    break;
+                }
+                $start = $root->paragraph + 1;
+                $end = $root->paragraph + $root->chapter_len - 1;
+                $nextLevelChapter = PaliText::where('book', $root->book)
+                    ->whereBetween('paragraph', [$start, $end])
+                    ->whereBetween('level', [$root->level + 1, 7])
+                    ->orderBy('level', 'asc')
+                    ->first();
+                if ($nextLevelChapter) {
+                    //存在子目录
+                    $chapters = PaliText::where('book', $root->book)
+                        ->whereBetween('paragraph', [$start, $end])
+                        ->where('level', $nextLevelChapter->level)
+                        ->orderBy('paragraph', 'asc')
+                        ->get();
+                } else {
+                    $chapters = PaliText::where('book', $root->book)
+                        ->whereBetween('paragraph', [$start, $end])
+                        ->orderBy('paragraph', 'asc')
+                        ->get();
+                }
+                $all_count = count($chapters);
+                break;
+            case 'paragraph':
+                $result = PaliText::where('book', $request->get('book'))
+                    ->where('paragraph', $request->get('para'))
+                    ->first();
+                if ($result) {
+                    return $this->ok($result);
+                } else {
+                    return $this->error("no data");
+                }
+                break;
+
+            case 'book-toc':
+                /**
+                 * 获取全书目录
+                 * 2023-1-25 改进算法
+                 * 需求:目录显示丛书以及此丛书下面的所有书。比如,选择清净道论的一个章节。显示清净道论两本书的目录
+                 * 算法:
+                 * 1. 查询这个目录的顶级目录
+                 * 2. 查询book-title 获取丛书名
+                 * 3. 根据从书名找到全部的书
+                 * 4. 获取全部书的目录
+                 */
+
+                if ($request->has('series')) {
+                    $book_title = $request->get('series');
+                    //获取丛书书目列表
+                    $books = BookTitle::where('title', $request->get('series'))->get();
+                } else {
+                    //查询这个目录的顶级目录
+                    $path = PaliText::where('book', $request->get('book'))
+                        ->where('paragraph', $request->get('para'))
+                        ->select('path')->first();
+                    if (!$path) {
+                        return $this->error("no data");
+                    }
+                    $json = \json_decode($path->path);
+                    $root = null;
+                    foreach ($json as $key => $value) {
+                        # code...
+                        if ($value->level == 1) {
+                            $root = $value;
+                            break;
+                        }
+                    }
+                    if ($root === null) {
+                        return $this->error("no data");
+                    }
+                    //查询书起始段落
+                    $rootPara = PaliText::where('book', $root->book)
+                        ->where('paragraph', $root->paragraph)
+                        ->first();
+                    //获取丛书书名
+                    $book_title = BookTitle::where('book', $rootPara->book)
+                        ->where('paragraph', $rootPara->paragraph)
+                        ->value('title');
+                    //获取丛书书目列表
+                    $books = BookTitle::where('title', $book_title)->get();
+                }
+
+
+                $chapters = [];
+                $chapters[] = ['book' => 0, 'paragraph' => 0, 'toc' => $book_title, 'level' => 1];
+                foreach ($books as  $book) {
+                    # code...
+                    $rootPara = PaliText::where('book', $book->book)
+                        ->where('paragraph', $book->paragraph)
+                        ->first();
+                    $table = PaliText::where('book', $rootPara->book)
+                        ->whereBetween('paragraph', [$rootPara->paragraph, ($rootPara->paragraph + $rootPara->chapter_len - 1)])
+                        ->where('level', '<', 8);
+                    $all_count = $table->count();
+                    $curr_chapters = $table->select(['book', 'paragraph', 'toc', 'level'])->orderBy('paragraph')->get();
+                    foreach ($curr_chapters as  $chapter) {
+                        # code...
+                        $chapters[] = ['book' => $chapter->book, 'paragraph' => $chapter->paragraph, 'toc' => $chapter->toc, 'level' => ($chapter->level + 1)];
+                    }
+                }
+
+                break;
+        }
+
+        if ($request->get('view') !== 'book-toc') {
+            foreach ($chapters as $key => $value) {
+                if (is_object($value)) {
+                    //TODO $value->book 可能不存在
+                    $progress_key = "/chapter_dynamic/{$value->book}/{$value->paragraph}/global";
+                    $chapters[$key]->progress_line = RedisClusters::get($progress_key);
+                }
+            }
+        }
+        return $this->ok(["rows" => $chapters, "count" => $all_count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $id)
+    {
+        //
+        $para = explode('-', $id);
+        $paragraph = PaliText::where('book', $para[0])->where('paragraph', $para[1])->first();
+        return $this->ok(new PaliTextResource($paragraph));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, PaliText $paliText)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(PaliText $paliText)
+    {
+        //
+    }
+}

+ 73 - 0
api-v12/app/Http/Controllers/PgPaliDictDownloadController.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserDict;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Redis;
+
+class PgPaliDictDownloadController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        $currPage = $request->get('page',1);
+        $path = 'export/fts/pali';
+        $filename = $path."/pali-{$currPage}.syn";
+        if(Redis::exists($filename)){
+            $content = Redis::get($filename);
+            return $this->ok($content);
+        }else{
+            return $this->error('no file',200,200);
+        }
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function show(UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, UserDict $userDict)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserDict  $userDict
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserDict $userDict)
+    {
+        //
+    }
+}

+ 511 - 0
api-v12/app/Http/Controllers/ProgressChapterController.php

@@ -0,0 +1,511 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\DB;
+use App\Models\ProgressChapter;
+use App\Models\Channel;
+use App\Models\Tag;
+use App\Models\TagMap;
+use App\Models\PaliText;
+use App\Models\View;
+use App\Models\Like;
+use Illuminate\Http\Request;
+use App\Http\Api\StudioApi;
+use App\Tools\RedisClusters;
+
+class ProgressChapterController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+
+        $minProgress = (float)$request->get('progress', 0.8);
+
+        $offset = (int)$request->get('offset', 0);
+
+        $limit = (int)$request->get('limit', 20);
+
+        $channel_id = $request->get('channel');
+
+        //
+
+        $chapters = false;
+        switch ($request->get('view')) {
+            case 'ids':
+                $aChannel = explode(',', $request->get('channel'));
+                $chapters = ProgressChapter::select("channel_id")->selectRaw("uid as id")
+                    ->with(['channel' => function ($query) {  //city对应上面province模型中定义的city方法名  闭包内是子查询
+                        return $query->select('*');
+                    }])
+                    ->where("book", $request->get('book'))
+                    ->where("para", $request->get('par'))
+                    ->whereIn('channel_id', $aChannel)->get();
+                $all_count = count($chapters);
+                break;
+            case 'studio':
+                #查询该studio的channel
+                $name = $request->get('name');
+                $studioId = StudioApi::getIdByName($request->get('name'));
+                if ($studioId === false) {
+                    return $this->error('no user');
+                }
+                $table = Channel::where('owner_uid', $studioId);
+                if ($request->get('public') === "true") {
+                    $table = $table->where('status', 30);
+                }
+                $channels = $table->select('uid')->get();
+                $chapters = ProgressChapter::whereIn('progress_chapters.channel_id', $channels)
+                    ->leftJoin('pali_texts', function ($join) {
+                        $join->on('progress_chapters.book', '=', 'pali_texts.book');
+                        $join->on('progress_chapters.para', '=', 'pali_texts.paragraph');
+                    })
+                    ->where('progress', '>', 0.85)
+                    ->orderby('progress_chapters.created_at', 'desc')
+                    ->skip($request->get("offset", 0))
+                    ->take($request->get("limit", 1000))
+                    ->get();
+                $all_count = ProgressChapter::whereIn('progress_chapters.channel_id', $channels)
+                    ->where('progress', '>', 0.85)->count();
+                break;
+            case 'tag':
+                $tm = (new TagMap)->getTable();
+                $pc = (new ProgressChapter)->getTable();
+                $t = (new Tag)->getTable();
+                $query = "select t.name,count(*) from $tm  tm
+                            join tags as t on tm.tag_id = t.id
+                            join progress_chapters as pc on tm.anchor_id = pc.uid
+                            where tm.table_name  = 'progress_chapters' and
+                            pc.progress > ?
+                            group by t.name;";
+                $chapters = DB::select($query, [$minProgress]);
+                if ($chapters) {
+                    $all_count = count($chapters);
+                } else {
+                    $all_count = 0;
+                }
+                break;
+            case 'chapter-tag':
+                if ($request->get('tags') && $request->get('tags') !== '') {
+                    $tags = explode(',', $request->get('tags'));
+                    foreach ($tags as $tag) {
+                        # code...
+                        if (!empty($tag)) {
+                            $tagNames[] = $tag;
+                        }
+                    }
+                }
+                $tm = (new TagMap)->getTable();
+                $pc = (new ProgressChapter)->getTable();
+                $tg = (new Tag)->getTable();
+                $pt = (new PaliText)->getTable();
+                $param[] = $minProgress;
+                if (isset($tagNames)) {
+                    $where1 = " where co = " . count($tagNames);
+                    $a = implode(",", array_fill(0, count($tagNames), '?'));
+                    $in1 = "and t.name in ({$a})";
+                    $param  = array_merge($param, $tagNames);
+                } else {
+                    $where1 = " ";
+                    $in1 = " ";
+                }
+                if (Str::isUuid($channel_id)) {
+                    $channel = "and channel_id = '{$channel_id}' ";
+                } else {
+                    $channel = "";
+                }
+
+                $query = "
+                    select tags.id,tags.name,co as count
+                        from (
+                            select tm.tag_id,count(*) as co from (
+                                select anchor_id as id from (
+                                    select tm.anchor_id , count(*) as co
+                                        from $tm as  tm
+                                        left join $tg as t on tm.tag_id = t.id
+                                        left join $pc as pc on tm.anchor_id = pc.uid
+                                        where tm.table_name  = 'progress_chapters' and
+                                              pc.progress  > ?
+                                        $in1
+                                        $channel
+                                        group by tm.anchor_id
+                                ) T
+                                    $where1
+                            ) CID
+                            left join $tm as tm on tm.anchor_id = CID.id
+                            group by tm.tag_id
+                        ) tid
+                        left join $tg on $tg.id = tid.tag_id
+                        order by count desc
+                    ";
+                if (isset($param)) {
+                    $chapters = DB::select($query, $param);
+                } else {
+                    $chapters = DB::select($query);
+                }
+                $all_count = count($chapters);
+                break;
+            case 'lang':
+
+                $chapters = ProgressChapter::select('lang')
+                    ->selectRaw('count(*) as count')
+                    ->where("progress", ">", $minProgress)
+                    ->groupBy('lang')
+                    ->get();
+                $all_count = count($chapters);
+                break;
+            case 'channel-type':
+                break;
+            case 'channel':
+                /**
+                 * 总共有多少channel
+                 */
+                $chapters = ProgressChapter::select('channel_id')
+                    ->selectRaw('count(*) as count')
+                    ->with(['channel' => function ($query) {
+                        return $query->select('*');
+                    }])
+                    ->leftJoin('channels', 'progress_chapters.channel_id', '=', 'channels.uid')
+                    ->where("progress", ">", $minProgress)
+                    ->where('channels.status', '>=', 30);
+                if (!empty($request->get('channel_type'))) {
+                    $chapters =  $chapters->where('channels.type', $request->get('channel_type'));
+                }
+                if (!empty($request->get('lang'))) {
+                    $chapters =  $chapters->where('progress_chapters.lang', $request->get('lang'));
+                }
+                $chapters =  $chapters->groupBy('channel_id')
+                    ->orderBy('count', 'desc')
+                    ->get();
+                foreach ($chapters as $key => $chapter) {
+                    $chapter->studio = StudioApi::getById($chapter->channel->owner_uid);
+                }
+                $all_count = count($chapters);
+                break;
+            case 'chapter_channels':
+                /**
+                 * 某个章节 有多少channel
+                 */
+
+                $chapters = ProgressChapter::select(
+                    'book',
+                    'para',
+                    'progress_chapters.uid',
+                    'progress_chapters.channel_id',
+                    'progress',
+                    'channels.name',
+                    'channels.type',
+                    'channels.owner_uid',
+                    'progress_chapters.updated_at'
+                )
+                    ->leftJoin('channels', 'progress_chapters.channel_id', '=', 'channels.uid')
+                    ->where("book", $request->get('book'))
+                    ->where("para", $request->get('par'))
+                    ->orderBy('progress', 'desc')
+                    ->get();
+                foreach ($chapters as $key => $value) {
+                    # code...
+                    $chapters[$key]->views = View::where("target_id", $value->uid)->count();
+
+                    $likes = Like::where("target_id", $value->uid)
+                        ->groupBy("type")
+                        ->select("type")
+                        ->selectRaw("count(*)")
+                        ->get();
+                    if (isset($_COOKIE["user_uid"])) {
+                        foreach ($likes as $key1 => $like) {
+                            # 查看这些点赞里有没有我点的
+                            $myLikeId = Like::where([
+                                "target_id" => $value->uid,
+                                'type' => $like->type,
+                                'user_id' => $_COOKIE["user_uid"]
+                            ])->value('id');
+                            if ($myLikeId) {
+                                $likes[$key1]->selected = $myLikeId;
+                            }
+                        }
+                    }
+                    $chapters[$key]->likes = $likes;
+                    $chapters[$key]->studio = StudioApi::getById($value->owner_uid);
+                    $chapters[$key]->channel = ['uid' => $value->channel_id, 'name' => $value->name, 'type' => $value->type];
+                    $progress_key = "/chapter_dynamic/{$value->book}/{$value->para}/ch_{$value->channel_id}";
+                    $chapters[$key]->progress_line = RedisClusters::get($progress_key);
+                }
+
+                $all_count = count($chapters);
+                break;
+            case 'chapter':
+                $tm = (new TagMap)->getTable();
+                $pc = (new ProgressChapter)->getTable();
+                $tg = (new Tag)->getTable();
+                $pt = (new PaliText)->getTable();
+
+                //标签过滤
+                if ($request->has('tags') && !empty($request->get('tags'))) {
+                    $tags = explode(',', $request->get('tags'));
+                    foreach ($tags as $tag) {
+                        # code...
+                        if (!empty($tag)) {
+                            $tagNames[] = $tag;
+                        }
+                    }
+                }
+                if (isset($tagNames)) {
+                    $where1 = " where co = " . count($tagNames);
+                    $a = implode(",", array_fill(0, count($tagNames), '?'));
+                    $in1 = "and t.name in ({$a})";
+                    $param = $tagNames;
+                } else {
+                    $where1 = " ";
+                    $in1 = " ";
+                }
+                if ($request->has('studio')) {
+                    $studioId = StudioApi::getIdByName($request->get('studio'));
+                    $table = Channel::where('owner_uid', $studioId);
+                    if ($request->get('public') === "true") {
+                        $table = $table->where('status', 30);
+                    }
+                    $channels = $table->select('uid')->get();
+                    $arrChannel = [];
+                    foreach ($channels as $oneChannel) {
+                        # code...
+                        if (Str::isUuid($oneChannel->uid)) {
+                            $arrChannel[] = "'{$oneChannel->uid}'";
+                        }
+                    }
+                    $channel = "and channel_id in (" . implode(',', $arrChannel) . ") ";
+                } else {
+                    if (Str::isUuid($channel_id)) {
+                        $channel = "and channel_id = '{$channel_id}' ";
+                    } else {
+                        $channel = "";
+                    }
+                }
+
+                //完成度过滤
+                $param[] = $minProgress;
+
+                //语言过滤
+                if (!empty($request->get('lang'))) {
+                    $whereLang = " and pc.lang = ? ";
+                    $param[] = $request->get('lang');
+                } else {
+                    $whereLang = "   ";
+                }
+                //channel type过滤
+                if ($request->has('channel_type') && !empty($request->get('channel_type'))) {
+                    $channel_type = "and ch.type = ? ";
+                    $param[] = $request->get('channel_type');
+                } else {
+                    $channel_type = "";
+                }
+
+                $param_count = $param;
+                $param[] = $offset;
+
+
+                $query = "
+                select tpc.pc_uid as uid, tpc.book ,tpc.para,tpc.channel_id,tpc.title,pt.toc,pt.path,tpc.progress,tpc.summary,tpc.created_at,tpc.updated_at
+                    from (
+						select pcd.uid as pc_uid, ch.uid as ch_uid, book , para, channel_id,progress, title ,pcd.summary , pcd.created_at,pcd.updated_at
+							from (
+								select uid, book,para,lang,progress,channel_id,title,summary ,created_at ,updated_at
+									from (
+										select anchor_id as cid
+											from (
+												select tm.anchor_id , count(*) as co
+													from $tm as  tm
+													left join $tg as t on tm.tag_id = t.id
+													where tm.table_name  = 'progress_chapters'
+													$in1
+													group by tm.anchor_id
+											) T
+											$where1
+									) CID
+								left join $pc as pc on CID.cid = pc.uid
+								where pc.progress > ?
+								$channel  $whereLang
+							) pcd
+						left join channels as ch on pcd.channel_id = ch.uid
+						where ch.status >= 30 $channel_type
+                        order by pcd.created_at desc
+                        limit {$limit} offset ?
+                    ) tpc
+                    left join $pt as pt on tpc.book = pt.book and tpc.para = pt.paragraph;";
+                $chapters = DB::select($query, $param);
+                foreach ($chapters as $key => $chapter) {
+                    # code...
+                    $chapter->channel = Channel::where('uid', $chapter->channel_id)->select(['name', 'owner_uid'])->first();
+                    $chapter->studio = StudioApi::getById($chapter->channel["owner_uid"]);
+                    $chapter->views = View::where("target_id", $chapter->uid)->count();
+                    $chapter->likes = Like::where(["type" => "like", "target_id" => $chapter->uid])->count();
+                    $chapter->tags = TagMap::where("anchor_id", $chapter->uid)
+                        ->leftJoin('tags', 'tag_maps.tag_id', '=', 'tags.id')
+                        ->select(['tags.id', 'tags.name', 'tags.description'])
+                        ->get();
+                }
+
+                //计算按照这个条件搜索到的总数
+                $query  = "
+                         select count(*) as count
+							from (
+								select *
+								from (
+									select anchor_id as cid
+										from (
+											select tm.anchor_id , count(*) as co
+												from $tm as  tm
+												left join $tg as t on tm.tag_id = t.id
+												where tm.table_name  = 'progress_chapters'
+												$in1
+												group by tm.anchor_id
+										) T
+										$where1
+								) CID
+								left join $pc as pc on CID.cid = pc.uid
+								where pc.progress > ?
+								$channel   $whereLang
+							) pcd
+							left join channels as ch on pcd.channel_id = ch.uid
+							where ch.status >= 30 $channel_type
+
+                ";
+                $count = DB::select($query, $param_count);
+                $all_count = $count[0]->count;
+                break;
+            case 'top':
+                break;
+            case 'search':
+                $key = $request->get('key');
+                $table = ProgressChapter::where('title', 'like', "%{$key}%");
+                //获取记录总条数
+                $all_count = $table->count();
+                //处理排序
+                if ($request->has("order") && $request->has("dir")) {
+                    $table = $table->orderBy($request->get("order"), $request->get("dir"));
+                } else {
+                    //默认排序
+                    $table = $table->orderBy('updated_at', 'desc');
+                }
+                //处理分页
+                if ($request->has("limit")) {
+                    if ($request->has("offset")) {
+                        $offset = $request->get("offset");
+                    } else {
+                        $offset = 0;
+                    }
+                    $table = $table->skip($offset)->take($request->get("limit"));
+                }
+                //获取数据
+                $chapters = $table->get();
+                //TODO 移到resource
+                foreach ($chapters as $key => $chapter) {
+                    # code...
+                    $chapter->toc = PaliText::where('book', $chapter->book)->where('paragraph', $chapter->para)->value('toc');
+                    $chapter->path = PaliText::where('book', $chapter->book)->where('paragraph', $chapter->para)->value('path');
+                    $chapter->channel = Channel::where('uid', $chapter->channel_id)->select(['name', 'owner_uid'])->first();
+                    if ($chapter->channel) {
+                        $chapter->studio = StudioApi::getById($chapter->channel["owner_uid"]);
+                    } else {
+                        $chapter->channel = [
+                            'name' => "unknown",
+                            'owner_uid' => "unknown",
+                        ];
+                        $chapter->studio = [
+                            'id' => "",
+                            'nickName' => "unknown",
+                            'realName' => "unknown",
+                            'avatar' => '',
+                        ];
+                    }
+
+                    $chapter->views = View::where("target_id", $chapter->uid)->count();
+                    $chapter->likes = Like::where(["type" => "like", "target_id" => $chapter->uid])->count();
+                    $chapter->tags = TagMap::where("anchor_id", $chapter->uid)
+                        ->leftJoin('tags', 'tag_maps.tag_id', '=', 'tags.id')
+                        ->select(['tags.id', 'tags.name', 'tags.description'])
+                        ->get();
+                }
+                break;
+            case 'public':
+                break;
+        }
+
+        if ($chapters) {
+            return $this->ok(["rows" => $chapters, "count" => $all_count]);
+        } else {
+            return $this->error("no data");
+        }
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function create()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function show(ProgressChapter $progressChapter)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(ProgressChapter $progressChapter)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, ProgressChapter $progressChapter)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\ProgressChapter  $progressChapter
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(ProgressChapter $progressChapter)
+    {
+        //
+    }
+}

+ 92 - 0
api-v12/app/Http/Controllers/ProgressImgController.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use App\Tools\RedisClusters;
+use Illuminate\Support\Facades\Log;
+
+class ProgressImgController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+        return response()->stream(function () use ($id) {
+            $key = str_replace('-','/',$id);
+            $svg = RedisClusters::get('svg/'.$key, function () use ($key) {
+                $viewHeight = 60;
+                $svg = "<svg xmlns='http://www.w3.org/2000/svg'  fill='currentColor' viewBox='0 0 300 60'>";
+                $data = RedisClusters::get($key);
+                if(is_array($data)){
+                    $point = [];
+                    foreach ($data as $key => $value) {
+                        $point[] = ($key*10) . ',' . $viewHeight-($value/20)-3;
+                    }
+                    $svg .= '<polyline points="'. implode(' ',$point) . '"';
+                    $svg .= ' style="fill:none;stroke:green;stroke-width:3" /></svg>';
+                }else{
+                    $svg .= '<polyline points="0,0 1,0" /></svg>';
+                }
+                return $svg;
+            } , config('mint.cache.expire') );
+            echo $svg;
+        }, 200, ['Content-Type' => 'image/svg+xml']);
+    /*
+    ————————————————
+    原文作者:Summer
+    转自链接:https://learnku.com/laravel/wikis/25600
+    版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请保留以上作者信息和原文链接。
+    */
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 203 - 0
api-v12/app/Http/Controllers/ProjectController.php

@@ -0,0 +1,203 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Project;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\ShareApi;
+
+use App\Http\Resources\ProjectResource;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+
+class ProjectController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('notification auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if ($request->has('studio')) {
+            $studioId = StudioApi::getIdByName($request->get('studio'));
+        } else {
+            $studioId = $user['user_uid'];
+        }
+
+        switch ($request->get('view')) {
+            case 'studio':
+                $table = Project::where('owner_id', $studioId)
+                    ->whereNull('parent_id')
+                    ->where('type', $request->get('type', 'instance'));
+                break;
+            case 'project-tree':
+                $table = Project::where('uid', $request->get('project_id'))
+                    ->orWhereJsonContains('path', $request->get('project_id'));
+                break;
+            case 'shared':
+                $type = $request->get('type', 'instance');
+                $resList = ShareApi::getResList($studioId, $type === 'instance' ? 7 : 6);
+                $resId = [];
+                foreach ($resList as $res) {
+                    $resId[] = $res['res_id'];
+                }
+                $table = Project::whereIn('uid', $resId);
+                break;
+            case 'community':
+                $table = Project::where('owner_id', '<>', $studioId)
+                    ->whereNull('parent_id')
+                    ->where('privacy', 'public')
+                    ->where('type', $request->get('type', 'instance'));
+                break;
+            default:
+                return $this->error('view', 200, 200);
+                break;
+        }
+
+        if ($request->has('keyword')) {
+            $table = $table->where('title', 'like', '%' . $request->get('keyword') . '%');
+        }
+        if ($request->has('status')) {
+            $table = $table->whereIn('status', explode(',', $request->get('status')));
+        }
+        $count = $table->count();
+
+        $sql = $table->toSql();
+        Log::debug('sql', ['sql' => $sql]);
+
+        $table = $table->orderBy($request->get('order', 'id'), $request->get('dir', 'asc'));
+
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 10000));
+
+        $result = $table->get();
+
+        return $this->ok(
+            [
+                "rows" => ProjectResource::collection($result),
+                "count" => $count,
+            ]
+        );
+    }
+
+    public static function canEdit($user_uid, $studio_uid)
+    {
+        return $user_uid == $studio_uid;
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $studioId = StudioApi::getIdByName($request->get('studio_name'));
+        if (!self::canEdit($user['user_uid'], $studioId)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $new = Project::firstOrNew(['uid' => $request->get('id')]);
+        if (Str::isUuid($request->get('id'))) {
+            $new->uid = $request->get('id');
+        } else {
+            $new->uid =  Str::uuid();
+        }
+        $new->title = $request->get('title');
+        $new->description = $request->get('description');
+        $new->parent_id = $request->get('parent_id');
+        $new->editor_id = $user['user_uid'];
+        $new->owner_id = $studioId;
+        $new->type = $request->get('type', 'instance');
+
+
+        if (Str::isUuid($request->get('parent_id'))) {
+            $parentPath = Project::where('uid', $request->get('parent_id'))->value('path');
+            $parentPath = json_decode($parentPath);
+            if (!is_array($parentPath)) {
+                $parentPath = array();
+            }
+            array_push($parentPath, $new->parent_id);
+            $new->path = json_encode($parentPath, JSON_UNESCAPED_UNICODE);
+        }
+        $new->save();
+
+        return $this->ok(new ProjectResource($new));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Project $project)
+    {
+        //
+        return $this->ok(new ProjectResource($project));
+    }
+
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Project $project)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (!self::canEdit($user['user_uid'], $project->owner_id)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+
+        $project->title = $request->get('title');
+        $project->description = $request->get('description');
+        $project->parent_id = $request->get('parent_id');
+        $project->editor_id = $user['user_uid'];
+        $project->privacy = $request->get('privacy');
+
+
+        if (Str::isUuid($request->get('parent_id'))) {
+            $parentPath = Project::where('uid', $request->get('parent_id'))->value('path');
+            $parentPath = json_decode($parentPath);
+            if (!is_array($parentPath)) {
+                $parentPath = array();
+            }
+            array_push($parentPath, $project->parent_id);
+            $project->path = json_encode($parentPath, JSON_UNESCAPED_UNICODE);
+        }
+        $project->save();
+
+        return $this->ok(new ProjectResource($project));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Project $project)
+    {
+        //
+    }
+}

+ 149 - 0
api-v12/app/Http/Controllers/ProjectTreeController.php

@@ -0,0 +1,149 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Project;
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Str;
+use App\Http\Api\StudioApi;
+use Illuminate\Support\Facades\Log;
+
+class ProjectTreeController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            Log::error('notification auth failed {request}', ['request' => $request]);
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $studioId = StudioApi::getIdByName($request->get('studio_name'));
+        if (!ProjectController::canEdit($user['user_uid'], $studioId)) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $newData = [];
+        foreach ($request->get('data') as $key => $value) {
+            $data = [
+                'uid' => Str::uuid(),
+                'old_id' => $value['id'],
+                'title' => $value['title'],
+                'type' => $value['type'],
+                'res_id' => $value['res_id'],
+                'parent_id' => $value['parent_id'],
+                'path' => null,
+                'owner_id' => $studioId,
+                'editor_id' => $user['user_uid'],
+                'created_at' => now(),
+                'updated_at' => now(),
+            ];
+            if (isset($value['weight'])) {
+                $data['weight'] = $value['weight'];
+            }
+            $newData[] = $data;
+        }
+        foreach ($newData as $key => $value) {
+            if ($value['parent_id']) {
+                $parent = null;
+                /*
+                $parent = \array_find($newData, function ($element) use ($value) {
+                    return $element['old_id'] == $value['parent_id'];
+                });
+                */
+                foreach ($newData as $item) {
+                    if ($item['old_id'] == $value['parent_id']) {
+                        $parent = $item;
+                        break;
+                    }
+                }
+                if ($parent) {
+                    $newData[$key]['parent_id'] = $parent['uid'];
+                    $parentPath = $parent['path'] ? json_decode($parent['path']) : [];
+                    $newData[$key]['path'] = json_encode([...$parentPath, $parent['uid']], JSON_UNESCAPED_UNICODE);
+                } else {
+                    $newData[$key]['parent_id'] = null;
+                }
+            } else if (!empty($request->get('parent_id'))) {
+                $pPath = Project::where('uid', $request->get('parent_id'))->value('path');
+                $parentPath = json_decode($pPath);
+                if (!is_array($parentPath)) {
+                    $parentPath = [];
+                }
+                $newData[$key]['path'] = json_encode([...$parentPath, $request->get('parent_id')], JSON_UNESCAPED_UNICODE);
+                $newData[$key]['parent_id'] = $request->get('parent_id');
+            }
+        }
+        $output = [];
+        foreach ($newData as $key => $value) {
+            $children = array_filter($newData, function ($element) use ($value) {
+                return $element['parent_id'] === $value['uid'];
+            });
+            $output[] = [
+                'id' => $value['uid'],
+                'resId' => $value['res_id'],
+                'isLeaf' => count($children) === 0,
+            ];
+            unset($newData[$key]['old_id']);
+            unset($newData[$key]['res_id']);
+        }
+
+        $ok = Project::insert($newData);
+
+        if ($ok) {
+            return $this->ok(['rows' => $output, count($output)]);
+        } else {
+            return $this->error('error', 200, 200);
+        }
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Project $project)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Project $project)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Project  $project
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Project $project)
+    {
+        //
+    }
+}

+ 104 - 0
api-v12/app/Http/Controllers/RecentController.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Recent;
+use Illuminate\Http\Request;
+use App\Http\Resources\RecentResource;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Str;
+
+class RecentController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->view) {
+            case 'user':
+                $table = Recent::where('user_uid',$request->get('id'));
+                break;
+            default:
+                return $this->error('known view');
+                break;
+        }
+
+        $table->orderBy($request->get('order','updated_at'),$request->get('dir','desc'));
+        $count = $table->count();
+        $table->skip($request->get("offset",0))
+              ->take($request->get('limit',1000));
+
+        $result = $table->get();
+		return $this->ok(["rows"=>RecentResource::collection($result),"count"=>$count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),[],401);
+        }
+
+        $validated = $request->validate([
+            'type' => 'required',
+            'article_id' => 'required',
+        ]);
+
+        $row = Recent::firstOrNew([
+            "type"=>$request->get("type"),
+            "article_id"=>$request->get("article_id"),
+            "user_uid"=>$user['user_uid'],
+        ],[
+            "id"=>Str::uuid(),
+        ]);
+        $row->param = $request->get("param",null);
+        $row->save();
+        return $this->ok(new RecentResource($row));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Recent  $recent
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Recent $recent)
+    {
+        //
+        return $this->ok(new RecentResource($recent));
+
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Recent  $recent
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Recent $recent)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Recent  $recent
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Recent $recent)
+    {
+        //
+    }
+}

+ 113 - 0
api-v12/app/Http/Controllers/RelatedParagraphController.php

@@ -0,0 +1,113 @@
+<?php
+/*
+ *查询相关联的书
+ *mula->attakhata->tika
+ *算法:
+ *在原始的html 文件里 如 s0404m1.mul.htm 有 <a name="para2_an8"></a>
+ * 在 so404a.att.htm 里也有 </a><a name="para2_an8"></a>
+ * 这说明这两个段落是关联段落,para2是段落编号 an8是书名只要书名一样,段落编号一样。
+ * 两个就是关联段落
+ *
+ * 表名:cs6_para
+ * 所以数据库结构是
+ * book 书号 1-217
+ * para 段落号
+ * bookid
+ * cspara 上述段落号
+ * book_name 上述书名
+ *
+ * 输入 book para
+ * 查询书名和段落号
+ * 输入这个书名和段落号
+ * 查询有多少段落有一样的书名和段落号
+ * 有些book 里面有两本书。所以又加了一个bookid
+ * 每个bookid代表一本真正的书。所以bookid 要比 book 多
+ * bookid 是为了输出书名用的。不是为了查询相关段落
+ *
+ * 数据要求:
+ * 制作时包含全部段落。做好后把没有相关段落的段落删掉??
+ *
+ */
+namespace App\Http\Controllers;
+
+use App\Models\RelatedParagraph;
+use Illuminate\Http\Request;
+use App\Http\Resources\RelatedParagraphResource;
+
+class RelatedParagraphController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $first = RelatedParagraph::where('book',$request->get('book'))
+                                    ->where('para',$request->get('para'))
+                                    ->where('cs_para','>',0)
+                                    ->first();
+        $result = RelatedParagraph::where('book_name',$first->book_name)
+                                    ->where('cs_para',$first->cs_para)
+                                    ->orderBy('book_id')
+                                    ->orderBy('para')
+                                    ->get();
+        $books=[];
+        foreach ($result as $value) {
+            # 把段落整合成书。有几本书就有几条输出纪录
+            if(!isset($books[$value->book_id])){
+                $books[$value->book_id]['book'] = $value->book;
+                $books[$value->book_id]['book_id'] = $value->book_id;
+                $books[$value->book_id]['cs6_para'] = $value->cs_para;
+            }
+            $books[$value->book_id]['para'][]=$value->para;
+        }
+        return $this->ok(["rows"=>RelatedParagraphResource::collection($books),"count"=>count($books)]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\RelatedParagraph  $relatedParagraph
+     * @return \Illuminate\Http\Response
+     */
+    public function show(RelatedParagraph $relatedParagraph)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\RelatedParagraph  $relatedParagraph
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, RelatedParagraph $relatedParagraph)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\RelatedParagraph  $relatedParagraph
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(RelatedParagraph $relatedParagraph)
+    {
+        //
+    }
+}

+ 293 - 0
api-v12/app/Http/Controllers/RelationController.php

@@ -0,0 +1,293 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Relation;
+use Illuminate\Http\Request;
+use App\Http\Resources\RelationResource;
+use App\Http\Api\AuthApi;
+use Illuminate\Support\Facades\App;
+use PhpOffice\PhpSpreadsheet\Spreadsheet;
+use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
+use Illuminate\Support\Facades\Cache;
+use App\Tools\RedisClusters;
+
+class RelationController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $key = 'relation-vocabulary';
+        if($request->has('vocabulary')){
+            if(RedisClusters::has($key)){
+                return $this->ok(RedisClusters::get($key));
+            }
+        }
+        $table = Relation::select(['id','name','case','from','to',
+                                    'category','editor_id','match',
+                                    'updated_at','created_at']);
+        if(($request->has('case'))){
+            $table = $table->whereIn('case', explode(",",$request->get('case')) );
+        }
+        if(($request->has('search'))){
+            $table = $table->where('name', 'like', $request->get('search')."%");
+        }
+        if(($request->has('name'))){
+            $table = $table->where('name',$request->get('name'));
+        }
+        if(($request->has('from'))){
+            $table = $table->whereJsonContains('from->case',$request->get('from'));
+        }
+        if(($request->has('to'))){
+            $table = $table->whereJsonContains('to',$request->get('to'));
+        }
+        if(($request->has('match'))){
+            $table = $table->whereJsonContains('match',$request->get('match'));
+        }
+        if(($request->has('category'))){
+            $table = $table->where('category',$request->get('category'));
+        }
+        $table = $table->orderBy($request->get('order','updated_at'),$request->get('dir','desc'));
+        $count = $table->count();
+
+        $table = $table->skip($request->get("offset",0))
+                       ->take($request->get('limit',1000));
+        $result = $table->get();
+
+        $output = ["rows"=>RelationResource::collection($result),"count"=>$count];
+
+        if($request->has('vocabulary')){
+            if(!RedisClusters::has($key)){
+                RedisClusters::put($key,$output,config('mint.cache.expire'));
+            }
+        }
+        return $this->ok($output);
+    }
+
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'),[],401);
+        }
+        //TODO 判断权限
+        $validated = $request->validate([
+            'name' => 'required',
+        ]);
+        $case = $request->get('case','');
+        $new = new Relation;
+        $new->name = $validated['name'];
+
+        $new->case = $request->get('case');
+        $new->category = $request->get('category');
+
+        if($request->has('from')){
+            $new->from = json_encode($request->get('from'),JSON_UNESCAPED_UNICODE);
+        }else{
+            $new->from = null;
+        }
+        if($request->has('to')){
+            $new->to = json_encode($request->get('to'),JSON_UNESCAPED_UNICODE);
+        }else{
+            $new->to = null;
+        }
+        if($request->has('match')){
+            $new->match = json_encode($request->get('match'),JSON_UNESCAPED_UNICODE);
+        }else{
+            $new->match = null;
+        }
+        $new->editor_id = $user['user_uid'];
+        $new->save();
+        return $this->ok(new RelationResource($new));
+
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Relation  $relation
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Relation $relation)
+    {
+        //
+        return $this->ok(new RelationResource($relation));
+    }
+
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Relation  $relation
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Relation $relation)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+
+        $relation->name = $request->get('name');
+        $relation->case = $request->get('case');
+        $relation->category = $request->get('category');
+
+        if($request->has('from')){
+            $relation->from = json_encode($request->get('from'),JSON_UNESCAPED_UNICODE);
+        }else{
+            $relation->from = null;
+        }
+        if($request->has('to')){
+            $relation->to = json_encode($request->get('to'),JSON_UNESCAPED_UNICODE);
+        }else{
+            $relation->to = null;
+        }
+        if($request->has('match')){
+            $relation->match = json_encode($request->get('match'),JSON_UNESCAPED_UNICODE);
+        }else{
+            $relation->match = null;
+        }
+        $relation->editor_id = $user['user_uid'];
+        $relation->save();
+        return $this->ok(new RelationResource($relation));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Relation  $relation
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request,Relation $relation)
+    {
+        //
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+        //TODO 判断当前用户是否有权限
+        $delete = 0;
+        $delete = $relation->delete();
+
+        return $this->ok($delete);
+    }
+
+    public function export(){
+        $spreadsheet = new Spreadsheet();
+        $activeWorksheet = $spreadsheet->getActiveSheet();
+        $activeWorksheet->setCellValue('A1', 'id');
+        $activeWorksheet->setCellValue('B1', 'name');
+        $activeWorksheet->setCellValue('C1', 'from');
+        $activeWorksheet->setCellValue('D1', 'to');
+        $activeWorksheet->setCellValue('E1', 'match');
+        $activeWorksheet->setCellValue('F1', 'category');
+
+        $nissaya = Relation::cursor();
+        $currLine = 2;
+        foreach ($nissaya as $key => $row) {
+            # code...
+            $activeWorksheet->setCellValue("A{$currLine}", $row->id);
+            $activeWorksheet->setCellValue("B{$currLine}", $row->name);
+            $activeWorksheet->setCellValue("C{$currLine}", $row->from);
+            $activeWorksheet->setCellValue("D{$currLine}", $row->to);
+            $activeWorksheet->setCellValue("E{$currLine}", $row->match);
+            $activeWorksheet->setCellValue("F{$currLine}", $row->category);
+            $currLine++;
+        }
+        $writer = new Xlsx($spreadsheet);
+        header('Content-Type: application/vnd.ms-excel');
+        header('Content-Disposition: attachment; filename="relation.xlsx"');
+        $writer->save("php://output");
+    }
+
+    public function import(Request $request){
+        $user = AuthApi::current($request);
+        if(!$user){
+            return $this->error(__('auth.failed'));
+        }
+
+        $filename = $request->get('filename');
+        $reader = new \PhpOffice\PhpSpreadsheet\Reader\Xlsx();
+        $reader->setReadDataOnly(true);
+        $spreadsheet = $reader->load($filename);
+        $activeWorksheet = $spreadsheet->getActiveSheet();
+        $currLine = 2;
+        $countFail = 0;
+        $error = "";
+        do {
+            # code...
+            $id = $activeWorksheet->getCell("A{$currLine}")->getValue();
+            $name = $activeWorksheet->getCell("B{$currLine}")->getValue();
+            $from = $activeWorksheet->getCell("C{$currLine}")->getValue();
+            $to = $activeWorksheet->getCell("D{$currLine}")->getValue();
+            $match = $activeWorksheet->getCell("E{$currLine}")->getValue();
+            $category = $activeWorksheet->getCell("F{$currLine}")->getValue();
+            if(!empty($name)){
+                                //查询是否有冲突数据
+                //查询此id是否有旧数据
+                if(!empty($id)){
+                    $oldRow = Relation::find($id);
+                }
+                //查询是否跟已有数据重复
+                /*
+                $row = Relation::where(['name'=>$name,
+                                        'from'=>json_decode($from,true),
+                                        'to'=>$to,
+                                        'match'=>$match,
+                                        'category'=>$category
+                                        ])->first();
+                */
+                $row = false;
+                if(!$row){
+                    //不重复
+                    if(isset($oldRow) && $oldRow){
+                        //有旧的记录-修改旧数据
+                        $row = $oldRow;
+                    }else{
+                        //没找到旧的记录-新建
+                        $row = new Relation();
+                    }
+                }else{
+                    //重复-如果与旧的id不同旧报错
+                    if(isset($oldRow) && $oldRow && $row->id !== $id){
+                        $error .= "重复的数据:{$id} - {$word}\n";
+                        $currLine++;
+                        $countFail++;
+                        continue;
+                    }
+                }
+                $row->name = $name;
+                if(empty($from)){
+                    $row->from = null;
+                }else{
+                    $row->from = $from;
+                }
+                $row->to = $to;
+                $row->match = $match;
+                $row->category = $category;
+                $row->editor_id = $user['user_uid'];
+                $row->save();
+            }else{
+                break;
+            }
+            $currLine++;
+        } while (true);
+        return $this->ok(["success"=>$currLine-2-$countFail,'fail'=>($countFail)],$error);
+    }
+}

+ 88 - 0
api-v12/app/Http/Controllers/ResetPasswordController.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserInfo;
+use Illuminate\Http\Request;
+
+class ResetPasswordController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = UserInfo::where('reset_password_token',$request->get('token'))
+                        ->where('username',$request->get('username'))
+                        ->first();
+        if(!$user){
+        return $this->error('no token',404,404);
+        }
+        if(mb_strlen($request->get('password'),'UTF-8')<6){
+            return $this->error('input is invalid',402,402);
+        }
+        $user->password = md5($request->get('password'));
+        $user->reset_password_token = null;
+        $ok = $user->save();
+        if($ok){
+            return $this->ok($user);
+        }else{
+            return $this->error('fail to set password',500,500);
+        }
+
+    }
+
+    /**
+     * 根据token获取用户名.
+     *
+     * @param  string  $token
+     * @return \Illuminate\Http\Response
+     */
+    public function show($token)
+    {
+        //
+        $user = UserInfo::where('reset_password_token',$token)
+                        ->select(['username'])->first();
+        if(!$user){
+            return $this->error('no token',404,404);
+        }
+        return $this->ok($user);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\UserInfo  $userInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, UserInfo $userInfo)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserInfo  $userInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserInfo $userInfo)
+    {
+        //
+    }
+}

+ 419 - 0
api-v12/app/Http/Controllers/SearchController.php

@@ -0,0 +1,419 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\BookTitle;
+use App\Models\FtsText;
+use App\Models\Tag;
+use App\Models\TagMap;
+use App\Models\PaliText;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\DB;
+use App\Http\Resources\SearchResource;
+use App\Http\Resources\SearchTitleResource;
+use App\Http\Resources\SearchBookResource;
+use Illuminate\Support\Facades\Log;
+use App\Tools\Tools;
+use App\Models\WbwTemplate;
+use App\Models\PageNumber;
+use App\Tools\PaliSearch;
+use Illuminate\Support\Facades\App;
+
+
+class SearchController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request){
+        switch ($request->get('view','pali')) {
+            case 'pali':
+                $pageHead = ['M','P','T','V','O'];
+                $key = $request->get('key');
+                if(substr($key,0,4) === 'para' || in_array(substr($key,0,1),$pageHead)){
+                    return $this->page($request);
+                }else{
+                    return $this->pali_rpc($request);
+                }
+                break;
+            case 'page':
+                return $this->page($request);
+                break;
+            case 'title':
+                $key = strtolower($request->get('key'));
+                $table = PaliText::where('level','<',8)
+                                 ->where(function ($query) use($key){
+                                     $query->where('title_en','like',"%{$key}%")
+                                         ->orWhere('title','like',"%{$key}%");
+                                 });
+                Log::info($table->toSql());
+                if($request->has('tags')){
+                    //查询搜索范围
+                    $tagItems = explode(';',$request->get('tags'));
+                    $bookId = [];
+                    foreach ($tagItems as $tagItem) {
+                        # code...
+                        $bookId = array_merge($bookId,$this->getBookIdByTags(explode(',',$tagItem)));
+                    }
+                    $table = $table->whereIn('pcd_book_id',$bookId);
+                }
+                $count = $table->count();
+                $table = $table->orderBy($request->get('orderby','book'),$request->get('dir','asc'));
+                $table = $table->skip($request->get("offset",0))
+                               ->take($request->get('limit',10));
+                $result = $table->get();
+                return $this->ok(["rows"=>SearchTitleResource::collection($result),"count"=>$count]);
+                break;
+            default:
+                # code...
+                break;
+        }
+    }
+    public function pali(Request $request)
+    {
+        //
+        $bookId = [];
+        if($request->has('book')){
+            $bookId = [(int)$request->get('book')];
+        }else if($request->has('tags')){
+            //查询搜索范围
+            //查询搜索范围
+            $tagItems = explode(';',$request->get('tags'));
+
+            foreach ($tagItems as $tagItem) {
+                $bookId = array_merge($bookId,$this->getBookIdByTags(explode(',',$tagItem)));
+            }
+        }
+
+        $searchChapters = [];
+        $searchBooks = [];
+        $searchBookId = [];
+        $queryBookId = '';
+        if(count($bookId) > 0){
+            $queryBookId = ' AND pcd_book_id in ('.implode(',',$bookId).') ';
+        }
+        $key = explode(';',$request->get('key')) ;
+        $param = [];
+        $countParam = [];
+        switch ($request->get('match','case')) {
+            case 'complete':
+            case 'case':
+                # code...
+                $querySelect_rank_base = " ts_rank('{0.1, 1, 0.3, 0.2}',
+                                                full_text_search_weighted,
+                                                websearch_to_tsquery('pali', ?)) ";
+                $querySelect_rank_head = implode('+', array_fill(0, count($key), $querySelect_rank_base));
+                $param = array_merge($param,$key);
+                $querySelect_rank = " {$querySelect_rank_head} AS rank, ";
+                $querySelect_highlight = " ts_headline('pali', content,
+                                            websearch_to_tsquery('pali', ?),
+                                            'StartSel = ~~, StopSel = ~~,MaxWords=3500, MinWords=3500,HighlightAll=TRUE')
+                                            AS highlight,";
+                array_push($param,implode(' ',$key));
+                break;
+            case 'similar':
+                # 形似,去掉变音符号
+                $key = Tools::getWordEn($key[0]);
+                $querySelect_rank = "
+                    ts_rank('{0.1, 1, 0.3, 0.2}',
+                        full_text_search_weighted_unaccent,
+                        websearch_to_tsquery('pali_unaccent', ?))
+                    AS rank, ";
+                    $param[] = $key;
+                $querySelect_highlight = " ts_headline('pali_unaccent', content,
+                        websearch_to_tsquery('pali_unaccent', ?),
+                        'StartSel = ~~, StopSel = ~~,MaxWords=3500, MinWords=3500,HighlightAll=TRUE')
+                        AS highlight,";
+                $param[] = $key;
+                break;
+        }
+        $_queryWhere = $this->getQueryWhere($request->get('key'),$request->get('match','case'));
+        $queryWhere = $_queryWhere['query'];
+        $param = array_merge($param,$_queryWhere['param']);
+
+        $querySelect_2 = "  book,paragraph,content ";
+
+        $queryCount = "SELECT count(*) as co FROM fts_texts WHERE {$queryWhere} {$queryBookId};";
+        $resultCount = DB::select($queryCount, $_queryWhere['param']);
+
+        $limit = $request->get('limit',10);
+        $offset = $request->get('offset',0);
+        switch ( $request->get('orderby',"rank")) {
+            case 'rank':
+                $orderby = " ORDER BY rank DESC ";
+                break;
+            case 'paragraph':
+                $orderby = " ORDER BY book,paragraph ";
+                break;
+            default:
+                $orderby = "";
+                break;
+        };
+        $query = "SELECT
+            {$querySelect_rank}
+            {$querySelect_highlight}
+            {$querySelect_2}
+            FROM fts_texts
+            WHERE
+                {$queryWhere}
+                {$queryBookId}
+                {$orderby}
+            LIMIT ? OFFSET ? ;";
+        $param[] = $limit;
+        $param[] = $offset;
+
+        $result = DB::select($query, $param);
+
+        return $this->ok(["rows"=>SearchResource::collection($result),"count"=>$resultCount[0]->co]);
+    }
+    public function pali_rpc(Request $request)
+    {
+        //
+        $bookId = [];
+        if($request->has('book')){
+            $bookId = [(int)$request->get('book')];
+        }else if($request->has('tags')){
+            //查询搜索范围
+            //查询搜索范围
+            $tagItems = explode(';',$request->get('tags'));
+
+            foreach ($tagItems as $tagItem) {
+                $bookId = array_merge($bookId,$this->getBookIdByTags(explode(',',$tagItem)));
+            }
+        }
+
+        $key = explode(';',$request->get('key')) ;
+        $limit = $request->get('limit',10);
+        $offset = $request->get('offset',0);
+        $matchMode = $request->get('match','case');
+        $result = PaliSearch::search($key,$bookId,$matchMode,$offset,$limit);
+        return $this->ok(["rows"=>SearchResource::collection(collect($result['rows'])),"count"=>$result['total']]);
+    }
+
+    public function page(Request $request)
+    {
+        //
+        $searchChapters = [];
+        $searchBooks = [];
+        $searchBookId = [];
+        $queryBookId = '';
+        $bookId = [];
+        if($request->has('book')){
+            $bookId[] = $request->get('book');
+        }else if($request->has('tags')){
+            //查询搜索范围
+            //查询搜索范围
+            $tagItems = explode(';',$request->get('tags'));
+            foreach ($tagItems as $tagItem) {
+                # code...
+                $bookId = array_merge($bookId,$this->getBookIdByTags(explode(',',$tagItem)));
+            }
+        }
+
+        $key = $request->get('key');
+        $searchKey = '';
+        $page = explode('.',$key);
+        if(count($page)===2){
+            $table = PageNumber::where('type',$request->get('type'))
+                               ->where('volume',(int)$page[0])
+                               ->where('page',(int)$page[1]);
+        }else{
+            if(is_numeric($key)){
+                $table = PageNumber::where('type',$request->get('type'))->where('page',$key);
+            }else{
+                $table = PageNumber::where('type',$request->get('type'))->where('page',(int)$key);
+            }
+        }
+
+
+
+        if(count($bookId)>0){
+            $table = $table->whereIn('pcd_book_id',$bookId);
+        }
+        $count = $table->count();
+        $table = $table->select(['book','paragraph']);
+        $table->skip($request->get("offset",0))->take($request->get('limit',10));
+        $result = $table->get();
+
+        return $this->ok(["rows"=>SearchResource::collection($result),"count"=>$count]);
+    }
+
+    public function book_list(Request $request){
+        $searchChapters = [];
+        $searchBooks = [];
+        $queryBookId = '';
+
+        $bookId = [];
+        if($request->has('tags')){
+            //查询搜索范围
+            $tagItems = explode(';',$request->get('tags'));
+
+            foreach ($tagItems as $tagItem) {
+                # code...
+                $bookId = array_merge($bookId,$this->getBookIdByTags(explode(',',$tagItem)));
+            }
+            $queryBookId = ' AND pcd_book_id in ('.implode(',',$bookId).') ';
+        }
+        $key = $request->get('key');
+        switch ($request->get('view','pali')) {
+            case 'pali':
+                # code...
+                $pageHead = ['M','P','T','V','O'];
+                if(substr($key,0,4) === 'para' || in_array(substr($key,0,1),$pageHead)){
+                    $queryWhere = "type='.ctl.' AND word = ?";
+                    $query = "SELECT pcd_book_id, count(*) as co FROM wbw_templates WHERE {$queryWhere} {$queryBookId} GROUP BY pcd_book_id ORDER BY co DESC;";
+                    $result = DB::select($query, [$key]);
+
+                }else{
+
+                    $rpc_result = PaliSearch::book_list(explode(';',$key),
+                                                        $bookId,
+                                                        $request->get('match','case'));
+                    $result = collect($rpc_result['rows']);
+                    /*
+                        $queryWhere = $this->getQueryWhere($key,$request->get('match','case'));
+                        $query = "SELECT pcd_book_id, count(*) as co FROM fts_texts WHERE {$queryWhere['query']} {$queryBookId} GROUP BY pcd_book_id ORDER BY co DESC;";
+                        $result = DB::select($query, $queryWhere['param']);
+                    */
+                }
+                break;
+            case 'page':
+                $type = $request->get('type','P');
+                $word = "{$type}%0{$key}";
+                $queryWhere = "type='.ctl.' AND word like ?";
+                $query = "SELECT pcd_book_id, count(*) as co FROM wbw_templates WHERE {$queryWhere} {$queryBookId} GROUP BY pcd_book_id ORDER BY co DESC;";
+                $result = DB::select($query, [$word]);
+                break;
+            case 'title':
+                $keyLike = '%'.$key.'%';
+                $queryWhere = "\"level\" < 8 and (\"title_en\"::text like ? or \"title\"::text like ?)";
+                $query = "SELECT pcd_book_id, count(*) as co FROM pali_texts WHERE {$queryWhere} {$queryBookId} GROUP BY pcd_book_id ORDER BY co DESC;";
+                $result = DB::select($query, [$keyLike,$keyLike]);
+                break;
+            default:
+                # code...
+                return $this->error('unknown view');
+                break;
+        }
+
+        if($result){
+            return $this->ok(["rows"=>SearchBookResource::collection($result),"count"=>count($result)]);
+        }else{
+            return $this->ok(["rows"=>[],"count"=>0]);
+        }
+
+    }
+
+    private function getQueryWhere($key,$match){
+        $key = explode(';',$key) ;
+        $param = [];
+        $queryWhere = '';
+        switch ($match) {
+            case 'complete':
+            case 'case':
+                # code...
+                $queryWhereBase = " full_text_search_weighted @@ websearch_to_tsquery('pali', ?) ";
+                $queryWhereBody = implode(' or ', array_fill(0, count($key), $queryWhereBase));
+                $queryWhere = " ({$queryWhereBody}) ";
+                $param = array_merge($param,$key);
+                break;
+            case 'similar':
+                # 形似,去掉变音符号
+                $queryWhere = " full_text_search_weighted_unaccent @@ websearch_to_tsquery('pali_unaccent', ?) ";
+                $key = Tools::getWordEn($key[0]);
+                $param = [$key];
+                break;
+        };
+        return (['query'=>$queryWhere,'param'=>$param]);
+    }
+
+    public function getBookIdByTags($tags){
+        $searchBookId = [];
+        if(empty($tags)){
+            return $searchBookId;
+        }
+
+        //查询搜索范围
+        $tagIds = Tag::whereIn('name',$tags)->select('id')->get();
+        $paliTextIds = TagMap::where('table_name','pali_texts')->whereIn('tag_id',$tagIds)->select('anchor_id')->get();
+        $paliPara=[];
+        foreach ($paliTextIds as $key => $value) {
+            # code...
+            if(isset($paliPara[$value->anchor_id])){
+                $paliPara[$value->anchor_id]++;
+            }else{
+                $paliPara[$value->anchor_id]=1;
+            }
+        }
+        $paliId=[];
+        foreach ($paliPara as $key => $value) {
+            # code...
+            if($value===count($tags)){
+                $paliId[] = $key;
+            }
+        }
+        $para = PaliText::where('level',1)->whereIn('uid',$paliId)->get();
+
+        if(count($para)>0){
+            foreach ($para as $key => $value) {
+                # code...
+                $book_id = BookTitle::where('book',$value['book'])
+                                    ->where('paragraph',$value['paragraph'])
+                                    ->value('sn');
+                if(!empty($book_id)){
+                    $searchBookId[] = $book_id;
+                }
+            }
+        }
+        return $searchBookId;
+
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 80 - 0
api-v12/app/Http/Controllers/SearchPageNumberController.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\PageNumber;
+use App\Models\WbwTemplate;
+use Illuminate\Http\Request;
+
+class SearchPageNumberController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\PageNumber  $pageNumber
+     * @return \Illuminate\Http\Response
+     */
+    public function show(string $number)
+    {
+        $pages = PageNumber::where('page',$number)
+                        ->select(['type','volume','page','book','paragraph','pcd_book_id'])
+                        ->get();
+        $para = WbwTemplate::where('real','para'.$number)->select(['book','paragraph','pcd_book_id'])->get();
+        foreach ($para as $key => $value) {
+            # code...
+            $pages[] = [
+                'type'=>'para',
+                'volume'=>0,
+                'page'=>$number,
+                'book'=>$value->book,
+                'paragraph'=>$value->paragraph,
+                'pcd_book_id'=>$value->pcd_book_id,
+            ];
+        }
+        return $this->ok($pages);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\PageNumber  $pageNumber
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, PageNumber $pageNumber)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\PageNumber  $pageNumber
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(PageNumber $pageNumber)
+    {
+        //
+    }
+}

+ 148 - 0
api-v12/app/Http/Controllers/SearchPaliDataController.php

@@ -0,0 +1,148 @@
+<?php
+/**
+ * 输出巴利语全文搜索数据
+ * 提供给搜索引擎
+ */
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\BookTitle;
+use App\Models\WbwTemplate;
+
+class SearchPaliDataController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $book = $request->get('book');
+
+        $maxParagraph = WbwTemplate::where('book',$book)->max('paragraph');
+        $pageSize = $request->get('page_size',1000);
+        $start = $request->get('start',1);
+        $output = array();
+        if($start+$pageSize>$maxParagraph){
+            $endOfPara = $maxParagraph+1;
+        }else{
+            $endOfPara = $start+$pageSize;
+        }
+
+        for($iPara=$start; $iPara < $endOfPara; $iPara++){
+            $content = $this->getContent($book,$iPara);
+            //查找黑体字
+            $words = WbwTemplate::where('book',$book)
+                                ->where('paragraph',$iPara)
+                                ->orderBy('wid')->get();
+            $bold1 = array();
+            $bold2 = array();
+            $bold3 = array();
+            $currBold = array();
+            foreach ($words as $word) {
+                if($word->style==='bld'){
+                    $currBold[] = $word->real;
+                }else{
+                    $countBold = count($currBold);
+                    if($countBold === 1){
+                        $bold1[] = $currBold[0];
+                    }else if($countBold === 2){
+                        $bold2 = array_merge($bold2,$currBold);
+                    }else if($countBold > 0){
+                        $bold3 = array_merge($bold3,$currBold);
+                    }
+                    $currBold = [];
+                }
+            }
+            $pcd_book = BookTitle::where('book',$book)
+                    ->where('paragraph','<=',$iPara)
+                    ->orderBy('paragraph','desc')
+                    ->first();
+            if($pcd_book){
+                $pcd_book_id = $pcd_book->sn;
+            }else{
+                $pcd_book_id = BookTitle::where('book',$book)
+                                        ->orderBy('paragraph')
+                                        ->value('sn');
+            }
+
+            $update = ['book'=>$book,
+                        'paragraph' => $iPara,
+                        'bold1' => implode(' ',$bold1),
+                        'bold2' => implode(' ',$bold2),
+                        'bold3' => implode(' ',$bold3),
+                        'content' => $content,
+                        'pcd_book_id' => $pcd_book_id
+                    ];
+            $output[] = $update;
+        }
+        return $this->ok(['rows'=>$output,'count'=>$maxParagraph]);
+    }
+    private function getContent($book,$para){
+        $words = WbwTemplate::where('book',$book)
+                            ->where('paragraph',$para)
+                            ->where('type',"<>",".ctl.")
+                            ->orderBy('wid')->get();
+        $content = '';
+        foreach ($words as  $word) {
+            if($word->style === 'bld'){
+                if(strpos($word->word,"{")===FALSE){
+                    $content .= "**{$word->word}** ";
+                }else{
+                    $content .= str_replace(['{','}'],['**','** '],$word->word);
+                }
+            }else if($word->style === 'note'){
+                $content .= " _{$word->word}_ ";
+            }else{
+                $content .= $word->word . " ";
+            }
+        }
+        return $content;
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 141 - 0
api-v12/app/Http/Controllers/SearchPaliWbwController.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace App\Http\Controllers;
+
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Models\WbwTemplate;
+use App\Http\Resources\SearchPaliWbwResource;
+use App\Http\Resources\SearchBookResource;
+
+class SearchPaliWbwController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //获取书的范围
+        $bookId = [];
+        $search = new SearchController;
+        if($request->has('book')){
+            foreach (explode(',',$request->get('book')) as $key => $id) {
+                $bookId[] = (int)$id;
+            }
+        }else if($request->has('tags')){
+            //查询搜索范围
+            //查询搜索范围
+            $tagItems = explode(';',$request->get('tags'));
+
+            foreach ($tagItems as $tagItem) {
+                $bookId = array_merge($bookId,$search->getBookIdByTags(explode(',',$tagItem)));
+            }
+        }
+
+        $keyWords = explode(',',$request->get('key'));
+        $table = WbwTemplate::whereIn('real',$keyWords)
+                            ->groupBy(['book','paragraph'])
+                            ->selectRaw('book,paragraph,sum(weight) as rank');
+        $whereBold = '';
+        if($request->get('bold')==='on'){
+            $table = $table->where('style','bld');
+            $whereBold = " and style='bld'";
+        }else if($request->get('bold')==='off'){
+            $table = $table->where('style','<>','bld');
+            $whereBold = " and style <> 'bld'";
+        }
+        $placeholderWord = implode(",",array_fill(0, count($keyWords), '?')) ;
+        $whereWord = "real in ({$placeholderWord})";
+        $whereBookId = '';
+        if(count($bookId)>0){
+            $table =  $table->whereIn('pcd_book_id',$bookId);
+            $placeholderBookId = implode(",",array_fill(0, count($bookId), '?')) ;
+            $whereBookId = " and pcd_book_id in ({$placeholderBookId}) ";
+        }
+        $queryCount = "SELECT count(*) FROM ( SELECT book,paragraph FROM wbw_templates WHERE $whereWord $whereBookId $whereBold  GROUP BY book,paragraph) T;";
+        $count = DB::select($queryCount,array_merge($keyWords,$bookId));
+
+        $table =  $table->orderBy('rank','desc');
+        $table =  $table->skip($request->get("offset",0))
+                        ->take($request->get('limit',10));
+
+        $result = $table->get();
+        return $this->ok([
+            "rows"=>SearchPaliWbwResource::collection($result),
+            "count"=>$count[0]->count,
+            ]);
+    }
+
+    public function book_list(Request $request){
+        //获取书的范围
+        $bookId = [];
+        $search = new SearchController;
+        if($request->has('tags')){
+            //查询搜索范围
+            //查询搜索范围
+            $tagItems = explode(';',$request->get('tags'));
+
+            foreach ($tagItems as $tagItem) {
+                $bookId = array_merge($bookId,$search->getBookIdByTags(explode(',',$tagItem)));
+            }
+        }
+        $keyWords = explode(',',$request->get('key'));
+        $table = WbwTemplate::whereIn('real',$keyWords);
+
+        if(count($bookId)>0){
+            $table = $table->whereIn('pcd_book_id',$bookId);
+        }
+        $table = $table->groupBy('pcd_book_id')
+                       ->selectRaw('pcd_book_id,count(*) as co')
+                       ->orderBy('co','desc');
+        $result = $table->get();
+        return $this->ok(["rows"=>SearchBookResource::collection($result),"count"=>count($result)]);
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        return $this->index($request);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\WbwTemplate  $wbwTemplate
+     * @return \Illuminate\Http\Response
+     */
+    public function show(WbwTemplate $wbwTemplate)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\WbwTemplate  $wbwTemplate
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, WbwTemplate $wbwTemplate)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\WbwTemplate  $wbwTemplate
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(WbwTemplate $wbwTemplate)
+    {
+        //
+    }
+}

+ 154 - 0
api-v12/app/Http/Controllers/SearchPlusController.php

@@ -0,0 +1,154 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Services\OpenSearchService;
+use Illuminate\Http\Request;
+
+class SearchPlusController extends Controller
+{
+    protected $searchService;
+
+    /**
+     * 构造函数,注入 OpenSearchService
+     *
+     * @param  \App\Services\OpenSearchService  $searchService
+     */
+    public function __construct(OpenSearchService $searchService)
+    {
+        $this->searchService = $searchService;
+    }
+
+    /**
+     * Display a listing of the resource.
+     *
+     * 处理搜索请求,支持 fuzzy / exact / semantic / hybrid 四种模式。
+     * 接收查询参数并调用 OpenSearchService 执行搜索。
+     *
+     * 支持 GET 和 POST 请求
+     *
+     * @param  \Illuminate\Http\Request  $request
+     *   - q (string): 搜索关键词
+     *   - resource_type (string): 资源类型 (article|term|dictionary|translation|origin_text|nissaya)
+     *   - granularity (string): 文档颗粒度 (book|chapter|sutta|section|paragraph|sentence)
+     *   - language (string): 语言,如 pali, zh-Hans, zh-Hant, en-US, my
+     *   - category (string): 文档分类 (pali|commentary|subcommentary)
+     *   - tags (array): 标签过滤
+     *   - page_refs (array): 页码标记 ["V3.81","M3.58"]
+     *   - related_id (array): 关联 ID,如 ["chapter_93-5","m.n. 38"]
+     *   - author (string): 作者或译者 (metadata.author)
+     *   - channel (string): 来源渠道 (metadata.channel)
+     *   - page (int): 页码,默认 1
+     *   - page_size (int): 每页数量,默认 20,最大 100
+     *   - search_mode (string): fuzzy|exact|semantic|hybrid,默认 fuzzy
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function index(Request $request)
+    {
+        // 获取所有输入参数(自动兼容 GET 和 POST)
+        $input = $request->all();
+
+        // 基础参数 - 使用 $input 或直接用 $request->input() (已兼容 GET/POST)
+        $query        = $request->input('q', '');
+        $page         = max(1, (int) $request->input('page', 1));
+        $pageSize     = min(100, (int) $request->input('page_size', 20));
+        $searchMode   = $request->input('search_mode', 'fuzzy');
+        $resourceType = $request->input('resource_type'); // 资源类型
+        $resourceId = $request->input('resource_id'); // 资源类型
+        $granularity  = $request->input('granularity');   // 文档颗粒度
+        $language     = $request->input('language');      // 语言
+        $category     = $request->input('category');      // 分类
+        $tags         = $request->input('tags', []);      // 标签
+        $pageRefs     = $request->input('page_refs', []); // 页码标记
+        $relatedId    = $request->input('related_id'); // 关联 ID
+        $author       = $request->input('author');        // 作者/译者 (metadata.author)
+        $channel      = $request->input('channel');       // 来源渠道 (metadata.channel)
+
+        // 确保数组类型参数正确解析(POST 时可能是 JSON 字符串)
+        $tags = is_array($tags) ? $tags : (is_string($tags) ? json_decode($tags, true) ?? [] : []);
+        $pageRefs = is_array($pageRefs) ? $pageRefs : (is_string($pageRefs) ? json_decode($pageRefs, true) ?? [] : []);
+
+        // 组装搜索参数
+        $params = [
+            'query'        => $query,
+            'page'         => $page,
+            'pageSize'     => $pageSize,
+            'searchMode'   => $searchMode,
+            'resourceType' => $resourceType,
+            'resourceId'   => $resourceId,
+            'granularity'  => $granularity,
+            'language'     => $language,
+            'category'     => $category,
+            'tags'         => $tags,
+            'pageRefs'     => $pageRefs,
+            'relatedId'    => $relatedId,
+            'author'       => $author,
+            'channel'      => $channel,
+        ];
+
+        try {
+            // 调用 OpenSearchService 执行搜索
+            $result = $this->searchService->search($params);
+
+            return response()->json([
+                'success' => true,
+                'data'    => $result,
+                'query_info' => [
+                    'original_query' => $query,
+                    'search_mode'    => $searchMode,
+                    'request_method' => $request->method(), // 可选:返回请求方法
+                ],
+            ]);
+        } catch (\Exception $e) {
+            return response()->json([
+                'success' => false,
+                'error'   => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * POST 方式调用搜索接口(与 index 方法功能相同)
+     *
+     * @route POST /api/search
+     * @param JSON: 搜索参数(与 index 方法参数相同)
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function store(Request $request)
+    {
+        // 直接调用 index 方法,复用搜索逻辑
+        return $this->index($request);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * 更新资源
+     * @route PUT /api/search/{uid}
+     * @param JSON: OpenSearch 格式数据
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $uid) {}
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * 删除资源
+     * @route DELETE /api/search/{uid}
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($uid) {}
+}

+ 194 - 0
api-v12/app/Http/Controllers/SearchSuggestController.php

@@ -0,0 +1,194 @@
+<?php
+// api-v8/app/Http/Controllers/SearchSuggestController.php
+namespace App\Http\Controllers;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+use App\Services\OpenSearchService;
+
+/**
+ * 搜索自动建议控制器
+ *
+ * 返回示例:
+ *
+ * 请求:GET /api/v2/suggest?q=dhamma&fields=title,content&limit=10
+ *
+ * 返回:
+ * {
+ *   "success": true,
+ *   "data": {
+ *     "query": "dhamma",
+ *     "suggestions": [
+ *       {
+ *         "text": "dhammapada",
+ *         "source": "title",
+ *         "score": 5.2,
+ *         "resource_type": "sutta",
+ *         "language": "pali",
+ *         "doc_id": "doc_123"
+ *       },
+ *       {
+ *         "text": "dhammapadā",
+ *         "source": "content",
+ *         "score": 4.8,
+ *         "resource_type": "commentary",
+ *         "language": "pali",
+ *         "doc_id": "doc_456"
+ *       }
+ *     ],
+ *     "total": 2
+ *   }
+ * }
+ */
+class SearchSuggestController extends Controller
+{
+    protected $searchService;
+
+    /**
+     * 构造函数,注入 OpenSearchService
+     *
+     * @param  \App\Services\OpenSearchService  $searchService
+     */
+    public function __construct(OpenSearchService $searchService)
+    {
+        $this->searchService = $searchService;
+    }
+
+    /**
+     * # 1. 查询所有字段(默认)
+GET /api/v2/suggest?q=dhamma&limit=10
+
+# 2. 只查询标题
+GET /api/v2/suggest?q=dhamma&fields=title&limit=10
+
+# 3. 查询标题和内容
+GET /api/v2/suggest?q=dhamma&fields=title,content&limit=10
+
+# 4. 查询页面引用,带语言过滤
+GET /api/v2/suggest?q=M.1&fields=page_refs&language=pali&limit=5
+
+# 5. 数组形式传递多个字段
+GET /api/v2/suggest?q=dhamma&fields[]=title&fields[]=content&limit=10
+     */
+    /**
+     * 自动建议接口
+     *
+     * 基于 OpenSearch completion suggester,支持从不同字段获取建议。
+     *
+     * @param  \Illuminate\Http\Request  $request
+     *   - q (string): 输入的部分文本(必填)
+     *   - fields (string|array): 要查询的字段,可选值:
+     *       - 不传:查询所有字段 (title, content, page_refs)
+     *       - 单个字段:'title' | 'content' | 'page_refs'
+     *       - 多个字段:'title,content' 或 ['title', 'content']
+     *   - language (string): 语言过滤,可选(如:pali, zh, en)
+     *   - limit (int): 每个字段返回的建议数量,默认 10,最大 50
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function index(Request $request)
+    {
+        // 验证必填参数
+        $query = $request->input('q', '');
+        if (empty($query)) {
+            return response()->json([
+                'success' => false,
+                'error'   => '缺少参数 q(查询文本)'
+            ], 400);
+        }
+
+        // 解析 fields 参数
+        $fields = $this->parseFields($request->input('fields'));
+
+        // 获取其他参数
+        $language = $request->input('language', null);
+        $limit = min(50, max(1, (int) $request->input('limit', 10)));
+
+        try {
+            // 调用搜索服务
+            $rawSuggestions = $this->searchService->suggest(
+                $query,
+                $fields,
+                $language,
+                $limit
+            );
+
+            // 格式化返回结果
+            $suggestions = $this->formatSuggestions($rawSuggestions);
+
+            return response()->json([
+                'success' => true,
+                'data'    => [
+                    'query'       => $query,
+                    'suggestions' => $suggestions,
+                    'total'       => count($suggestions)
+                ]
+            ]);
+        } catch (\InvalidArgumentException $e) {
+            return response()->json([
+                'success' => false,
+                'error'   => '无效的字段参数:' . $e->getMessage(),
+                'hint'    => '有效的字段值:title, content, page_refs'
+            ], 400);
+        } catch (\Exception $e) {
+            return response()->json([
+                'success' => false,
+                'error'   => '搜索建议失败:' . $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * 解析 fields 参数
+     *
+     * @param  mixed  $fields
+     * @return string|array|null
+     */
+    protected function parseFields($fields)
+    {
+        if ($fields === null) {
+            return null;  // 查询所有字段
+        }
+
+        if (is_string($fields)) {
+            // 如果是逗号分隔的字符串,转换为数组
+            if (strpos($fields, ',') !== false) {
+                $fieldsArray = array_map('trim', explode(',', $fields));
+                return $fieldsArray;
+            }
+            // 单个字段
+            return $fields;
+        }
+
+        if (is_array($fields)) {
+            return $fields;
+        }
+
+        return null;
+    }
+
+    /**
+     * 格式化建议结果
+     *
+     * @param  array  $rawSuggestions
+     * @return array
+     */
+    protected function formatSuggestions(array $rawSuggestions): array
+    {
+        return collect($rawSuggestions)->map(function ($item) {
+            $docSource = $item['doc_source'] ?? [];
+
+            return [
+                'text'          => $item['text'] ?? '',
+                'source'        => $item['source'] ?? null,
+                'score'         => round($item['score'] ?? 0, 2),
+                'resource_type' => $docSource['resource_type'] ?? null,
+                'language'      => $docSource['language'] ?? null,
+                'doc_id'        => $item['doc_id'] ?? null,
+                // 可选:添加更多元数据
+                'category'      => $docSource['category'] ?? null,
+                'granularity'   => $docSource['granularity'] ?? null,
+            ];
+        })->all();
+    }
+}

+ 78 - 0
api-v12/app/Http/Controllers/SearchTitleController.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\PaliText;
+use Illuminate\Http\Request;
+use App\Http\Resources\SearchTitleIndexResource;
+
+class SearchTitleController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $key = strtolower($request->get('key'));
+        $table = PaliText::where('level','<',8)
+                         ->where(function ($query) use($key){
+                            $query->where('title_en','like',"%{$key}%")
+                                  ->orWhere('title','like',"%{$key}%");
+                        });
+        $count = $table->count();
+        $table = $table->orderBy('title_en');
+        $table = $table->skip($request->get("offset",0))
+                         ->take($request->get('limit',10));
+
+        $result = $table->get();
+        return $this->ok(["rows"=>SearchTitleIndexResource::collection($result),"count"=>$count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function show(PaliText $paliText)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, PaliText $paliText)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(PaliText $paliText)
+    {
+        //
+    }
+}

+ 71 - 0
api-v12/app/Http/Controllers/SearchWordSliceController.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Models\WordIndex;
+use App\Tools\CaseMan;
+use Illuminate\Support\Facades\Log;
+
+class SearchWordSliceController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $slice
+     * @return \Illuminate\Http\Response
+     */
+    public function show($slice)
+    {
+        //
+        $words = WordIndex::where('word', 'like', str_replace('-', '%', $slice))
+            ->orderBy('len')
+            ->select(['word', 'count', 'len'])->get();
+
+        return $this->ok(['rows' => $words, 'count' => count($words)]);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 112 - 0
api-v12/app/Http/Controllers/SentHistoryController.php

@@ -0,0 +1,112 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\SentHistory;
+use Illuminate\Http\Request;
+use App\Http\Resources\SentHistoryResource;
+use App\Http\Api\UserApi;
+
+class SentHistoryController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->view) {
+            case 'sentence':
+                $table = SentHistory::where('sent_uid',$request->get('id'));
+                break;
+            default:
+                return $this->error('known view');
+                break;
+        }
+        if($request->has('fork')){
+            $table = $table->whereNotNull('fork_from');
+        }
+        $count = $table->count();
+        $table->orderBy($request->get('order','created_at'),
+                        $request->get('dir','desc'));
+        $table->skip($request->get("offset",0))
+              ->take($request->get('limit',100));
+
+        $result = $table->get();
+		return $this->ok(["rows"=>SentHistoryResource::collection($result),"count"=>$count]);
+
+    }
+
+    public function contribution(Request $request){
+                /**
+         *  计算用户贡献度
+         *  算法:统计句子历史记录里的用户贡献句子的数量
+         *  TODO:
+         *  应该祛除重复的句子,一个句子的多次修改只计算一次
+         *  只统计一个月内的数值
+         */
+        $result = SentHistory::select('user_uid')
+                            ->selectRaw('count(*)')
+                            ->groupBy('user_uid')
+                            ->orderBy('count','desc')
+                            ->take(10)
+                            ->get();
+
+
+        foreach ($result as $key => $user) {
+            $userInfo = UserApi::getByUuid($user->user_uid);
+            $user->username = [
+                'nickname'=>$userInfo['nickName'],
+                'username'=>$userInfo['userName'],
+            ];
+        }
+        return $this->ok($result);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\SentHistory  $sentHistory
+     * @return \Illuminate\Http\Response
+     */
+    public function show(SentHistory $sentHistory)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\SentHistory  $sentHistory
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, SentHistory $sentHistory)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\SentHistory  $sentHistory
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(SentHistory $sentHistory)
+    {
+        //
+    }
+}

+ 96 - 0
api-v12/app/Http/Controllers/SentInChannelController.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Sentence;
+use Illuminate\Http\Request;
+use App\Http\Api\ChannelApi;
+use Illuminate\Support\Str;
+
+class SentInChannelController extends Controller
+{
+    /**
+     * 用channel 和句子编号列表查询句子
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $sent = $request->get('sentences') ;
+        $query = [];
+        foreach ($sent as $value) {
+            # code...
+            $ids = explode('-',$value);
+            if(count($ids)===4){
+                $query[] = $ids;
+            }
+        }
+        $channelsQuery = array();
+        $channelsInput = $request->get('channels');
+        foreach ($channelsInput as $value) {
+            if(Str::isUuid($value)){
+                $channelsQuery[] = $value;
+            }else{
+                $channelId = ChannelApi::getSysChannel($value);
+                if($channelId){
+                    $channelsQuery[] = $channelId;
+                }
+            }
+        }
+
+        $table = Sentence::select(['id','book_id','paragraph',
+                                   'word_start','word_end','content','content_type',
+                                   'editor_uid','channel_uid','updated_at'])
+                        ->whereIn('channel_uid', $channelsQuery)
+                        ->whereIns(['book_id','paragraph','word_start','word_end'],$query);
+        $result = $table->get();
+        return $this->ok(["rows"=>$result,"count"=>count($result)]);
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Sentence $sentence)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Sentence $sentence)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Sentence $sentence)
+    {
+        //
+    }
+}

+ 272 - 0
api-v12/app/Http/Controllers/SentPrController.php

@@ -0,0 +1,272 @@
+<?php
+
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Http\Request;
+
+use App\Models\SentPr;
+use App\Models\Channel;
+use App\Models\PaliSentence;
+use App\Models\Sentence;
+use App\Models\Notification;
+use App\Http\Resources\SentPrResource;
+use App\Http\Api\Mq;
+use App\Http\Api\AuthApi;
+
+class SentPrController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'sent-info':
+                $table = SentPr::where('book_id', $request->get('book'))
+                    ->where('paragraph', $request->get('para'))
+                    ->where('word_start', $request->get('start'))
+                    ->where('word_end', $request->get('end'))
+                    ->where('channel_uid', $request->get('channel'));
+                $all_count = $table->count();
+                $result = $table->orderBy('created_at', 'desc')->get();
+
+                break;
+        }
+        if ($result) {
+            //修改notification 已读状态
+            $user = AuthApi::current($request);
+            if ($user) {
+                $id = array();
+                foreach ($result as $key => $row) {
+                    $id[] = $row->uid;
+                }
+                Notification::whereIn('res_id', $id)
+                    ->where('to', $user['user_uid'])
+                    ->update(['status' => 'read']);
+            }
+            return $this->ok([
+                "rows" => SentPrResource::collection($result),
+                "count" => $all_count
+            ]);
+        } else {
+            return $this->error("no data");
+        }
+    }
+
+    public function pr_tree(Request $request)
+    {
+        $output = [];
+        $sentences = $request->get("data");
+        foreach ($sentences as $key => $sentence) {
+            # 先查句子信息
+            $sentInfo = Sentence::where('book_id', $sentence['book'])
+                ->where('paragraph', $sentence['paragraph'])
+                ->where('word_start', $sentence['word_start'])
+                ->where('word_end', $sentence['word_end'])
+                ->where('channel_uid', $sentence['channel_id'])
+                ->first();
+            $sentPr = SentPr::where('book_id', $sentence['book'])
+                ->where('paragraph', $sentence['paragraph'])
+                ->where('word_start', $sentence['word_start'])
+                ->where('word_end', $sentence['word_end'])
+                ->where('channel_uid', $sentence['channel_id'])
+                ->select('content', 'editor_uid')
+                ->orderBy('created_at', 'desc')->get();
+            if (count($sentPr) > 0) {
+                if ($sentInfo) {
+                    $content = $sentInfo->content;
+                } else {
+                    $content = "null";
+                }
+                $output[] = [
+                    'sentence' => [
+                        'book' => $sentence['book'],
+                        'paragraph' => $sentence['paragraph'],
+                        'word_start' => $sentence['word_start'],
+                        'word_end' => $sentence['word_end'],
+                        'channel_id' => $sentence['channel_id'],
+                        'content' => $content,
+                        'pr_count' => count($sentPr),
+                    ],
+                    'pr' => $sentPr,
+                ];
+            }
+        }
+        return $this->ok(['rows' => $output, 'count' => count($output)]);
+    }
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $user_uid = $user['user_uid'];
+
+        $data = $request->all();
+
+
+        #查询是否存在
+        #同样的内容只能提交一次
+        $exists = SentPr::where('book_id', $data['book'])
+            ->where('paragraph', $data['para'])
+            ->where('word_start', $data['begin'])
+            ->where('word_end', $data['end'])
+            ->where('content', $data['text'])
+            ->where('channel_uid', $data['channel'])
+            ->exists();
+        if ($exists) {
+            return $this->error("已经存在同样的修改建议", 200, 200);
+        }
+
+        #不存在,新建
+        $new = new SentPr();
+        $new->id = app('snowflake')->id();
+        $new->uid = Str::uuid();
+        $new->book_id = $data['book'];
+        $new->paragraph = $data['para'];
+        $new->word_start = $data['begin'];
+        $new->word_end = $data['end'];
+        $new->channel_uid = $data['channel'];
+        $new->editor_uid = $user_uid;
+        $new->content = $data['text'];
+        $new->language = Channel::where('uid', $data['channel'])->value('lang');
+        $new->status = 1; //未处理状态
+        $new->strlen = mb_strlen($data['text'], "UTF-8");
+        $new->create_time = time() * 1000;
+        $new->modify_time = time() * 1000;
+        $new->save();
+
+        $suggestionData =  [
+            'data' => new SentPrResource($new),
+            'token' => AuthApi::getToken($request),
+            'notification' => $request->get('notification', true),
+            'webhook' => $request->get('webhook', true),
+        ];
+        Mq::publish(
+            'suggestion',
+            $suggestionData
+        );
+
+        $robotMessageOk = true;
+        $webHookMessage = "";
+
+        #同时返回此句子pr数量
+        $info['book_id'] = $data['book'];
+        $info['paragraph'] = $data['para'];
+        $info['word_start'] = $data['begin'];
+        $info['word_end'] = $data['end'];
+        $info['channel_uid'] = $data['channel'];
+        $count = SentPr::where('book_id', $data['book'])
+            ->where('paragraph', $data['para'])
+            ->where('word_start', $data['begin'])
+            ->where('word_end', $data['end'])
+            ->where('channel_uid', $data['channel'])
+            ->count();
+
+        return $this->ok(["new" => $info, "count" => $count, "webhook" => ["message" => $webHookMessage, "ok" => $robotMessageOk]]);
+    }
+
+    /**
+     * Display the specified resource.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $uid
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, string $uid)
+    {
+        //
+
+        $pr = SentPr::where('uid', $uid)->first();
+        if (!$pr) {
+            return $this->error('no data', 404, 404);
+        }
+        //修改notification 已读状态
+        $user = AuthApi::current($request);
+        if ($user) {
+            Notification::where('res_id', $uid)
+                ->where('to', $user['user_uid'])
+                ->update(['status' => 'read']);
+        }
+
+        return $this->ok(new SentPrResource($pr));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\SentPr  $sentPr
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, string $id)
+    {
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+
+        $sentPr = SentPr::find($id);
+        if (!$sentPr) {
+            return $this->error('no res');
+        }
+        if ($sentPr->editor_uid !== $user['user_uid']) {
+            return $this->error('not power', 403, 403);
+        }
+        $sentPr->content = $request->get('text');
+        $sentPr->modify_time = time() * 1000;
+        $sentPr->save();
+        return $this->ok($sentPr);
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  string $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, string $id)
+    {
+        //
+        $user = AuthApi::current($request);
+        if (!$user) {
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        $old = SentPr::where('id', $id)->first();
+        if (!$old) {
+            return $this->error('no res');
+        }
+        //鉴权
+        if ($old->editor_uid !== $user["user_uid"]) {
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $result = SentPr::where('id', $id)
+            ->where('editor_uid', $user["user_uid"])
+            ->delete();
+        if ($result > 0) {
+            #同时返回此句子pr数量
+            $count = SentPr::where('book_id', $old->book_id)
+                ->where('paragraph', $old->paragraph)
+                ->where('word_start', $old->word_start)
+                ->where('word_end', $old->word_end)
+                ->where('channel_uid', $old->channel_uid)
+                ->count();
+            return $this->ok($count);
+        } else {
+            return $this->error('not power', 403, 403);
+        }
+    }
+}

+ 111 - 0
api-v12/app/Http/Controllers/SentSimController.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\SentSim;
+use App\Models\PaliSentence;
+use Illuminate\Http\Request;
+use App\Http\Resources\SentSimResource;
+
+class SentSimController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'sentence':
+                $sentId = PaliSentence::where('book',$request->get('book'))
+                                ->where('paragraph',$request->get('paragraph'))
+                                ->where('word_begin',$request->get('start'))
+                                ->where('word_end',$request->get('end'))
+                                ->value('id');
+                if(!$sentId){
+                    return $this->error("no sent");
+                }
+                $table = SentSim::where('sent1',$sentId)
+                                ->orderBy('sim','desc');
+                break;
+        }
+        $table->where('sim','>=',$request->get('sim',0));
+        $count = $table->count();
+        $table->skip($request->get("offset",0))
+              ->take($request->get('limit',20));
+        $result = $table->get();
+        if($result){
+            return $this->ok(["rows"=>SentSimResource::collection($result),"count"=>$count]);
+        }else{
+            return $this->error("no data");
+        }
+    }
+
+    /**
+     * Show the form for creating a new resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function create()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\SentSim  $sentSim
+     * @return \Illuminate\Http\Response
+     */
+    public function show(SentSim $sentSim)
+    {
+        //
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     *
+     * @param  \App\Models\SentSim  $sentSim
+     * @return \Illuminate\Http\Response
+     */
+    public function edit(SentSim $sentSim)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\SentSim  $sentSim
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, SentSim $sentSim)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\SentSim  $sentSim
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(SentSim $sentSim)
+    {
+        //
+    }
+}

+ 85 - 0
api-v12/app/Http/Controllers/SentenceAttachmentController.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Http\Requests\StoreSentenceAttachmentRequest;
+use App\Http\Requests\UpdateSentenceAttachmentRequest;
+use Illuminate\Http\Request;
+use App\Models\SentenceAttachment;
+use App\Http\Resources\SentenceAttachmentResource;
+
+class SentenceAttachmentController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        switch ($request->view) {
+            case 'sentence':
+                $table = SentenceAttachment::where('sentence_id', $request->get('id'));
+                break;
+            default:
+                return $this->error('known view');
+                break;
+        }
+
+        $table->orderBy($request->get('order', 'updated_at'), $request->get('dir', 'desc'));
+        $count = $table->count();
+        $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+        return $this->ok([
+            "rows" => SentenceAttachmentResource::collection($result),
+            "count" => $count
+        ]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \App\Http\Requests\StoreSentenceAttachmentRequest  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\SentenceAttachment  $sentenceAttachment
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $sentenceAttachment)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \App\Http\Requests\UpdateSentenceAttachmentRequest  $request
+     * @param  \App\Models\SentenceAttachment  $sentenceAttachment
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, SentenceAttachment $sentenceAttachment)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\SentenceAttachment  $sentenceAttachment
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(SentenceAttachment $sentenceAttachment)
+    {
+        //
+    }
+}

+ 630 - 0
api-v12/app/Http/Controllers/SentenceController.php

@@ -0,0 +1,630 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Sentence;
+use App\Models\Channel;
+use App\Models\SentHistory;
+use App\Models\WbwAnalysis;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Redis;
+
+use App\Http\Resources\SentResource;
+use App\Http\Api\AuthApi;
+use App\Http\Api\ShareApi;
+use App\Http\Api\ChannelApi;
+use App\Http\Api\PaliTextApi;
+use App\Http\Api\Mq;
+use App\Models\AccessToken;
+use App\Tools\RedisClusters;
+use App\Tools\OpsLog;
+
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;
+
+class SentenceController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        $user = AuthApi::current($request);
+        $result = false;
+        $indexCol = [
+            'id',
+            'uid',
+            'book_id',
+            'paragraph',
+            'word_start',
+            'word_end',
+            'content',
+            'content_type',
+            'channel_uid',
+            'editor_uid',
+            'fork_at',
+            'acceptor_uid',
+            'pr_edit_at',
+            'updated_at'
+        ];
+
+        switch ($request->get('view')) {
+            case 'public':
+                //获取全部公开的译文
+                //首先获取某个类型的 channel 列表
+                $channels = [];
+                $channel_type = $request->get('channel_type', 'translation');
+                if ($channel_type === "original") {
+                    $pali_channel = ChannelApi::getSysChannel("_System_Pali_VRI_");
+                    if ($pali_channel !== false) {
+                        $channels[] = $pali_channel;
+                    }
+                } else {
+                    $channelList = Channel::where('type', $channel_type)
+                        ->where('status', 30)
+                        ->select('uid')->get();
+                    foreach ($channelList as $channel) {
+                        # code...
+                        $channels[] = $channel->uid;
+                    }
+                }
+                $table = Sentence::select($indexCol)
+                    ->whereIn('channel_uid', $channels)
+                    ->where('updated_at', '>', $request->get('updated_after', '1970-1-1'));
+                break;
+            case 'fulltext':
+                if (isset($_COOKIE['user_uid'])) {
+                    $userUid = $_COOKIE['user_uid'];
+                }
+                $key = $request->get('key');
+                if (empty($key)) {
+                    return $this->error("没有关键词");
+                }
+                $table = Sentence::select($indexCol)
+                    ->where('content', 'like', '%' . $key . '%')
+                    ->where('editor_uid', $userUid);
+
+                break;
+            case 'channel':
+                //句子编号列表在某个channel下的全部内容
+                $sent = explode(',', $request->get('sentence'));
+                $query = [];
+                foreach ($sent as $value) {
+                    # code...
+                    $ids = explode('-', $value);
+                    $query[] = $ids;
+                }
+                $table = Sentence::select($indexCol)
+                    ->where('channel_uid', $request->get('channel'))
+                    ->whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query);
+                break;
+            case 'sent-can-read':
+                /**
+                 * 某句的全部译文
+                 */
+                //获取用户有阅读权限的所有channel
+                //全网公开
+                $type = $request->get('type', 'translation');
+                $channelTable = Channel::where("type", $type)->select(['uid', 'name']);
+                $channelPub = $channelTable->where('status', 30)->get();
+
+                $user = AuthApi::current($request);
+                $channelShare = array();
+                $channelMy = array();
+                if ($user) {
+                    //自己的
+                    $channelMy = Channel::where('owner_uid', $user['user_uid'])
+                        ->where('type', $type)
+                        ->get();
+                    //协作
+                    $channelShare = ShareApi::getResList($user['user_uid'], 2);
+                }
+                $channelCanRead = [];
+                foreach ($channelPub as $key => $value) {
+                    $channelCanRead[$value->uid] = [
+                        'id' => $value->uid,
+                        'role' => 'member',
+                        'name' => $value->name,
+                    ];
+                }
+                foreach ($channelShare as $key => $value) {
+                    if ($value['type'] === $type) {
+                        $channelCanRead[$value['res_id']] = [
+                            'id' => $value['res_id'],
+                            'role' => 'member',
+                            'name' => $value['res_title'],
+                        ];
+                        if ($value['power'] >= 20) {
+                            $channelCanRead[$value['res_id']]['role'] = "editor";
+                        }
+                    }
+                }
+                foreach ($channelMy as $key => $value) {
+                    $channelCanRead[$value->uid] = [
+                        'id' => $value->uid,
+                        'role' => 'owner',
+                        'name' => $value->name,
+                    ];
+                }
+                $channels = [];
+                $excludeChannels = explode(',', $request->get('excludes'));
+
+                foreach ($channelCanRead as $key => $value) {
+                    # code...
+                    if (!in_array($key, $excludeChannels)) {
+                        $channels[] = $key;
+                    }
+                }
+                $sent = explode('-', $request->get('sentence'));
+                $table = Sentence::select($indexCol)
+                    ->whereIn('channel_uid', $channels)
+                    ->where('ver', '>', 1)
+                    ->where('book_id', $sent[0])
+                    ->where('paragraph', $sent[1])
+                    ->where('word_start', $sent[2])
+                    ->where('word_end', $sent[3]);
+                break;
+            case 'chapter':
+                $chapter =  PaliTextApi::getChapterStartEnd($request->get('book'), $request->get('para'));
+                $table = Sentence::where('ver', '>', 1)
+                    ->where('book_id', $request->get('book'))
+                    ->whereBetween('paragraph', $chapter)
+                    ->whereIn('channel_uid', explode(',', $request->get('channels')));
+                break;
+            case 'paragraph':
+                $table = Sentence::where('ver', '>', 1)
+                    ->where('book_id', $request->get('book'))
+                    ->whereIn('paragraph', explode(',', $request->get('para')))
+                    ->whereIn('channel_uid', explode(',', $request->get('channels')))
+                    ->orderBy('book_id')->orderBy('paragraph')->orderBy('word_start');
+                break;
+            case 'my-edit':
+                //我编辑的
+                if (!$user) {
+                    return $this->error(__('auth.failed'), 401, 401);
+                }
+                $table = Sentence::where('editor_uid', $user['user_uid'])
+                    ->where('ver', '>', 1);
+                break;
+            default:
+                # code...
+                break;
+        }
+        if (!empty($request->get("key"))) {
+            $table = $table->where('content', 'like', '%' . $request->get("key") . '%');
+        }
+
+        $count = $table->count();
+        if ($request->get('strlen', false)) {
+            $totalStrLen = $table->sum('strlen');
+        }
+        if ($request->get('view') !== 'paragraph') {
+            $table = $table->orderBy(
+                $request->get('order', 'updated_at'),
+                $request->get('dir', 'desc')
+            );
+        }
+
+        $table = $table->skip($request->get("offset", 0))
+            ->take($request->get('limit', 1000));
+        $result = $table->get();
+
+        if ($result) {
+            $output = ["count" => $count];
+            if (
+                $request->get('view') === 'sent-can-read' ||
+                $request->get('view') === 'channel' ||
+                $request->get('view') === 'chapter' ||
+                $request->get('view') === 'paragraph' ||
+                $request->get('view') === 'my-edit'
+            ) {
+                $output["rows"] = SentResource::collection($result);
+            } else {
+                $output["rows"] = $result;
+            }
+            if (isset($totalStrLen)) {
+                $output['total_strlen'] = $totalStrLen;
+            }
+            return $this->ok($output);
+        } else {
+            return $this->error("没有查询到数据");
+        }
+    }
+    /**
+     * 用channel 和句子编号列表查询句子
+     */
+    public function sent_in_channel(Request $request)
+    {
+        $sent = $request->get('sentences');
+        $query = [];
+        foreach ($sent as $value) {
+            # code...
+            $ids = explode('-', $value);
+            if (count($ids) === 4) {
+                $query[] = $ids;
+            }
+        }
+        $table = Sentence::select(['id', 'book_id', 'paragraph', 'word_start', 'word_end', 'content', 'channel_uid', 'updated_at'])
+            ->where('channel_uid', $request->get('channel'))
+            ->whereIns(['book_id', 'paragraph', 'word_start', 'word_end'], $query);
+        $result = $table->get();
+        if ($result) {
+            return $this->ok(["rows" => $result, "count" => count($result)]);
+        } else {
+            return $this->error("没有查询到数据");
+        }
+    }
+
+    private function UserCanEdit($userId, $channelId, $book, $access_token = null)
+    {
+        $channel = Channel::where('uid', $channelId)->first();
+        if (!$channel) {
+            return false;
+        }
+        if ($channel->owner_uid !== $userId) {
+            //判断是否为协作
+            $power = ShareApi::getResPower($userId, $channel->uid, 2);
+            if ($power < 20) {
+                //判断token
+                if (!$access_token) {
+                    Log::error('no access token');
+                    return false;
+                }
+                $key = AccessToken::where('res_id', $channelId)->value('token');
+                $jwt = JWT::decode($access_token, new Key($key, 'HS512'));
+                Log::debug('access token', ['jwt' => $jwt]);
+                if ($jwt->book !== $book) {
+                    Log::error('access token error');
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+    /**
+     * 新建多个句子
+     * 如果句子存在,修改
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //鉴权
+        $user = AuthApi::current($request);
+        if (!$user) {
+            //未登录用户
+            return $this->error(__('auth.failed'), 401, 401);
+        }
+        if (!$request->has('sentences')) {
+            return $this->error('no date', 200, 200);
+        }
+        $destChannel = null;
+        if ($request->has('channel')) {
+            if ($this->UserCanEdit(
+                $user["user_uid"],
+                $request->get('channel'),
+                $request->get('book', 0),
+                $request->get('access_token', null)
+            )) {
+                $destChannel = Channel::where('uid', $request->get('channel'))->first();;
+            } else {
+                return $this->error(__('auth.failed'), 403, 403);
+            }
+        }
+        $sentFirst = null;
+        $changedSent = [];
+        foreach ($request->get('sentences') as $key => $sent) {
+            # 权限
+            if (!$request->has('channel')) {
+
+                if ($this->UserCanEdit(
+                    $user["user_uid"],
+                    $sent['channel_uid'],
+                    $sent['book_id'],
+                    isset($sent['access_token']) ? $sent['access_token'] : null
+                )) {
+                    $destChannel = Channel::where('uid', $sent['channel_uid'])->first();
+                } else {
+                    continue;
+                }
+            }
+            /*
+            $destChannel = Channel::where('uid', $sent['channel_uid'])->first();
+            if (!$destChannel) {
+                continue;
+            }
+            if ($destChannel->owner_uid !== $user["user_uid"]) {
+                //判断是否为协作
+                $power = ShareApi::getResPower($user["user_uid"], $destChannel->uid, 2);
+                if ($power < 20) {
+                    //判断token
+                    if (!isset($sent['access_token'])) {
+                        Log::error('no access token');
+                        continue;
+                    }
+                    $key = AccessToken::where('res_id', $destChannel->uid)->value('token');
+                    $jwt = JWT::decode($sent['access_token'], new Key($key, 'HS512'));
+                    Log::debug('access token', ['jwt' => $jwt]);
+                    if ($jwt->book !== $sent['book_id']) {
+                        Log::error('access token error');
+                        continue;
+                    }
+                }
+            }
+*/
+            if ($sentFirst === null) {
+                $sentFirst = $sent;
+            }
+            $row = Sentence::firstOrNew([
+                "book_id" => $sent['book_id'],
+                "paragraph" => $sent['paragraph'],
+                "word_start" => $sent['word_start'],
+                "word_end" => $sent['word_end'],
+                "channel_uid" => $destChannel->uid,
+            ], [
+                "id" => app('snowflake')->id(),
+                "uid" => Str::uuid(),
+            ]);
+            $row->content = $sent['content'];
+            if (isset($sent['content_type']) && !empty($sent['content_type'])) {
+                $row->content_type = $sent['content_type'];
+            }
+            $row->strlen = mb_strlen($sent['content'], "UTF-8");
+            $row->language = $destChannel->lang;
+            $row->status = $destChannel->status;
+            if ($request->has('copy')) {
+                //复制句子,保留原作者信息
+                $row->editor_uid = $sent["editor_uid"];
+                $row->acceptor_uid = $user["user_uid"];
+                $row->pr_edit_at = $sent["updated_at"];
+                if ($request->has('fork_from')) {
+                    $row->fork_at = now();
+                }
+            } else {
+                $row->editor_uid = $user["user_uid"];
+                $row->acceptor_uid = null;
+                $row->pr_edit_at = null;
+            }
+            $row->create_time = time() * 1000;
+            $row->modify_time = time() * 1000;
+            $row->save();
+
+            $changedSent[] = $row->uid;
+
+            //保存历史记录
+            if ($request->has('copy')) {
+                $fork_from = $request->get('fork_from', null);
+                $this->saveHistory(
+                    $row->uid,
+                    $sent["editor_uid"],
+                    $sent['content'],
+                    $user["user_uid"],
+                    $fork_from
+                );
+            } else {
+                $this->saveHistory($row->uid, $user["user_uid"], $sent['content'], $user["user_uid"]);
+            }
+            //清除缓存
+            $sentId = "{$sent['book_id']}-{$sent['paragraph']}-{$sent['word_start']}-{$sent['word_end']}";
+            $hKey = "/sentence/res-count/{$sentId}/";
+            Redis::del($hKey);
+        }
+        if ($sentFirst !== null) {
+            Mq::publish('progress', [
+                'book' => $sentFirst['book_id'],
+                'para' => $sentFirst['paragraph'],
+                'channel' => $destChannel->uid,
+            ]);
+        }
+
+        $result = Sentence::whereIn('uid', $changedSent)->get();
+        return $this->ok([
+            'rows' => SentResource::collection($result),
+            'count' => count($result)
+        ]);
+    }
+
+    private function saveHistory($uid, $editor, $content, $user_uid = null, $fork_from = null, $pr_from = null)
+    {
+        $newHis = new SentHistory();
+        $newHis->id = app('snowflake')->id();
+        $newHis->sent_uid = $uid;
+        $newHis->user_uid = $editor;
+        if (empty($content)) {
+            $newHis->content = "";
+        } else {
+            $newHis->content = $content;
+        }
+        if ($fork_from) {
+            $newHis->fork_from = $fork_from;
+            $newHis->accepter_uid = $user_uid;
+        }
+        if ($pr_from) {
+            $newHis->pr_from = $pr_from;
+            $newHis->accepter_uid = $user_uid;
+        }
+        $newHis->create_time = time() * 1000;
+        $newHis->save();
+    }
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Sentence $sentence)
+    {
+        //
+        return $this->ok(new SentResource($sentence));
+    }
+
+
+    /**
+     * 修改单个句子
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $id book_para_start_end_channel
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request,  $id)
+    {
+        //
+        $param = \explode('_', $id);
+
+        //鉴权
+        $user = AuthApi::current($request);
+        if (!$user) {
+            //未登录鉴权失败
+            return $this->error(__('auth.failed'), 403, 403);
+        }
+        $channel = Channel::where('uid', $param[4])->first();
+        if (!$channel) {
+            return $this->error("not found channel");
+        }
+        if ($channel->owner_uid !== $user["user_uid"]) {
+            // 判断是否为协作
+            $power = ShareApi::getResPower($user["user_uid"], $channel->uid, 2);
+            if ($power < 20) {
+                return $this->error(__('auth.failed'), 403, 403);
+            }
+        }
+
+        $sent = Sentence::firstOrNew([
+            "book_id" => $param[0],
+            "paragraph" => $param[1],
+            "word_start" => $param[2],
+            "word_end" => $param[3],
+            "channel_uid" => $param[4],
+        ], [
+            "id" => app('snowflake')->id(),
+            "uid" => Str::orderedUuid(),
+            "create_time" => time() * 1000,
+        ]);
+        $sent->content = $request->get('content');
+        if ($request->has('contentType')) {
+            $sent->content_type = $request->get('contentType');
+        }
+        $sent->language = $channel->lang;
+        $sent->status = $channel->status;
+        $sent->strlen = mb_strlen($request->get('content'), "UTF-8");
+        $sent->modify_time = time() * 1000;
+        if ($request->has('prEditor')) {
+            $realEditor = $request->get('prEditor');
+            $sent->acceptor_uid = $user["user_uid"];
+            $sent->pr_edit_at = $request->get('prEditAt');
+            $sent->pr_id = $request->get('prId');
+        } else {
+            $realEditor = $user["user_uid"];
+            $sent->acceptor_uid = null;
+            $sent->pr_edit_at = null;
+            $sent->pr_id = null;
+        }
+        $sent->editor_uid = $realEditor;
+        $sent->save();
+        $sent = $sent->refresh();
+        //清除缓存
+        $sentId = "{$sent['book_id']}-{$sent['paragraph']}-{$sent['word_start']}-{$sent['word_end']}";
+        $hKey = "/sentence/res-count/{$sentId}/";
+        Redis::del($hKey);
+        OpsLog::debug($user["user_uid"], $sent);
+
+        //清除cache
+        $channelId = $param[4];
+        $currSentId = "{$param[0]}-{$param[1]}-{$param[2]}-{$param[3]}";
+        RedisClusters::forget("/sent/{$channelId}/{$currSentId}");
+        //保存历史记录
+        if ($request->has('prEditor')) {
+            $this->saveHistory(
+                $sent->uid,
+                $realEditor,
+                $request->get('content'),
+                $user["user_uid"],
+                null,
+                $request->get('prUuid'),
+            );
+        } else {
+            $this->saveHistory($sent->uid, $realEditor, $request->get('content'));
+        }
+
+        Mq::publish('progress', [
+            'book' => $param[0],
+            'para' => $param[1],
+            'channel' => $channelId,
+        ]);
+        Mq::publish('content', new SentResource($sent));
+
+        if ($channel->type === 'nissaya' && $sent->content_type === 'json') {
+            $this->updateWbwAnalyses($sent->content, $channel->lang, $user["user_id"]);
+        }
+
+        return $this->ok(new SentResource($sent));
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Sentence $sentence)
+    {
+        //
+    }
+
+    private function updateWbwAnalyses($data, $lang, $editorId)
+    {
+        $wbwData = json_decode($data);
+        $currWbwId = 0;
+        $prefix = 'wbw-preference';
+        foreach ($wbwData as $key => $word) {
+            # code...
+            if (count($word->sn) === 1) {
+                $currWbwId = $word->uid;
+                WbwAnalysis::where('wbw_id', $word->uid)->delete();
+            }
+            $newData = [
+                'wbw_id' => $currWbwId,
+                'wbw_word' => $word->real->value,
+                'book_id' => $word->book,
+                'paragraph' => $word->para,
+                'wid' => $word->sn[0],
+                'type' => 0,
+                'data' => '',
+                'confidence' => 100,
+                'lang' => $lang,
+                'editor_id' => $editorId,
+                'created_at' => now(),
+                'updated_at' => now()
+            ];
+            $newData['type'] = 3;
+            if (!empty($word->meaning->value)) {
+                $newData['data'] = $word->meaning->value;
+                WbwAnalysis::insert($newData);
+                RedisClusters::put("{$prefix}/{$word->real->value}/3/{$editorId}", $word->meaning->value);
+                RedisClusters::put("{$prefix}/{$word->real->value}/3/0", $word->meaning->value);
+            }
+            if (isset($word->factors) && isset($word->factorMeaning)) {
+                $factors = explode('+', str_replace('-', '+', $word->factors->value));
+                $factorMeaning = explode('+', str_replace('-', '+', $word->factorMeaning->value));
+                foreach ($factors as $key => $factor) {
+                    if (isset($factorMeaning[$key])) {
+                        if (!empty($factorMeaning[$key])) {
+                            $newData['wbw_word'] = $factor;
+                            $newData['data'] = $factorMeaning[$key];
+                            $newData['type'] = 5;
+                            WbwAnalysis::insert($newData);
+                            RedisClusters::put("{$prefix}/{$factor}/5/{$editorId}", $factorMeaning[$key]);
+                            RedisClusters::put("{$prefix}/{$factor}/5/0", $factorMeaning[$key]);
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 88 - 0
api-v12/app/Http/Controllers/SentenceIOController.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Sentence;
+use App\Models\Channel;
+use Illuminate\Http\Request;
+
+class SentenceIOController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $table = Sentence::select(['uid','book_id','paragraph',
+                                    'word_start','word_end',
+                                    'content','content_type',
+                                    'channel_uid','editor_uid','language',
+                                    'updated_at','created_at']);
+        switch ($request->get('view')) {
+            case 'public':
+                $channels = Channel::where('status',30)
+                                ->where('type',$request->get('type','translation'))
+                                ->select('uid')->get();
+                $table->whereIn('channel_uid',$channels)
+                      ->where('updated_at','>',$request->get('updated_at','2000-1-1'));
+            break;
+        }
+        $count = $table->count();
+        //处理排序
+        $table->orderBy('updated_at','asc');
+        //处理分页
+        $table->skip($request->get("offset",0))
+              ->take($request->get("limit",200));
+        //获取数据
+        $result = $table->get();
+        return $this->ok(["rows"=>$result,"count"=>$count]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Sentence $sentence)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Sentence $sentence)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Sentence $sentence)
+    {
+        //
+    }
+}

+ 394 - 0
api-v12/app/Http/Controllers/SentenceInfoController.php

@@ -0,0 +1,394 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Sentence;
+use App\Models\PaliSentence;
+use App\Models\PaliText;
+
+use Illuminate\Http\Request;
+use App\Http\Resources\SentResource;
+use App\Tools\RedisClusters;
+
+class SentenceInfoController extends Controller
+{
+    protected $_endParagraph;
+    protected $_startParagraph;
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request) {}
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    private function getSentProgress(Request $request, $date = '')
+    {
+        $channel = $request->get('channel');
+        $from = $request->get('from');
+        if ($request->has('to')) {
+            $to = $request->get('to');
+        } else {
+            $to = $this->_endParagraph;
+        }
+
+
+        #默认完成度显示字符数
+        # strlen
+        # palistrlen 巴利语等效字符数
+        # page
+        # percent
+        $view = 'strlen';
+        if ($request->has('view')) {
+            $view = $request->get('view');
+        } else if ($request->has('type')) {
+            $view = $request->get('type');
+        }
+
+
+        #一页书中的字符数
+        $pageStrLen = 2000;
+        if ($request->has('strlen')) {
+            $pageStrLen = $request->get('strlen');
+        }
+        if ($request->has('pagelen')) {
+            $pageStrLen = $request->get('pagelen');
+        }
+
+        # 页数
+        $pageNumber = 300;
+        if ($request->has('pages')) {
+            $pageNumber = $request->get('pages');
+        }
+
+        $db = Sentence::where('sentences.channel_uid', $request->get('channel'))
+            ->where('sentences.book_id', '>=', $request->get('book'))
+            ->where('sentences.paragraph', '>=', $request->get('from'))
+            ->where('sentences.paragraph', '<=', $to);
+        if ($view === "palistrlen") {
+            $db = $db->leftJoin('pali_texts', function ($join) {
+                $join->on('sentences.book_id', '=', 'pali_texts.book');
+                $join->on('sentences.paragraph', '=', 'pali_texts.paragraph');
+            });
+        }
+        if (!empty($date)) {
+            $db = $db->whereDate('sentences.created_at', '=', $date);
+        }
+        if ($view === "palistrlen") {
+            return $db->sum('pali_texts.lenght');
+        }
+        $strlen = $db->sum('sentences.strlen');
+
+        if (is_null($strlen) || $strlen === 0) {
+            return 0;
+        }
+        #计算已完成百分比
+        $percent = 0;
+        if (($view === 'page' && !empty($request->get('pages'))) || $view === 'percent') {
+            #计算完成的句子在巴利语句子表中的字符串长度百分比
+            $db = Sentence::select(['book_id', 'paragraph', 'word_start', 'word_end'])
+                ->where('channel_uid', $request->get('channel'))
+                ->where('book_id', '>=', $request->get('book'))
+                ->where('paragraph', '>=', $request->get('from'))
+                ->where('paragraph', '<=', $to);
+            if (!empty($date)) {
+                $db = $db->whereDate('created_at', '=', $date);
+            }
+            $sentFinished = $db->get();
+            #查询这些句子的总共等效巴利语字符数
+            $allStrLen = PaliSentence::where('book', $request->get('book'))
+                ->where('paragraph', '>=', $request->get('from'))
+                ->where('paragraph', '<=', $to)
+                ->sum('length');
+            $para_strlen = 0;
+
+            foreach ($sentFinished as $sent) {
+                # code...
+                $key_sent_id = $sent->book_id . '-' . $sent->paragraph . '-' . $sent->word_start . '-' . $sent->word_end;
+                $para_strlen += RedisClusters::remember(
+                    'pali-sent/strlen/' . $key_sent_id,
+                    config('mint.cache.expire'),
+                    function () use ($sent) {
+                        return PaliSentence::where('book', $sent->book_id)
+                            ->where('paragraph', $sent->paragraph)
+                            ->where('word_begin', $sent->word_start)
+                            ->where('word_end', $sent->word_end)
+                            ->value('length');
+                    }
+                );
+            }
+
+            $percent = $para_strlen / $allStrLen;
+        }
+        switch ($view) {
+            case 'page':
+                # 输出已经完成的页数
+                if (!empty($request->get('pages'))) {
+                    #给了页码,用百分比计算
+                    $resulte = $percent * $request->get('pages');
+                } else {
+                    #没给页码,用每页字符数计算
+                    $resulte = $strlen / $pageStrLen;
+                }
+                break;
+            case 'percent': //百分比
+                $resulte = sprintf('%.2f', $percent);
+                break;
+            case 'strlen':
+            default:
+                # code...
+                $resulte = $strlen;
+                break;
+        }
+        #保留小数点后两位
+        $resulte = sprintf('%.2f', $resulte);
+        return $resulte;
+    }
+    /**
+     * 输出一张图片显示进度
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     * http://127.0.0.1:8000/api/sentence/progress/image?channel=00ae2c48-c204-4082-ae79-79ba2740d506&&book=168&from=916&to=926&view=page&pages=400
+     */
+    public function showprogress(Request $request)
+    {
+        $resulte = $this->getSentProgress($request);
+        $svg = "<svg  xmlns='http://www.w3.org/2000/svg'  xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 100 25'>";
+
+        switch ($request->get('view')) {
+            case 'percent':
+                # code...
+                $resulte = $resulte * 100;
+                $svg .= "<rect id='frontground' x='0' y='0' width='100' height='25' fill='#cccccc' ></rect>";
+                $svg .= "<text id='bg_text'  x='5' y='21' fill='#006600' style='font-size:25px;'>$resulte%</text>";
+                $svg .= "<rect id='background' x='0' y='0' width='100' height='25' fill='#006600' clip-path='url(#textClipPath)'></rect>";
+                $svg .= "<text id='bg_text'  x='5' y='21' fill='#ffffff' style='font-size:25px;' clip-path='url(#textClipPath)'>$resulte%</text>";
+                $svg .= "<clipPath id='textClipPath'>";
+                $svg .= "    <rect x='0' y='0' width='$resulte' height='25'></rect>";
+                $svg .= "</clipPath>";
+                break;
+            case 'strlen':
+            case 'page':
+            default:
+                $svg .= "<text id='bg_text'  x='5' y='21' fill='#006600' style='font-size:25px;'>$resulte</text>";
+                break;
+        }
+        $svg .= "</svg>";
+
+        return response($svg, 200, [
+            'Content-Type' => 'image/svg+xml'
+        ]);
+    }
+
+    //http://127.0.0.1:8000/api/sentence/progress/daily/image?channel=00ae2c48-c204-4082-ae79-79ba2740d506&&book=168&from=916&to=926&view=page
+    public function showprogressdaily(Request $request)
+    {
+        $imgWidth = 300;
+        $imgHeight = 100;
+        $xAxisOffset = 16;
+        $yAxisOffset = 25;
+        $maxDay = 20;
+        $maxPage = 20;
+        $yLineSpace = 5;
+
+        $yMin = 20; //y轴满刻度数值 最小
+
+        #默认完成度显示字符数
+        # strlen
+        # page
+        # percent
+        $view = 'strlen';
+        if ($request->has('view')) {
+            $view = $request->get('view');
+        }
+        if ($request->has('type')) {
+            $view = $request->get('type');
+        }
+
+
+
+        $pagePix = ($imgHeight - $xAxisOffset) / $maxPage;
+        $dayPix = ($imgWidth - $yAxisOffset) / $maxDay;
+
+        ob_clean();
+        ob_start();
+        $channel = $request->get('channel');
+        $from = $request->get('from');
+        if ($request->has('to')) {
+            $to = $request->get('to');
+        } else {
+            $chapterLen = PaliText::where('book', $request->get('book'))->where('paragraph', $from)->value('chapter_len');
+            $to =  $from + $chapterLen - 1;
+            $this->_endParagraph = $to;
+        }
+
+        $img = imagecreate($imgWidth, $imgHeight) or die('create image fail ');
+
+        #颜色定义
+        //background color
+        imagecolorallocate($img, 255, 255, 255);
+        $color = imagecolorallocate($img, 0, 0, 0);
+        $gray = imagecolorallocate($img, 180, 180, 180);
+        $dataLineColor = imagecolorallocate($img, 50, 50, 255);
+
+
+
+        $max = 0;
+        $values = [];
+        #按天获取数据
+        for ($i = 1; $i <= $maxDay; $i++) {
+            $day = strtotime("today -{$i} day");
+            $date = date("Y-m-d", $day);
+            $current = $this->getSentProgress($request, $date);
+            $values[] = $current;
+            if ($max < $current) {
+                $max = $current;
+            }
+        }
+        /*
+        * 计算Y 轴满刻度值
+        * 算法 不足 20 按 20 算 小于100 满刻度是是50的整倍数
+        * 小于1000 满刻度是是500的整倍数
+        */
+
+        if ($max < $yMin) {
+            $yMax = $yMin;
+        } else {
+            $len = strlen($max);
+            $yMax = pow(10, $len);
+            if ($max < $yMax / 2) {
+                $yMax = $yMax / 2;
+            }
+        }
+        //根据满刻度像素数 计算缩放比例
+        $yPix = $imgHeight - $xAxisOffset; //y轴实际像素数
+        $rate = $yPix / $yMax;
+
+        $svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"currentColor\" class=\"bi bi-alarm-fill\" viewBox=\"0 0 $imgWidth $imgHeight\">";
+
+        //绘制坐标轴
+        imageline($img, 0, $imgHeight - $xAxisOffset, $imgWidth, $imgHeight - $xAxisOffset, $color);
+        imageline($img, $yAxisOffset, $imgHeight, $yAxisOffset, 0, $color);
+        // x 轴
+        $y = $imgHeight - $xAxisOffset + 1;
+        $svg .= "<line x1='$yAxisOffset'  y1='$y' x2='$imgWidth'   y2='$y' style='stroke:#666666;'></line>";
+        // y 轴
+        $x = $yAxisOffset - 1;
+        $svg .= "<line x1='$x'  y1='0' x2='$x'   y2='" . ($imgHeight - $xAxisOffset) . "' style='stroke:#666666;'></line>";
+        //绘制x轴刻度线
+        for ($i = 0; $i < $maxDay; $i++) {
+            $space = ($imgWidth - $yAxisOffset) / $maxDay;
+            $x = $imgWidth - $i * $space - $space / 2;
+            $dayOffset = $maxDay - $i;
+            $date = strtotime("today -{$i} day");
+            $day = date("d", $date);
+            imageline($img, $x, ($imgHeight - $xAxisOffset), $x, ($imgHeight - $xAxisOffset + 5), $gray);
+            imagestring($img, 5, $x, ($imgHeight - $xAxisOffset - 2), $day, $color);
+
+            $y = $imgHeight - $xAxisOffset + 1;
+            $height = 5;
+            $svg .= "<line x1='$x'  y1='$y' x2='$x'   y2='" . ($y + $height) . "' style='stroke:#666666;'></line>";
+            $svg .= "<text x='" . ($x - 5) . "' y='" . ($y + 12) . "' style='font-size:8px;'>$day</text>";
+        }
+
+
+        //绘制y轴刻度线 将y轴五等分
+        $step = $yMax / 5 * $rate;
+        for ($i = 1; $i < 5; $i++) {
+            # code...
+            $yValue = $yMax / 5 * $i;
+            if ($yValue >= 1000000) {
+                $yValue = ($yValue / 1000000) . 'm';
+            } else if ($yValue >= 1000) {
+                $yValue = ($yValue / 1000) . 'k';
+            }
+            $x = $yAxisOffset;
+            $y = $imgHeight - $yAxisOffset - $i * $step;
+            $svg .= "<line x1='$x'  y1='$y' x2='" . ($x - 5) . "'   y2='$y' style='stroke:#666666;'></line>";
+            $svg .= "<text x='" . ($x - 18) . "' y='" . ($y + 4) . "' style='font-size:8px;'>$yValue</text>";
+        }
+        for ($i = 1; $i < $maxPage / $yLineSpace; $i++) {
+            $space = ($imgHeight - $xAxisOffset) / $maxPage * $yLineSpace;
+            $y = $imgHeight - $yAxisOffset - $i * $space;
+            imageline($img, $yAxisOffset, $y, $imgWidth, $y, $gray);
+            imagestring($img, 5, 0, $y - 5, $i * $yLineSpace, $color);
+        }
+        // 绘制柱状图
+        $rectWidth = $dayPix * 0.9;
+        $last = 0;
+        foreach ($values as $key => $value) {
+            # code...
+            $value = $value * $rate;
+            $x = $imgWidth - ($dayPix * $key + $yAxisOffset);
+            $y = $imgHeight - $xAxisOffset - $value;
+            $svg .= "<rect x='$x' y='$y' height='{$value}' width='{$rectWidth}' style='stroke:#006600; fill: #006600'/>";
+            if ($key > 0) {
+                imageline($img, ($imgWidth - $key * $dayPix), $imgHeight - $xAxisOffset - $value, ($imgWidth - ($key - 1) * $dayPix), $imgHeight - $xAxisOffset - $last, $dataLineColor);
+            }
+            $last = $value;
+        }
+
+        $svg .= "</svg>";
+
+        imagegif($img);
+        imagedestroy($img);
+
+        $content = ob_get_clean();
+        return response($svg, 200, [
+            'Content-Type' => 'image/svg+xml'
+        ]);
+    }
+
+    /**
+     * Display the specified resource.
+     * @param  \Illuminate\Http\Request  $request
+     * @param  string  $sentenceId
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request, string $sentenceId)
+    {
+        //
+        $sentence = Sentence::where('book_id', $request->get('book'))
+            ->where('paragraph', $request->get('par'))
+            ->where('word_start', $request->get('start'))
+            ->where('word_end', $request->get('end'))
+            ->where('channel_uid', $request->get('channel'))
+            ->firstOrFail();
+        return $this->ok(new SentResource($sentence));
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Sentence $sentence)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\Sentence  $sentence
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Sentence $sentence)
+    {
+        //
+    }
+}

+ 118 - 0
api-v12/app/Http/Controllers/SentencesInChapterController.php

@@ -0,0 +1,118 @@
+<?php
+/**
+ * 输出某章节的句子列表。算法跟随章节显示功能
+ */
+namespace App\Http\Controllers;
+
+use App\Models\PaliText;
+use App\Models\PaliSentence;
+use Illuminate\Http\Request;
+
+class SentencesInChapterController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $book = $request->get('book');
+        $para = $request->get('para');
+        $chapter = PaliText::where('book',$book)
+                           ->where('paragraph',$para)
+                           ->first();
+        if(!$chapter){
+            return $this->error("no chapter data");
+        }
+        $paraFrom = $para;
+        $paraTo = $para+$chapter->chapter_len-1;
+
+        //1. 计算 标题和下一级第一个标题之间 是否有间隔
+        $nextChapter =  PaliText::where('book',$book)
+                        ->where('paragraph',">",$para)
+                        ->where('level','<',8)
+                        ->orderBy('paragraph')
+                        ->value('paragraph');
+        $between = $nextChapter - $para;
+        //查找子目录
+        $chapterLen = $chapter->chapter_len;
+        $toc = PaliText::where('book',$book)
+                ->whereBetween('paragraph',[$paraFrom+1,$paraFrom+$chapterLen-1])
+                ->where('level','<',8)
+                ->orderBy('paragraph')
+                ->select(['book','paragraph','level','toc'])
+                ->get();
+
+        if($between > 1){
+        //有间隔
+            $paraTo = $nextChapter - 1;
+        }else{
+            if($chapter->chapter_strlen>2000){
+                if(count($toc)>0){
+                    //有子目录只输出标题和目录
+                    $paraTo = $paraFrom;
+                }else{
+                    //没有子目录 全部输出
+                }
+            }else{
+                //章节小。全部输出 不输出子目录
+                $toc = [];
+            }
+        }
+
+        $sent = PaliSentence::where('book',$book)
+                            ->whereBetween('paragraph',[$paraFrom,$paraTo])
+                            ->select(['book','paragraph','word_begin','word_end'])
+                            ->orderBy('paragraph')
+                            ->orderBy('word_begin')
+                            ->get();
+        return $this->ok(['rows'=>$sent,'count'=>count($sent)]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function show(PaliText $paliText)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, PaliText $paliText)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\PaliText  $paliText
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(PaliText $paliText)
+    {
+        //
+    }
+}

+ 191 - 0
api-v12/app/Http/Controllers/ShareController.php

@@ -0,0 +1,191 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Share;
+use App\Models\GroupInfo;
+use Illuminate\Http\Request;
+use App\Http\Resources\ShareResource;
+use App\Http\Api\AuthApi;
+use App\Http\Api\ShareApi;
+use Illuminate\Support\Str;
+
+class ShareController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $user = AuthApi::current($request);
+        $result = false;
+        $role = "member";
+        $indexCol = ['id', 'res_id', 'res_type', 'power', 'updated_at', 'created_at'];
+        switch ($request->get('view')) {
+            case 'res':
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                $table = Share::where('res_id', $request->get('id'));
+                $power = ShareApi::getResPower($user['user_uid'], $request->get('id'), $table->value('res_type'));
+                switch ($power) {
+                    case 10:
+                        $role = "member";
+                        break;
+                    case 20:
+                        $role = "editor";
+                        break;
+                    case 30:
+                        $role = "owner";
+                        break;
+                }
+                break;
+            case 'group':
+                if (!$user) {
+                    return $this->error(__('auth.failed'));
+                }
+                //TODO 判断当前用户是否有指定的 group 的权限
+                if (GroupInfo::where('uid', $request->get('id'))->where('owner', $user['user_uid'])->exists()) {
+                    $role = "owner";
+                }
+                $table = Share::where('cooperator_id', $request->get('id'));
+                break;
+        }
+        if (isset($_GET["search"])) {
+            //TODO 搜索资源标题
+            $table = $table->where('title', 'like', $_GET["search"] . "%");
+        }
+        $count = $table->count();
+        if (isset($_GET["order"]) && isset($_GET["dir"])) {
+            $table = $table->orderBy($_GET["order"], $_GET["dir"]);
+        } else {
+            $table = $table->orderBy('updated_at', 'desc');
+        }
+
+        $table->skip($request->get('offset', 0))
+            ->take($request->get('limit', 1000));
+
+        $result = $table->get();
+        //TODO 获取当前用户的身份
+
+
+        if ($result) {
+            return $this->ok(["rows" => ShareResource::collection($result), "count" => $count, 'role' => $role]);
+        } else {
+            return $this->error("没有查询到数据");
+        }
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+        foreach ($request->get('user_id') as $key => $value) {
+            if (!Str::isUuid($value)) {
+                continue;
+            }
+            $row = Share::where('cooperator_id', $value)
+                ->where('res_id', $request->get('res_id'))->first();
+            if (!$row) {
+                $row = new Share();
+                $row->id = app('snowflake')->id();
+                $row->cooperator_id = $value;
+                $row->res_id = $request->get('res_id');
+                $row->res_type = $request->get('res_type');
+                $row->create_time = time() * 1000;
+            }
+            $c_type = ['user' => 0, 'group' => 1];
+            $row->cooperator_type = $c_type[$request->get('user_type')];
+            switch ($request->get('role')) {
+                case 'manager':
+                case 'editor':
+                    $row->power = 20;
+                    break;
+                case 'reader':
+                    $row->power = 10;
+                    break;
+            }
+            $row->modify_time = time() * 1000;
+            $row->save();
+        }
+        return $this->ok(count($request->get('user_id')));
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  \App\Models\Share  $share
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Share $share)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Share  $share
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, Share $share)
+    {
+        //查询权限
+        $currUser = AuthApi::current($request);
+        if (!$currUser) {
+            return $this->error(__('auth.failed'));
+        }
+
+        $power = ShareApi::getResPower($currUser['user_uid'], $share->res_id, $share->res_type);
+        if (!$power || $power <= 20) {
+            //普通成员没有删除权限
+            return $this->error(__('auth.failed'));
+        }
+        switch ($request->get('role')) {
+            case 'manager':
+            case 'editor':
+                $share->power = 20;
+                break;
+            case 'reader':
+                $share->power = 10;
+                break;
+        }
+        $share->modify_time = time() * 1000;
+        $share->save();
+        return $this->ok($share);
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\Share  $share
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(Request $request, Share $share)
+    {
+        //查询权限
+        $currUser = AuthApi::current($request);
+        if (!$currUser) {
+            return $this->error(__('auth.failed'));
+        }
+
+        $power = ShareApi::getResPower($currUser['user_uid'], $share->res_id, $share->res_type);
+        if (!$power || $power <= 20) {
+            //普通成员没有删除权限
+            return $this->error(__('auth.failed'));
+        }
+
+        $delete = $share->delete();
+        return $this->ok($delete);
+    }
+}

+ 121 - 0
api-v12/app/Http/Controllers/SignUpController.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\UserInfo;
+use App\Models\Invite;
+use App\Models\Channel;
+use Illuminate\Support\Str;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Log;
+
+class SignUpController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //先查询invite核对uuid
+        if(!Invite::where('id',$request->get('token'))
+                  ->where('email',$request->get('email'))->exists()){
+            $this->error('error token','',200);
+        }
+        if(UserInfo::where('username',$request->get('name'))->exists()){
+            $this->error('avoid user name','',200);
+        }
+
+        try {
+            DB::transaction(function() use($request){
+                $user = new UserInfo;
+                $user->userid = Str::Uuid();
+                $user->username = $request->get('username');
+                $user->nickname = $request->get('nickname');
+                $user->email = $request->get('email');
+                $user->password = md5($request->get('password'));
+                $user->role = json_encode(['basic']);
+                $user->create_time = time()*1000;
+                $user->modify_time = time()*1000;
+                $user->save();
+
+                //标记invite
+                Invite::where('id',$request->get('token'))
+                        ->where('email',$request->get('email'))
+                        ->update(['status'=>'sign-up']);
+                //建立channel
+
+                $channel_draft = new Channel;
+                $channel_draft->id = app('snowflake')->id();
+                $channel_draft->name = 'draft';
+                $channel_draft->owner_uid = $user->userid;
+                $channel_draft->type = "translation";
+                $channel_draft->lang = $request->get('lang');
+                $channel_draft->status = 5;
+                $channel_draft->editor_id = $user->id;
+                $channel_draft->create_time = time()*1000;
+                $channel_draft->modify_time = time()*1000;
+                $channel_draft->save();
+            });
+        }catch(\Exception $e) {
+            Log::error('user create fail',['data'=>$e]);
+            return $this->error('user create fail',500,500);
+        }
+        return $this->ok('ok');
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string $username
+     * @return \Illuminate\Http\Response
+     */
+    public function show(Request $request,string $username)
+    {
+        //
+        $email = UserInfo::where('email',$request->get('email'))->exists();
+        $user = UserInfo::where('username',$username)->exists();
+        if($email && $user){
+            //send email
+            return $this->ok('ok');
+        }else{
+            return $this->error(['email'=>$email,'username'=>$user],[200],200);
+        }
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  \App\Models\UserInfo  $userInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, UserInfo $userInfo)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  \App\Models\UserInfo  $userInfo
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy(UserInfo $userInfo)
+    {
+        //
+    }
+}

+ 96 - 0
api-v12/app/Http/Controllers/SiteInfoController.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\App;
+use App\Tools\RedisClusters;
+use App\Services\AIModelService;
+
+
+class SiteInfoController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index()
+    {
+        //
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  string  $language
+     * @return \Illuminate\Http\Response
+     */
+    public function show($language)
+    {
+        if (!in_array($language, ['en', 'zh-Hans', 'zh-Hant'])) {
+            App::setLocale('en');
+        } else {
+            App::setLocale($language);
+        }
+        $model = app(AIModelService::class);
+        $response = [
+            'logo' => __("site.logo"),
+            'title' => __('site.title'),
+            'subhead' => __('site.subhead'),
+            'keywords' => __('site.keywords'),
+            'description' => __('site.description'),
+            'copyright' => __('site.copyright'),
+            'author' => [
+                'name' => __('site.author.name'),
+                'email' => __('site.author.email'),
+            ],
+            'settings' => [
+                'models' => $model->getSysModels(),
+            ]
+        ];
+        return response()->json(
+            $response,
+            200,
+            [
+                'Content-Type' => 'application/json;charset=UTF-8',
+                'Charset' => 'utf-8'
+            ],
+            JSON_UNESCAPED_UNICODE
+        );
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 68 - 0
api-v12/app/Http/Controllers/SnowFlakeIdController.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class SnowFlakeIdController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        $output = [];
+        for ($i=0 ; $i < $request->get('count',1)  ; $i++ ) {
+            $output[] = app('snowflake')->id();
+        }
+        return $this->ok(['rows'=>$output,'count'=>count($output)]);
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

+ 88 - 0
api-v12/app/Http/Controllers/StudioController.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use App\Http\Api\AuthApi;
+use App\Http\Api\StudioApi;
+use App\Http\Api\ShareApi;
+use App\Models\Channel;
+
+class StudioController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function index(Request $request)
+    {
+        //
+        switch ($request->get('view')) {
+            case 'collaboration-channel':
+                //协作channel 拥有者列表
+                $studioId = StudioApi::getIdByName($request->get('studio_name'));
+                $resList = ShareApi::getResList($studioId,2);
+                $resId=[];
+                foreach ($resList as $res) {
+                    $resId[] = $res['res_id'];
+                }
+                $owners = Channel::whereIn('uid', $resId)
+                                ->where('owner_uid','<>', $studioId)
+                                ->select('owner_uid')
+                                ->groupBy('owner_uid')->get();
+                $output = [];
+                foreach ($owners as $key => $owner) {
+                    # code...
+                    $output[] = StudioApi::getById($owner->owner_uid);
+                }
+                return $this->ok(['rows'=>$output,'count'=>count($output)]);
+                break;
+        }
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\Response
+     */
+    public function store(Request $request)
+    {
+        //
+    }
+
+    /**
+     * Display the specified resource.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function show($id)
+    {
+        //
+    }
+
+    /**
+     * Update the specified resource in storage.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function update(Request $request, $id)
+    {
+        //
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     *
+     * @param  int  $id
+     * @return \Illuminate\Http\Response
+     */
+    public function destroy($id)
+    {
+        //
+    }
+}

Неке датотеке нису приказане због велике количине промена