本次更新涵盖多个功能模块的优化与新增: 1. 新增Wi-Fi直连配对方式与协作画布模块 2. 完成设备管理重命名API与前端适配 3. 优化日签卡片空数据保护与UI细节 4. 新增剪贴板工具与每日运势会话 5. 修复应用锁恢复、语音消息录制等已知问题 6. 完善文件传输统计与配对逻辑 7. 更新安卓权限配置与iOS隐私描述 8. 新增自动化测试脚本与文档 9. 清理旧版审计报告与测试文件
1111 lines
43 KiB
PHP
1111 lines
43 KiB
PHP
<?php
|
||
namespace app\api\controller;
|
||
|
||
use app\common\controller\Api;
|
||
use think\Db;
|
||
use think\Cache;
|
||
|
||
class Fortune extends Api
|
||
{
|
||
protected $noNeedLogin = ['daily', 'history', 'date', 'config', 'themes', 'image', 'sixtySeconds', 'huangli', 'horoscope', 'install', 'test', 'push'];
|
||
protected $noNeedRight = ['*'];
|
||
|
||
private $fortuneLevels = ['大吉' => 95, '中吉' => 83, '小吉' => 74, '吉' => 64, '末吉' => 54, '凶' => 40, '大凶' => 20];
|
||
private $fortuneWeights = ['大吉' => 5, '中吉' => 15, '小吉' => 20, '吉' => 25, '末吉' => 20, '凶' => 12, '大凶' => 3];
|
||
private $dimensionKeys = ['love', 'career', 'wealth', 'health', 'study', 'social'];
|
||
private $luckyNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 13, 16, 18, 21, 24, 27, 33, 36, 42, 56, 66, 77, 88, 99];
|
||
private $luckyColors = ['朱红', '靛蓝', '翠绿', '琥珀', '月白', '玄青', '鹅黄', '藕荷', '石青', '胭脂', '松花', '丁香', '竹青', '秋香', '黛蓝', '绛紫'];
|
||
private $luckyDirections = ['正东', '正南', '正西', '正北', '东南', '东北', '西南', '西北'];
|
||
private $luckyConstellations = ['白羊座', '金牛座', '双子座', '巨蟹座', '狮子座', '处女座', '天秤座', '天蝎座', '射手座', '摩羯座', '水瓶座', '双鱼座'];
|
||
private $suitableItems = ['求财', '出行', '签约', '纳财', '安床', '祭祀', '祈福', '求嗣', '开光', '修造', '动土', '嫁娶', '入宅', '安葬', '开市', '栽种', '牧养', '求医', '会亲友', '入学', '上梁', '竖柱', '经络', '裁衣', '合帐'];
|
||
private $unsuitableItems = ['动土', '嫁娶', '开市', '出行', '安葬', '入宅', '掘井', '破土', '开仓', '出财', '伐木', '作梁', '行丧', '造桥', '筑堤', '开渠', '安机', '造车', '经络', '词讼'];
|
||
|
||
private $signTexts = [
|
||
'大吉' => [
|
||
'鹏程万里风正举,一帆风顺向天涯,贵人相助逢佳运,事事如意福无涯',
|
||
'紫气东来映彩霞,鸿运当头万事佳,心想事成无阻碍,前程似锦步步花',
|
||
'春风得意马蹄疾,一日看尽长安花,时来运转逢佳期,万事亨通福满家',
|
||
'金光大道向前行,步步高升事业兴,贵人指路明方向,一帆风顺到功成',
|
||
'天降鸿福喜盈门,万事如意乐无痕,财运亨通人缘好,幸福安康永长存',
|
||
'龙腾虎跃展宏图,鹏飞万里志不孤,贵人扶持前路阔,功成名就天下殊',
|
||
'瑞雪兆丰年年好,春风送暖日日新,福星高照诸事顺,喜气盈门万事成',
|
||
'云开雾散见青天,柳暗花明又一村,否极泰来时运转,一帆风顺好前程',
|
||
],
|
||
'中吉' => [
|
||
'春风化雨润无声,静待花开自有情,莫道前路多坎坷,柳暗花明又一村',
|
||
'月到中秋分外明,守得云开见月明,勤勉耕耘终有报,水到渠成自然行',
|
||
'半壁江山半壁春,一分耕耘一分真,虽非大吉亦无碍,稳步前行福自临',
|
||
'溪水长流终入海,春风化雨润花开,莫嫌此去路稍远,自有明灯照前途',
|
||
'中天皓月照长空,半是晴明半是风,若能守得初心在,自有花开月正中',
|
||
'山间清泉石上流,不急不缓自悠悠,中吉之运宜守成,稳中求进莫强求',
|
||
],
|
||
'小吉' => [
|
||
'细水长流终入海,积少成多自有财,小吉虽非大富贵,平安喜乐亦开怀',
|
||
'微风拂面春意暖,细雨润物花自开,虽无大运亦无碍,小有收获亦开怀',
|
||
'小桥流水人家好,平淡生活亦逍遥,小吉之运宜静守,不急不躁福自到',
|
||
'晨曦微露见曙光,小有进展亦无妨,持之以恒终有报,水滴石穿见真章',
|
||
],
|
||
'吉' => [
|
||
'山高水远路漫漫,行到尽头是坦途,莫愁前路无知己,天下谁人不识君',
|
||
'千里之行始足下,万丈高楼平地起,吉运当头宜进取,把握时机莫迟疑',
|
||
'天朗气清惠风和,吉日良辰好事多,若能勤勉加努力,自有收获满筐箩',
|
||
'东风送暖入屠苏,吉运临门喜气浮,诸事顺遂宜进取,把握当下莫虚度',
|
||
'日行一善积阴德,吉人自有天相扶,心正不怕影子斜,坦荡行路自无阻',
|
||
],
|
||
'末吉' => [
|
||
'月有阴晴圆缺时,人有悲欢离合期,末吉虽非大好运,守得云开见明月',
|
||
'花开花落自有时,潮涨潮落自有期,末吉之运宜谨慎,韬光养晦待时机',
|
||
'秋风吹叶落纷纷,末吉之运需修身,若能忍得一时苦,自有春风再临门',
|
||
'寒冬将尽春将来,末吉之运莫悲哀,守得初心终不悔,否极泰来花自开',
|
||
],
|
||
'凶' => [
|
||
'风雨欲来花满楼,凶运当头需慎行,退一步海阔天空,忍一时风平浪静',
|
||
'山雨欲来风满楼,凶运临头莫强求,韬光养晦待时机,否极泰来自有时',
|
||
'乌云遮日暂无光,凶运当头莫惊慌,静待时机守本分,雨过天晴见彩虹',
|
||
],
|
||
'大凶' => [
|
||
'暴风骤雨夜深沉,大凶之运需谨慎,守得本心莫妄动,风雨之后见彩虹',
|
||
'山崩地裂天翻覆,大凶之运莫强出,退守家园修德行,否极泰来自有时',
|
||
],
|
||
];
|
||
|
||
private $cachePrefix = 'fortune_';
|
||
private $cacheTTL = 86400;
|
||
|
||
public function _initialize()
|
||
{
|
||
header('Access-Control-Allow-Origin: *');
|
||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Device-Id, Token');
|
||
if ($this->request->method() === 'OPTIONS') {
|
||
http_response_code(204);
|
||
exit;
|
||
}
|
||
parent::_initialize();
|
||
}
|
||
|
||
public function daily()
|
||
{
|
||
$uid = $this->request->param('uid', '');
|
||
$regen = $this->request->param('regen', 0);
|
||
$theme = $this->request->param('theme', 'ancient');
|
||
$constellation = $this->request->param('constellation', '');
|
||
|
||
if (empty($uid)) {
|
||
$this->error('uid参数必填', null, 400);
|
||
}
|
||
|
||
$today = date('Y-m-d');
|
||
$seed = $this->generateSeed($uid, $today, $regen ? time() : 0);
|
||
|
||
$cacheKey = $this->cachePrefix . 'daily_' . $uid . '_' . $today . '_' . md5($seed);
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached && !$regen) {
|
||
$cached['is_today'] = ($today === $cached['date']);
|
||
$cached['can_regenerate'] = ($today === $cached['date']);
|
||
$this->success('ok', $cached);
|
||
return;
|
||
}
|
||
|
||
$record = $this->findRecord($uid, $today, $seed);
|
||
if ($record && !$regen) {
|
||
$data = $this->formatRecord($record, $today);
|
||
Cache::set($cacheKey, $data, $this->cacheTTL);
|
||
$this->success('ok', $data);
|
||
return;
|
||
}
|
||
|
||
$data = $this->generateFortune($uid, $today, $seed, $constellation);
|
||
|
||
$huangli = $this->fetchHuangli($today);
|
||
if ($huangli) {
|
||
$data['huangli'] = $huangli;
|
||
}
|
||
|
||
$this->saveRecord($uid, $today, $data, $seed);
|
||
|
||
$data['is_today'] = true;
|
||
$data['can_regenerate'] = true;
|
||
$data['regen_count'] = $regen ? ($record ? ($record['regen_count'] + 1) : 1) : 0;
|
||
|
||
Cache::set($cacheKey, $data, $this->cacheTTL);
|
||
$this->success('ok', $data);
|
||
}
|
||
|
||
public function history()
|
||
{
|
||
$uid = $this->request->param('uid', '');
|
||
$page = $this->request->param('page', 1);
|
||
$limit = $this->request->param('limit', 10);
|
||
$sort = $this->request->param('sort', 'newest');
|
||
|
||
if (empty($uid)) {
|
||
$this->error('uid参数必填', null, 400);
|
||
}
|
||
|
||
$limit = min(max(intval($limit), 1), 30);
|
||
$page = max(intval($page), 1);
|
||
|
||
$query = Db::name('fortune_record')->where('uid', $uid);
|
||
$total = $query->count();
|
||
|
||
$orderDir = ($sort === 'oldest') ? 'asc' : 'desc';
|
||
$list = Db::name('fortune_record')
|
||
->where('uid', $uid)
|
||
->order('date', $orderDir)
|
||
->page($page, $limit)
|
||
->select();
|
||
|
||
$today = date('Y-m-d');
|
||
$formattedList = [];
|
||
foreach ($list as $item) {
|
||
$formattedList[] = $this->formatRecord($item, $today);
|
||
}
|
||
|
||
$this->success('ok', [
|
||
'total' => $total,
|
||
'page' => $page,
|
||
'limit' => $limit,
|
||
'list' => $formattedList,
|
||
]);
|
||
}
|
||
|
||
public function date()
|
||
{
|
||
$uid = $this->request->param('uid', '');
|
||
$date = $this->request->param('date', '');
|
||
$theme = $this->request->param('theme', 'ancient');
|
||
|
||
if (empty($uid) || empty($date)) {
|
||
$this->error('uid和date参数必填', null, 400);
|
||
}
|
||
|
||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||
$this->error('date格式错误,应为YYYY-MM-DD', null, 400);
|
||
}
|
||
|
||
$today = date('Y-m-d');
|
||
$record = Db::name('fortune_record')
|
||
->where('uid', $uid)
|
||
->where('date', $date)
|
||
->order('id', 'desc')
|
||
->find();
|
||
|
||
if (!$record) {
|
||
$seed = $this->generateSeed($uid, $date, 0);
|
||
$data = $this->generateFortune($uid, $date, $seed, '');
|
||
$this->saveRecord($uid, $date, $data, $seed);
|
||
$data['is_today'] = ($date === $today);
|
||
$data['can_regenerate'] = ($date === $today);
|
||
$this->success('ok', $data);
|
||
return;
|
||
}
|
||
|
||
$data = $this->formatRecord($record, $today);
|
||
$this->success('ok', $data);
|
||
}
|
||
|
||
public function config()
|
||
{
|
||
$uid = $this->request->param('uid', '');
|
||
|
||
if (empty($uid)) {
|
||
$this->error('uid参数必填', null, 400);
|
||
}
|
||
|
||
if ($this->request->isPost()) {
|
||
$this->updateConfig($uid);
|
||
return;
|
||
}
|
||
|
||
$config = Db::name('fortune_config')->where('uid', $uid)->find();
|
||
if (!$config) {
|
||
$config = $this->createDefaultConfig($uid);
|
||
}
|
||
|
||
$this->success('ok', $this->formatConfig($config));
|
||
}
|
||
|
||
public function themes()
|
||
{
|
||
$themes = [
|
||
[
|
||
'key' => 'ancient',
|
||
'name' => '古风签筒',
|
||
'icon' => '🏯',
|
||
'description' => '金色主题,签文卦象,中式装饰',
|
||
'is_default' => true,
|
||
],
|
||
[
|
||
'key' => 'wechat',
|
||
'name' => '微信运动',
|
||
'icon' => '📊',
|
||
'description' => '蓝色科技,进度条,数据可视化',
|
||
],
|
||
[
|
||
'key' => 'apple',
|
||
'name' => 'Apple Health',
|
||
'icon' => '⌚',
|
||
'description' => '白底极简,环形图,iOS原生感',
|
||
],
|
||
];
|
||
|
||
$this->success('ok', ['themes' => $themes]);
|
||
}
|
||
|
||
public function image()
|
||
{
|
||
$uid = $this->request->param('uid', '');
|
||
$date = $this->request->param('date', date('Y-m-d'));
|
||
$theme = $this->request->param('theme', 'ancient');
|
||
|
||
if (empty($uid)) {
|
||
$this->error('uid参数必填', null, 400);
|
||
}
|
||
|
||
$imageUrl = $this->generateFortuneImage($uid, $date, $theme);
|
||
|
||
$this->success('ok', [
|
||
'image_url' => $imageUrl,
|
||
'width' => 750,
|
||
'height' => 1334,
|
||
'theme' => $theme,
|
||
'generated_at' => time(),
|
||
]);
|
||
}
|
||
|
||
public function sixtySeconds()
|
||
{
|
||
$source = $this->request->param('source', '');
|
||
$cacheKey = $this->cachePrefix . '60s_' . date('Y-m-d');
|
||
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached) {
|
||
$this->success('ok', $cached);
|
||
return;
|
||
}
|
||
|
||
$data = $this->fetch60sNews($source);
|
||
if ($data) {
|
||
Cache::set($cacheKey, $data, $this->cacheTTL);
|
||
$this->success('ok', $data);
|
||
} else {
|
||
$this->error('60秒新闻获取失败,请稍后重试', null, 503);
|
||
}
|
||
}
|
||
|
||
public function huangli()
|
||
{
|
||
$date = $this->request->param('date', date('Y-m-d'));
|
||
$cacheKey = $this->cachePrefix . 'huangli_' . $date;
|
||
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached) {
|
||
$this->success('ok', $cached);
|
||
return;
|
||
}
|
||
|
||
$data = $this->fetchHuangli($date);
|
||
if ($data) {
|
||
Cache::set($cacheKey, $data, $this->cacheTTL);
|
||
$this->success('ok', $data);
|
||
} else {
|
||
$this->error('黄历数据获取失败', null, 503);
|
||
}
|
||
}
|
||
|
||
public function horoscope()
|
||
{
|
||
$sign = $this->request->param('sign', '');
|
||
$time = $this->request->param('time', 'today');
|
||
$source = $this->request->param('source', '');
|
||
|
||
if (empty($sign)) {
|
||
$this->error('sign参数必填', null, 400);
|
||
}
|
||
|
||
$cacheKey = $this->cachePrefix . 'horoscope_' . $sign . '_' . $time . '_' . date('Y-m-d');
|
||
|
||
$cached = Cache::get($cacheKey);
|
||
if ($cached) {
|
||
$this->success('ok', $cached);
|
||
return;
|
||
}
|
||
|
||
$data = $this->fetchHoroscope($sign, $time, $source);
|
||
if ($data) {
|
||
Cache::set($cacheKey, $data, $this->cacheTTL);
|
||
$this->success('ok', $data);
|
||
} else {
|
||
$this->error('星座运势获取失败', null, 503);
|
||
}
|
||
}
|
||
|
||
public function install()
|
||
{
|
||
$tables = [];
|
||
try {
|
||
$sqls = [
|
||
"CREATE TABLE IF NOT EXISTS `tool_fortune_data` (
|
||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||
`type` varchar(20) NOT NULL DEFAULT '',
|
||
`level` varchar(10) NOT NULL DEFAULT '',
|
||
`content` text,
|
||
`category` varchar(50) NOT NULL DEFAULT '',
|
||
`weight` int(11) NOT NULL DEFAULT 1,
|
||
`created_at` int(11) unsigned NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_type` (`type`),
|
||
KEY `idx_level` (`level`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||
"CREATE TABLE IF NOT EXISTS `tool_fortune_record` (
|
||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||
`uid` varchar(128) NOT NULL DEFAULT '',
|
||
`date` varchar(10) NOT NULL DEFAULT '',
|
||
`fortune_level` varchar(10) NOT NULL DEFAULT '',
|
||
`fortune_score` int(11) NOT NULL DEFAULT 0,
|
||
`sign_text` text,
|
||
`dimensions_json` text,
|
||
`lucky_json` text,
|
||
`suitable_json` text,
|
||
`unsuitable_json` text,
|
||
`theme` varchar(20) NOT NULL DEFAULT 'ancient',
|
||
`regen_count` int(11) NOT NULL DEFAULT 0,
|
||
`image_url` varchar(512) NOT NULL DEFAULT '',
|
||
`seed` varchar(64) NOT NULL DEFAULT '',
|
||
`created_at` int(11) unsigned NOT NULL DEFAULT 0,
|
||
`updated_at` int(11) unsigned NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_uid_date_seed` (`uid`, `date`, `seed`),
|
||
KEY `idx_uid` (`uid`),
|
||
KEY `idx_date` (`date`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||
"CREATE TABLE IF NOT EXISTS `tool_fortune_config` (
|
||
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
|
||
`uid` varchar(128) NOT NULL DEFAULT '',
|
||
`theme` varchar(20) NOT NULL DEFAULT 'ancient',
|
||
`show_dims_json` text,
|
||
`show_lucky` tinyint(1) NOT NULL DEFAULT 1,
|
||
`show_yiji` tinyint(1) NOT NULL DEFAULT 1,
|
||
`show_sign` tinyint(1) NOT NULL DEFAULT 1,
|
||
`show_news60s` tinyint(1) NOT NULL DEFAULT 0,
|
||
`show_weiyu` tinyint(1) NOT NULL DEFAULT 1,
|
||
`push_enabled` tinyint(1) NOT NULL DEFAULT 1,
|
||
`push_time` varchar(10) NOT NULL DEFAULT '08:00',
|
||
`push_freq` varchar(20) NOT NULL DEFAULT 'daily',
|
||
`sort_order` varchar(20) NOT NULL DEFAULT 'newest_first',
|
||
`constellation` varchar(20) NOT NULL DEFAULT '',
|
||
`birthday` varchar(20) NOT NULL DEFAULT '',
|
||
`fortune_reminder` tinyint(1) NOT NULL DEFAULT 0,
|
||
`extra_json` text,
|
||
`created_at` int(11) unsigned NOT NULL DEFAULT 0,
|
||
`updated_at` int(11) unsigned NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (`id`),
|
||
UNIQUE KEY `uk_uid` (`uid`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
||
];
|
||
|
||
foreach ($sqls as $sql) {
|
||
Db::execute($sql);
|
||
}
|
||
$tables = ['tool_fortune_data', 'tool_fortune_record', 'tool_fortune_config'];
|
||
|
||
$this->seedFortuneDataDb();
|
||
|
||
$this->success('Tables created successfully', ['tables' => $tables]);
|
||
} catch (\Exception $e) {
|
||
$this->error('安装失败: ' . $e->getMessage(), null, 500);
|
||
}
|
||
}
|
||
|
||
public function test()
|
||
{
|
||
$results = [];
|
||
|
||
$apis = [
|
||
'60s_viki' => 'https://60s.viki.moe/v2/60s',
|
||
'60s_vvhan' => 'https://api.vvhan.com/api/60s',
|
||
'60s_tenapi' => 'https://tenapi.cn/v2/60s',
|
||
'horoscope_vvhan' => 'https://api.vvhan.com/api/horoscope?type=白羊&time=today',
|
||
'horoscope_tenapi' => 'https://tenapi.cn/v2/star?type=白羊座',
|
||
'huangli_vvhan' => 'https://api.vvhan.com/api/laohuangli',
|
||
'huangli_timor' => 'https://timor.tech/api/holiday/info/' . date('Y-m-d'),
|
||
'fortune_vvhan' => 'https://api.vvhan.com/api/fortune',
|
||
'lucky_vvhan' => 'https://api.vvhan.com/api/lucky',
|
||
'daily_vvhan' => 'https://api.vvhan.com/api/daily',
|
||
'nongli_vvhan' => 'https://api.vvhan.com/api/nongli',
|
||
'dujitang_vvhan' => 'https://api.vvhan.com/api/dujitang',
|
||
'history_viki' => 'https://60s.viki.moe/v2/history',
|
||
];
|
||
|
||
foreach ($apis as $name => $url) {
|
||
$start = microtime(true);
|
||
try {
|
||
$ch = curl_init();
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_URL => $url,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => 5,
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_USERAGENT => 'XianYan/12.0 Fortune API Tester',
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
$error = curl_error($ch);
|
||
curl_close($ch);
|
||
|
||
$elapsed = round((microtime(true) - $start) * 1000);
|
||
$results[$name] = [
|
||
'url' => $url,
|
||
'status' => $httpCode,
|
||
'ok' => ($httpCode >= 200 && $httpCode < 300),
|
||
'time_ms' => $elapsed,
|
||
'error' => $error ?: null,
|
||
'response_preview' => mb_substr($response, 0, 200),
|
||
];
|
||
} catch (\Exception $e) {
|
||
$results[$name] = [
|
||
'url' => $url,
|
||
'status' => 0,
|
||
'ok' => false,
|
||
'time_ms' => round((microtime(true) - $start) * 1000),
|
||
'error' => $e->getMessage(),
|
||
];
|
||
}
|
||
}
|
||
|
||
$this->success('ok', $results);
|
||
}
|
||
|
||
private function generateSeed($uid, $date, $extra = 0)
|
||
{
|
||
return md5($uid . '_' . $date . '_' . $extra);
|
||
}
|
||
|
||
private function generateFortune($uid, $date, $seed, $constellation = '')
|
||
{
|
||
mt_srand(crc32($seed));
|
||
|
||
$mainLevel = $this->weightedRandom($this->fortuneWeights);
|
||
$mainScore = $this->fortuneLevels[$mainLevel] + mt_rand(-5, 5);
|
||
$mainScore = max(0, min(100, $mainScore));
|
||
|
||
$signText = $this->getRandomSignText($mainLevel);
|
||
|
||
$dimensions = [];
|
||
foreach ($this->dimensionKeys as $dim) {
|
||
$dimLevel = $this->weightedRandom($this->fortuneWeights);
|
||
$dimScore = $this->fortuneLevels[$dimLevel] + mt_rand(-8, 8);
|
||
$dimScore = max(0, min(100, $dimScore));
|
||
$dimensions[$dim] = [
|
||
'level' => $dimLevel,
|
||
'score' => $dimScore,
|
||
];
|
||
}
|
||
|
||
$lucky = [
|
||
'number' => $this->luckyNumbers[mt_rand(0, count($this->luckyNumbers) - 1)],
|
||
'color' => $this->luckyColors[mt_rand(0, count($this->luckyColors) - 1)],
|
||
'direction' => $this->luckyDirections[mt_rand(0, count($this->luckyDirections) - 1)],
|
||
'constellation' => $constellation ?: $this->luckyConstellations[mt_rand(0, count($this->luckyConstellations) - 1)],
|
||
];
|
||
|
||
$suitableCount = mt_rand(2, 4);
|
||
$suitableKeys = array_rand($this->suitableItems, min($suitableCount, count($this->suitableItems)));
|
||
if (!is_array($suitableKeys)) $suitableKeys = [$suitableKeys];
|
||
$suitable = array_map(function ($k) { return $this->suitableItems[$k]; }, $suitableKeys);
|
||
|
||
$unsuitableCount = mt_rand(1, 3);
|
||
$unsuitableKeys = array_rand($this->unsuitableItems, min($unsuitableCount, count($this->unsuitableItems)));
|
||
if (!is_array($unsuitableKeys)) $unsuitableKeys = [$unsuitableKeys];
|
||
$unsuitable = array_map(function ($k) { return $this->unsuitableItems[$k]; }, $unsuitableKeys);
|
||
|
||
return [
|
||
'date' => $date,
|
||
'uid' => $uid,
|
||
'fortune_level' => $mainLevel,
|
||
'fortune_score' => $mainScore,
|
||
'sign_text' => $signText,
|
||
'dimensions' => $dimensions,
|
||
'lucky' => $lucky,
|
||
'suitable' => array_values($suitable),
|
||
'unsuitable' => array_values($unsuitable),
|
||
'theme' => 'ancient',
|
||
'image_url' => '',
|
||
'created_at' => time(),
|
||
];
|
||
}
|
||
|
||
private function weightedRandom($weights)
|
||
{
|
||
$total = array_sum($weights);
|
||
$rand = mt_rand(1, $total);
|
||
$cumulative = 0;
|
||
foreach ($weights as $key => $weight) {
|
||
$cumulative += $weight;
|
||
if ($rand <= $cumulative) {
|
||
return $key;
|
||
}
|
||
}
|
||
return array_key_last($weights);
|
||
}
|
||
|
||
private function getRandomSignText($level)
|
||
{
|
||
$texts = isset($this->signTexts[$level]) ? $this->signTexts[$level] : $this->signTexts['吉'];
|
||
return $texts[mt_rand(0, count($texts) - 1)];
|
||
}
|
||
|
||
private function saveRecord($uid, $date, $data, $seed)
|
||
{
|
||
try {
|
||
$existing = Db::name('fortune_record')
|
||
->where('uid', $uid)
|
||
->where('date', $date)
|
||
->where('seed', $seed)
|
||
->find();
|
||
|
||
if ($existing) {
|
||
return $existing['id'];
|
||
}
|
||
|
||
Db::name('fortune_record')->insert([
|
||
'uid' => $uid,
|
||
'date' => $date,
|
||
'fortune_level' => $data['fortune_level'],
|
||
'fortune_score' => $data['fortune_score'],
|
||
'sign_text' => $data['sign_text'],
|
||
'dimensions_json' => json_encode($data['dimensions'], JSON_UNESCAPED_UNICODE),
|
||
'lucky_json' => json_encode($data['lucky'], JSON_UNESCAPED_UNICODE),
|
||
'suitable_json' => json_encode($data['suitable'], JSON_UNESCAPED_UNICODE),
|
||
'unsuitable_json' => json_encode($data['unsuitable'], JSON_UNESCAPED_UNICODE),
|
||
'theme' => isset($data['theme']) ? $data['theme'] : 'ancient',
|
||
'regen_count' => 0,
|
||
'image_url' => isset($data['image_url']) ? $data['image_url'] : '',
|
||
'seed' => $seed,
|
||
'created_at' => time(),
|
||
'updated_at' => time(),
|
||
]);
|
||
|
||
return Db::name('fortune_record')->getLastInsID();
|
||
} catch (\Exception $e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private function findRecord($uid, $date, $seed = '')
|
||
{
|
||
$query = Db::name('fortune_record')
|
||
->where('uid', $uid)
|
||
->where('date', $date);
|
||
|
||
if ($seed) {
|
||
$query = $query->where('seed', $seed);
|
||
}
|
||
|
||
return $query->order('id', 'desc')->find();
|
||
}
|
||
|
||
private function formatRecord($record, $today = '')
|
||
{
|
||
if (!$today) $today = date('Y-m-d');
|
||
|
||
$dimensions = json_decode($record['dimensions_json'], true) ?: [];
|
||
$lucky = json_decode($record['lucky_json'], true) ?: [];
|
||
$suitable = json_decode($record['suitable_json'], true) ?: [];
|
||
$unsuitable = json_decode($record['unsuitable_json'], true) ?: [];
|
||
|
||
return [
|
||
'date' => $record['date'],
|
||
'uid' => $record['uid'],
|
||
'fortune_level' => $record['fortune_level'],
|
||
'fortune_score' => $record['fortune_score'],
|
||
'sign_text' => $record['sign_text'],
|
||
'dimensions' => $dimensions,
|
||
'lucky' => $lucky,
|
||
'suitable' => $suitable,
|
||
'unsuitable' => $unsuitable,
|
||
'theme' => $record['theme'],
|
||
'is_today' => ($record['date'] === $today),
|
||
'can_regenerate' => ($record['date'] === $today),
|
||
'regen_count' => $record['regen_count'],
|
||
'image_url' => $record['image_url'],
|
||
'created_at' => $record['created_at'],
|
||
];
|
||
}
|
||
|
||
private function createDefaultConfig($uid)
|
||
{
|
||
$config = [
|
||
'uid' => $uid,
|
||
'theme' => 'ancient',
|
||
'show_dims_json' => json_encode($this->dimensionKeys),
|
||
'show_lucky' => 1,
|
||
'show_yiji' => 1,
|
||
'show_sign' => 1,
|
||
'show_news60s' => 0,
|
||
'show_weiyu' => 1,
|
||
'push_enabled' => 1,
|
||
'push_time' => '08:00',
|
||
'push_freq' => 'daily',
|
||
'sort_order' => 'newest_first',
|
||
'constellation' => '',
|
||
'birthday' => '',
|
||
'fortune_reminder' => 0,
|
||
'extra_json' => '{}',
|
||
'created_at' => time(),
|
||
'updated_at' => time(),
|
||
];
|
||
|
||
try {
|
||
Db::name('fortune_config')->insert($config);
|
||
} catch (\Exception $e) {
|
||
}
|
||
|
||
return $config;
|
||
}
|
||
|
||
private function formatConfig($config)
|
||
{
|
||
return [
|
||
'uid' => $config['uid'],
|
||
'theme' => $config['theme'],
|
||
'show_dims' => json_decode($config['show_dims_json'], true) ?: $this->dimensionKeys,
|
||
'show_lucky' => (bool)$config['show_lucky'],
|
||
'show_yiji' => (bool)$config['show_yiji'],
|
||
'show_sign' => (bool)$config['show_sign'],
|
||
'show_news60s' => (bool)$config['show_news60s'],
|
||
'show_weiyu' => (bool)$config['show_weiyu'],
|
||
'push_enabled' => (bool)$config['push_enabled'],
|
||
'push_time' => $config['push_time'],
|
||
'push_freq' => $config['push_freq'],
|
||
'sort_order' => $config['sort_order'],
|
||
'constellation' => $config['constellation'],
|
||
'birthday' => $config['birthday'],
|
||
'fortune_reminder' => (bool)$config['fortune_reminder'],
|
||
'updated_at' => $config['updated_at'],
|
||
];
|
||
}
|
||
|
||
private function updateConfig($uid)
|
||
{
|
||
$fields = [
|
||
'theme', 'show_lucky', 'show_yiji', 'show_sign',
|
||
'show_news60s', 'show_weiyu', 'push_enabled', 'push_time',
|
||
'push_freq', 'sort_order', 'constellation', 'birthday', 'fortune_reminder',
|
||
];
|
||
|
||
$config = Db::name('fortune_config')->where('uid', $uid)->find();
|
||
if (!$config) {
|
||
$this->createDefaultConfig($uid);
|
||
}
|
||
|
||
$update = ['updated_at' => time()];
|
||
|
||
$showDims = $this->request->post('show_dims');
|
||
if ($showDims !== null) {
|
||
$update['show_dims_json'] = is_array($showDims) ? json_encode($showDims) : $showDims;
|
||
}
|
||
|
||
foreach ($fields as $field) {
|
||
$val = $this->request->post($field);
|
||
if ($val !== null) {
|
||
$update[$field] = $val;
|
||
}
|
||
}
|
||
|
||
try {
|
||
Db::name('fortune_config')->where('uid', $uid)->update($update);
|
||
$this->success('配置已更新');
|
||
} catch (\Exception $e) {
|
||
$this->error('配置更新失败: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
private function fetch60sNews($preferredSource = '')
|
||
{
|
||
$sources = [];
|
||
if ($preferredSource === 'viki') {
|
||
$sources = ['https://60s.viki.moe/v2/60s'];
|
||
} elseif ($preferredSource === 'vvhan') {
|
||
$sources = ['https://api.vvhan.com/api/60s'];
|
||
} elseif ($preferredSource === 'tenapi') {
|
||
$sources = ['https://tenapi.cn/v2/60s'];
|
||
} else {
|
||
$sources = [
|
||
'https://60s.viki.moe/v2/60s',
|
||
'https://api.vvhan.com/api/60s',
|
||
'https://tenapi.cn/v2/60s',
|
||
];
|
||
}
|
||
|
||
foreach ($sources as $idx => $url) {
|
||
$sourceName = ['viki', 'vvhan', 'tenapi'][$idx] ?? 'unknown';
|
||
$response = $this->httpGet($url, 5);
|
||
if (!$response) continue;
|
||
|
||
$json = json_decode($response, true);
|
||
if (!$json) continue;
|
||
|
||
if ($sourceName === 'viki' && isset($json['data'])) {
|
||
$newsData = is_array($json['data']) ? $json['data'] : [];
|
||
$weiyu = '';
|
||
if (isset($json['data']['weiyu'])) {
|
||
$weiyu = $json['data']['weiyu'];
|
||
$newsData = isset($json['data']['news']) ? $json['data']['news'] : [];
|
||
if (!empty($newsData) && isset($newsData[0]['content'])) {
|
||
$newsData = array_map(function($item) { return isset($item['content']) ? $item['content'] : ''; }, $newsData);
|
||
}
|
||
} elseif (isset($json['tip'])) {
|
||
$weiyu = $json['tip'];
|
||
}
|
||
return [
|
||
'date' => date('Y-m-d'),
|
||
'news' => $newsData,
|
||
'weiyu' => $weiyu,
|
||
'source' => 'viki',
|
||
'update_time' => date('Y-m-d H:i:s'),
|
||
];
|
||
}
|
||
|
||
if ($sourceName === 'vvhan' && isset($json['data']) && is_array($json['data'])) {
|
||
return [
|
||
'date' => date('Y-m-d'),
|
||
'news' => $json['data'],
|
||
'weiyu' => isset($json['tip']) ? $json['tip'] : '',
|
||
'source' => 'vvhan',
|
||
'update_time' => date('Y-m-d H:i:s'),
|
||
];
|
||
}
|
||
|
||
if ($sourceName === 'tenapi' && isset($json['data']) && is_array($json['data'])) {
|
||
return [
|
||
'date' => date('Y-m-d'),
|
||
'news' => $json['data'],
|
||
'weiyu' => '',
|
||
'source' => 'tenapi',
|
||
'update_time' => date('Y-m-d H:i:s'),
|
||
];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function fetchHuangli($date)
|
||
{
|
||
$sources = [
|
||
['url' => 'https://api.vvhan.com/api/laohuangli?date=' . $date, 'type' => 'vvhan'],
|
||
['url' => 'https://timor.tech/api/holiday/info/' . $date, 'type' => 'timor'],
|
||
];
|
||
|
||
foreach ($sources as $source) {
|
||
$response = $this->httpGet($source['url'], 5);
|
||
if (!$response) continue;
|
||
|
||
$json = json_decode($response, true);
|
||
if (!$json) continue;
|
||
|
||
if ($source['type'] === 'vvhan' && isset($json['data'])) {
|
||
$d = $json['data'];
|
||
return [
|
||
'date' => $date,
|
||
'lunar' => isset($d['lunar']) ? $d['lunar'] : '',
|
||
'ganzhi' => isset($d['ganzhi']) ? $d['ganzhi'] : '',
|
||
'animal' => isset($d['animal']) ? $d['animal'] : '',
|
||
'weekday' => isset($d['weekday']) ? $d['weekday'] : '',
|
||
'suit' => isset($d['suit']) ? $d['suit'] : '',
|
||
'taboo' => isset($d['taboo']) ? $d['taboo'] : '',
|
||
'star' => isset($d['star']) ? $d['star'] : '',
|
||
'conflict' => isset($d['conflict']) ? $d['conflict'] : '',
|
||
'pengzu' => isset($d['pengzu']) ? $d['pengzu'] : '',
|
||
'source' => 'vvhan',
|
||
];
|
||
}
|
||
|
||
if ($source['type'] === 'timor' && isset($json['info'])) {
|
||
$info = $json['info'];
|
||
return [
|
||
'date' => $date,
|
||
'lunar' => isset($info['cn']) ? $info['cn'] : '',
|
||
'ganzhi' => isset($info['lunar']) ? $info['lunar'] : '',
|
||
'animal' => isset($info['animal']) ? $info['animal'] : '',
|
||
'weekday' => '',
|
||
'suit' => '',
|
||
'taboo' => '',
|
||
'star' => isset($info['star']) ? $info['star'] : '',
|
||
'conflict' => '',
|
||
'pengzu' => '',
|
||
'source' => 'timor',
|
||
];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function fetchHoroscope($sign, $time = 'today', $preferredSource = '')
|
||
{
|
||
$sources = [];
|
||
if ($preferredSource === 'vvhan') {
|
||
$sources = [['url' => 'https://api.vvhan.com/api/horoscope?type=' . urlencode($sign) . '&time=' . $time, 'type' => 'vvhan']];
|
||
} elseif ($preferredSource === 'tenapi') {
|
||
$sources = [['url' => 'https://tenapi.cn/v2/star?type=' . urlencode($sign), 'type' => 'tenapi']];
|
||
} else {
|
||
$sources = [
|
||
['url' => 'https://api.vvhan.com/api/horoscope?type=' . urlencode($sign) . '&time=' . $time, 'type' => 'vvhan'],
|
||
['url' => 'https://tenapi.cn/v2/star?type=' . urlencode($sign), 'type' => 'tenapi'],
|
||
];
|
||
}
|
||
|
||
foreach ($sources as $source) {
|
||
$response = $this->httpGet($source['url'], 5);
|
||
if (!$response) continue;
|
||
|
||
$json = json_decode($response, true);
|
||
if (!$json) continue;
|
||
|
||
if ($source['type'] === 'vvhan' && isset($json['data'])) {
|
||
$d = $json['data'];
|
||
return [
|
||
'name' => $sign,
|
||
'date' => date('Y-m-d'),
|
||
'fortune' => isset($d['fortune']) ? $d['fortune'] : [],
|
||
'luck' => isset($d['luck']) ? $d['luck'] : [],
|
||
'summary' => isset($d['summary']) ? $d['summary'] : '',
|
||
'grade' => isset($d['grade']) ? $d['grade'] : '',
|
||
'source' => 'vvhan',
|
||
];
|
||
}
|
||
|
||
if ($source['type'] === 'tenapi' && isset($json['data'])) {
|
||
$d = $json['data'];
|
||
return [
|
||
'name' => $sign,
|
||
'date' => date('Y-m-d'),
|
||
'fortune' => [
|
||
'all' => isset($d['allstar']) ? $d['allstar'] : '',
|
||
'love' => isset($d['lovestar']) ? $d['lovestar'] : '',
|
||
'work' => isset($d['workstar']) ? $d['workstar'] : '',
|
||
'money' => isset($d['moneystar']) ? $d['moneystar'] : '',
|
||
],
|
||
'luck' => [
|
||
'color' => isset($d['lucky_color']) ? $d['lucky_color'] : '',
|
||
'number' => isset($d['lucky_number']) ? $d['lucky_number'] : '',
|
||
'direction' => isset($d['lucky_direction']) ? $d['lucky_direction'] : '',
|
||
'constellation' => isset($d['lucky_constellation']) ? $d['lucky_constellation'] : '',
|
||
],
|
||
'summary' => isset($d['summary']) ? $d['summary'] : '',
|
||
'grade' => '',
|
||
'source' => 'tenapi',
|
||
];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function generateFortuneImage($uid, $date, $theme)
|
||
{
|
||
$basePath = RUNTIME_PATH . 'fortune' . DIRECTORY_SEPARATOR;
|
||
if (!is_dir($basePath)) {
|
||
mkdir($basePath, 0755, true);
|
||
}
|
||
|
||
$fileName = 'fortune_' . md5($uid) . '_' . str_replace('-', '', $date) . '_' . $theme . '.png';
|
||
$filePath = $basePath . $fileName;
|
||
|
||
if (file_exists($filePath)) {
|
||
return '/runtime/fortune/' . $fileName;
|
||
}
|
||
|
||
$width = 750;
|
||
$height = 1334;
|
||
$image = imagecreatetruecolor($width, $height);
|
||
|
||
if (!$image) {
|
||
return '';
|
||
}
|
||
|
||
if ($theme === 'ancient') {
|
||
$bg = imagecolorallocate($image, 28, 16, 8);
|
||
$textColor = imagecolorallocate($image, 212, 168, 67);
|
||
} elseif ($theme === 'wechat') {
|
||
$bg = imagecolorallocate($image, 26, 35, 50);
|
||
$textColor = imagecolorallocate($image, 66, 165, 245);
|
||
} else {
|
||
$bg = imagecolorallocate($image, 255, 255, 255);
|
||
$textColor = imagecolorallocate($image, 28, 28, 30);
|
||
}
|
||
|
||
imagefill($image, 0, 0, $bg);
|
||
|
||
$fontPath = '';
|
||
$possibleFonts = [
|
||
ROOT_PATH . 'public/assets/fonts/NotoSerifSC-Regular.otf',
|
||
ROOT_PATH . 'public/static/fonts/NotoSerifSC-Regular.otf',
|
||
'/usr/share/fonts/truetype/noto/NotoSerifSC-Regular.otf',
|
||
];
|
||
foreach ($possibleFonts as $fp) {
|
||
if (file_exists($fp)) {
|
||
$fontPath = $fp;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if ($fontPath && function_exists('imagettftext')) {
|
||
imagettftext($image, 48, 0, 250, 200, $textColor, $fontPath, 'Daily Fortune');
|
||
imagettftext($image, 72, 0, 280, 400, $textColor, $fontPath, 'Da Ji');
|
||
} else {
|
||
imagestring($image, 5, 250, 200, 'Daily Fortune', $textColor);
|
||
imagestring($image, 5, 280, 400, 'Da Ji', $textColor);
|
||
}
|
||
|
||
imagepng($image, $filePath);
|
||
imagedestroy($image);
|
||
|
||
return '/runtime/fortune/' . $fileName;
|
||
}
|
||
|
||
private function httpGet($url, $timeout = 5)
|
||
{
|
||
try {
|
||
$ch = curl_init();
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_URL => $url,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_SSL_VERIFYHOST => false,
|
||
CURLOPT_USERAGENT => 'XianYan/12.0 Fortune API',
|
||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||
]);
|
||
$response = curl_exec($ch);
|
||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if ($httpCode >= 200 && $httpCode < 300 && $response) {
|
||
return $response;
|
||
}
|
||
return null;
|
||
} catch (\Exception $e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function seedFortuneDataDb()
|
||
{
|
||
try {
|
||
$count = Db::name('fortune_data')->count();
|
||
if ($count > 0) return;
|
||
|
||
foreach ($this->signTexts as $level => $texts) {
|
||
foreach ($texts as $text) {
|
||
Db::name('fortune_data')->insert([
|
||
'type' => 'sign', 'level' => $level, 'content' => $text,
|
||
'weight' => $this->fortuneWeights[$level], 'created_at' => time()
|
||
]);
|
||
}
|
||
}
|
||
|
||
foreach ($this->suitableItems as $item) {
|
||
Db::name('fortune_data')->insert([
|
||
'type' => 'yi', 'level' => '吉', 'content' => $item,
|
||
'category' => '宜', 'weight' => 10, 'created_at' => time()
|
||
]);
|
||
}
|
||
|
||
foreach ($this->unsuitableItems as $item) {
|
||
Db::name('fortune_data')->insert([
|
||
'type' => 'ji', 'level' => '凶', 'content' => $item,
|
||
'category' => '忌', 'weight' => 10, 'created_at' => time()
|
||
]);
|
||
}
|
||
|
||
foreach ($this->luckyColors as $color) {
|
||
Db::name('fortune_data')->insert([
|
||
'type' => 'lucky_color', 'level' => '', 'content' => $color,
|
||
'category' => '幸运颜色', 'weight' => 5, 'created_at' => time()
|
||
]);
|
||
}
|
||
|
||
foreach ($this->luckyDirections as $dir) {
|
||
Db::name('fortune_data')->insert([
|
||
'type' => 'lucky_direction', 'level' => '', 'content' => $dir,
|
||
'category' => '幸运方位', 'weight' => 5, 'created_at' => time()
|
||
]);
|
||
}
|
||
} catch (\Exception $e) {
|
||
}
|
||
}
|
||
|
||
public function push()
|
||
{
|
||
@set_time_limit(30);
|
||
@ignore_user_abort(true);
|
||
|
||
$today = date('Y-m-d');
|
||
$results = ['date' => $today, 'pushed' => 0, 'skipped' => 0, 'errors' => []];
|
||
|
||
$configs = [];
|
||
try {
|
||
$configs = Db::name('fortune_config')
|
||
->where('push_enabled', 1)
|
||
->where('push_time', date('H:i'))
|
||
->select();
|
||
} catch (\Exception $e) {
|
||
$results['errors'][] = 'DB query failed: ' . $e->getMessage();
|
||
$this->success('push scan done', $results);
|
||
return;
|
||
}
|
||
|
||
if (empty($configs)) {
|
||
$this->success('no users to push', $results);
|
||
return;
|
||
}
|
||
|
||
foreach ($configs as $config) {
|
||
try {
|
||
$uid = $config['uid'];
|
||
|
||
$existing = Db::name('fortune_record')
|
||
->where('uid', $uid)
|
||
->where('date', $today)
|
||
->find();
|
||
|
||
if ($existing) {
|
||
$results['skipped']++;
|
||
continue;
|
||
}
|
||
|
||
$seed = $this->generateSeed($uid, $today, 0);
|
||
$fortuneData = $this->generateFortune($uid, $today, $seed, $config['constellation']);
|
||
|
||
$huangli = $this->fetchHuangliSafe($today);
|
||
if ($huangli) {
|
||
$fortuneData['huangli'] = $huangli;
|
||
}
|
||
|
||
$this->saveRecord($uid, $today, $fortuneData, $seed);
|
||
$results['pushed']++;
|
||
|
||
} catch (\Exception $e) {
|
||
$results['skipped']++;
|
||
$results['errors'][] = "uid={$config['uid']}: " . $e->getMessage();
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$this->success('push done', $results);
|
||
}
|
||
|
||
private function fetchHuangliSafe($date)
|
||
{
|
||
try {
|
||
return $this->fetchHuangli($date);
|
||
} catch (\Exception $e) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|