Jelajahi Sumber

新增动漫音色管理及批量应用音频的接口

lh 1 bulan lalu
induk
melakukan
7f8e909c03

+ 241 - 0
app/Http/Controllers/Anime/AudioController.php

@@ -0,0 +1,241 @@
+<?php
+
+namespace App\Http\Controllers\Anime;
+
+use App\Libs\ApiResponse;
+use App\Libs\Utils;
+use App\Services\Anime\AudioService;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Controller as BaseController;
+use Illuminate\Support\Facades\Validator;
+
+/**
+ * 音色库管理
+ *
+ * 列表 / 可选音色:登录用户均可访问,范围为公司(cpid)内「公共音色(user_id=0) + 自己创建的」;超管可看全部。
+ * 新增 / 修改 / 删除:仅超管(superadmin)和管理员(admin),组员(user)无权。
+ * 批量应用音色到资产:组员仅可操作自己创建的资产,管理员可操作本公司(含同组组员)资产,超管不受限制。
+ */
+class AudioController extends BaseController
+{
+    use ApiResponse;
+
+    protected $audioService;
+
+    public function __construct(AudioService $audioService)
+    {
+        $this->audioService = $audioService;
+    }
+
+    /**
+     * 音色列表(分页)
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function list(Request $request)
+    {
+        $data = $request->all();
+
+        $validator = Validator::make($data, [
+            'page'     => 'nullable|integer|min:1',
+            'per_page' => 'nullable|integer|min:1|max:100',
+            'cpid'     => 'nullable|integer|min:1',
+            'user_id'  => 'nullable|integer|min:0',
+            'timbre_name' => 'nullable|string|max:100',
+            'language'    => 'nullable|string|max:64',
+            'remark'      => 'nullable|string|max:500',
+        ], [
+            'page.integer'     => 'page必须为整数',
+            'page.min'         => 'page不能小于1',
+            'per_page.integer' => 'per_page必须为整数',
+            'per_page.min'     => 'per_page不能小于1',
+            'per_page.max'     => 'per_page不能超过100',
+            'cpid.integer'     => 'cpid必须为整数',
+            'cpid.min'         => 'cpid不能小于1',
+            'user_id.integer'  => 'user_id必须为整数',
+            'user_id.min'      => 'user_id不能小于0',
+            'timbre_name.max'  => '音色名称不能超过100个字符',
+            'language.max'     => '语种不能超过64个字符',
+            'remark.max'       => '备注不能超过500个字符',
+        ]);
+
+        if ($validator->fails()) {
+            Utils::throwError('1002:' . $validator->errors()->first());
+        }
+
+        $result = $this->audioService->getList($data);
+
+        $list = collect($result->items())->map(function ($item) {
+            return $this->audioService->formatRow($item);
+        });
+
+        return $this->success([
+            'meta' => getMeta($result),
+            'list' => $list,
+        ]);
+    }
+
+    /**
+     * 可选音色(不分页)
+     *
+     * 数据范围、筛选条件与返回结构均与列表一致,仅去掉分页信息。
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function audios(Request $request)
+    {
+        $data = $request->all();
+
+        $validator = Validator::make($data, [
+            'timbre_name' => 'nullable|string|max:100',
+            'language'    => 'nullable|string|max:64',
+            'remark'      => 'nullable|string|max:500',
+            'cpid'     => 'nullable|integer|min:1',
+            'user_id'  => 'nullable|integer|min:0',
+        ], [
+            'timbre_name.max'  => '音色名称不能超过100个字符',
+            'language.max'     => '语种不能超过64个字符',
+            'cpid.integer'     => 'cpid必须为整数',
+            'cpid.min'         => 'cpid不能小于1',
+            'user_id.integer'  => 'user_id必须为整数',
+            'user_id.min'      => 'user_id不能小于0',
+        ]);
+
+        if ($validator->fails()) {
+            Utils::throwError('1002:' . $validator->errors()->first());
+        }
+
+        return $this->success([
+            'list' => $this->audioService->getOptions($data),
+        ]);
+    }
+
+    /**
+     * 新增音色
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function add(Request $request)
+    {
+        $data = $request->all();
+
+        $validator = Validator::make($data, [
+            'timbre_name' => 'required|string|max:100',
+            'timbre_url' => 'required|string|max:500',
+            'language'   => 'nullable|string|max:64',
+            'remark'     => 'nullable|string|max:500',
+            'cpid'       => 'nullable|integer|min:1',
+            'user_id'    => 'nullable|integer|min:0',
+        ], [
+            'timbre_name.required' => '请传入音色名称',
+            'timbre_name.max'      => '音色名称不能超过100个字符',
+            'timbre_url.required' => '请传入音色音频地址',
+            'timbre_url.max'    => '音色音频地址不能超过500个字符',
+            'language.max'      => '语种不能超过64个字符',
+            'remark.max'        => '备注不能超过500个字符',
+            'cpid.integer'      => 'cpid必须为整数',
+            'cpid.min'          => 'cpid不能小于1',
+            'user_id.integer'   => 'user_id必须为整数',
+            'user_id.min'       => 'user_id不能小于0',
+        ]);
+
+        if ($validator->fails()) {
+            Utils::throwError('1002:' . $validator->errors()->first());
+        }
+
+        return $this->success($this->audioService->addAudio($data));
+    }
+
+    /**
+     * 修改音色
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function edit(Request $request)
+    {
+        $data = $request->all();
+
+        $validator = Validator::make($data, [
+            'id'         => 'required|integer|min:1',
+            'timbre_name' => 'nullable|string|max:100',
+            'timbre_url' => 'nullable|string|max:500',
+            'language'   => 'nullable|string|max:64',
+            'remark'     => 'nullable|string|max:500',
+        ], [
+            'id.required'  => '请传入音色ID',
+            'id.integer'   => '音色ID必须为整数',
+            'id.min'       => '音色ID不能小于1',
+            'timbre_name.max' => '音色名称不能超过100个字符',
+            'timbre_url.max' => '音色音频地址不能超过500个字符',
+            'language.max' => '语种不能超过64个字符',
+            'remark.max'   => '备注不能超过500个字符',
+        ]);
+
+        if ($validator->fails()) {
+            Utils::throwError('1002:' . $validator->errors()->first());
+        }
+
+        return $this->success($this->audioService->editAudio($data));
+    }
+
+    /**
+     * 删除音色(软删除)
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function delete(Request $request)
+    {
+        $data = $request->all();
+
+        $validator = Validator::make($data, [
+            'id' => 'required|integer|min:1',
+        ], [
+            'id.required' => '请传入音色ID',
+            'id.integer'  => '音色ID必须为整数',
+            'id.min'      => '音色ID不能小于1',
+        ]);
+
+        if ($validator->fails()) {
+            Utils::throwError('1002:' . $validator->errors()->first());
+        }
+
+        $result = $this->audioService->deleteAudio($data);
+
+        return $this->success(['success' => $result ? 1 : 0]);
+    }
+
+    /**
+     * 批量应用音色到资产
+     *
+     * product_ids 支持数组或英文逗号分隔字符串,与 timbre_url 均为必传;
+     * 校验资产存在且同属一个公司后,覆盖写入 mp_products.timbre_url。
+     * 权限:组员仅可操作自己创建的资产,管理员可操作本公司(含同组组员)资产,超管不受限制。
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function batchApplyTimbreUrl(Request $request)
+    {
+        $data = $request->all();
+
+        $validator = Validator::make($data, [
+            'product_ids' => 'required',
+            'timbre_url'  => 'required|string|max:255',
+        ], [
+            'product_ids.required' => '请传入资产ID',
+            'timbre_url.required'  => '请传入音色音频地址',
+            'timbre_url.max'       => '音色音频地址不能超过255个字符',
+        ]);
+
+        if ($validator->fails()) {
+            Utils::throwError('1002:' . $validator->errors()->first());
+        }
+
+        return $this->success($this->audioService->batchApplyTimbreUrl($data));
+    }
+}

+ 597 - 0
app/Services/Anime/AudioService.php

@@ -0,0 +1,597 @@
+<?php
+
+namespace App\Services\Anime;
+
+use App\Consts\ErrorConst;
+use App\Facade\Site;
+use App\Libs\Utils;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 动漫音色库(mp_anime_audios)
+ *
+ * 可见范围(列表 / 可选音色,登录用户均可访问):
+ * - superadmin:全部数据,可按 cpid 筛选;
+ * - admin / user:本公司(cpid)范围内,user_id=0 的公共音色 + user_id=自己 的私有音色。
+ *
+ * 操作权限:
+ * - 音色的新增 / 修改 / 删除:仅 superadmin(超管)与 admin(管理员),user(组员)无权;
+ * - 批量应用到资产(batchApplyTimbreUrl):user 仅可操作自己创建的资产,admin 可操作本公司
+ *   (含同组组员)的资产,superadmin 不受限制(但仍要求所选资产同属一个公司);
+ * - admin 只能在上述可见范围内操作(不可跨公司、不可操作他人私有音色);
+ * - superadmin 拥有全部权限。
+ */
+class AudioService
+{
+    /**
+     * 音色列表(分页)
+     *
+     * @param array $params
+     * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+     */
+    public function getList(array $params)
+    {
+        $perPage = (int)getProp($params, 'per_page', 15);
+        if ($perPage < 1) {
+            $perPage = 15;
+        }
+        if ($perPage > 100) {
+            $perPage = 100;
+        }
+
+        return $this->buildQuery($params)->paginate($perPage);
+    }
+
+    /**
+     * 可选音色(不分页)
+     *
+     * 数据范围与筛选条件同列表,返回结构也与列表每一项完全一致。
+     *
+     * @param array $params
+     * @return array
+     */
+    public function getOptions(array $params): array
+    {
+        $rows = $this->buildQuery($params)->get();
+
+        $list = [];
+        foreach ($rows as $row) {
+            $list[] = $this->formatRow($row);
+        }
+
+        return $list;
+    }
+
+    /**
+     * 新增音色
+     *
+     * 归属规则:管理员强制落在本公司且归属自己;超管可传 cpid 指定公司(默认主公司),
+     * 也可传 user_id=0 写入公共库。
+     *
+     * @param array $params
+     * @return array
+     */
+    public function addAudio(array $params): array
+    {
+        $this->assertManager();
+
+        $timbreName = trim((string)getProp($params, 'timbre_name', ''));
+        if ($timbreName === '') {
+            Utils::throwError('1002:请传入音色名称');
+        }
+        if (mb_strlen($timbreName) > 100) {
+            Utils::throwError('1002:音色名称不能超过100个字符');
+        }
+
+        $timbreUrl = trim((string)getProp($params, 'timbre_url', ''));
+        if ($timbreUrl === '') {
+            Utils::throwError('1002:请传入音色音频地址');
+        }
+        if (mb_strlen($timbreUrl) > 500) {
+            Utils::throwError('1002:音色音频地址不能超过500个字符');
+        }
+
+        $language = trim((string)getProp($params, 'language', ''));
+        if (mb_strlen($language) > 64) {
+            Utils::throwError('1002:语种不能超过64个字符');
+        }
+
+        $remark = trim((string)getProp($params, 'remark', ''));
+        if (mb_strlen($remark) > 500) {
+            Utils::throwError('1002:备注不能超过500个字符');
+        }
+
+        $cpid   = $this->resolveCpid($params);
+        $userId = $this->resolveUserId($params);
+
+        $this->assertTimbreNameUnique($cpid, $userId, $timbreName);
+
+        $now = date('Y-m-d H:i:s');
+        $id  = DB::table('mp_anime_audios')->insertGetId([
+            'timbre_name' => $timbreName,
+            'user_id'    => $userId,
+            'cpid'       => $cpid,
+            'timbre_url' => $timbreUrl,
+            'language'   => $language,
+            'remark'     => $remark,
+            'is_deleted' => 0,
+            'created_at' => $now,
+            'updated_at' => $now,
+        ]);
+        if (!$id) {
+            Utils::throwError(ErrorConst::DB_INVALID);
+        }
+
+        return $this->formatRow($this->findRow($id));
+    }
+
+    /**
+     * 修改音色
+     *
+     * 仅允许修改名称、音频地址、语种、备注;归属公司(cpid)与归属用户不允许调整。
+     *
+     * @param array $params
+     * @return array
+     */
+    public function editAudio(array $params): array
+    {
+        $this->assertManager();
+
+        $id = (int)getProp($params, 'id', 0);
+        if ($id < 1) {
+            Utils::throwError('1002:请传入音色ID');
+        }
+
+        // 可见范围校验:管理员改不到别的公司 / 别人的私有音色
+        $row = $this->findRow($id);
+
+        $update = [];
+
+        if (array_key_exists('timbre_name', $params)) {
+            $timbreName = trim((string)$params['timbre_name']);
+            if ($timbreName === '') {
+                Utils::throwError('1002:请传入音色名称');
+            }
+            if (mb_strlen($timbreName) > 100) {
+                Utils::throwError('1002:音色名称不能超过100个字符');
+            }
+            if ($timbreName !== (string)$row->timbre_name) {
+                $this->assertTimbreNameUnique((int)$row->cpid, (int)$row->user_id, $timbreName, (int)$row->id);
+            }
+            $update['timbre_name'] = $timbreName;
+        }
+
+        if (array_key_exists('timbre_url', $params)) {
+            $timbreUrl = trim((string)$params['timbre_url']);
+            if ($timbreUrl === '') {
+                Utils::throwError('1002:请传入音色音频地址');
+            }
+            if (mb_strlen($timbreUrl) > 500) {
+                Utils::throwError('1002:音色音频地址不能超过500个字符');
+            }
+            $update['timbre_url'] = $timbreUrl;
+        }
+
+        if (array_key_exists('language', $params)) {
+            $language = trim((string)$params['language']);
+            if (mb_strlen($language) > 64) {
+                Utils::throwError('1002:语种不能超过64个字符');
+            }
+            $update['language'] = $language;
+        }
+
+        if (array_key_exists('remark', $params)) {
+            $remark = trim((string)$params['remark']);
+            if (mb_strlen($remark) > 500) {
+                Utils::throwError('1002:备注不能超过500个字符');
+            }
+            $update['remark'] = $remark;
+        }
+
+        if (empty($update)) {
+            Utils::throwError('1002:未传入需要修改的字段');
+        }
+
+        $update['updated_at'] = date('Y-m-d H:i:s');
+        DB::table('mp_anime_audios')->where('id', (int)$row->id)->update($update);
+
+        return $this->formatRow($this->findRow((int)$row->id));
+    }
+
+    /**
+     * 删除音色(软删除 is_deleted=1)
+     *
+     * @param array $params
+     * @return int
+     */
+    public function deleteAudio(array $params): int
+    {
+        $this->assertManager();
+
+        $id = (int)getProp($params, 'id', 0);
+        if ($id < 1) {
+            Utils::throwError('1002:请传入音色ID');
+        }
+
+        // 可见范围校验:管理员删不到别的公司 / 别人的私有音色
+        $row = $this->findRow($id);
+
+        return (int)DB::table('mp_anime_audios')->where('id', (int)$row->id)->update([
+            'is_deleted' => 1,
+            'updated_at' => date('Y-m-d H:i:s'),
+        ]);
+    }
+
+    /**
+     * 批量应用音色到多个资产(覆盖写入 mp_products.timbre_url)
+     *
+     * 校验顺序:资产是否存在 → 所选资产是否同属一个公司(cpid) → 当前用户的操作范围
+     * (组员仅可操作自己创建的资产,管理员可操作本公司含同组组员的资产,超管不受限制)。
+     * 校验通过后,把传入的 timbre_url 覆盖写入所选资产的 timbre_url 字段。
+     *
+     * @param array $params
+     * @return array
+     */
+    public function batchApplyTimbreUrl(array $params): array
+    {
+        $timbreUrl = trim((string)getProp($params, 'timbre_url', ''));
+        if ($timbreUrl === '') {
+            Utils::throwError('1002:请传入音色音频地址');
+        }
+        // mp_products.timbre_url 为 varchar(255)
+        if (mb_strlen($timbreUrl) > 255) {
+            Utils::throwError('1002:音色音频地址不能超过255个字符');
+        }
+
+        $productIds = $this->parseProductIds(getProp($params, 'product_ids', ''));
+        if (empty($productIds)) {
+            Utils::throwError('1002:请传入资产ID');
+        }
+
+        // 1. 资产必须存在且未删除
+        $rows = DB::table('mp_products')
+            ->whereIn('id', $productIds)
+            ->where('is_deleted', 0)
+            ->get(['id', 'cpid', 'user_id']);
+        if (count($rows) !== count($productIds)) {
+            Utils::throwError('1002:部分资产不存在或已删除');
+        }
+
+        // 2. 所选资产必须同属一个公司
+        $cpids = [];
+        foreach ($rows as $row) {
+            $cpids[(int)$row->cpid] = 1;
+        }
+        if (count($cpids) > 1) {
+            Utils::throwError('1002:所选资产不属于同一公司,无法批量应用');
+        }
+        $cpid = (int)key($cpids);
+
+        // 3. 归属校验:组员仅可操作自己创建的资产,管理员可操作本公司(含同组组员)资产,超管不受限制
+        $this->assertApplyOwnership($rows, $cpid);
+
+        $affected = DB::table('mp_products')
+            ->whereIn('id', $productIds)
+            ->where('is_deleted', 0)
+            ->update([
+                'timbre_url' => $timbreUrl,
+                'updated_at' => date('Y-m-d H:i:s'),
+            ]);
+
+        return [
+            'success'     => 1,
+            'total'       => count($productIds),
+            'affected'    => (int)$affected,
+            'cpid'        => $cpid,
+            'product_ids' => $productIds,
+            'timbre_url'  => $timbreUrl,
+        ];
+    }
+
+    /**
+     * 解析资产ID入参:支持数组、逗号分隔字符串、JSON 数组
+     *
+     * @param mixed $value
+     * @return array
+     */
+    private function parseProductIds($value): array
+    {
+        if (is_array($value)) {
+            $items = $value;
+        } elseif (is_string($value)) {
+            $decoded = json_decode($value, true);
+            $items   = is_array($decoded) ? $decoded : explode(',', $value);
+        } else {
+            $items = [$value];
+        }
+
+        $ids = [];
+        foreach ($items as $item) {
+            if (is_array($item)) {
+                $ids = array_merge($ids, $this->parseProductIds($item));
+                continue;
+            }
+            $id = (int)$item;
+            if ($id > 0) {
+                $ids[] = $id;
+            }
+        }
+
+        return array_values(array_unique($ids));
+    }
+
+    /**
+     * 批量应用音色的操作范围校验
+     *
+     * - superadmin:不限制(同属一个公司的要求已在调用方校验);
+     * - admin:仅可操作本公司(cpid)的资产,含同组组员创建的;
+     * - user:仅可操作本公司(cpid)且 user_id 为本人 的资产;
+     * - 其他角色:一律拒绝。
+     *
+     * @param \Illuminate\Support\Collection $rows 所选资产记录(含 id / cpid / user_id)
+     * @param int                            $cpid 所选资产所属公司
+     * @return void
+     */
+    private function assertApplyOwnership($rows, int $cpid): void
+    {
+        $role = $this->role();
+
+        if ($role === 'superadmin') {
+            return;
+        }
+
+        if (!in_array($role, ['admin', 'user'], true)) {
+            Utils::throwError(ErrorConst::NOT_ACCESS);
+        }
+
+        // 管理员与组员都只能作用于本公司资产
+        if ($cpid !== (int)Site::getCpid()) {
+            Utils::throwError(ErrorConst::NOT_ACCESS);
+        }
+
+        // 组员只能处理自己创建的资产
+        if ($role === 'user') {
+            $uid = (int)Site::getUid();
+            foreach ($rows as $row) {
+                if ((int)$row->user_id !== $uid) {
+                    Utils::throwError('1005:组员仅可操作自己创建的资产');
+                }
+            }
+        }
+    }
+
+    /**
+     * 格式化单条音色返回数据
+     *
+     * 不返回归属用户昵称与公司名称,仅保留 ID 维度信息。
+     *
+     * @param object $row
+     * @return array
+     */
+    public function formatRow($row): array
+    {
+        return [
+            'id'          => (int)$row->id,
+            'timbre_name' => (string)$row->timbre_name,
+            'timbre_url'  => (string)$row->timbre_url,
+            'language'    => (string)getProp($row, 'language', ''),
+            'remark'      => (string)getProp($row, 'remark', ''),
+            'created_at'  => transDate($row->created_at),
+            'updated_at'  => transDate($row->updated_at),
+        ];
+    }
+
+    /**
+     * 列表 / 可选音色共用的查询构造(含可见范围与筛选条件)
+     *
+     * - superadmin:全部,可按 cpid 与 user_id 筛选;
+     * - 其他角色:本公司 且(user_id=0 的公共音色 或 自己创建的私有音色)。
+     *
+     * @param array $params
+     * @return \Illuminate\Database\Query\Builder
+     */
+    private function buildQuery(array $params)
+    {
+        $query = DB::table('mp_anime_audios as a')
+            ->where('a.is_deleted', 0)
+            ->select($this->audioColumns());
+
+        if ($this->isSuperadmin()) {
+            $cpid = (int)getProp($params, 'cpid', 0);
+            if ($cpid > 0) {
+                $query->where('a.cpid', $cpid);
+            }
+
+            $userId = (int)getProp($params, 'user_id', -1);
+            if ($userId >= 0) {
+                $query->where('a.user_id', $userId);
+            }
+        } else {
+            $uid = (int)Site::getUid();
+            $query->where('a.cpid', (int)Site::getCpid())
+                ->where(function ($q) use ($uid) {
+                    $q->where('a.user_id', 0)->orWhere('a.user_id', $uid);
+                });
+        }
+
+        // 音色名称模糊筛选
+        $timbreName = trim((string)getProp($params, 'timbre_name', ''));
+        if ($timbreName !== '') {
+            $query->where('a.timbre_name', 'like', "%{$timbreName}%");
+        }
+
+        // 语种模糊筛选(如传「中文」可命中「中文,美式英语」)
+        $language = trim((string)getProp($params, 'language', ''));
+        if ($language !== '') {
+            $query->where('a.language', 'like', "%{$language}%");
+        }
+
+        // 备注模糊筛选
+        $remark = trim((string)getProp($params, 'remark', ''));
+        if ($remark !== '') {
+            $query->where('a.remark', 'like', "%{$remark}%");
+        }
+
+        return $query->orderByDesc('a.id');
+    }
+
+    /**
+     * 按主键取未删除的音色,并做可见范围校验
+     *
+     * @param int $id
+     * @return object
+     */
+    private function findRow(int $id)
+    {
+        $row = DB::table('mp_anime_audios as a')
+            ->where('a.id', $id)
+            ->where('a.is_deleted', 0)
+            ->select($this->audioColumns())
+            ->first();
+
+        if (!$row) {
+            Utils::throwError('1001:音色不存在');
+        }
+
+        if (!$this->isSuperadmin()) {
+            $userId = (int)$row->user_id;
+            $uid    = (int)Site::getUid();
+
+            // 跨公司,或既不是公共音色也不是本人创建的音色,一律按无权限处理
+            if ((int)$row->cpid !== (int)Site::getCpid() || ($userId !== 0 && $userId !== $uid)) {
+                Utils::throwError(ErrorConst::NOT_ACCESS);
+            }
+        }
+
+        return $row;
+    }
+
+    /**
+     * 同一公司、同一归属用户下音色名称不可重复
+     *
+     * @param int    $cpid
+     * @param int    $userId
+     * @param string $timbreName
+     * @param int    $excludeId
+     * @return void
+     */
+    private function assertTimbreNameUnique(int $cpid, int $userId, string $timbreName, int $excludeId = 0): void
+    {
+        $query = DB::table('mp_anime_audios')
+            ->where('cpid', $cpid)
+            ->where('user_id', $userId)
+            ->where('timbre_name', $timbreName)
+            ->where('is_deleted', 0);
+
+        if ($excludeId > 0) {
+            $query->where('id', '<>', $excludeId);
+        }
+
+        if ($query->exists()) {
+            Utils::throwError('1002:该音色名称已存在');
+        }
+    }
+
+    /**
+     * 查询字段
+     *
+     * @return array
+     */
+    private function audioColumns(): array
+    {
+        return [
+            'a.id',
+            'a.timbre_name',
+            'a.user_id',
+            'a.cpid',
+            'a.timbre_url',
+            'a.language',
+            'a.remark',
+            'a.created_at',
+            'a.updated_at',
+        ];
+    }
+
+    /**
+     * 解析归属公司:管理员强制本公司;超管可指定,未指定时默认主公司
+     *
+     * @param array $params
+     * @return int
+     */
+    private function resolveCpid(array $params): int
+    {
+        if ($this->isSuperadmin()) {
+            $cpid = (int)getProp($params, 'cpid', 0);
+            if ($cpid < 1) {
+                $cpid = (int)Site::getCpid();
+            }
+            if ($cpid < 1) {
+                $cpid = 1;
+            }
+            if (!DB::table('mp_company')->where('id', $cpid)->exists()) {
+                Utils::throwError('1002:所选公司不存在');
+            }
+            return $cpid;
+        }
+
+        $cpid = (int)Site::getCpid();
+        if ($cpid < 1) {
+            Utils::throwError(ErrorConst::NOT_ACCESS);
+        }
+
+        return $cpid;
+    }
+
+    /**
+     * 解析归属用户:默认当前登录用户;超管可指定(user_id=0 表示写入公共库)
+     *
+     * @param array $params
+     * @return int
+     */
+    private function resolveUserId(array $params): int
+    {
+        $uid = (int)Site::getUid();
+
+        if ($this->isSuperadmin() && array_key_exists('user_id', $params) && $params['user_id'] !== '' && $params['user_id'] !== null) {
+            $target = (int)$params['user_id'];
+            if ($target === 0) {
+                return 0;
+            }
+            if ($target !== $uid
+                && !DB::table('mp_manage_users')->where('id', $target)->where('is_deleted', 0)->exists()) {
+                Utils::throwError('1002:归属用户不存在');
+            }
+            return $target;
+        }
+
+        return $uid;
+    }
+
+    /**
+     * 仅超管与管理员可管理音色
+     *
+     * @return void
+     */
+    private function assertManager(): void
+    {
+        if (!in_array($this->role(), ['superadmin', 'admin'], true)) {
+            Utils::throwError('1005:仅管理员可管理音色');
+        }
+    }
+
+    /**
+     * @return bool
+     */
+    private function isSuperadmin(): bool
+    {
+        return $this->role() === 'superadmin';
+    }
+
+    /**
+     * @return string
+     */
+    private function role(): string
+    {
+        return (string)Site::getRole();
+    }
+}

+ 9 - 1
routes/api.php

@@ -14,6 +14,7 @@ use App\Http\Controllers\Points\PointsController;
 use App\Http\Controllers\Points\PointsStatsController;
 use App\Http\Controllers\Manage\ManageUserController;
 use App\Http\Controllers\Manage\ArtStyleController;
+use App\Http\Controllers\Anime\AudioController;
 use App\Http\Controllers\TaskCenter\TaskCenterController;
 use Illuminate\Support\Facades\Route;
 
@@ -123,7 +124,6 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
         Route::get('delete', [ArtStyleController::class, 'delete']);      // 删除画风(软删除)
     });
 
-
     Route::group(['prefix' => 'deepseek'], function () {
         Route::post('chatWithReasoner', [DeepSeekController::class, 'chatWithReasoner']);
         Route::get('timbreList', [DeepSeekController::class, 'timbreList']);
@@ -310,6 +310,14 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
             Route::post('editPromptTemplate', [PromptTemplateController::class, 'editTemplate']);         // 编辑提示词模板
             Route::get('deletePromptTemplate', [PromptTemplateController::class, 'deleteTemplate']);      // 删除提示词模板
 
+            // 音色管理
+            Route::get('audioList', [AudioController::class, 'list']);       // 音色列表
+            Route::get('audios', [AudioController::class, 'audios']);   // 音色详情                                      
+            Route::post('addAudio', [AudioController::class, 'add']);        // 添加音色
+            Route::post('editAudio', [AudioController::class, 'edit']);      // 编辑音色
+            Route::get('deleteAudio', [AudioController::class, 'delete']);   // 删除音色
+            Route::post('batchApplyTimbreUrl', [AudioController::class, 'batchApplyTimbreUrl']);  // 批量应用音色到资产
+
             // 任务中心
             Route::get('taskCenter/detail', [TaskCenterController::class, 'detail']);      // 查询任务详情
             Route::get('taskCenter/list', [TaskCenterController::class, 'taskList']);      // 任务列表(分页)