Files
xianyan/docs/toolsapi/application/api/controller/Fortune.php
Developer 228095f80a chore: 完成v6.7.0版本迭代更新
本次更新涵盖多个功能模块的优化与新增:
1. 新增Wi-Fi直连配对方式与协作画布模块
2. 完成设备管理重命名API与前端适配
3. 优化日签卡片空数据保护与UI细节
4. 新增剪贴板工具与每日运势会话
5. 修复应用锁恢复、语音消息录制等已知问题
6. 完善文件传输统计与配对逻辑
7. 更新安卓权限配置与iOS隐私描述
8. 新增自动化测试脚本与文档
9. 清理旧版审计报告与测试文件
2026-05-14 05:35:18 +08:00

1111 lines
43 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}
}