Просмотр исходного кода

✨ (tipitaka): 章节 channel 列表迁 v3(tipitaka-chapter-channels)

取代 GET /v2/progress?view=chapter_channels&book=&par=(v2 原样保留,dashboard-v4 在用)。

- GET /v3/tipitaka-chapter-channels/{book}/{para}:某章有哪些 channel 及完成度,
  按 progress 降序 + channel_id 稳定排序,分页(per_page ≤ 200)
- 只列看得见的 channel:公开 + 自己的 + 共享给自己或所在群组的
  (v2 连私有 channel 也对所有人列)
- 字段重组为 channel{id,name,type,lang} + studio;去掉前端没用的 likes;
  views / studio 整页批量查,消除逐行 N+1
- dashboard-v6 资源页签切到 v3 类型化客户端,删掉 v2 手写类型
- OpenAPI 规格与 schema.d.ts 重新生成;v2-progress override 标注已被取代
- 测试:Pest 6 条、api-test cases/10、dashboard-test tipitaka-chapter-channels.spec
- CLAUDE.md 的 v2 → v3 对照表加一行

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 1 день назад
Родитель
Сommit
b9f19978a3
24 измененных файлов с 3559 добавлено и 62 удалено
  1. 1 0
      CLAUDE.md
  2. 70 0
      api-test/cases/10_tipitaka_chapter_channels.php
  3. 537 0
      api-test/client/lib/Api/TipitakaChapterChannelsApi.php
  4. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200Response.php
  5. 695 0
      api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner.php
  6. 511 0
      api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel.php
  7. 511 0
      api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio.php
  8. 50 0
      api-v13/app/Http/Controllers/V3/TipitakaChapterChannelController.php
  9. 32 0
      api-v13/app/Http/Requests/V3/IndexTipitakaChapterChannelRequest.php
  10. 52 0
      api-v13/app/Http/Resources/V3/TipitakaChapterChannelResource.php
  11. 196 0
      api-v13/app/Services/V3/TipitakaChapterChannelService.php
  12. 5 0
      api-v13/routes/v3/tipitaka.php
  13. 155 0
      api-v13/tests/Feature/TipitakaChapterChannelV3Test.php
  14. 76 0
      dashboard-test/src/tests/tipitaka-chapter-channels.spec.ts
  15. 0 34
      dashboard-v6/src/api/progress.ts
  16. 87 0
      dashboard-v6/src/api/schema.d.ts
  17. 26 0
      dashboard-v6/src/api/tipitaka-chapter-channel.ts
  18. 1 1
      dashboard-v6/src/components/channel/ChannelListItem.tsx
  19. 3 5
      dashboard-v6/src/components/tipitaka/ChapterInChannel.tsx
  20. 15 20
      dashboard-v6/src/components/tipitaka/PaliChapterChannelList.tsx
  21. 2 0
      openapi/public/assets/protocol/main.yaml
  22. 1 1
      openapi/public/assets/protocol/overrides/v2-progress.yaml
  23. 1 1
      openapi/public/assets/protocol/resources/auto/v2-progress.yaml
  24. 89 0
      openapi/public/assets/protocol/resources/auto/v3-tipitaka-chapter-channels-book-para.yaml

+ 1 - 0
CLAUDE.md

@@ -354,6 +354,7 @@ Resource / FormRequest / 测试同理;URL 只换版本前缀
 | `access-token`(agent 用途)/ `AccessTokenController@store` | `agent-grants` / `V3\AgentGrantController` | 人类为自己名下的 AI 模型签 channel 写入授权。载荷由服务端决定(v2 原样签客户端 payload)、绑定模型(act)与签发人(grantor)、带模型 token_version、独立密钥;写入时放 `X-Agent-Grant` 头而不是请求体 |
 | `sent-discussion-tree`(部分)/ `DiscussionController@discussion_tree` | `tipitaka-discussions/{channel}` / `V3\TipitakaDiscussionController` | 新能力为主:按经文范围(channel 必填;`book` + `chapter` 或 `para`/`to`,不给 book 即整个 channel)列挂在句子上的话题,每条带 `sentence` 坐标,按经文顺序排;`type` 可逗号多选,默认只列话题,可见性同 `/v3/discussions`。v2 那条要调用方逐句列出坐标、只回标题与回复数,未迁也不迁(只有 v4 在用)。与 `/v3/discussions`(单个对象)必填参数不同,按规范第 1 条分成两个端点 |
 | `sign-in` + `auth/current` / `AuthController@signIn` + `@getUserInfoByToken` | `sessions` + `me` / `V3\SessionController` + `V3\MeController` | 登录一次返回 `{token, user}`,不再二次查询;`login` 字段收用户名或邮箱(邮箱不区分大小写),失败统一 422 `errors.login`;按账号 + IP 限流。`me` 的字段沿用 v2 的驼峰用户摘要(`UserService::profile()`),`roles` 恒为数组,另加 `email`,不再回显 token。token 与 v2 完全相同、两边互认 |
+| `progress?view=chapter_channels` / `ProgressChapterController@index`(一个 view 分支) | `tipitaka-chapter-channels/{book}/{para}` / `V3\TipitakaChapterChannelController` | 某章有哪些 channel 及完成度(阅读页版本切换)。`par`→路径 `{para}`;**只列看得见的 channel**(公开 + 自己的 + 共享给自己或所在群组的;v2 连私有 channel 也对所有人列);字段重组:行 `uid`→`id`,`channel_id`/`name`/`type` 收进 `channel{id,name,type,lang}`;去掉 `likes`(前端没用);views/studio 整页批量查,消除 N+1;按 progress 降序 + channel_id 稳定排序,分页(per_page 最大 200)。v6、mobile、wikipali-plugins(`versions --chapter`)已切 |
 
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;

+ 70 - 0
api-test/cases/10_tipitaka_chapter_channels.php

@@ -0,0 +1,70 @@
+<?php
+
+use \WikipaliApi\Api\TipitakaChapterChannelsApi;
+
+/*
+ * 一章有哪些 channel 及完成度:GET /v3/tipitaka-chapter-channels/{book}/{para}
+ *
+ * 只读、无写操作。章节坐标从 fixture channel 自己的进度里取(各服务器数据不同),
+ * 再核对这一章的列表里有它。fixture channel 是公开的,匿名就看得到。
+ * 可见性矩阵(私有 / 共享 / 群组)、排序、N+1 由 api-v13 的 TipitakaChapterChannelV3Test 覆盖。
+ */
+
+/** fixture channel 有进度的某一章的 [book, para] */
+function chapter_channel_coord(): array
+{
+    $res = raw('GET', '/v3/progress', ['channels' => fixtures()['channel'], 'per_page' => 1]);
+    assert_status(200, $res, '取 fixture channel 的章节进度');
+    $row = $res['body']['data'][0] ?? null;
+    if (! $row) {
+        skip('fixture channel 在这台服务器上没有章节进度');
+    }
+
+    return [(int) $row['book'], (int) $row['para']];
+}
+
+test('GET /v3/tipitaka-chapter-channels/{book}/{para} → 200,含 fixture channel', function () {
+    [$book, $para] = chapter_channel_coord();
+    $res = request(fn () => api(TipitakaChapterChannelsApi::class)
+        ->getApiV3TipitakaChapterChannelsBookParaWithHttpInfo($book, $para, 200));
+    assert_status(200, $res, 'tipitaka-chapter-channels');
+    assert_has_key('data', $res['body'], 'tipitaka-chapter-channels');
+    assert_has_key('meta', $res['body'], 'tipitaka-chapter-channels');
+
+    $hit = null;
+    foreach ($res['body']['data'] as $row) {
+        if (($row['channel']['id'] ?? null) === fixtures()['channel']) {
+            $hit = $row;
+        }
+    }
+    if (! $hit) {
+        fail('tipitaka-chapter-channels:应包含 fixture channel');
+    }
+    assert_eq($book, $hit['book'] ?? null, 'book');
+    assert_eq($para, $hit['para'] ?? null, 'para');
+    foreach (['id', 'progress', 'views', 'updated_at', 'studio'] as $key) {
+        assert_has_key($key, $hit, 'tipitaka-chapter-channels 行');
+    }
+
+    // 按完成度降序
+    $progress = array_column($res['body']['data'], 'progress');
+    $sorted = $progress;
+    rsort($sorted);
+    assert_eq($sorted, $progress, '按 progress 降序');
+});
+
+test('GET /v3/tipitaka-chapter-channels/{book}/{para}(没有进度的章节)→ 200 空列表', function () {
+    $res = raw('GET', '/v3/tipitaka-chapter-channels/1/999999');
+    assert_status(200, $res, '空章节');
+    assert_eq([], $res['body']['data'] ?? null, '空章节 data');
+});
+
+test('GET /v3/tipitaka-chapter-channels/{book}/{para}?per_page=500 → 422', function () {
+    $res = raw('GET', '/v3/tipitaka-chapter-channels/1/2', ['per_page' => 500]);
+    assert_status(422, $res, 'per_page 超限');
+    assert_field_error('per_page', $res, 'per_page 超限');
+});
+
+test('GET /v3/tipitaka-chapter-channels/{非数字}/{para} → 404', function () {
+    assert_status(404, raw('GET', '/v3/tipitaka-chapter-channels/abc/2'), '非数字 book');
+});

+ 537 - 0
api-test/client/lib/Api/TipitakaChapterChannelsApi.php

@@ -0,0 +1,537 @@
+<?php
+/**
+ * TipitakaChapterChannelsApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * TipitakaChapterChannelsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class TipitakaChapterChannelsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3TipitakaChapterChannelsBookPara' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3TipitakaChapterChannelsBookPara
+     *
+     * 列出一章的 channel
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaChapterChannelsBookPara($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+        list($response) = $this->getApiV3TipitakaChapterChannelsBookParaWithHttpInfo($book, $para, $per_page, $page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaChapterChannelsBookParaWithHttpInfo
+     *
+     * 列出一章的 channel
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaChapterChannelsBookParaWithHttpInfo($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+        $request = $this->getApiV3TipitakaChapterChannelsBookParaRequest($book, $para, $per_page, $page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaChapterChannelsBookParaAsync
+     *
+     * 列出一章的 channel
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaChapterChannelsBookParaAsync($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+        return $this->getApiV3TipitakaChapterChannelsBookParaAsyncWithHttpInfo($book, $para, $per_page, $page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaChapterChannelsBookParaAsyncWithHttpInfo
+     *
+     * 列出一章的 channel
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaChapterChannelsBookParaAsyncWithHttpInfo($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response';
+        $request = $this->getApiV3TipitakaChapterChannelsBookParaRequest($book, $para, $per_page, $page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaChapterChannelsBookPara'
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaChapterChannelsBookParaRequest($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+
+        // verify the required parameter 'book' is set
+        if ($book === null || (is_array($book) && count($book) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $book when calling getApiV3TipitakaChapterChannelsBookPara'
+            );
+        }
+
+        // verify the required parameter 'para' is set
+        if ($para === null || (is_array($para) && count($para) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $para when calling getApiV3TipitakaChapterChannelsBookPara'
+            );
+        }
+
+
+
+
+        $resourcePath = '/v3/tipitaka-chapter-channels/{book}/{para}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+        // path params
+        if ($book !== null) {
+            $resourcePath = str_replace(
+                '{book}',
+                ObjectSerializer::toPathValue($book),
+                $resourcePath
+            );
+        }
+        // path params
+        if ($para !== null) {
+            $resourcePath = str_replace(
+                '{para}',
+                ObjectSerializer::toPathValue($para),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+        // this endpoint requires Bearer authentication (access token)
+        if (!empty($this->config->getAccessToken())) {
+            $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken();
+        }
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaChapterChannelsBookPara200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_chapter_channels_book_para__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 695 - 0
api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner.php

@@ -0,0 +1,695 @@
+<?php
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_chapter_channels_book_para__200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'book' => 'int',
+        'para' => 'int',
+        'progress' => 'float',
+        'progress_line' => 'int[]',
+        'views' => 'int',
+        'updated_at' => 'string',
+        'channel' => '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel',
+        'studio' => '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'book' => null,
+        'para' => null,
+        'progress' => null,
+        'progress_line' => null,
+        'views' => null,
+        'updated_at' => null,
+        'channel' => null,
+        'studio' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'book' => false,
+        'para' => false,
+        'progress' => false,
+        'progress_line' => true,
+        'views' => false,
+        'updated_at' => false,
+        'channel' => false,
+        'studio' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'book' => 'book',
+        'para' => 'para',
+        'progress' => 'progress',
+        'progress_line' => 'progress_line',
+        'views' => 'views',
+        'updated_at' => 'updated_at',
+        'channel' => 'channel',
+        'studio' => 'studio'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'book' => 'setBook',
+        'para' => 'setPara',
+        'progress' => 'setProgress',
+        'progress_line' => 'setProgressLine',
+        'views' => 'setViews',
+        'updated_at' => 'setUpdatedAt',
+        'channel' => 'setChannel',
+        'studio' => 'setStudio'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'book' => 'getBook',
+        'para' => 'getPara',
+        'progress' => 'getProgress',
+        'progress_line' => 'getProgressLine',
+        'views' => 'getViews',
+        'updated_at' => 'getUpdatedAt',
+        'channel' => 'getChannel',
+        'studio' => 'getStudio'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('book', $data ?? [], null);
+        $this->setIfExists('para', $data ?? [], null);
+        $this->setIfExists('progress', $data ?? [], null);
+        $this->setIfExists('progress_line', $data ?? [], null);
+        $this->setIfExists('views', $data ?? [], null);
+        $this->setIfExists('updated_at', $data ?? [], null);
+        $this->setIfExists('channel', $data ?? [], null);
+        $this->setIfExists('studio', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets book
+     *
+     * @return int|null
+     */
+    public function getBook()
+    {
+        return $this->container['book'];
+    }
+
+    /**
+     * Sets book
+     *
+     * @param int|null $book book
+     *
+     * @return self
+     */
+    public function setBook($book)
+    {
+        if (is_null($book)) {
+            throw new \InvalidArgumentException('non-nullable book cannot be null');
+        }
+        $this->container['book'] = $book;
+
+        return $this;
+    }
+
+    /**
+     * Gets para
+     *
+     * @return int|null
+     */
+    public function getPara()
+    {
+        return $this->container['para'];
+    }
+
+    /**
+     * Sets para
+     *
+     * @param int|null $para para
+     *
+     * @return self
+     */
+    public function setPara($para)
+    {
+        if (is_null($para)) {
+            throw new \InvalidArgumentException('non-nullable para cannot be null');
+        }
+        $this->container['para'] = $para;
+
+        return $this;
+    }
+
+    /**
+     * Gets progress
+     *
+     * @return float|null
+     */
+    public function getProgress()
+    {
+        return $this->container['progress'];
+    }
+
+    /**
+     * Sets progress
+     *
+     * @param float|null $progress progress
+     *
+     * @return self
+     */
+    public function setProgress($progress)
+    {
+        if (is_null($progress)) {
+            throw new \InvalidArgumentException('non-nullable progress cannot be null');
+        }
+        $this->container['progress'] = $progress;
+
+        return $this;
+    }
+
+    /**
+     * Gets progress_line
+     *
+     * @return int[]|null
+     */
+    public function getProgressLine()
+    {
+        return $this->container['progress_line'];
+    }
+
+    /**
+     * Sets progress_line
+     *
+     * @param int[]|null $progress_line progress_line
+     *
+     * @return self
+     */
+    public function setProgressLine($progress_line)
+    {
+        if (is_null($progress_line)) {
+            array_push($this->openAPINullablesSetToNull, 'progress_line');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('progress_line', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['progress_line'] = $progress_line;
+
+        return $this;
+    }
+
+    /**
+     * Gets views
+     *
+     * @return int|null
+     */
+    public function getViews()
+    {
+        return $this->container['views'];
+    }
+
+    /**
+     * Sets views
+     *
+     * @param int|null $views views
+     *
+     * @return self
+     */
+    public function setViews($views)
+    {
+        if (is_null($views)) {
+            throw new \InvalidArgumentException('non-nullable views cannot be null');
+        }
+        $this->container['views'] = $views;
+
+        return $this;
+    }
+
+    /**
+     * Gets updated_at
+     *
+     * @return string|null
+     */
+    public function getUpdatedAt()
+    {
+        return $this->container['updated_at'];
+    }
+
+    /**
+     * Sets updated_at
+     *
+     * @param string|null $updated_at updated_at
+     *
+     * @return self
+     */
+    public function setUpdatedAt($updated_at)
+    {
+        if (is_null($updated_at)) {
+            throw new \InvalidArgumentException('non-nullable updated_at cannot be null');
+        }
+        $this->container['updated_at'] = $updated_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets channel
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel|null
+     */
+    public function getChannel()
+    {
+        return $this->container['channel'];
+    }
+
+    /**
+     * Sets channel
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel|null $channel channel
+     *
+     * @return self
+     */
+    public function setChannel($channel)
+    {
+        if (is_null($channel)) {
+            throw new \InvalidArgumentException('non-nullable channel cannot be null');
+        }
+        $this->container['channel'] = $channel;
+
+        return $this;
+    }
+
+    /**
+     * Gets studio
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio|null
+     */
+    public function getStudio()
+    {
+        return $this->container['studio'];
+    }
+
+    /**
+     * Sets studio
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio|null $studio studio
+     *
+     * @return self
+     */
+    public function setStudio($studio)
+    {
+        if (is_null($studio)) {
+            array_push($this->openAPINullablesSetToNull, 'studio');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('studio', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['studio'] = $studio;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 511 - 0
api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel.php

@@ -0,0 +1,511 @@
+<?php
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_chapter_channels_book_para__200_response_data_inner_channel';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'name' => 'string',
+        'type' => 'string',
+        'lang' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'name' => null,
+        'type' => null,
+        'lang' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'name' => false,
+        'type' => false,
+        'lang' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'name' => 'name',
+        'type' => 'type',
+        'lang' => 'lang'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'name' => 'setName',
+        'type' => 'setType',
+        'lang' => 'setLang'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'name' => 'getName',
+        'type' => 'getType',
+        'lang' => 'getLang'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('name', $data ?? [], null);
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('lang', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets name
+     *
+     * @return string|null
+     */
+    public function getName()
+    {
+        return $this->container['name'];
+    }
+
+    /**
+     * Sets name
+     *
+     * @param string|null $name name
+     *
+     * @return self
+     */
+    public function setName($name)
+    {
+        if (is_null($name)) {
+            throw new \InvalidArgumentException('non-nullable name cannot be null');
+        }
+        $this->container['name'] = $name;
+
+        return $this;
+    }
+
+    /**
+     * Gets type
+     *
+     * @return string|null
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string|null $type type
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets lang
+     *
+     * @return string|null
+     */
+    public function getLang()
+    {
+        return $this->container['lang'];
+    }
+
+    /**
+     * Sets lang
+     *
+     * @param string|null $lang lang
+     *
+     * @return self
+     */
+    public function setLang($lang)
+    {
+        if (is_null($lang)) {
+            throw new \InvalidArgumentException('non-nullable lang cannot be null');
+        }
+        $this->container['lang'] = $lang;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 511 - 0
api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio.php

@@ -0,0 +1,511 @@
+<?php
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_chapter_channels_book_para__200_response_data_inner_studio';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'nick_name' => 'string',
+        'real_name' => 'string',
+        'avatar' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'nick_name' => null,
+        'real_name' => null,
+        'avatar' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'nick_name' => false,
+        'real_name' => false,
+        'avatar' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'nick_name' => 'nickName',
+        'real_name' => 'realName',
+        'avatar' => 'avatar'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'nick_name' => 'setNickName',
+        'real_name' => 'setRealName',
+        'avatar' => 'setAvatar'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'nick_name' => 'getNickName',
+        'real_name' => 'getRealName',
+        'avatar' => 'getAvatar'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('nick_name', $data ?? [], null);
+        $this->setIfExists('real_name', $data ?? [], null);
+        $this->setIfExists('avatar', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets nick_name
+     *
+     * @return string|null
+     */
+    public function getNickName()
+    {
+        return $this->container['nick_name'];
+    }
+
+    /**
+     * Sets nick_name
+     *
+     * @param string|null $nick_name nick_name
+     *
+     * @return self
+     */
+    public function setNickName($nick_name)
+    {
+        if (is_null($nick_name)) {
+            throw new \InvalidArgumentException('non-nullable nick_name cannot be null');
+        }
+        $this->container['nick_name'] = $nick_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets real_name
+     *
+     * @return string|null
+     */
+    public function getRealName()
+    {
+        return $this->container['real_name'];
+    }
+
+    /**
+     * Sets real_name
+     *
+     * @param string|null $real_name real_name
+     *
+     * @return self
+     */
+    public function setRealName($real_name)
+    {
+        if (is_null($real_name)) {
+            throw new \InvalidArgumentException('non-nullable real_name cannot be null');
+        }
+        $this->container['real_name'] = $real_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets avatar
+     *
+     * @return string|null
+     */
+    public function getAvatar()
+    {
+        return $this->container['avatar'];
+    }
+
+    /**
+     * Sets avatar
+     *
+     * @param string|null $avatar 仅在部分查询口径下返回。
+     *
+     * @return self
+     */
+    public function setAvatar($avatar)
+    {
+        if (is_null($avatar)) {
+            throw new \InvalidArgumentException('non-nullable avatar cannot be null');
+        }
+        $this->container['avatar'] = $avatar;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 50 - 0
api-v13/app/Http/Controllers/V3/TipitakaChapterChannelController.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexTipitakaChapterChannelRequest;
+use App\Http\Resources\V3\TipitakaChapterChannelResource;
+use App\Services\V3\TipitakaChapterChannelService;
+
+/**
+ * 某一章有哪些 channel 及各自的完成度——阅读页的「版本切换」用。
+ *
+ * 取代 `GET /v2/progress?view=chapter_channels&book=&par=`(`ProgressChapterController@index`
+ * 的一个 view 分支)。v2 那份在 dashboard-v4 下线前原样保留,不要改动。
+ */
+class TipitakaChapterChannelController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly TipitakaChapterChannelService $chapterChannels) {}
+
+    /**
+     * 列出一章的 channel
+     *
+     * 按完成度从高到低。只列看得见的 channel:公开的,加上登录用户自己的、共享给他(或他所在群组)的;
+     * 未登录只看得到公开的。章节不存在或没有任何 channel 时返回空列表。
+     *
+     * `progress` 是 0–1 的完成度;`progress_line` 是近几周每周的修改次数(从早到晚,由 `upgrade:chapter.dynamic.weekly` 每周算一次,
+     * 没算过就是 null),画活跃度小图用;
+     * `views` 是这一章在该 channel 下被浏览的次数。
+     *
+     * @unauthenticated
+     *
+     * @urlParam book integer required 书号。Example: 94
+     * @urlParam para integer required 章节起始段落号。Example: 385
+     *
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 100
+     */
+    public function __invoke(IndexTipitakaChapterChannelRequest $request, int $book, int $para)
+    {
+        return TipitakaChapterChannelResource::collection($this->chapterChannels->list(
+            $book,
+            $para,
+            $this->currentUserUid($request),
+            $request->integer('per_page', 100),
+        ));
+    }
+}

+ 32 - 0
api-v13/app/Http/Requests/V3/IndexTipitakaChapterChannelRequest.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/tipitaka-chapter-channels/{book}/{para}` 的入参。
+ *
+ * book / para 在路径上(路由约束保证是数字),这里只有分页。
+ */
+class IndexTipitakaChapterChannelRequest extends FormRequest
+{
+    /**
+     * 公开的只读接口;可见性在 Service 里按登录用户判。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'page' => ['sometimes', 'integer', 'min:1'],
+            'per_page' => ['sometimes', 'integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 52 - 0
api-v13/app/Http/Resources/V3/TipitakaChapterChannelResource.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Services\V3\TipitakaChapterChannelService;
+use Illuminate\Http\Request;
+
+/**
+ * 某一章里的一个 channel 及其完成度。
+ *
+ * 底层是 progress_chapters 一行(join channels)。`views` / `studio` / `progress_line`
+ * 由 {@see TipitakaChapterChannelService} 整页批量取好挂在模型上,这里不查库。
+ *
+ * 类型核对自 information_schema:progress_chapters.uid / channel_id 是 uuid,
+ * progress 是 double,book / para 是 integer;channels.type / lang 是 varchar。
+ */
+class TipitakaChapterChannelResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     book: int,
+     *     para: int,
+     *     progress: float,
+     *     progress_line: array<int, int>|null,
+     *     views: int,
+     *     updated_at: string,
+     *     channel: array{id: string, name: string, type: string, lang: string},
+     *     studio: array{id: string, nickName: string, realName: string, avatar?: string}|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'id' => (string) $this->uid,
+            'book' => (int) $this->book,
+            'para' => (int) $this->para,
+            'progress' => (float) $this->progress,
+            'progress_line' => is_array($this->progress_line) ? array_values($this->progress_line) : null,
+            'views' => (int) $this->views,
+            'updated_at' => $this->updated_at?->toJSON(),
+            'channel' => [
+                'id' => (string) $this->channel_id,
+                'name' => (string) $this->channel_name,
+                'type' => (string) $this->channel_type,
+                'lang' => (string) $this->channel_lang,
+            ],
+            'studio' => $this->studio,
+        ];
+    }
+}

+ 196 - 0
api-v13/app/Services/V3/TipitakaChapterChannelService.php

@@ -0,0 +1,196 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Facades\RedisCache;
+use App\Models\GroupInfo;
+use App\Models\GroupMember;
+use App\Models\ProgressChapter;
+use App\Models\Share;
+use App\Models\View;
+use App\Services\UserService;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
+
+/**
+ * 某一章有哪些 channel(译本 / 逐词 / 原文……)及各自的完成度。
+ *
+ * 取代 v2 `ProgressChapterController@index` 的 `view=chapter_channels` 分支。v2 那份
+ * 原样保留、不共用(CLAUDE.md 铁律 2)。v3 端点用 {@see self::list()},Laravel library 的
+ * 作品版本页用 {@see self::all()}(经 `TipitakaCatalogService::workChannels()`)。与 v2 的差别:
+ *
+ * - **只列看得见的 channel**:公开的(status=30),加上登录用户自己的、共享给他(或他所在
+ *   群组)的。v2 不判断可见性,私有 channel 的名字与进度对所有人可见。
+ * - 不再返回 likes(前端没用);views、studio 改成整页批量取,查询数与行数无关。
+ */
+class TipitakaChapterChannelService
+{
+    /** channels.status:全网公开 */
+    private const STATUS_PUBLIC = 30;
+
+    /** shares.res_type:channel */
+    private const SHARE_CHANNEL = 2;
+
+    public function __construct(private readonly UserService $users) {}
+
+    /**
+     * 列出一章的 channel,按完成度从高到低(分页,给 v3 端点)。
+     *
+     * 返回的模型上已挂好 `views`、`studio`、`progress_line` 三个属性,Resource 直接读,不再查库。
+     *
+     * @param  string|null  $viewerUid  当前登录用户;未登录只看得到公开 channel
+     * @return LengthAwarePaginator<int, ProgressChapter>
+     */
+    public function list(int $book, int $para, ?string $viewerUid, int $perPage): LengthAwarePaginator
+    {
+        $page = $this->query($book, $para, $viewerUid)->paginate($perPage);
+        $this->enrich($page->getCollection());
+
+        return $page;
+    }
+
+    /**
+     * 同 {@see self::list()},但不分页、可排除一个 channel——给 Laravel library 的作品页用
+     * (一章最多几十个 channel,整页渲染)。
+     *
+     * @param  string|null  $exceptChannel  不列的 channel(译本详情页的「其他版本」要排除当前译本)
+     * @return Collection<int, ProgressChapter>
+     */
+    public function all(int $book, int $para, ?string $viewerUid, ?string $exceptChannel = null): Collection
+    {
+        $rows = $this->query($book, $para, $viewerUid)
+            ->when($exceptChannel, fn (Builder $q) => $q->where('progress_chapters.channel_id', '<>', $exceptChannel))
+            ->get();
+        $this->enrich($rows);
+
+        return $rows;
+    }
+
+    /**
+     * 一章的进度行 join channel,已套可见性,按完成度降序。
+     *
+     * @return Builder<ProgressChapter>
+     */
+    private function query(int $book, int $para, ?string $viewerUid): Builder
+    {
+        return ProgressChapter::query()
+            ->select([
+                'progress_chapters.uid',
+                'progress_chapters.book',
+                'progress_chapters.para',
+                'progress_chapters.channel_id',
+                'progress_chapters.progress',
+                'progress_chapters.updated_at',
+                'channels.name as channel_name',
+                'channels.type as channel_type',
+                'channels.lang as channel_lang',
+                'channels.owner_uid as channel_owner_uid',
+            ])
+            // inner join:channel 已删除的进度行没有意义,不列
+            ->join('channels', 'progress_chapters.channel_id', '=', 'channels.uid')
+            ->where('progress_chapters.book', $book)
+            ->where('progress_chapters.para', $para)
+            ->where(fn (Builder $q) => $this->readableBy($q, $viewerUid))
+            ->orderByDesc('progress_chapters.progress')
+            // 完成度相同时要稳定的 tiebreaker,否则跨页会漂移
+            ->orderBy('progress_chapters.channel_id');
+    }
+
+    /**
+     * 整批补上 views / studio / progress_line,查询数与行数无关。
+     *
+     * @param  Collection<int, ProgressChapter>  $rows
+     */
+    private function enrich(Collection $rows): void
+    {
+        $views = $this->viewCounts($rows->pluck('uid')->all());
+        $studios = $this->studios($rows->pluck('channel_owner_uid')->all());
+
+        foreach ($rows as $row) {
+            $row->setAttribute('views', $views[$row->uid] ?? 0);
+            $row->setAttribute('studio', $studios[$row->channel_owner_uid] ?? null);
+            $row->setAttribute('progress_line', RedisCache::get(
+                "/chapter_dynamic/{$row->book}/{$row->para}/ch_{$row->channel_id}"
+            ));
+        }
+    }
+
+    /**
+     * 可见性:公开的,或(登录时)自己的、共享给自己或自己所在群组的。
+     */
+    private function readableBy(Builder $query, ?string $viewerUid): void
+    {
+        $query->where('channels.status', self::STATUS_PUBLIC);
+        if ($viewerUid === null) {
+            return;
+        }
+
+        $query->orWhere('channels.owner_uid', $viewerUid);
+
+        $cooperators = GroupMember::where('user_id', $viewerUid)->pluck('group_id')->push($viewerUid);
+        $shared = Share::whereIn('cooperator_id', $cooperators)
+            ->where('res_type', self::SHARE_CHANNEL)
+            ->pluck('res_id')
+            // shares.res_id 是 varchar,channels.uid 是 uuid;非法值进了 where 会让 pgsql 报错
+            ->filter(fn ($id) => Str::isUuid($id))
+            ->unique()
+            ->values();
+        if ($shared->isNotEmpty()) {
+            $query->orWhereIn('channels.uid', $shared);
+        }
+    }
+
+    /**
+     * 每个章节进度行被浏览的次数(views 表一行一次)。
+     *
+     * @param  list<string>  $uids  progress_chapters.uid
+     * @return array<string, int>
+     */
+    private function viewCounts(array $uids): array
+    {
+        if (! $uids) {
+            return [];
+        }
+
+        return View::whereIn('target_id', $uids)
+            ->groupBy('target_id')
+            ->selectRaw('target_id, count(*) as n')
+            ->pluck('n', 'target_id')
+            ->map(fn ($n) => (int) $n)
+            ->all();
+    }
+
+    /**
+     * channel 所有者的摘要。所有者可能是用户,也可能是群组(v2 `StudioApi::getById` 的口径)。
+     *
+     * @param  list<string|null>  $ownerUids
+     * @return array<string, array{id: string, nickName: string, realName: string, avatar?: string}>
+     */
+    private function studios(array $ownerUids): array
+    {
+        $output = [];
+        foreach ($this->users->byUuids($ownerUids) as $uid => $profile) {
+            $output[$uid] = array_filter([
+                'id' => $uid,
+                'nickName' => (string) ($profile['nickName'] ?? ''),
+                'realName' => (string) ($profile['realName'] ?? ''),
+                'avatar' => $profile['avatar'] ?? null,
+            ], fn ($v) => $v !== null);
+        }
+
+        $groups = array_values(array_unique(array_diff(array_filter($ownerUids), array_keys($output))));
+        if ($groups) {
+            foreach (GroupInfo::whereIn('uid', $groups)->get(['uid', 'name']) as $group) {
+                $output[$group->uid] = [
+                    'id' => $group->uid,
+                    'nickName' => (string) $group->name,
+                    'realName' => (string) $group->uid,
+                ];
+            }
+        }
+
+        return $output;
+    }
+}

+ 5 - 0
api-v13/routes/v3/tipitaka.php

@@ -18,6 +18,7 @@
 |
 */
 
+use App\Http\Controllers\V3\TipitakaChapterChannelController;
 use App\Http\Controllers\V3\TipitakaDiscussionController;
 use App\Http\Controllers\V3\TipitakaReadingController;
 use App\Http\Controllers\V3\TipitakaRelatedParagraphAggregateController;
@@ -34,6 +35,10 @@ Route::get('tipitaka-reading/{channel}', TipitakaReadingController::class)
 Route::get('tipitaka-discussions/{channel}', TipitakaDiscussionController::class)
     ->whereUuid('channel');
 
+// 一章有哪些 channel 及其完成度(原 v2 progress?view=chapter_channels)。book、para 都必填,进路径。
+Route::get('tipitaka-chapter-channels/{book}/{para}', TipitakaChapterChannelController::class)
+    ->whereNumber(['book', 'para']);
+
 // 段落关联关系:查询(原 v2 related-paragraph)+ 聚合(book_name 列表 / 某 book_name 的 cs_para)。
 Route::get('tipitaka-related-paragraphs/aggregate', [TipitakaRelatedParagraphAggregateController::class, 'index'])
     ->name('tipitaka-related-paragraphs.aggregate');

+ 155 - 0
api-v13/tests/Feature/TipitakaChapterChannelV3Test.php

@@ -0,0 +1,155 @@
+<?php
+
+use App\Facades\RedisCache;
+use App\Models\Channel;
+use App\Models\GroupMember;
+use App\Models\ProgressChapter;
+use App\Models\Share;
+use App\Models\View;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * GET /v3/tipitaka-chapter-channels/{book}/{para} —— 一章有哪些 channel 及完成度。
+ *
+ * 这里断言的是形状而非契约(spectator 未安装,见 v3-resource skill 第 3 步)。
+ */
+function makeChapterChannelRow(string $channelUid, float $progress, int $book = 9101, int $para = 5): ProgressChapter
+{
+    $row = new ProgressChapter;
+    $row->forceFill([
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'book' => $book,
+        'para' => $para,
+        'lang' => 'zh-Hans',
+        'channel_id' => $channelUid,
+        'progress' => $progress,
+        'all_trans' => 0,
+        'public' => 0,
+    ])->save();
+
+    return $row;
+}
+
+function makePrivateChannel(string $ownerUid, string $name): string
+{
+    $uid = makeChannel($ownerUid, $name);
+    Channel::where('uid', $uid)->update(['status' => 10]);
+
+    return $uid;
+}
+
+function shareChannelTo(string $channelUid, string $cooperatorUid): void
+{
+    (new Share)->forceFill([
+        'id' => random_int(1, PHP_INT_MAX),
+        'res_id' => $channelUid,
+        'res_type' => 2,
+        'cooperator_id' => $cooperatorUid,
+        'cooperator_type' => 0,
+        'power' => 10,
+        'create_time' => time() * 1000,
+        'modify_time' => time() * 1000,
+    ])->save();
+}
+
+it('lists channels of a chapter ordered by progress', function () {
+    $owner = makeStudio('cc-owner');
+    $low = makeChannel($owner, 'low');
+    $high = makeChannel($owner, 'high');
+    makeChapterChannelRow($low, 0.2);
+    $top = makeChapterChannelRow($high, 0.9);
+    makeChapterChannelRow($high, 0.5, 9101, 6);   // 别的章节
+
+    View::forceCreate(['id' => (string) Str::uuid(), 'target_id' => $top->uid, 'target_type' => 'chapter', 'user_ip' => '127.0.0.1']);
+    View::forceCreate(['id' => (string) Str::uuid(), 'target_id' => $top->uid, 'target_type' => 'chapter', 'user_ip' => '127.0.0.1']);
+    RedisCache::put("/chapter_dynamic/9101/5/ch_{$high}", [0, 3, 1]);
+
+    $json = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5')->assertOk()->json();
+
+    expect($json['data'])->toHaveCount(2)
+        ->and($json['meta']['total'])->toBe(2)
+        ->and($json['data'][0])->toHaveKeys(['id', 'book', 'para', 'progress', 'progress_line', 'views', 'updated_at', 'channel', 'studio'])
+        ->and($json['data'][0]['channel'])->toMatchArray(['id' => $high, 'name' => 'high', 'type' => 'translation'])
+        ->and($json['data'][0]['progress'])->toBe(0.9)
+        ->and($json['data'][0]['views'])->toBe(2)
+        ->and($json['data'][0]['progress_line'])->toBe([0, 3, 1])
+        ->and($json['data'][0]['studio'])->toMatchArray(['id' => $owner, 'nickName' => 'cc-owner'])
+        ->and($json['data'][1]['channel']['id'])->toBe($low)
+        ->and($json['data'][1]['progress_line'])->toBeNull();
+});
+
+it('hides private channels from anonymous and other users', function () {
+    $owner = makeStudio('cc-private-owner');
+    makeChapterChannelRow(makeChannel($owner, 'public'), 0.5);
+    makeChapterChannelRow(makePrivateChannel($owner, 'secret'), 0.9);
+
+    $anonymous = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5')->assertOk()->json('data');
+    $stranger = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5', authHeader(makeStudio('cc-stranger')))
+        ->assertOk()->json('data');
+
+    expect(array_column(array_column($anonymous, 'channel'), 'name'))->toBe(['public'])
+        ->and(array_column(array_column($stranger, 'channel'), 'name'))->toBe(['public']);
+});
+
+it('shows private channels to the owner and to users they are shared with', function () {
+    $owner = makeStudio('cc-share-owner');
+    $direct = makeStudio('cc-share-direct');
+    $viaGroup = makeStudio('cc-share-group');
+    $secret = makePrivateChannel($owner, 'secret');
+    makeChapterChannelRow($secret, 0.9);
+
+    shareChannelTo($secret, $direct);
+    $groupUid = (string) Str::uuid();
+    (new GroupMember)->forceFill([
+        'id' => random_int(1, PHP_INT_MAX),
+        'user_id' => $viaGroup,
+        'group_id' => $groupUid,
+        'power' => 10,
+        'level' => 0,
+        'status' => 0,
+    ])->save();
+    shareChannelTo($secret, $groupUid);
+
+    foreach ([$owner, $direct, $viaGroup] as $viewer) {
+        expect($this->getJson('/api/v3/tipitaka-chapter-channels/9101/5', authHeader($viewer))
+            ->assertOk()->json('data.0.channel.id'))->toBe($secret);
+    }
+});
+
+it('returns an empty list for a chapter without channels', function () {
+    $this->getJson('/api/v3/tipitaka-chapter-channels/9101/999')
+        ->assertOk()
+        ->assertJsonPath('data', [])
+        ->assertJsonPath('meta.total', 0);
+});
+
+it('rejects a non-numeric path and an oversized page', function () {
+    // 路径参数格式不对匹配不上路由 → 404;查询参数不对 → 422
+    $this->getJson('/api/v3/tipitaka-chapter-channels/abc/5')->assertNotFound();
+    $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5?per_page=500')
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['per_page']]);
+});
+
+it('keeps the query count independent of the row count', function () {
+    $owner = makeStudio('cc-n1');
+    $countQueriesFor = function (int $rows, int $para) use ($owner): int {
+        foreach (range(1, $rows) as $i) {
+            makeChapterChannelRow(makeChannel(makeStudio("cc-n1-{$para}-{$i}"), "c{$i}"), $i / 100, 9102, $para);
+        }
+        DB::flushQueryLog();
+        DB::enableQueryLog();
+        $this->getJson("/api/v3/tipitaka-chapter-channels/9102/{$para}", authHeader($owner))->assertOk();
+        $count = count(DB::getQueryLog());
+        DB::disableQueryLog();
+
+        return $count;
+    };
+
+    expect($countQueriesFor(12, 1))->toBe($countQueriesFor(2, 2));
+});

+ 76 - 0
dashboard-test/src/tests/tipitaka-chapter-channels.spec.ts

@@ -0,0 +1,76 @@
+import { test, expect, type Page } from '@playwright/test';
+import { collect } from '../lib/collect.js';
+import { shotDir } from '../lib/env.js';
+
+/**
+ * 三藏书目页 → 点开一章 → 抽屉里的「资源」页签(GET /v3/tipitaka-chapter-channels/{book}/{para})
+ *
+ * 只读,不建数据。断言页面上列出的 channel 与接口返回的一致(名字、完成度),
+ * 且请求走的是 v3、不再调 v2 的 progress?view=chapter_channels。
+ */
+const SHOT = shotDir('tipitaka-chapter-channels');
+const shot = (page: Page, name: string) => page.screenshot({ path: `${SHOT}/${name}.png` });
+
+interface Row {
+  progress: number;
+  channel: { id: string; name: string; type: string };
+}
+
+test.describe('三藏书目 · 章节资源列表', () => {
+  test('点开一章,资源页签按完成度列出看得见的 channel', async ({ page }) => {
+    const log = collect(page);
+    const v2Calls: string[] = [];
+    page.on('request', (r) => {
+      if (r.url().includes('view=chapter_channels')) v2Calls.push(r.url());
+    });
+
+    await test.step('1. 打开书目页,选长部,点开戒蕴品', async () => {
+      await page.goto('workspace/tipitaka/lib/default', { waitUntil: 'networkidle' });
+      // 书目页初始没有章节,选一个分类(长部)才列出章节卡片
+      await page.locator('.ant-tree-title', { hasText: /^长部$/ }).first().click();
+      await page.waitForURL(/\/lib\/default\/.+/);
+      // 长部戒蕴品:本机与线上都有多个译本
+      const firstChapter = page.getByRole('heading', { level: 5, name: '(DN) Sīlakkhandhavaggapāḷi' });
+      await expect(firstChapter).toBeVisible({ timeout: 20_000 });
+      await firstChapter.click();
+      await expect(page.locator('.ant-drawer-open')).toBeVisible();
+      await shot(page, '01-drawer');
+    });
+
+    let rows: Row[] = [];
+    await test.step('2. 切到「资源」页签:请求 v3,列表与接口一致', async () => {
+      const listed = page.waitForResponse((r) => r.url().includes('/api/v3/tipitaka-chapter-channels/'));
+      await page.locator('.ant-drawer-open').getByRole('tab', { name: '资源' }).click();
+      const res = await listed;
+      expect(res.status()).toBe(200);
+      rows = ((await res.json()) as { data: Row[] }).data;
+
+      // 按完成度降序
+      const progress = rows.map((r) => r.progress);
+      expect(progress).toEqual([...progress].sort((a, b) => b - a));
+
+      if (rows.length === 0) {
+        test.info().annotations.push({ type: 'note', description: '这一章没有任何 channel,只验证了请求' });
+        return;
+      }
+      // 默认停在「译文」页签,每页 5 条:按接口顺序核对第一页的链接与完成度
+      const translations = rows.filter((r) => r.channel.type === 'translation').slice(0, 5);
+      const items = page.getByRole('tabpanel', { name: '译文' }).locator('.ant-list-item');
+      await expect(items).toHaveCount(translations.length);
+      for (const [i, row] of translations.entries()) {
+        const item = items.nth(i);
+        await expect(item.locator(`a[href*="channel=${row.channel.id}"]`)).toBeVisible();
+        await expect(item).toContainText(row.channel.name);
+        await expect(item).toContainText(`${Math.ceil(row.progress * 100)}%`);
+      }
+      await shot(page, '02-resources');
+    });
+
+    await test.step('3. 没有调 v2 的 chapter_channels,也没有页面异常与 v3 报错', async () => {
+      expect(v2Calls).toEqual([]);
+      const bad = log.all.filter((l) => l.startsWith('[PAGEERROR]') || l.includes('/api/v3/tipitaka-chapter-channels/'));
+      log.dump('章节资源列表');
+      expect(bad).toEqual([]);
+    });
+  });
+});

+ 0 - 34
dashboard-v6/src/api/progress.ts

@@ -65,37 +65,3 @@ export interface IChapterLangListResponse {
   message: string;
   data: { rows: ILangList[]; count: number };
 }
-
-/**
- * progress?view=chapter_channels&book=98&par=22
- */
-export interface IChapterChannelData {
-  book: number;
-  para: number;
-  uid: string;
-  channel_id: string;
-  progress: number;
-  progress_line?: number[];
-  updated_at: string;
-  views: number;
-  likes: number[];
-  channel: {
-    type: TChannelType;
-    owner_uid: string;
-    editor_id: number;
-    name: string;
-    summary: string;
-    lang: string;
-    status: number;
-    created_at: string;
-    updated_at: string;
-    uid: string;
-  };
-  studio: IStudio;
-}
-
-export interface IChapterChannelListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: IChapterChannelData[]; count: number };
-}

+ 87 - 0
dashboard-v6/src/api/schema.d.ts

@@ -8283,6 +8283,32 @@ export interface paths {
         patch?: never;
         trace?: never;
     };
+    "/v3/tipitaka-chapter-channels/{book}/{para}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 列出一章的 channel
+         * @description 按完成度从高到低。只列看得见的 channel:公开的,加上登录用户自己的、共享给他(或他所在群组)的;
+         *     未登录只看得到公开的。章节不存在或没有任何 channel 时返回空列表。
+         *     `progress` 是 0–1 的完成度;`progress_line` 是近几周每周的修改次数(从早到晚,由 `upgrade:chapter.dynamic.weekly` 每周算一次,
+         *     没算过就是 null),画活跃度小图用;
+         *     `views` 是这一章在该 channel 下被浏览的次数。
+         *
+         *     实现:`V3\TipitakaChapterChannelController@__invoke`
+         */
+        get: operations["get_api_v3_tipitaka_chapter_channels_book_para_"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/tipitaka-discussions/{channel}": {
         parameters: {
             query?: never;
@@ -36429,6 +36455,67 @@ export interface operations {
             };
         };
     };
+    get_api_v3_tipitaka_chapter_channels_book_para_: {
+        parameters: {
+            query?: {
+                /** @description 每页数量,最大 200 */
+                per_page?: number;
+                /** @description 页码 */
+                page?: number;
+            };
+            header?: never;
+            path: {
+                /**
+                 * @description 书号
+                 * @example 94
+                 */
+                book: number;
+                /**
+                 * @description 章节起始段落号
+                 * @example 385
+                 */
+                para: number;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            book?: number;
+                            para?: number;
+                            progress?: number;
+                            progress_line?: number[] | null;
+                            views?: number;
+                            updated_at?: string;
+                            channel?: {
+                                id?: string;
+                                name?: string;
+                                type?: string;
+                                lang?: string;
+                            };
+                            studio?: {
+                                id?: string;
+                                nickName?: string;
+                                realName?: string;
+                                /** @description 仅在部分查询口径下返回。 */
+                                avatar?: string;
+                            } | null;
+                        }[];
+                        meta?: components["schemas"]["PaginationMeta"];
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
     get_api_v3_tipitaka_discussions_channel_: {
         parameters: {
             query?: {

+ 26 - 0
dashboard-v6/src/api/tipitaka-chapter-channel.ts

@@ -0,0 +1,26 @@
+/**
+ * 一章有哪些 channel 及各自的完成度(阅读页的版本列表)。
+ *
+ * `GET /v3/tipitaka-chapter-channels/{book}/{para}`,取代 v2 的
+ * `progress?view=chapter_channels`。只列当前用户看得见的 channel。
+ */
+import { api, unwrap } from "./client";
+import type { paths } from "./schema";
+
+type ChapterChannelList =
+  paths["/v3/tipitaka-chapter-channels/{book}/{para}"]["get"]["responses"][200]["content"]["application/json"];
+
+export type ChapterChannel = NonNullable<ChapterChannelList["data"]>[number];
+
+/** 单章最多几十个 channel,一页 200 足够取全。 */
+const PAGE_SIZE = 200;
+
+export const fetchChapterChannels = async (
+  book: number,
+  para: number
+): Promise<ChapterChannel[]> =>
+  unwrap(
+    await api.GET("/v3/tipitaka-chapter-channels/{book}/{para}", {
+      params: { path: { book, para }, query: { per_page: PAGE_SIZE } },
+    })
+  ).data ?? [];

+ 1 - 1
dashboard-v6/src/components/channel/ChannelListItem.tsx

@@ -6,7 +6,7 @@ import type { IStudio } from "../../api/Auth";
 
 interface IWidget {
   channel: IChannelApiData;
-  studio: IStudio;
+  studio?: IStudio;
   showProgress?: boolean;
   showLike?: boolean;
 }

+ 3 - 5
dashboard-v6/src/components/tipitaka/ChapterInChannel.tsx

@@ -1,6 +1,6 @@
 import { Button, Col, List, Modal, Row, Space, Tabs } from "antd";
 import { Typography } from "antd";
-import { LikeOutlined, EyeOutlined } from "@ant-design/icons";
+import { EyeOutlined } from "@ant-design/icons";
 import { Tiny } from "@ant-design/plots";
 
 import type { IChannelApiData } from "../../api/channel";
@@ -17,11 +17,10 @@ const { Text } = Typography;
 
 export interface IChapterChannelData {
   channel: IChannelApiData;
-  studio: IStudio;
+  studio?: IStudio;
   progress: number;
   progressLine?: number[];
   hit: number;
-  like: number;
   updatedAt: string;
 }
 interface IWidgetChapterInChannel {
@@ -100,8 +99,7 @@ const ChapterInChannelWidget = ({
                 <Text type="secondary">
                   <Space style={{ paddingLeft: "2em" }}>
                     <EyeOutlined />
-                    {item.hit} | <LikeOutlined />
-                    {item.like} |
+                    {item.hit} |
                     <TimeShow updatedAt={item.updatedAt} /> |
                     <ProgressOutlinedIcon />
                     {`${item.progress}%`}

+ 15 - 20
dashboard-v6/src/components/tipitaka/PaliChapterChannelList.tsx

@@ -1,10 +1,9 @@
 import { useState, useEffect } from "react";
 
-import { get } from "../../request";
-
 import type { IChapter } from "./BookViewer";
 import ChapterInChannel, { type IChapterChannelData } from "./ChapterInChannel";
-import type { IChapterChannelListResponse } from "../../api/progress";
+import { fetchChapterChannels } from "../../api/tipitaka-chapter-channel";
+import type { TChannelType } from "../../api/channel";
 
 interface IWidget {
   para: IChapter;
@@ -20,25 +19,21 @@ const PaliChapterChannelList = ({
   const [tableData, setTableData] = useState<IChapterChannelData[]>([]);
 
   useEffect(() => {
-    const url = `/api/v2/progress?view=chapter_channels&book=${para.book}&par=${para.para}`;
-    get<IChapterChannelListResponse>(url).then(function (json) {
-      const newData: IChapterChannelData[] = json.data.rows.map((item) => {
-        return {
+    fetchChapterChannels(para.book, para.para).then((rows) => {
+      setTableData(
+        rows.map((item) => ({
           channel: {
-            name: item.channel.name,
-            id: item.channel.uid,
-            type: item.channel.type,
+            name: item.channel?.name ?? "",
+            id: item.channel?.id ?? "",
+            type: item.channel?.type as TChannelType | undefined,
           },
-          studio: item.studio,
-          progress: Math.ceil(item.progress * 100),
-          progressLine: item.progress_line,
-          hit: item.views,
-          like: 0,
-          updatedAt: item.updated_at,
-        };
-      });
-      setTableData(newData);
-      console.log("chapter", newData);
+          studio: item.studio?.id ? { ...item.studio, id: item.studio.id } : undefined,
+          progress: Math.ceil((item.progress ?? 0) * 100),
+          progressLine: item.progress_line ?? undefined,
+          hit: item.views ?? 0,
+          updatedAt: item.updated_at ?? "",
+        }))
+      );
     });
   }, [para]);
 

+ 2 - 0
openapi/public/assets/protocol/main.yaml

@@ -725,6 +725,8 @@ paths:
     $ref: ./resources/auto/v3-search-search.yaml
   /v3/sessions:
     $ref: ./resources/auto/v3-sessions.yaml
+  '/v3/tipitaka-chapter-channels/{book}/{para}':
+    $ref: ./resources/auto/v3-tipitaka-chapter-channels-book-para.yaml
   '/v3/tipitaka-discussions/{channel}':
     $ref: ./resources/auto/v3-tipitaka-discussions-channel.yaml
   '/v3/tipitaka-reading/{channel}':

+ 1 - 1
openapi/public/assets/protocol/overrides/v2-progress.yaml

@@ -13,7 +13,7 @@ get:
                     items:
                       anyOf:
                         - type: object
-                          description: view=chapter_channels
+                          description: view=chapter_channels(已被 `GET /v3/tipitaka-chapter-channels/{book}/{para}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除)
                           properties:
                             book:
                               type: integer

+ 1 - 1
openapi/public/assets/protocol/resources/auto/v2-progress.yaml

@@ -123,7 +123,7 @@ get:
                   rows:
                     type: array
                     items:
-                      anyOf: [{ type: object, description: view=chapter_channels, properties: { book: { type: integer }, para: { type: integer }, uid: { type: string }, channel_id: { type: string }, name: { type: string }, type: { type: string }, progress: { type: number }, updated_at: { type: string }, channel: { type: object, properties: { uid: { type: string }, name: { type: string }, type: { type: string } } } } }, { type: object, description: view=ids, properties: { id: { type: string } } }, { type: object, description: view=channel, properties: { channel_id: { type: string }, count: { type: integer }, studio: { type: object, properties: { id: { type: string }, nickName: { type: string }, realName: { type: string }, studioName: { type: string }, avatar: { type: string } } }, channel: { type: object, properties: { name: { type: string }, summary: { type: string, nullable: true }, lang: { type: string } } } } }]
+                      anyOf: [{ type: object, description: 'view=chapter_channels(已被 `GET /v3/tipitaka-chapter-channels/{book}/{para}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除)', properties: { book: { type: integer }, para: { type: integer }, uid: { type: string }, channel_id: { type: string }, name: { type: string }, type: { type: string }, progress: { type: number }, updated_at: { type: string }, channel: { type: object, properties: { uid: { type: string }, name: { type: string }, type: { type: string } } } } }, { type: object, description: view=ids, properties: { id: { type: string } } }, { type: object, description: view=channel, properties: { channel_id: { type: string }, count: { type: integer }, studio: { type: object, properties: { id: { type: string }, nickName: { type: string }, realName: { type: string }, studioName: { type: string }, avatar: { type: string } } }, channel: { type: object, properties: { name: { type: string }, summary: { type: string, nullable: true }, lang: { type: string } } } } }]
                   count:
                     type: integer
     401:

+ 89 - 0
openapi/public/assets/protocol/resources/auto/v3-tipitaka-chapter-channels-book-para.yaml

@@ -0,0 +1,89 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-tipitaka-chapter-channels-book-para.yaml
+get:
+  summary: '列出一章的 channel'
+  tags:
+    - tipitaka-chapter-channels
+  operationId: get_api_v3_tipitaka_chapter_channels_book_para_
+  description: |-
+    按完成度从高到低。只列看得见的 channel:公开的,加上登录用户自己的、共享给他(或他所在群组)的;
+    未登录只看得到公开的。章节不存在或没有任何 channel 时返回空列表。
+    `progress` 是 0–1 的完成度;`progress_line` 是近几周每周的修改次数(从早到晚,由 `upgrade:chapter.dynamic.weekly` 每周算一次,
+    没算过就是 null),画活跃度小图用;
+    `views` 是这一章在该 channel 下被浏览的次数。
+
+    实现:`V3\TipitakaChapterChannelController@__invoke`
+  security:
+    - {  }
+    -
+      bearerAuth: []
+  parameters:
+    -
+      name: book
+      in: path
+      required: true
+      schema:
+        type: integer
+      description: 书号
+      example: 94
+    -
+      name: para
+      in: path
+      required: true
+      schema:
+        type: integer
+      description: 章节起始段落号
+      example: 385
+    -
+      name: per_page
+      in: query
+      schema:
+        type: integer
+        default: 100
+      description: '每页数量,最大 200'
+    -
+      name: page
+      in: query
+      schema:
+        type: integer
+        default: 1
+      description: 页码
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    id:
+                      type: string
+                    book:
+                      type: integer
+                    para:
+                      type: integer
+                    progress:
+                      type: number
+                    progress_line:
+                      type: array
+                      items: { type: integer }
+                      nullable: true
+                    views:
+                      type: integer
+                    updated_at:
+                      type: string
+                    channel:
+                      type: object
+                      properties: { id: { type: string }, name: { type: string }, type: { type: string }, lang: { type: string } }
+                    studio:
+                      type: object
+                      properties: { id: { type: string }, nickName: { type: string }, realName: { type: string }, avatar: { type: string, description: 仅在部分查询口径下返回。 } }
+                      nullable: true
+              meta:
+                $ref: '../../main.yaml#/components/schemas/PaginationMeta'
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'