chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\forum\controller;
use ywxapp\controller\FrontendBase;
/**
* 论坛前台控制器基类
* 继承 AddonFrontend(插件专用前台基类,提供 fetch 与会员 auth 注入)
* 注意:框架 BaseController 未暴露 assign(),这里补充 assign() 代理,与 docs 保持一致。
*/
class ForumFrontend extends FrontendBase
{
protected $noNeedLogin = ['*'];
protected $noNeedVerify = ['*'];
/**
* 初始化:调用父类,并保证布局所需的 user 变量始终存在(游客为空数组)
*/
public function _initialize()
{
parent::_initialize();
$this->assign('member', $this->member() ?? []);
}
/**
* assign 代理,返回 $this 便于链式
*/
protected function assign($name, $value = null)
{
$this->view->assign($name, $value);
return $this;
}
/**
* 获取当前登录会员(未登录返回 null)
* @return \ywxapp\model\Member|null
*/
protected function member()
{
if ($this->auth && $this->auth->isLogin) {
return $this->auth->info;
}
return null;
}
/**
* 是否已登录
*/
protected function requireLogin(): bool
{
return $this->auth && $this->auth->isLogin;
}
}
+347
View File
@@ -0,0 +1,347 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\forum\controller;
use addon\forum\model\ForumBoard;
use addon\forum\model\ForumTopic;
use addon\forum\model\ForumReply;
use addon\forum\model\ForumMessage;
use ywxapp\model\BaseModel;
use think\facade\Db;
class Index extends ForumFrontend
{
protected $noNeedLogin = [];
protected $noNeedVerify = ['*'];
/**
* 社区首页:版块 + 帖子流
*/
public function index()
{
$boards = ForumBoard::enabledList();
$topics = ForumTopic::listFront(0, '', 'new', 1, 20);
$hot = ForumTopic::listFront(0, '', 'hot', 1, 10);
$this->attachAuthors($topics);
$this->attachAuthors($hot);
// 当前会员未读消息
$unread = 0;
if ($member = $this->member()) {
$unread = ForumMessage::unreadCount((int) $member['uid']);
}
$this->assign([
'boards' => $boards,
'topics' => $topics,
'hot' => $hot,
'unread' => $unread,
'typeMap'=> ForumTopic::$typeMap,
]);
return $this->fetch('index/index');
}
/**
* 版块帖子列表
*/
public function board($slug = '')
{
$board = ForumBoard::findBySlug($slug);
if (!$board) {
$this->assign('msg', '版块不存在');
return $this->fetch('index/notfound');
}
$type = $this->request->param('type', '');
$order = $this->request->param('order', 'new');
$page = (int) $this->request->param('page', 1);
$topics = ForumTopic::listFront((int) $board->id, $type, $order, $page, 20);
$this->attachAuthors($topics);
$this->assign([
'board' => $board,
'topics' => $topics,
'type' => $type,
'order' => $order,
'typeMap'=> ForumTopic::$typeMap,
]);
return $this->fetch('index/board');
}
/**
* 帖子详情 + 回复列表
*/
public function topic($id = 0)
{
$topic = ForumTopic::findById((int) $id);
if (!$topic || (int) $topic->status !== 1) {
$this->assign('msg', '帖子不存在、待审核或已下架');
return $this->fetch('index/notfound');
}
ForumTopic::addViews((int) $topic->id);
$replies = ForumReply::listByTopic((int) $topic->id);
$board = ForumBoard::find((int) $topic->board_id);
// 作者信息(复用会员表),带默认值避免模板内联查库
$author = Db::name('member')->where('id', $topic->user_id)
->field('id,nickname,avatar,intro')->find();
if (!$author) {
$author = ['id' => $topic->user_id, 'nickname' => '匿名', 'avatar' => '', 'intro' => ''];
}
// 每条回复附带用户信息
$uids = array_column($replies->toArray(), 'user_id');
$users = [];
if (!empty($uids)) {
$users = Db::name('member')->where('id', 'in', array_unique($uids))
->column('nickname,avatar', 'id');
}
$replyList = [];
foreach ($replies as $rp) {
$u = $users[$rp['user_id']] ?? ['nickname' => '匿名', 'avatar' => ''];
$replyList[] = [
'id' => $rp['id'],
'user_id' => $rp['user_id'],
'floor' => $rp['floor'],
'content' => $rp['content'],
'create_at' => $rp['create_at'],
'nickname' => $u['nickname'] ?: '匿名',
'avatar' => $u['avatar'] ?: '',
];
}
$this->assign([
'topic' => $topic,
'author' => $author,
'replyList' => $replyList,
'board' => $board,
'related' => \addon\forum\service\ForumSearchService::related((int) $topic->id, 6),
'typeName' => ForumTopic::$typeMap[$topic->type] ?? '',
]);
return $this->fetch('index/topic');
}
/**
* 发帖页
*/
public function post()
{
if (!$this->requireLogin()) {
return redirect('/user/login');
}
$boards = ForumBoard::enabledList();
$this->assign('boards', $boards);
$this->assign('typeMap', ForumTopic::$typeMap);
$cfg = \ywxapp\service\AddonService::config('forum');
$this->assign('editorHeight', (int) ($cfg['editor_height'] ?? 360));
return $this->fetch('index/post');
}
/**
* 提交发帖
*/
public function doPost()
{
if (!$this->requireLogin()) {
return $this->error('请先登录');
}
$member = $this->member();
$data = $this->request->post();
$title = trim((string) ($data['title'] ?? ''));
$content = trim((string) ($data['content'] ?? ''));
$boardId = (int) ($data['board_id'] ?? 0);
$type = (int) ($data['type'] ?? 0);
$lat = (float) ($data['lat'] ?? 0);
$lng = (float) ($data['lng'] ?? 0);
if ($title === '' || $content === '' || $boardId < 1) {
return $this->error('请填写完整信息');
}
if (!isset(ForumTopic::$typeMap[$type])) {
$type = ForumTopic::TYPE_ASK;
}
$needAudit = (int) config('forum.need_audit');
$status = $needAudit ? 0 : 1; // 0待审 1正常
$auditReason = '';
// 机审(云审):开启 need_audit 且无 AI 审核服务时,按策略直接通过/转人工
if ($needAudit) {
$ai = new \addon\wxchat\service\AiAuditService();
$res = $ai->audit($title . "\n" . $content);
if ($res['code'] === 0) {
if (!empty($res['data']['blocked'])) {
// 机审命中违规:直接驳回,不进入人工队列
$status = -1;
$auditReason = '机审不通过:' . ($res['data']['reason'] ?? '内容违规');
}
// 机审通过:保持待审(status=0),等待人工复核
}
// AI 服务不可用(code!=0 且非 blocked):维持待审,转人工队列
}
\addon\forum\library\ForumSchema::ensure();
$topicId = ForumTopic::insertGetId([
'board_id' => $boardId,
'user_id' => $member['id'],
'title' => $title,
'content' => $content,
'type' => $type,
'status' => $status,
'lat' => $lat,
'lng' => $lng,
'audit_reason' => $auditReason,
'create_at' => time(),
'update_at' => time(),
]);
ForumBoard::incr($boardId, 'topic_count', 1);
\addon\forum\service\ForumSearchService::sync($topicId);
if ($status === -1) {
return $this->success(['id' => $topicId], $auditReason);
}
return $this->success(['id' => $topicId], $needAudit ? '发布成功,等待审核' : '发布成功');
}
/**
* 提交回复
*/
public function doReply()
{
if (!$this->requireLogin()) {
return $this->error('请先登录');
}
$member = $this->member();
$data = $this->request->post();
$topicId = (int) ($data['topic_id'] ?? 0);
$content = trim((string) ($data['content'] ?? ''));
if ($topicId < 1 || $content === '') {
return $this->error('回复内容不能为空');
}
$topic = ForumTopic::findById($topicId);
if (!$topic) {
return $this->error('帖子不存在');
}
$floor = ForumReply::countByTopic($topicId) + 1;
$replyId = ForumReply::insertGetId([
'topic_id' => $topicId,
'user_id' => $member['id'],
'content' => $content,
'floor' => $floor,
'status' => 1,
'create_at' => time(),
'update_at' => time(),
]);
// 更新帖子回复计数 + 最后回复
ForumTopic::where('id', $topicId)->inc('reply_count', 1)->update([
'last_reply_id' => $replyId,
'last_reply_uid' => $member['id'],
'last_reply_time' => time(),
]);
ForumBoard::incr((int) $topic->board_id, 'reply_count', 1);
// 给帖子作者发消息(非自己)
if ($topic->user_id != $member['id']) {
ForumMessage::insert([
'user_id' => $topic->user_id,
'from_uid' => $member['id'],
'type' => 'reply',
'topic_id' => $topicId,
'reply_id' => $replyId,
'content' => mb_substr(strip_tags($content), 0, 120),
'is_read' => 0,
'create_at' => time(),
]);
}
return $this->success(['floor' => $floor], '回复成功');
}
/**
* 搜索
*/
public function search()
{
$kw = trim((string) $this->request->param('q', ''));
$list = [];
if ($kw !== '') {
$list = \addon\forum\service\ForumSearchService::search($kw, (int) $this->request->param('page', 1), 20);
}
$this->assign('kw', $kw);
$this->assign('list', $list);
return $this->fetch('index/search');
}
/**
* 附近帖子(GEO/LBS):由前端定位传入 lat/lng,展示半径内帖子
*/
public function nearby()
{
$lat = (float) $this->request->param('lat', 0);
$lng = (float) $this->request->param('lng', 0);
$radius = (float) $this->request->param('radius', 10);
$list = \addon\forum\service\ForumSearchService::nearby($lat, $lng, $radius, 30);
$this->assign('list', $list);
$this->assign('lat', $lat);
$this->assign('lng', $lng);
$this->assign('radius', $radius);
return $this->fetch('index/nearby');
}
/**
* 批量给帖子列表(Paginator)注入 author 字段,避免模板读取不存在的关联属性
* @param \think\Paginator $paginator
*/
private function attachAuthors($collection)
{
if (!is_iterable($collection)) {
return;
}
$uids = [];
foreach ($collection as $t) {
$uids[] = $t['user_id'] ?? ($t->user_id ?? 0);
}
$uids = array_filter(array_unique($uids));
$users = [];
if (!empty($uids)) {
$users = Db::name('member')->where('id', 'in', $uids)
->column('nickname,avatar', 'id');
}
foreach ($collection as $t) {
$uid = $t['user_id'] ?? ($t->user_id ?? 0);
$u = $users[$uid] ?? ['nickname' => '匿名', 'avatar' => ''];
$t->author = [
'nickname' => $u['nickname'] ?: '匿名',
'avatar' => $u['avatar'] ?: '',
];
}
}
/**
* 用户主页(展示其论坛数据)
*/
public function user($id = 0)
{
$user = Db::name('member')->where('id', (int) $id)->field('id,nickname,avatar,intro')->find();
if (!$user) {
$this->assign('msg', '用户不存在');
return $this->fetch('index/notfound');
}
$topics = ForumTopic::where('user_id', (int) $id)
->where('status', 1)
->order('id desc')
->limit(20)->select();
$this->assign('member', $user);
$this->assign('topics', $topics);
return $this->fetch('index/user');
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\forum\controller\backend;
use addon\forum\model\ForumBoard;
use ywxapp\controller\BackendBase;
class Board extends BackendBase
{
protected $noNeedLogin = [];
protected $noNeedVerify = ['*'];
public function index()
{
$list = ForumBoard::order('sort asc,id asc')->select();
$this->assign('list', $list);
return $this->fetch('board/index');
}
public function add()
{
return $this->fetch('board/add');
}
public function save()
{
$data = $this->request->post();
$data['create_at'] = time();
$data['update_at'] = time();
ForumBoard::insert($data);
return $this->success([], '保存成功');
}
public function edit($id = 0)
{
$row = ForumBoard::find($id);
$this->assign('row', $row);
return $this->fetch('board/edit');
}
public function update($id = 0)
{
$row = ForumBoard::find($id);
if (!$row) {
return $this->error('记录不存在');
}
$row->save($this->request->post());
return $this->success([], '更新成功');
}
public function delete($id = 0)
{
ForumBoard::where('id', $id)->delete();
return $this->success([], '删除成功');
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare (strict_types = 1);
namespace addon\forum\controller\backend;
use think\facade\Config;
use ywxapp\controller\BackendBase;
use ywxapp\service\AddonService;
/**
* 编辑器(UEditor)配置
*
* 在后台可视化管理发帖富文本编辑器的参数:
* 上传目录、图片/附件/视频的允许类型与大小上限、远程图片抓取开关、初始高度。
* 数据持久化到框架统一的插件配置表(wxapp_addon_config),运行时由
* AddonService::config('forum') 读取并与 config.php 默认值合并。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Editor extends BackendBase
{
/**
* 可被后台保存的配置字段 => 默认值
*/
protected array $fields = [
'editor_upload_dir' => 'uploads/forum',
'editor_image_ext' => 'jpg,jpeg,png,gif,bmp,webp',
'editor_image_size' => 10,
'editor_file_ext' => 'zip,rar,7z,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,md',
'editor_file_size' => 50,
'editor_video_ext' => 'mp4,webm,ogg,mov',
'editor_video_size' => 100,
'editor_catch_image' => 1,
'editor_height' => 360,
];
/**
* 读取当前有效配置(config.php 默认 + 后台保存覆盖)
*/
protected function currentConfig(): array
{
$defaults = (array) Config::get('forum', []);
$saved = AddonService::config('forum');
// 用 $fields 的默认值兜底,确保所有后台字段都有值(模板无需 default 过滤器)
return array_merge($this->fields, $defaults, $saved);
}
/**
* 配置页
*/
public function index()
{
$cfg = $this->currentConfig();
$this->assign('cfg', $cfg);
return $this->fetch('editor/index');
}
/**
* 保存配置
*/
public function save()
{
$post = (array) $this->request->post();
$values = [];
foreach ($this->fields as $key => $default) {
if (!isset($post[$key])) {
continue;
}
$raw = $post[$key];
if (is_bool($default)) {
$values[$key] = $raw ? 1 : 0;
} elseif (is_int($default)) {
$values[$key] = (int) $raw;
} else {
// 字符串 / 逗号分隔扩展名列表:去空格、过滤空项、小写
$str = trim((string) $raw);
if (str_contains($str, ',')) {
$parts = array_filter(array_map(
fn($s) => strtolower(trim($s, " .\t\n\r\0\x0B")),
explode(',', $str)
), fn($s) => $s !== '');
$str = implode(',', $parts);
}
$values[$key] = $str;
}
}
// 上传目录安全校验:仅允许相对路径,禁止目录穿越
if (isset($values['editor_upload_dir'])) {
$dir = preg_replace('#\.+/#', '', str_replace('\\', '/', $values['editor_upload_dir']));
$values['editor_upload_dir'] = trim($dir, '/') ?: $this->fields['editor_upload_dir'];
}
AddonService::config('forum', $values);
return $this->result->success([], '编辑器配置已保存');
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\forum\controller\backend;
use addon\forum\model\ForumReply;
use ywxapp\controller\BackendBase;
class Reply extends BackendBase
{
protected $noNeedLogin = [];
protected $noNeedVerify = ['*'];
public function index()
{
// layui.table 动态加载
if ($this->request->isAjax()) {
$page = $this->request->param('page/d', 1);
$limit = $this->request->param('limit/d', 20);
$map = [];
// 主题 ID 过滤
if ($topicId = $this->request->param('topic_id/d')) {
$map[] = ['r.topic_id', '=', $topicId];
}
// 关键词搜索(内容模糊匹配)
if ($keyword = $this->request->param('keyword/s')) {
$map[] = ['r.content', 'like', '%' . $keyword . '%'];
}
$rows = ForumReply::alias('r')
->join('forum_topic t', 't.id = r.topic_id', 'left')
->join('user u', 'u.uid = r.user_id', 'left')
->where($map)
->field([
'r.*',
't.title as topic_title',
'u.nickname as user_name',
])
->order('r.create_at', 'desc')
->paginate([
'list_rows' => $limit,
'page' => $page,
]);
$list = $rows->items();
$total = $rows->total();
foreach ($list as &$item) {
$item['content_preview'] = mb_substr(strip_tags($item['content'] ?? ''), 0, 40);
}
unset($item);
return $this->result->setCount($total)->success($list);
}
$this->assign('title', '回复管理');
return $this->fetch('reply/index');
}
public function delete($id = 0)
{
$id = $id ?: $this->request->param('id/d');
$row = ForumReply::find($id);
if ($row) {
$row->status = -1;
$row->save();
}
return $this->success([], '删除成功');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\forum\controller\backend;
use addon\forum\model\ForumTag;
use ywxapp\controller\BackendBase;
class Tag extends BackendBase
{
protected $noNeedLogin = [];
protected $noNeedVerify = ['*'];
public function index()
{
$list = ForumTag::order('id desc')->select();
$this->assign('list', $list);
return $this->fetch('tag/index');
}
public function save()
{
$name = trim((string) $this->request->post('name', ''));
if ($name === '') {
return $this->error('名称不能为空');
}
ForumTag::create(['name' => $name, 'create_at' => time()]);
return $this->success([], '保存成功');
}
public function delete($id = 0)
{
ForumTag::where('id', $id)->delete();
return $this->success([], '删除成功');
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
declare(strict_types=1);
namespace addon\forum\controller\backend;
use addon\forum\model\ForumTopic;
use addon\forum\model\ForumBoard;
use ywxapp\controller\BackendBase;
class Topic extends BackendBase
{
protected $noNeedLogin = [];
protected $noNeedVerify = ['*'];
public function index()
{
$status = $this->request->param('status', '');
$q = ForumTopic::with(['board']);
if ($status !== '' && in_array((int) $status, [-1, 0, 1], true)) {
$q->where('status', (int) $status);
}
$list = $q->order('id desc')->paginate(20);
$this->assign('list', $list);
$this->assign('typeMap', ForumTopic::$typeMap);
$this->assign('filterStatus', $status);
return $this->fetch('topic/index');
}
public function edit($id = 0)
{
$row = ForumTopic::find($id);
$boards = ForumBoard::enabledList();
$this->assign('row', $row);
$this->assign('boards', $boards);
$this->assign('typeMap', ForumTopic::$typeMap);
return $this->fetch('topic/edit');
}
public function update($id = 0)
{
$row = ForumTopic::find($id);
if (!$row) {
return $this->error('记录不存在');
}
$row->save($this->request->post());
\addon\forum\service\ForumSearchService::sync($id);
return $this->success([], '更新成功');
}
public function delete($id = 0)
{
$row = ForumTopic::find($id);
if ($row) {
$row->status = -1;
$row->save();
\addon\forum\service\ForumSearchService::remove($id);
}
return $this->success([], '删除成功');
}
/**
* 置顶/加精/审核状态切换
*/
public function state($id = 0)
{
$field = $this->request->post('field', '');
$value = (int) $this->request->post('value', 0);
if (!in_array($field, ['is_top', 'is_essence', 'status'])) {
return $this->error('非法字段');
}
ForumTopic::where('id', $id)->update([$field => $value]);
return $this->success([], '操作成功');
}
/**
* 审核通过(人工复核):置为正常可见
*/
public function audit($id = 0)
{
$row = ForumTopic::find($id);
if (!$row) {
return $this->error('帖子不存在');
}
$row->status = 1;
$row->audit_reason = '人工审核通过';
$row->save();
return $this->success([], '审核通过');
}
/**
* 审核驳回:下架并填写理由
*/
public function reject($id = 0)
{
$row = ForumTopic::find($id);
if (!$row) {
return $this->error('帖子不存在');
}
$reason = trim((string) $this->request->post('reason', ''));
$row->status = -1;
$row->audit_reason = $reason === '' ? '人工审核驳回' : $reason;
$row->save();
return $this->success([], '已驳回');
}
}
+499
View File
@@ -0,0 +1,499 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
declare (strict_types = 1);
namespace addon\forum\controller\backend;
use think\facade\Config;
use think\Response;
use ywxapp\controller\BackendBase;
use ywxapp\service\AddonService;
/**
* UEditor 服务端统一入口(论坛发帖富文本)
*
* 按 action 参数分发:
* config 返回编辑器配置
* uploadimage 图片上传
* uploadfile 附件上传
* uploadvideo 视频上传
* listimage 图片管理器列表
* listfile 附件管理器列表
* catchimage 远程图片抓取(粘贴 Word 时把外链图落地)
*
* 全部参数(上传目录、允许类型、大小上限、抓取开关)均由后台「编辑器配置」页
* 持久化到插件配置表,运行期与 config.php 默认值合并后生效。
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Ueditor extends BackendBase
{
/**
* 合并后的有效配置
*/
protected array $cfg = [];
/**
* 从 config.php 默认值 + 后台保存值(插件配置表)合并出当前配置
*/
protected function cfg(): array
{
if ($this->cfg) {
return $this->cfg;
}
$defaults = (array) Config::get('forum', []);
$saved = AddonService::config('forum');
$this->cfg = array_merge($defaults, $saved);
return $this->cfg;
}
/**
* 取逗号分隔扩展名列表为小写数组
*/
protected function extArray(string $key, array $fallback): array
{
$raw = $this->cfg()[$key] ?? '';
if (!is_string($raw) || $raw === '') {
return $fallback;
}
return array_values(array_filter(array_map(
fn($e) => strtolower(trim($e, " .\t\n\r\0\x0B")),
explode(',', $raw)
), fn($e) => $e !== ''));
}
/**
* 取图片/附件/视频的允许扩展名
*/
protected function imageExt(): array
{
return $this->extArray('editor_image_ext', ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']);
}
protected function fileExt(): array
{
return $this->extArray('editor_file_ext', ['zip', 'rar', '7z', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md']);
}
protected function videoExt(): array
{
return $this->extArray('editor_video_ext', ['mp4', 'webm', 'ogg', 'mov']);
}
/**
* 取大小上限(MB -> 字节)
*/
protected function sizeBytes(string $key, int $mbFallback): int
{
$v = (int) ($this->cfg()[$key] ?? $mbFallback);
return max(1, $v) * 1048576;
}
/**
* 统一入口
*/
public function index()
{
$action = (string) $this->request->param('action', '');
switch ($action) {
case 'config':
return $this->jsonp($this->editorConfig());
case 'uploadimage':
return $this->jsonp($this->upload('upfile', $this->imageExt(), 'image'));
case 'uploadfile':
return $this->jsonp($this->upload('upfile', $this->fileExt(), 'file'));
case 'uploadvideo':
return $this->jsonp($this->upload('upfile', $this->videoExt(), 'video'));
case 'uploadscrawl':
return $this->jsonp($this->uploadScrawl());
case 'listimage':
return $this->jsonp($this->listFiles('image', $this->imageExt()));
case 'listfile':
return $this->jsonp($this->listFiles('file', $this->fileExt()));
case 'catchimage':
return $this->jsonp($this->catchImage());
default:
return $this->jsonp(['state' => '请求地址出错']);
}
}
/**
* 输出 JSON,兼容 UEditor 的 jsonp 回调
*/
protected function jsonp(array $data): Response
{
$callback = (string) $this->request->param('callback', '');
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($callback !== '' && preg_match('/^[A-Za-z_][A-Za-z0-9_\.]*$/', $callback)) {
return Response::create($callback . '(' . $json . ')', 'html')
->contentType('application/javascript');
}
return Response::create($json, 'html')->contentType('application/json');
}
/**
* 编辑器服务端配置
*/
protected function editorConfig(): array
{
$prefix = '/' . trim($this->uploadDir(), '/') . '/';
$imageExt = $this->imageExt();
$fileExt = $this->fileExt();
$videoExt = $this->videoExt();
$imageSize = $this->sizeBytes('editor_image_size', 10);
$fileSize = $this->sizeBytes('editor_file_size', 50);
$videoSize = $this->sizeBytes('editor_video_size', 100);
return [
'imageActionName' => 'uploadimage',
'imageFieldName' => 'upfile',
'imageMaxSize' => $imageSize,
'imageAllowFiles' => array_map(fn($e) => '.' . $e, $imageExt),
'imageCompressEnable' => true,
'imageCompressBorder' => 1600,
'imageInsertAlign' => 'none',
'imageUrlPrefix' => '',
'imagePathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
'scrawlActionName' => 'uploadscrawl',
'scrawlFieldName' => 'upfile',
'scrawlPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
'scrawlMaxSize' => $imageSize,
'scrawlUrlPrefix' => '',
'scrawlInsertAlign' => 'none',
'catcherLocalDomain' => ['127.0.0.1', 'localhost'],
'catcherActionName' => 'catchimage',
'catcherFieldName' => 'source',
'catcherPathFormat' => $prefix . 'image/{yyyy}{mm}{dd}/{time}{rand:6}',
'catcherUrlPrefix' => '',
'catcherMaxSize' => $imageSize,
'catcherAllowFiles' => array_map(fn($e) => '.' . $e, $imageExt),
'videoActionName' => 'uploadvideo',
'videoFieldName' => 'upfile',
'videoPathFormat' => $prefix . 'video/{yyyy}{mm}{dd}/{time}{rand:6}',
'videoUrlPrefix' => '',
'videoMaxSize' => $videoSize,
'videoAllowFiles' => array_map(fn($e) => '.' . $e, $videoExt),
'fileActionName' => 'uploadfile',
'fileFieldName' => 'upfile',
'filePathFormat' => $prefix . 'file/{yyyy}{mm}{dd}/{time}{rand:6}',
'fileUrlPrefix' => '',
'fileMaxSize' => $fileSize,
'fileAllowFiles' => array_map(fn($e) => '.' . $e, $fileExt),
'imageManagerActionName' => 'listimage',
'imageManagerListPath' => $prefix . 'image/',
'imageManagerListSize' => 20,
'imageManagerUrlPrefix' => '',
'imageManagerInsertAlign' => 'none',
'imageManagerAllowFiles' => array_map(fn($e) => '.' . $e, $imageExt),
'fileManagerActionName' => 'listfile',
'fileManagerListPath' => $prefix . 'file/',
'fileManagerUrlPrefix' => '',
'fileManagerListSize' => 20,
'fileManagerAllowFiles' => array_map(fn($e) => '.' . $e, $fileExt),
];
}
/**
* 通用上传处理
*/
protected function upload(string $field, array $allowExt, string $group): array
{
$file = $this->request->file($field);
if (!$file) {
return ['state' => '未找到上传文件'];
}
$ext = strtolower($file->getOriginalExtension());
if (!in_array($ext, $allowExt, true)) {
return ['state' => '不允许的文件类型:' . $ext];
}
$limit = $group === 'video'
? $this->sizeBytes('editor_video_size', 100)
: ($group === 'file' ? $this->sizeBytes('editor_file_size', 50) : $this->sizeBytes('editor_image_size', 10));
if ($file->getSize() > $limit) {
return ['state' => '文件大小超出限制'];
}
if ($group === 'image') {
$info = @getimagesize($file->getRealPath());
if ($info === false) {
return ['state' => '文件不是有效的图片'];
}
}
$relativeDir = trim($this->uploadDir(), '/') . '/' . $group . '/' . date('Ymd');
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
return ['state' => '上传目录创建失败,请检查权限'];
}
$original = $file->getOriginalName();
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.' . $ext;
try {
$file->move($targetDir, $saveName);
} catch (\Throwable $e) {
return ['state' => '文件保存失败:' . $e->getMessage()];
}
$url = '/' . $relativeDir . '/' . $saveName;
return [
'state' => 'SUCCESS',
'url' => $url,
'title' => $original,
'original' => $original,
'type' => '.' . $ext,
'size' => (string) filesize($targetDir . DIRECTORY_SEPARATOR . $saveName),
];
}
/**
* 涂鸦上传:接收 base64 数据
*/
protected function uploadScrawl(): array
{
$base64 = (string) $this->request->post('upfile', '');
if ($base64 === '') {
return ['state' => '未接收到涂鸦数据'];
}
$binary = base64_decode($base64, true);
if ($binary === false || strlen($binary) > $this->sizeBytes('editor_image_size', 10)) {
return ['state' => '涂鸦数据无效或过大'];
}
$relativeDir = trim($this->uploadDir(), '/') . '/image/' . date('Ymd');
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
return ['state' => '上传目录创建失败,请检查权限'];
}
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.png';
if (@file_put_contents($targetDir . DIRECTORY_SEPARATOR . $saveName, $binary) === false) {
return ['state' => '涂鸦保存失败'];
}
return [
'state' => 'SUCCESS',
'url' => '/' . $relativeDir . '/' . $saveName,
'title' => $saveName,
'original' => $saveName,
'type' => '.png',
'size' => (string) strlen($binary),
];
}
/**
* 远程图片抓取
*/
protected function catchImage(): array
{
if ((int) ($this->cfg()['editor_catch_image'] ?? 1) !== 1) {
return ['state' => '远程图片抓取已关闭', 'list' => []];
}
$field = 'source';
$sources = $this->request->param($field, []);
if (!is_array($sources)) {
$sources = [$sources];
}
if (empty($sources)) {
return ['state' => '未接收到图片地址'];
}
$imageExt = $this->imageExt();
$relativeDir = trim($this->uploadDir(), '/') . '/image/' . date('Ymd');
$targetDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeDir);
if (!is_dir($targetDir) && !@mkdir($targetDir, 0755, true) && !is_dir($targetDir)) {
return ['state' => '上传目录创建失败,请检查权限'];
}
$list = [];
foreach ($sources as $remote) {
$remote = (string) $remote;
$item = ['state' => '抓取失败', 'source' => $remote, 'url' => ''];
if (!$this->isSafeRemoteUrl($remote)) {
$item['state'] = '非法的图片地址';
$list[] = $item;
continue;
}
$binary = $this->fetchRemote($remote);
if ($binary === null) {
$list[] = $item;
continue;
}
$info = @getimagesizefromstring($binary);
if ($info === false) {
$item['state'] = '远程文件不是有效图片';
$list[] = $item;
continue;
}
$ext = image_type_to_extension($info[2], false);
if (!in_array(strtolower((string) $ext), $imageExt, true)) {
$item['state'] = '不允许的图片类型';
$list[] = $item;
continue;
}
$saveName = date('His') . substr(md5(uniqid('', true)), 0, 10) . '.' . $ext;
if (@file_put_contents($targetDir . DIRECTORY_SEPARATOR . $saveName, $binary) === false) {
$item['state'] = '图片保存失败';
$list[] = $item;
continue;
}
$list[] = [
'state' => 'SUCCESS',
'url' => '/' . $relativeDir . '/' . $saveName,
'size' => (string) strlen($binary),
'title' => $saveName,
'original' => basename(parse_url($remote, PHP_URL_PATH) ?: $saveName),
'source' => $remote,
];
}
return ['state' => 'SUCCESS', 'list' => $list];
}
/**
* 图片 / 附件管理器列表
*/
protected function listFiles(string $group, array $allowExt): array
{
$start = (int) $this->request->param('start', 0);
$size = (int) $this->request->param('size', 20);
$size = $size > 0 && $size <= 100 ? $size : 20;
$relativeRoot = trim($this->uploadDir(), '/') . '/' . $group;
$rootDir = public_path() . str_replace('/', DIRECTORY_SEPARATOR, $relativeRoot);
if (!is_dir($rootDir)) {
return ['state' => 'SUCCESS', 'list' => [], 'start' => $start, 'total' => 0];
}
$files = [];
$iter = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($rootDir, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iter as $fileInfo) {
if (!$fileInfo->isFile()) {
continue;
}
if (!in_array(strtolower($fileInfo->getExtension()), $allowExt, true)) {
continue;
}
$relative = str_replace('\\', '/', substr($fileInfo->getPathname(), strlen(public_path())));
$files[] = [
'url' => '/' . ltrim($relative, '/'),
'mtime' => $fileInfo->getMTime(),
];
}
usort($files, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
$total = count($files);
$page = array_slice($files, $start, $size);
return ['state' => 'SUCCESS', 'list' => $page, 'start' => $start, 'total' => $total];
}
/**
* 校验远程地址是否安全,阻断 SSRF
*/
protected function isSafeRemoteUrl(string $url): bool
{
$parts = parse_url($url);
if (!$parts || empty($parts['scheme']) || empty($parts['host'])) {
return false;
}
if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return false;
}
$host = $parts['host'];
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return false;
}
$public = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
return $public !== false;
}
/**
* 下载远程内容
*/
protected function fetchRemote(string $url): ?string
{
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_USERAGENT => 'YwxApp-Forum/1.0',
CURLOPT_SSL_VERIFYPEER => true,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code !== 200 || strlen((string) $body) > $this->sizeBytes('editor_image_size', 10)) {
return null;
}
return (string) $body;
}
$ctx = stream_context_create(['http' => ['timeout' => 15, 'follow_location' => 0]]);
$body = @file_get_contents($url, false, $ctx, 0, $this->sizeBytes('editor_image_size', 10) + 1);
if ($body === false || strlen($body) > $this->sizeBytes('editor_image_size', 10)) {
return null;
}
return $body;
}
/**
* 读取上传目录配置(后台可配,安全校验防穿越)
*/
protected function uploadDir(): string
{
$dir = (string) ($this->cfg()['editor_upload_dir'] ?? 'uploads/forum');
$dir = str_replace('\\', '/', $dir);
$dir = preg_replace('#\.+/#', '', $dir) ?? 'uploads/forum';
return trim($dir, '/') ?: 'uploads/forum';
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace addon\forum\controller\member;
use ywxapp\controller\MemberBase;
use addon\forum\model\ForumTopic;
use addon\forum\model\ForumBoard;
use think\facade\Db;
use think\facade\Validate;
/**
* 会员中心 - 论坛(发帖 / 我的帖子)
*/
class Forum extends MemberBase
{
/**
* 当前会员主键(论坛 user_id 使用 auth->info 的 id
*/
protected function uid(): int
{
$info = $this->auth->info ?? [];
return (int) ($info['id'] ?? 0);
}
/** 发帖页 */
public function post()
{
if ($this->request->isPost()) {
return $this->doPost();
}
$boards = ForumBoard::enabledList();
$cfg = \ywxapp\service\AddonService::config('forum');
$this->view->assign('boards', $boards);
$this->view->assign('editorHeight', (int) ($cfg['editor_height'] ?? 360));
return $this->view->fetch('forum/post');
}
/** 提交发帖 */
protected function doPost()
{
$uid = $this->uid();
if ($uid < 1) {
return $this->result->error('请先登录');
}
$data = $this->request->post();
$validate = Validate::rule([
'board_id' => 'require|integer',
'title' => 'require|length:2,120',
'content' => 'require|length:5,65535',
]);
if (!$validate->check($data)) {
return $this->result->error($validate->getError());
}
$board = ForumBoard::find($data['board_id']);
if (!$board || $board->status != 1) {
return $this->result->error('版块不存在或已禁用');
}
$topicId = ForumTopic::insertGetId([
'user_id' => $uid,
'board_id' => (int) $data['board_id'],
'board_slug' => $board->slug ?? '',
'title' => trim($data['title']),
'content' => $data['content'],
'type' => (int) ($data['type'] ?? 99),
'reply_count' => 0,
'view_count' => 0,
'digest' => 0,
'top' => 0,
'status' => 1,
'create_at' => date('Y-m-d H:i:s'),
'update_at' => date('Y-m-d H:i:s'),
]);
// 版块帖子数 +1
ForumBoard::incr((int) $data['board_id'], 'topic_count', 1);
if ($topicId) {
return $this->result->success(['id' => $topicId], '发布成功');
}
return $this->result->error('发布失败,请重试');
}
/** 我的帖子管理 */
public function mytopics()
{
$uid = $this->uid();
if ($uid < 1) {
return $this->result->error('请先登录');
}
$page = (int) $this->request->get('page', 1);
$limit = 15;
$query = ForumTopic::where('user_id', $uid)->where('status', '>=', 0);
$total = (clone $query)->count();
$topics = $query->order('id desc')->page($page, $limit)->select();
$boardIds = $topics->column('board_id');
$boards = ForumBoard::whereIn('id', $boardIds ?: [0])->column('title', 'id');
$list = [];
foreach ($topics as $t) {
$list[] = [
'id' => $t->id,
'title' => $t->title,
'board_title' => $boards[$t->board_id] ?? '未知版块',
'reply_count' => $t->reply_count,
'view_count' => $t->view_count,
'status' => $t->status,
'create_at' => date('Y-m-d H:i', strtotime($t->create_at)),
];
}
if ($this->request->isAjax()) {
return $this->result->success([
'list' => $list,
'total' => $total,
'page' => $page,
], 'ok');
}
$this->view->assign('list', $list);
$this->view->assign('total', $total);
$this->view->assign('page', $page);
return $this->view->fetch('forum/mytopics');
}
/** 删除我的帖子 */
public function del()
{
if (!$this->request->isPost()) {
return $this->result->error('请求方式错误');
}
$uid = $this->uid();
if ($uid < 1) {
return $this->result->error('请先登录');
}
$id = (int) $this->request->post('id', 0);
if ($id < 1) {
return $this->result->error('参数错误');
}
$topic = ForumTopic::where('id', $id)->where('user_id', $uid)->find();
if (!$topic) {
return $this->result->error('帖子不存在');
}
$topic->status = -1;
$topic->save();
if ($topic->board_id > 0) {
ForumBoard::where('id', $topic->board_id)->where('topic_count', '>', 0)
->dec('topic_count', 1)->update();
}
return $this->result->success([], '已删除');
}
}