Merge branch 'new-miniapp'

This commit is contained in:
2026-05-22 17:55:54 +08:00
15 changed files with 1236 additions and 3 deletions
+29
View File
@@ -0,0 +1,29 @@
import request from '@/utils/request'
// 账号管理 API
export function apiAssetUserList(params: any) {
return request.get({ url: '/asset.AssetUser/lists', params })
}
export function apiAssetUserAdd(params: any) {
return request.post({ url: '/asset.AssetUser/add', params })
}
export function apiAssetUserEdit(params: any) {
return request.post({ url: '/asset.AssetUser/edit', params })
}
export function apiAssetUserDelete(params: any) {
return request.post({ url: '/asset.AssetUser/delete', params })
}
// 资源管理 API
export function apiAssetResourceList(params: any) {
return request.get({ url: '/asset.AssetResource/lists', params })
}
export function apiAssetResourceAdd(params: any) {
return request.post({ url: '/asset.AssetResource/add', params })
}
export function apiAssetResourceEdit(params: any) {
return request.post({ url: '/asset.AssetResource/edit', params })
}
export function apiAssetResourceDelete(params: any) {
return request.post({ url: '/asset.AssetResource/delete', params })
}
+5 -3
View File
@@ -102,8 +102,8 @@ export default defineComponent({
const visible = ref(false)
const fileList = ref<any[]>([])
// 仅 video + direct 时才接管 http-request
const useDirect = computed(() => props.direct && props.type === 'video')
// 仅 video/voice + direct 时才接管 http-request
const useDirect = computed(() => props.direct && ['video', 'voice'].includes(props.type))
const handleProgress = () => {
visible.value = true
@@ -151,6 +151,8 @@ export default defineComponent({
return '.jpg,.png,.gif,.jpeg,.ico'
case 'video':
return '.wmv,.avi,.mpg,.mpeg,.3gp,.mov,.mp4,.flv,.rmvb,.mkv'
case 'voice':
return '.mp3,.wav,.wma,.m4a,.aac,.amr'
default:
return '*'
}
@@ -162,7 +164,7 @@ export default defineComponent({
try {
const data = await uploadVideoDirectToCos({
file: options.file,
type: 'video',
type: props.type as any,
cid: Number((options.data as any)?.cid ?? 0),
onProgress(info) {
// 触发 ElUpload 内部进度(保持与默认上传一致的体验)
+291
View File
@@ -0,0 +1,291 @@
<template>
<div class="asset-resource-container">
<!-- 搜索区域 -->
<el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="标题">
<el-input
placeholder="请输入标题关键词"
v-model="searchData.title"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="上传时间">
<el-date-picker
v-model="searchData.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
clearable
style="width: 280px;"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 列表区域 -->
<el-card class="!border-none mt-4" shadow="never">
<div class="mb-4">
<el-button type="primary" @click="handleAdd">上传资源</el-button>
</div>
<el-tabs v-model="queryParams.type" @tab-change="handleTabChange">
<el-tab-pane label="图片" name="1"></el-tab-pane>
<el-tab-pane label="视频" name="2"></el-tab-pane>
<el-tab-pane label="语音" name="3"></el-tab-pane>
</el-tabs>
<el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="title" label="标题" min-width="80" />
<el-table-column label="预览" width="100">
<template #default="{ row }">
<el-image v-if="row.type == 1" :src="row.file_url" style="width: 50px; height: 50px;" :preview-src-list="[row.file_url]" preview-teleported />
<el-link v-else :href="row.file_url" target="_blank" type="primary">点击预览</el-link>
</template>
</el-table-column>
<el-table-column label="关联账号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<el-tag v-if="!row.users?.length" type="info" size="small">公开资源</el-tag>
<span v-else>{{ row.users?.map((u: any) => u.phone).join(', ') }}</span>
</template>
</el-table-column>
<el-table-column prop="create_time" label="上传时间" width="180" />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getList"
@current-change="getList"
/>
</div>
</el-card>
<!-- 新增/编辑资源弹窗 -->
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑资源' : '新增分发资源'" width="600px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="computedRules" label-width="100px">
<el-form-item label="资源类型" prop="type" v-if="!isEdit">
<el-radio-group v-model="formData.type">
<el-radio :label="1">图片</el-radio>
<el-radio :label="2">视频</el-radio>
<el-radio :label="3">语音</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入资源标题" />
</el-form-item>
<el-form-item label="资源文件" prop="file_url" v-if="!isEdit">
<material-picker v-if="formData.type === 1" v-model="formData.file_url" :limit="1" type="image" />
<upload v-else-if="formData.type === 2" type="video" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
<el-button type="primary">上传视频 (OSS直传)</el-button>
</upload>
<upload v-else-if="formData.type === 3" type="voice" direct :multiple="false" :limit="1" :show-progress="true" @success="handleUploadSuccess">
<el-button type="primary">上传语音 (OSS直传)</el-button>
</upload>
<div v-if="formData.file_url && formData.type !== 1" class="ml-4 text-primary truncate max-w-xs" :title="formData.file_url">
已上传: {{ formData.file_url }}
</div>
</el-form-item>
<el-form-item label="关联账号" prop="user_ids">
<el-select v-model="formData.user_ids" multiple filterable placeholder="不选择则为公开资源" clearable style="width: 100%;">
<el-option v-for="item in userOptions" :key="item.id" :label="item.phone" :value="item.id" />
</el-select>
<div style="font-size: 12px; color: #909399; margin-top: 4px;">不选择账号则资源为公开资源所有人可见</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">{{ isEdit ? '保存修改' : '确定发布' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiAssetResourceList, apiAssetResourceAdd, apiAssetResourceEdit, apiAssetResourceDelete, apiAssetUserList } from '@/api/asset'
import MaterialPicker from '@/components/material/picker.vue'
import Upload from '@/components/upload/index.vue'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const searchData = reactive<any>({
title: '',
dateRange: []
})
const queryParams = reactive<any>({
type: '1',
page_no: 1,
page_size: 15
})
const dialogVisible = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const isEdit = ref(false)
const editId = ref(0)
const userOptions = ref<any[]>([])
const formData = reactive<any>({
type: 1,
title: '',
file_url: '',
cover_url: '',
user_ids: []
})
const computedRules = computed(() => {
if (isEdit.value) {
return {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
}
}
return {
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
file_url: [{ required: true, message: '请输入或上传文件获取链接', trigger: 'blur' }]
}
})
const buildQueryParams = () => {
const params: any = { ...queryParams }
if (searchData.title) {
params.title = searchData.title
}
if (searchData.dateRange && searchData.dateRange.length === 2) {
params.start_time = searchData.dateRange[0]
params.end_time = searchData.dateRange[1]
}
return params
}
const getList = async () => {
loading.value = true
try {
const res = await apiAssetResourceList(buildQueryParams())
tableData.value = res.lists
total.value = res.count
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const fetchUserOptions = async () => {
try {
const res = await apiAssetUserList({ page_no: 1, page_size: 9999 })
userOptions.value = res.lists
} catch (e) {
console.error(e)
}
}
const handleSearch = () => {
queryParams.page_no = 1
getList()
}
const handleReset = () => {
searchData.title = ''
searchData.dateRange = []
queryParams.page_no = 1
getList()
}
const handleTabChange = () => {
queryParams.page_no = 1
getList()
}
const handleAdd = () => {
isEdit.value = false
editId.value = 0
Object.assign(formData, { type: parseInt(queryParams.type), title: '', file_url: '', cover_url: '', user_ids: [] })
fetchUserOptions()
dialogVisible.value = true
}
const handleEdit = (row: any) => {
isEdit.value = true
editId.value = row.id
Object.assign(formData, {
type: row.type,
title: row.title,
file_url: row.file_url,
cover_url: row.cover_url || '',
user_ids: row.users?.map((u: any) => u.id) || []
})
fetchUserOptions()
dialogVisible.value = true
}
const handleUploadSuccess = (response: any) => {
formData.file_url = response?.data?.uri || response?.data?.url || ''
ElMessage.success('上传成功')
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该资源吗?用户将无法再看到。', '提示', { type: 'warning' })
await apiAssetResourceDelete({ id: row.id })
ElMessage.success('删除成功')
getList()
} catch (e) {
// canceled
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitLoading.value = true
try {
if (isEdit.value) {
await apiAssetResourceEdit({
id: editId.value,
title: formData.title,
user_ids: formData.user_ids
})
ElMessage.success('编辑成功')
} else {
await apiAssetResourceAdd(formData)
ElMessage.success('上传分发成功')
}
dialogVisible.value = false
getList()
} catch (e) {
console.error(e)
} finally {
submitLoading.value = false
}
}
})
}
onMounted(() => {
getList()
})
</script>
+204
View File
@@ -0,0 +1,204 @@
<template>
<div class="asset-user-container">
<!-- 搜索区域 -->
<el-card class="!border-none" shadow="never">
<el-form class="ls-form" :model="searchData" inline>
<el-form-item class="w-[280px]" label="手机号">
<el-input
placeholder="请输入手机号"
v-model="searchData.phone"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 列表区域 -->
<el-card class="!border-none mt-4" shadow="never">
<div class="mb-4">
<el-button type="primary" @click="handleAdd">新增账号</el-button>
</div>
<el-table :data="tableData" v-loading="loading">
<!-- <el-table-column prop="id" label="ID" width="80" /> -->
<el-table-column prop="phone" label="手机号" />
<el-table-column prop="create_time" label="创建时间" width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
active-text="正常"
inactive-text="禁用"
inline-prompt
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="mt-4 flex justify-end">
<el-pagination
v-model:current-page="queryParams.page_no"
v-model:page-size="queryParams.page_size"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getList"
@current-change="getList"
/>
</div>
</el-card>
<!-- 编辑/新增弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号(作为登录账号)" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="formData.password" placeholder="为空则默认为 123456" show-password />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="formData.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { apiAssetUserList, apiAssetUserAdd, apiAssetUserEdit, apiAssetUserDelete } from '@/api/asset'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const searchData = reactive({
phone: ''
})
const queryParams = reactive<any>({
page_no: 1,
page_size: 15
})
const dialogVisible = ref(false)
const dialogTitle = ref('')
const submitLoading = ref(false)
const formRef = ref()
const formData = reactive({
id: '',
phone: '',
password: '',
status: 1
})
const rules = {
phone: [{ required: true, message: '请输入手机号', trigger: 'blur' }]
}
const getList = async () => {
loading.value = true
try {
const params = { ...queryParams, ...searchData }
const res = await apiAssetUserList(params)
tableData.value = res.lists
total.value = res.count
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handleSearch = () => {
queryParams.page_no = 1
getList()
}
const handleReset = () => {
searchData.phone = ''
queryParams.page_no = 1
getList()
}
const handleStatusChange = async (row: any) => {
try {
await apiAssetUserEdit({ id: row.id, status: row.status })
ElMessage.success(row.status === 1 ? '已启用' : '已禁用')
} catch (e) {
// 失败时回滚状态
row.status = row.status === 1 ? 0 : 1
console.error(e)
}
}
const handleAdd = () => {
dialogTitle.value = '新增账号'
Object.assign(formData, { id: '', phone: '', password: '', status: 1 })
dialogVisible.value = true
}
const handleEdit = (row: any) => {
dialogTitle.value = '编辑账号'
Object.assign(formData, { id: row.id, phone: row.phone, password: '', status: row.status })
dialogVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该账号及其关联资源吗?', '提示', { type: 'warning' })
await apiAssetUserDelete({ id: row.id })
ElMessage.success('删除成功')
getList()
} catch (e) {
// canceled
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitLoading.value = true
try {
if (formData.id) {
await apiAssetUserEdit(formData)
} else {
await apiAssetUserAdd(formData)
}
ElMessage.success('操作成功')
dialogVisible.value = false
getList()
} catch (e) {
console.error(e)
} finally {
submitLoading.value = false
}
}
})
}
onMounted(() => {
getList()
})
</script>
@@ -0,0 +1,162 @@
<?php
namespace app\adminapi\controller\asset;
use app\adminapi\controller\BaseAdminController;
use app\common\model\AssetResource;
use app\common\model\AssetUserResource;
use think\facade\Db;
class AssetResourceController extends BaseAdminController
{
/**
* @notes 资源列表
*/
public function lists()
{
$pageNo = $this->request->get('page_no', 1);
$pageSize = $this->request->get('page_size', 15);
$type = $this->request->get('type');
$title = $this->request->get('title', '');
$startTime = $this->request->get('start_time');
$endTime = $this->request->get('end_time');
$where = [];
if ($type) {
$where[] = ['type', '=', $type];
}
if ($title) {
$where[] = ['title', 'like', '%' . $title . '%'];
}
if ($startTime) {
$where[] = ['create_time', '>=', strtotime($startTime)];
}
if ($endTime) {
$where[] = ['create_time', '<=', strtotime($endTime) + 86399];
}
$count = AssetResource::where($where)->count();
$lists = AssetResource::with('users')
->where($where)
->order('id', 'desc')
->page($pageNo, $pageSize)
->select();
return $this->data([
'count' => $count,
'lists' => $lists,
'page_no' => $pageNo,
'page_size' => $pageSize,
]);
}
/**
* @notes 添加资源及分配账号
*/
public function add()
{
$params = $this->request->post();
if (empty($params['type']) || empty($params['title']) || empty($params['file_url'])) {
return $this->fail('请填写完整的资源信息');
}
Db::startTrans();
try {
$resource = AssetResource::create([
'type' => $params['type'],
'title' => $params['title'],
'file_url' => $params['file_url'],
'cover_url' => $params['cover_url'] ?? '',
]);
// 绑定用户
if (!empty($params['user_ids']) && is_array($params['user_ids'])) {
$userResources = [];
foreach ($params['user_ids'] as $userId) {
$userResources[] = [
'user_id' => $userId,
'resource_id' => $resource->id,
'create_time' => time(),
];
}
(new AssetUserResource())->saveAll($userResources);
}
Db::commit();
return $this->success('添加并分配成功', ['id' => $resource->id]);
} catch (\Exception $e) {
Db::rollback();
return $this->fail('操作失败: ' . $e->getMessage());
}
}
/**
* @notes 编辑资源(修改标题、关联账号)
*/
public function edit()
{
$params = $this->request->post();
$id = $params['id'] ?? 0;
if (empty($id)) {
return $this->fail('缺少参数');
}
$resource = AssetResource::find($id);
if (!$resource) {
return $this->fail('资源不存在');
}
Db::startTrans();
try {
// 更新标题
if (!empty($params['title'])) {
$resource->title = $params['title'];
$resource->save();
}
// 重新绑定用户(先删后加)
if (isset($params['user_ids'])) {
AssetUserResource::where('resource_id', $id)->delete();
$userIds = is_array($params['user_ids']) ? $params['user_ids'] : [];
if (!empty($userIds)) {
$userResources = [];
foreach ($userIds as $userId) {
$userResources[] = [
'user_id' => $userId,
'resource_id' => $id,
'create_time' => time(),
];
}
(new AssetUserResource())->saveAll($userResources);
}
}
Db::commit();
return $this->success('编辑成功');
} catch (\Exception $e) {
Db::rollback();
return $this->fail('操作失败: ' . $e->getMessage());
}
}
/**
* @notes 删除资源
*/
public function delete()
{
$id = $this->request->post('id');
if (empty($id)) {
return $this->fail('缺少参数');
}
Db::startTrans();
try {
AssetResource::destroy($id);
AssetUserResource::where('resource_id', $id)->delete();
Db::commit();
return $this->success('删除成功');
} catch (\Exception $e) {
Db::rollback();
return $this->fail('删除失败');
}
}
}
@@ -0,0 +1,116 @@
<?php
namespace app\adminapi\controller\asset;
use app\adminapi\controller\BaseAdminController;
use app\common\model\AssetUser;
class AssetUserController extends BaseAdminController
{
/**
* @notes 账号列表
*/
public function lists()
{
$pageNo = $this->request->get('page_no', 1);
$pageSize = $this->request->get('page_size', 15);
$phone = $this->request->get('phone', '');
$where = [];
if ($phone) {
$where[] = ['phone', 'like', '%' . $phone . '%'];
}
$count = AssetUser::where($where)->count();
$lists = AssetUser::where($where)
->order('id', 'desc')
->page($pageNo, $pageSize)
->select();
return $this->data([
'count' => $count,
'lists' => $lists,
'page_no' => $pageNo,
'page_size' => $pageSize,
]);
}
/**
* @notes 添加账号
*/
public function add()
{
$params = $this->request->post();
if (empty($params['phone'])) {
return $this->fail('手机号不能为空');
}
$exist = AssetUser::where('phone', $params['phone'])->find();
if ($exist) {
return $this->fail('手机号已存在');
}
// Default password 123456
$password = empty($params['password']) ? '123456' : $params['password'];
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$user = AssetUser::create([
'phone' => $params['phone'],
'password' => $passwordHash,
'status' => $params['status'] ?? 1,
]);
return $this->success('添加成功', ['id' => $user->id]);
}
/**
* @notes 编辑账号
*/
public function edit()
{
$params = $this->request->post();
if (empty($params['id'])) {
return $this->fail('缺少参数');
}
$user = AssetUser::find($params['id']);
if (!$user) {
return $this->fail('账号不存在');
}
if (!empty($params['phone']) && $params['phone'] != $user->phone) {
$exist = AssetUser::where('phone', $params['phone'])->find();
if ($exist) {
return $this->fail('手机号已存在');
}
$user->phone = $params['phone'];
}
if (!empty($params['password'])) {
$user->password = password_hash($params['password'], PASSWORD_DEFAULT);
}
if (isset($params['status'])) {
$user->status = $params['status'];
}
$user->save();
return $this->success('修改成功');
}
/**
* @notes 删除账号
*/
public function delete()
{
$id = $this->request->post('id');
if (empty($id)) {
return $this->fail('缺少参数');
}
AssetUser::destroy($id);
// 也需要删除关联的资源记录
\app\common\model\AssetUserResource::where('user_id', $id)->delete();
return $this->success('删除成功');
}
}
@@ -0,0 +1,286 @@
<?php
namespace app\api\controller\asset;
use app\api\controller\BaseApiController;
use app\common\model\AssetUser;
use app\common\model\AssetResource;
use app\common\model\AssetUserResource;
use think\facade\Db;
class AssetAppController extends BaseApiController
{
// 所有接口都免框架登录验证(使用独立 asset_token 体系,在方法内自行校验)
public array $notNeedLogin = ['login', 'getResourceList', 'changePassword', 'recordDownload'];
/** token 有效期:7 天 */
const TOKEN_EXPIRE = 86400 * 7;
/**
* @notes 通过 token 获取当前用户(不检查 status,仅查 token 有效性)
*/
private function getAssetUserRaw(): ?AssetUser
{
$token = $this->request->header('token');
if (empty($token)) {
return null;
}
// 方式1: 从数据库 token 字段查找
try {
$user = AssetUser::where('token', $token)
->where('token_expire_time', '>', time())
->find();
if ($user) {
return $user;
}
} catch (\Throwable $e) {
// token 字段不存在时忽略,走 cache 兜底
}
// 方式2: 从 file cache 查找
$userId = cache('asset_token_' . $token);
if ($userId) {
$user = AssetUser::where('id', $userId)->find();
return $user ?: null;
}
return null;
}
/**
* @notes 获取当前有效用户(status=1 才返回)
*/
private function getAssetUser(): ?AssetUser
{
$user = $this->getAssetUserRaw();
if ($user && $user->status == 1) {
return $user;
}
return null;
}
/**
* @notes 小程序端登录
*/
public function login()
{
$phone = $this->request->post('phone');
$password = $this->request->post('password');
if (empty($phone) || empty($password)) {
return $this->fail('手机号或密码不能为空');
}
$user = AssetUser::where('phone', $phone)->find();
if (!$user) {
return $this->fail('账号不存在');
}
if ($user->status != 1) {
return $this->fail('账号已被禁用');
}
if (!password_verify($password, $user->password)) {
return $this->fail('密码错误');
}
// 生成 token 并双写(DB + cache 兜底)
$token = md5($user->id . time() . uniqid('asset', true));
// 写入 file cache(始终可用)
cache('asset_token_' . $token, $user->id, self::TOKEN_EXPIRE);
// 尝试写入数据库(需要已执行 SQL 迁移)
try {
$user->token = $token;
$user->token_expire_time = time() + self::TOKEN_EXPIRE;
$user->save();
} catch (\Throwable $e) {
// token 字段不存在时忽略,cache 已经写入
}
return $this->success('登录成功', [
'token' => $token,
'user' => [
'id' => $user->id,
'phone' => $user->phone
]
]);
}
/**
* @notes 获取用户关联的资源列表 (或公开资源)
*/
public function getResourceList()
{
$token = $this->request->header('token');
$rawUser = !empty($token) ? $this->getAssetUserRaw() : null;
$user = ($rawUser && $rawUser->status == 1) ? $rawUser : null;
$isDisabled = ($rawUser && $rawUser->status != 1); // 用户存在但被禁用
$tokenInvalid = (!empty($token) && !$rawUser); // token 过期或无效
$type = $this->request->get('type', 1);
$pageNo = $this->request->get('page_no', 1);
$pageSize = $this->request->get('page_size', 20);
$days = (int)$this->request->get('days', 0);
$usageStatus = (int)$this->request->get('usage_status', 0); // 0=全部, 1=未使用, 2=已使用
$query = AssetResource::where('type', $type);
$usedResourceIds = [];
if ($user) {
// 登录用户:自己的专属资源 + 所有公开资源
$exclusiveIds = AssetUserResource::where('user_id', $user->id)->column('resource_id');
$associatedResourceIds = AssetUserResource::column('resource_id');
$query = $query->where(function ($q) use ($exclusiveIds, $associatedResourceIds) {
if (!empty($exclusiveIds)) {
$q->whereIn('id', $exclusiveIds);
}
if (!empty($associatedResourceIds)) {
if (!empty($exclusiveIds)) {
$q->whereOr('id', 'not in', $associatedResourceIds);
} else {
$q->whereNotIn('id', $associatedResourceIds);
}
}
});
// 获取该用户的使用记录
$usedResourceIds = \think\facade\Db::name('asset_resource_usage')
->where('user_id', $user->id)
->column('resource_id');
// 使用状态过滤
if ($usageStatus === 1) { // 未使用
if (!empty($usedResourceIds)) {
$query = $query->whereNotIn('id', $usedResourceIds);
}
} elseif ($usageStatus === 2) { // 已使用
if (empty($usedResourceIds)) {
return $this->data([
'lists' => [],
'count' => 0,
'page_no' => $pageNo,
'page_size' => $pageSize,
'is_disabled' => $isDisabled,
'token_invalid' => $tokenInvalid,
]);
}
$query = $query->whereIn('id', $usedResourceIds);
}
} else {
// 游客(未登录):只看公开资源(没关联给任何用户的资源)
$associatedResourceIds = AssetUserResource::column('resource_id');
if (!empty($associatedResourceIds)) {
$query = $query->whereNotIn('id', $associatedResourceIds);
}
}
// 时间过滤
if ($days > 0) {
$startTime = strtotime("-{$days} days", strtotime(date('Y-m-d')));
$query = $query->where('create_time', '>=', $startTime);
}
$count = (clone $query)->count();
$lists = (clone $query)
->order('id', 'desc')
->page($pageNo, $pageSize)
->select()
->toArray();
// 附加 is_used 字段
foreach ($lists as &$item) {
$item['is_used'] = in_array($item['id'], $usedResourceIds);
}
return $this->data([
'lists' => $lists,
'count' => $count,
'page_no' => $pageNo,
'page_size' => $pageSize,
'is_disabled' => $isDisabled, // 用户被禁用
'token_invalid' => $tokenInvalid, // token 过期或无效
]);
}
/**
* @notes 记录资源下载/使用
*/
public function recordDownload()
{
$user = $this->getAssetUser();
if (!$user) {
return $this->fail('请先登录', [], -1);
}
$resourceId = $this->request->post('resource_id');
if (empty($resourceId)) {
return $this->fail('参数缺失');
}
// 检查资源是否存在
$resource = AssetResource::find($resourceId);
if (!$resource) {
return $this->fail('资源不存在');
}
// 检查是否有关联权限 (或者是公开资源)
$isAssociated = AssetUserResource::where('user_id', $user->id)->where('resource_id', $resourceId)->find();
$isPublic = !AssetUserResource::where('resource_id', $resourceId)->find();
if (!$isAssociated && !$isPublic) {
return $this->fail('无权操作此资源');
}
// 记录下载
$exists = \think\facade\Db::name('asset_resource_usage')
->where('user_id', $user->id)
->where('resource_id', $resourceId)
->find();
if (!$exists) {
\think\facade\Db::name('asset_resource_usage')->insert([
'user_id' => $user->id,
'resource_id' => $resourceId,
'create_time' => time()
]);
}
return $this->success('记录成功');
}
/**
* @notes 修改密码
*/
public function changePassword()
{
$user = $this->getAssetUser();
if (!$user) {
return $this->fail('登录已过期,请重新登录', [], -1);
}
$oldPassword = $this->request->post('old_password');
$password = $this->request->post('password');
if (empty($oldPassword)) {
return $this->fail('请输入原密码');
}
if (empty($password)) {
return $this->fail('请输入新密码');
}
if (strlen($password) < 6) {
return $this->fail('新密码至少6位');
}
if (!password_verify($oldPassword, $user->password)) {
return $this->fail('原密码不正确');
}
$user->password = password_hash($password, PASSWORD_DEFAULT);
$user->save();
return $this->success('密码修改成功');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace app\common\model;
use app\common\service\FileService;
class AssetResource extends BaseModel
{
protected $name = 'asset_resource';
protected $autoWriteTimestamp = true;
public function getFileUrlAttr($value)
{
return trim($value) ? FileService::getFileUrl($value) : '';
}
public function setFileUrlAttr($value)
{
return trim($value) ? FileService::setFileUrl($value) : '';
}
public function getCoverUrlAttr($value)
{
return trim($value) ? FileService::getFileUrl($value) : '';
}
public function setCoverUrlAttr($value)
{
return trim($value) ? FileService::setFileUrl($value) : '';
}
// Relation to users via asset_user_resource
public function users()
{
return $this->belongsToMany(AssetUser::class, AssetUserResource::class, 'user_id', 'resource_id');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\common\model;
use think\model\concern\SoftDelete;
class AssetUser extends BaseModel
{
protected $name = 'asset_user';
protected $autoWriteTimestamp = true;
// Optional: Hide password when returning array/json
protected $hidden = ['password'];
}
@@ -0,0 +1,10 @@
<?php
namespace app\common\model;
use think\model\Pivot;
class AssetUserResource extends Pivot
{
protected $name = 'asset_user_resource';
protected $autoWriteTimestamp = 'create_time';
protected $updateTime = false;
}
@@ -19,6 +19,7 @@ class DirectUploadService
{
/** 视频允许的扩展名(沿用 config/project.file_video */
public const TYPE_VIDEO = 'video';
public const TYPE_VOICE = 'voice';
/** 默认凭证有效期 30 分钟 */
public const DEFAULT_DURATION = 1800;
@@ -26,6 +27,7 @@ class DirectUploadService
/** 允许扩展类型 → 大小上限(字节) */
private const MAX_SIZE = [
self::TYPE_VIDEO => 2 * 1024 * 1024 * 1024, // 2GB
self::TYPE_VOICE => 500 * 1024 * 1024, // 500MB
];
/**
@@ -144,6 +146,7 @@ class DirectUploadService
{
return match ($type) {
self::TYPE_VIDEO => FileEnum::VIDEO_TYPE,
self::TYPE_VOICE => FileEnum::FILE_TYPE,
default => FileEnum::FILE_TYPE,
};
}
+38
View File
@@ -0,0 +1,38 @@
-- 专属数字资产 菜单和权限配置
-- 注意:需要根据实际的菜单表结构调整字段名称(如前缀 zyt_ 可能需根据您的实际表前缀替换)
-- 1. 添加一级菜单:资源分发管理
INSERT INTO `zyt_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
VALUES (0, 'M', '资源分发管理', 'el-icon-FolderOpened', 110, '', '/asset', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- 获取刚插入的一级菜单ID
SET @asset_pid = LAST_INSERT_ID();
-- 2. 添加二级菜单:分发账号管理
INSERT INTO `zyt_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
VALUES (@asset_pid, 'C', '分发账号管理', 'el-icon-User', 1, 'asset.AssetUser/lists', '/asset/user', '/asset/user/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- 获取【分发账号管理】菜单ID
SET @user_menu_id = LAST_INSERT_ID();
-- 3. 添加账号相关按钮权限
INSERT INTO `zyt_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
VALUES
(@user_menu_id, 'A', '新增账号', '', 1, 'asset.AssetUser/add', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(@user_menu_id, 'A', '编辑账号', '', 2, 'asset.AssetUser/edit', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(@user_menu_id, 'A', '删除账号', '', 3, 'asset.AssetUser/delete', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- 4. 添加二级菜单:资源素材下发
INSERT INTO `zyt_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
VALUES (@asset_pid, 'C', '资源素材下发', 'el-icon-Files', 2, 'asset.AssetResource/lists', '/asset/resource', '/asset/resource/index', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
-- 获取【资源素材下发】菜单ID
SET @resource_menu_id = LAST_INSERT_ID();
-- 5. 添加资源相关按钮权限
INSERT INTO `zyt_system_menu` (`pid`, `type`, `name`, `icon`, `sort`, `perms`, `paths`, `component`, `selected`, `params`, `is_cache`, `is_show`, `is_disable`, `create_time`, `update_time`)
VALUES
(@resource_menu_id, 'A', '上传分配资源', '', 1, 'asset.AssetResource/add', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()),
(@resource_menu_id, 'A', '删除资源', '', 2, 'asset.AssetResource/delete', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE `zyt_asset_resource_usage` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) unsigned NOT NULL COMMENT '用户ID',
`resource_id` int(11) unsigned NOT NULL COMMENT '资源ID',
`create_time` int(11) DEFAULT '0' COMMENT '使用/下载时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_resource` (`user_id`, `resource_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私密资产-资源使用记录表';
+31
View File
@@ -0,0 +1,31 @@
CREATE TABLE `zyt_asset_user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`phone` varchar(20) NOT NULL COMMENT '手机号(登录账号)',
`password` varchar(255) NOT NULL COMMENT '密码',
`status` tinyint(1) DEFAULT '1' COMMENT '状态:1=正常,0=禁用',
`create_time` int(11) DEFAULT '0' COMMENT '创建时间',
`update_time` int(11) DEFAULT '0' COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私密资产-用户表';
CREATE TABLE `zyt_asset_resource` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '资源类型:1=图片,2=视频,3=语音',
`title` varchar(100) NOT NULL COMMENT '资源名称/标题',
`file_url` varchar(500) NOT NULL COMMENT '文件链接',
`cover_url` varchar(500) DEFAULT '' COMMENT '封面图链接(针对视频/语音)',
`create_time` int(11) DEFAULT '0' COMMENT '创建时间',
`update_time` int(11) DEFAULT '0' COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私密资产-资源表';
CREATE TABLE `zyt_asset_user_resource` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) unsigned NOT NULL COMMENT '用户ID',
`resource_id` int(11) unsigned NOT NULL COMMENT '资源ID',
`create_time` int(11) DEFAULT '0' COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_resource_id` (`resource_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私密资产-用户与资源关联表';
+4
View File
@@ -0,0 +1,4 @@
-- 为 asset_user 表添加 token 持久化字段(替代 file cache,避免 GC 清理导致 token 丢失)
ALTER TABLE `zyt_asset_user` ADD COLUMN `token` varchar(64) DEFAULT '' COMMENT '登录token' AFTER `status`;
ALTER TABLE `zyt_asset_user` ADD COLUMN `token_expire_time` int(11) DEFAULT '0' COMMENT 'token过期时间戳' AFTER `token`;
ALTER TABLE `zyt_asset_user` ADD INDEX `idx_token` (`token`);