Initial commit: Flutter 无书应用项目
This commit is contained in:
525
ht/test/API.md
Normal file
525
ht/test/API.md
Normal file
@@ -0,0 +1,525 @@
|
||||
# 诗词答题 API 文档
|
||||
|
||||
## 基础信息
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 基础URL | `/api.php` |
|
||||
| 返回格式 | JSON |
|
||||
| 编码 | UTF-8 |
|
||||
| 请求方式 | **GET / POST 都支持** |
|
||||
|
||||
### 请求方式说明
|
||||
|
||||
| 接口 | 推荐方式 | 原因 |
|
||||
|------|----------|------|
|
||||
| 获取题目 (question) | **GET** | 读取操作,简单可缓存 |
|
||||
| 下一题 (next) | **GET** | 读取操作 |
|
||||
| 获取新题 (fetch) | **GET** | 读取操作 |
|
||||
| 提交答案 (answer) | **POST** | 提交操作,更规范 |
|
||||
| 获取提示 (hint) | **GET** | 读取操作 |
|
||||
| 题目列表 (list) | **GET** | 读取操作 |
|
||||
| 刷新缓存 (refresh) | **GET** | 管理操作 |
|
||||
| 状态统计 (stats) | **GET** | 读取操作 |
|
||||
|
||||
## 通用返回格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| code | int | 状态码,0=成功,其他=错误 |
|
||||
| msg | string | 提示信息 |
|
||||
| data | object | 返回数据 |
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 获取题目
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=question&id=0
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 否 | 默认为 question |
|
||||
| id | int | 否 | 题目ID,默认0 |
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": 0,
|
||||
"total": 10,
|
||||
"question": "欲把西湖比西子,\"________\"",
|
||||
"author": "苏轼",
|
||||
"type": "江南",
|
||||
"grade": "小学",
|
||||
"dynasty": "宋朝",
|
||||
"options": [
|
||||
{"index": 1, "content": "山色空蒙雨亦奇"},
|
||||
{"index": 2, "content": "淡妆浓抹总相宜"},
|
||||
{"index": 3, "content": "门前流水尚能西"},
|
||||
{"index": 4, "content": "拄杖无时夜叩门"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取下一题(自动进度)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=next
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 是 | 固定为 next |
|
||||
|
||||
**说明**
|
||||
- **无需传 id 参数**,系统自动记住当前进度(使用 Session)
|
||||
- 每次刷新自动跳到下一题
|
||||
- 如果已经是最后一题,自动回到第 0 题(循环)
|
||||
|
||||
**使用方式**
|
||||
| 刷新次数 | 返回的 id |
|
||||
|----------|-----------|
|
||||
| 第 1 次 | 1 |
|
||||
| 第 2 次 | 2 |
|
||||
| 第 3 次 | 3 |
|
||||
| ... | ... |
|
||||
| 第 62 次 | 0 (循环) |
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": 1,
|
||||
"total": 62,
|
||||
"question": "人生得意须尽欢,\"__________\"",
|
||||
"author": "李白",
|
||||
"type": "豪放",
|
||||
"grade": "高中",
|
||||
"dynasty": "唐朝",
|
||||
"options": [...],
|
||||
"prev_id": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 刷新获取新题(推荐)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=fetch
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 是 | 固定为 fetch |
|
||||
|
||||
**说明**
|
||||
- **每次刷新都从百度 API 获取新题目**
|
||||
- 新题目自动写入本地缓存(去重)
|
||||
- 返回本次获取的随机一题
|
||||
- API 失败时自动降级使用本地缓存
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": null,
|
||||
"total": 65,
|
||||
"question": "人生得意须尽欢,\"__________\"",
|
||||
"author": "李白",
|
||||
"type": "豪放",
|
||||
"grade": "高中",
|
||||
"dynasty": "唐朝",
|
||||
"options": [...],
|
||||
"from_cache": false,
|
||||
"new_questions": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**额外返回字段**
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| from_cache | bool | 是否来自本地缓存 |
|
||||
| new_questions | int | 本次新增题目数量 |
|
||||
|
||||
---
|
||||
|
||||
### 4. 提交答案
|
||||
|
||||
**请求方式 1:GET(简单)**
|
||||
```
|
||||
GET /api.php?action=answer&id=0&answer=2
|
||||
```
|
||||
|
||||
**请求方式 2:POST(推荐)**
|
||||
```
|
||||
POST /api.php
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
action=answer&id=0&answer=2
|
||||
```
|
||||
|
||||
或使用 JSON:
|
||||
```
|
||||
POST /api.php
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"action": "answer",
|
||||
"id": 0,
|
||||
"answer": "2"
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 是 | 固定为 answer |
|
||||
| id | int | 是 | 题目ID |
|
||||
| answer | string | 是 | 答案序号(1-4) |
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": 0,
|
||||
"correct": true,
|
||||
"your_answer": "2",
|
||||
"correct_answer": "2",
|
||||
"next_id": 1,
|
||||
"has_next": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 获取提示
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=hint&id=0
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| action | string | 是 | 固定为 hint |
|
||||
| id | int | 是 | 题目ID |
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"id": 0,
|
||||
"hint": "这是首描写江南的诗,你在小学学过它。",
|
||||
"author": "苏轼",
|
||||
"dynasty": "宋朝"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 获取题目列表
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=list
|
||||
```
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"total": 10,
|
||||
"list": [
|
||||
{
|
||||
"id": 0,
|
||||
"question": "欲把西湖比西子,\"________\"...",
|
||||
"author": "苏轼",
|
||||
"dynasty": "宋朝"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 刷新缓存
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api.php?action=refresh
|
||||
```
|
||||
|
||||
**说明**
|
||||
- 强制从百度 API 获取最新题目
|
||||
- 合并到本地缓存(去重)
|
||||
|
||||
**返回**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "",
|
||||
"data": {
|
||||
"refreshed": true,
|
||||
"total": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 缓存机制
|
||||
|
||||
| 配置 | 值 | 说明 |
|
||||
|------|------|------|
|
||||
| 缓存文件 | `data/questions.json` | 本地JSON文件 |
|
||||
| 过期时间 | **永不过期** | 永久保存 |
|
||||
| 去重方式 | 按 `question_content` | 相同题目不重复存储 |
|
||||
| 降级策略 | API失败自动使用本地缓存 | 原始URL失效也能使用 |
|
||||
| API 限频 | 5秒内只请求一次 | 防止并发请求百度 |
|
||||
|
||||
### 缓存文件结构
|
||||
|
||||
```json
|
||||
{
|
||||
"updated": "2024-01-01 12:00:00",
|
||||
"count": 10,
|
||||
"questions": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 已实现优化
|
||||
|
||||
| 优化项 | 说明 |
|
||||
|--------|------|
|
||||
| 静态变量缓存 | 同一请求内多次读取只加载一次文件 |
|
||||
| API 请求锁 | 5秒内多人请求只发一次 API |
|
||||
| 原子写入 | 先写临时文件再 rename,防止数据损坏 |
|
||||
| 去重优化 | 只在写入时去重,读取时不处理 |
|
||||
| 超时控制 | API 超时 5 秒,连接超时 3 秒 |
|
||||
|
||||
### 性能预估
|
||||
|
||||
| 场景 | 响应时间 |
|
||||
|------|----------|
|
||||
| 读取本地缓存 | ~2-5ms |
|
||||
| API 请求成功 | ~200-500ms |
|
||||
| API 限频降级 | ~1ms(直接读缓存) |
|
||||
| 100人并发 | 无压力(读缓存为主) |
|
||||
|
||||
### 查看状态
|
||||
|
||||
```
|
||||
GET /api.php?action=stats
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total_questions": 149,
|
||||
"cache_file_size": "40.5 KB",
|
||||
"last_updated": "2026-03-29 05:04:04",
|
||||
"memory_usage": "256 KB"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 400 | 参数错误 |
|
||||
| 404 | 题目不存在 |
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### JavaScript
|
||||
```javascript
|
||||
// 获取第一题
|
||||
fetch('/api.php?action=question&id=0')
|
||||
.then(r => r.json())
|
||||
.then(d => console.log(d.data.question));
|
||||
|
||||
// 获取下一题(自动进度,无需传 id)
|
||||
fetch('/api.php?action=next')
|
||||
.then(r => r.json())
|
||||
.then(d => console.log(d.data.id, d.data.question));
|
||||
|
||||
// 提交答案
|
||||
fetch('/api.php?action=answer&id=0&answer=2')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if(d.data.correct) {
|
||||
console.log('回答正确!');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Flutter/Dart
|
||||
```dart
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
final dio = Dio();
|
||||
|
||||
// 获取题目
|
||||
Future<Map> getQuestion(int id) async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'question',
|
||||
'id': id,
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
// 获取下一题(自动进度,无需传 id)
|
||||
Future<Map> getNextQuestion() async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'next',
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
// 提交答案(POST 方式)
|
||||
Future<bool> checkAnswer(int id, String answer) async {
|
||||
final res = await dio.post('/api.php', data: {
|
||||
'action': 'answer',
|
||||
'id': id,
|
||||
'answer': answer,
|
||||
});
|
||||
return res.data['data']['correct'];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## App 集成建议
|
||||
|
||||
### 方案 1:App 自己管理进度(推荐)
|
||||
|
||||
使用 `?action=question` 接口,App 完全控制进度:
|
||||
|
||||
```dart
|
||||
class QuizApi {
|
||||
int _currentId = 0;
|
||||
int _total = 0;
|
||||
|
||||
Future<Map> getQuestion() async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'question',
|
||||
'id': _currentId,
|
||||
});
|
||||
final data = res.data['data'];
|
||||
_total = data['total'] ?? 0;
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Map> submitAnswer(String answer) async {
|
||||
final res = await dio.post('/api.php', data: {
|
||||
'action': 'answer',
|
||||
'id': _currentId,
|
||||
'answer': answer,
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
void nextQuestion() {
|
||||
_currentId++;
|
||||
if (_currentId >= _total) {
|
||||
_currentId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
int get currentId => _currentId;
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 不依赖 Session,App 完全控制进度
|
||||
- 可以随时跳转任意题目
|
||||
- 适合多端同步
|
||||
|
||||
---
|
||||
|
||||
### 方案 2:使用自动进度
|
||||
|
||||
使用 `?action=next` 接口,API 自动管理:
|
||||
|
||||
```dart
|
||||
Future<Map> getNextQuestion() async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'next',
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 简单,无需管理 ID
|
||||
- 自动循环
|
||||
|
||||
**缺点**:
|
||||
- 依赖 Session,需要保持 cookie
|
||||
- 无法自由跳转题目
|
||||
|
||||
---
|
||||
|
||||
### 方案 3:不断获取新题
|
||||
|
||||
使用 `?action=fetch` 接口,每次都从百度获取新题:
|
||||
|
||||
```dart
|
||||
Future<Map> getNewQuestion() async {
|
||||
final res = await dio.get('/api.php', queryParameters: {
|
||||
'action': 'fetch',
|
||||
});
|
||||
return res.data['data'];
|
||||
}
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
- 想要不断扩展题库
|
||||
- 用户每次刷新都可能看到新题
|
||||
353
ht/test/api.php
Normal file
353
ht/test/api.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
header("Content-Type:application/json;charset=UTF-8");
|
||||
session_start();
|
||||
|
||||
define('CACHE_DIR', __DIR__ . '/data/');
|
||||
define('CACHE_FILE', CACHE_DIR . 'questions.json');
|
||||
define('API_LOCK_FILE', CACHE_DIR . 'api.lock');
|
||||
define('API_INTERVAL', 5);
|
||||
|
||||
static $cacheData = null;
|
||||
|
||||
$result = ['code' => 0, 'msg' => '', 'data' => null];
|
||||
|
||||
$action = $_REQUEST['action'] ?? 'question';
|
||||
$id = isset($_REQUEST['id']) ? intval($_REQUEST['id']) : null;
|
||||
|
||||
switch($action){
|
||||
case 'question':
|
||||
$result['data'] = getQuestion($id ?? getCurrentId());
|
||||
break;
|
||||
case 'next':
|
||||
$result['data'] = getNextQuestionAuto();
|
||||
break;
|
||||
case 'fetch':
|
||||
$result['data'] = fetchNewQuestion();
|
||||
break;
|
||||
case 'answer':
|
||||
$result['data'] = checkAnswer($id, $_REQUEST['answer'] ?? '');
|
||||
break;
|
||||
case 'hint':
|
||||
$result['data'] = getHint($id);
|
||||
break;
|
||||
case 'list':
|
||||
$result['data'] = getQuestionList();
|
||||
break;
|
||||
case 'refresh':
|
||||
$result['data'] = refreshCache();
|
||||
break;
|
||||
case 'stats':
|
||||
$result['data'] = getStats();
|
||||
break;
|
||||
default:
|
||||
$result['code'] = 400;
|
||||
$result['msg'] = '未知操作';
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
function getStats(){
|
||||
global $cacheData;
|
||||
$cache = loadCache();
|
||||
return [
|
||||
'total_questions' => count($cache),
|
||||
'cache_file_size' => file_exists(CACHE_FILE) ? round(filesize(CACHE_FILE) / 1024, 2) . ' KB' : 0,
|
||||
'last_updated' => file_exists(CACHE_FILE) ? date('Y-m-d H:i:s', filemtime(CACHE_FILE)) : 'N/A',
|
||||
'memory_usage' => round(memory_get_peak_usage(true) / 1024, 2) . ' KB'
|
||||
];
|
||||
}
|
||||
|
||||
function getQuestion($id){
|
||||
$cache = loadCache();
|
||||
if(empty($cache)){
|
||||
return ['error' => '没有题目数据'];
|
||||
}
|
||||
if($id < 0 || $id >= count($cache)){
|
||||
return ['error' => '题目不存在', 'total' => count($cache)];
|
||||
}
|
||||
$item = $cache[$id];
|
||||
return [
|
||||
'id' => $id,
|
||||
'total' => count($cache),
|
||||
'question' => $item['question_content'],
|
||||
'author' => $item['type']['person'] ?? '',
|
||||
'type' => $item['type']['type'] ?? '',
|
||||
'grade' => $item['type']['grade'] ?? '',
|
||||
'dynasty' => $item['type']['dynasty'] ?? '',
|
||||
'options' => formatOptions($item['option_answers'] ?? [])
|
||||
];
|
||||
}
|
||||
|
||||
function getNextQuestionAuto(){
|
||||
$cache = loadCache();
|
||||
if(empty($cache)){
|
||||
return ['error' => '没有题目数据'];
|
||||
}
|
||||
$total = count($cache);
|
||||
$currentId = getCurrentId();
|
||||
$nextId = $currentId + 1;
|
||||
if($nextId >= $total){
|
||||
$nextId = 0;
|
||||
}
|
||||
setCurrentId($nextId);
|
||||
$result = getQuestion($nextId);
|
||||
$result['prev_id'] = $currentId;
|
||||
return $result;
|
||||
}
|
||||
|
||||
function getCurrentId(){
|
||||
return isset($_SESSION['current_question_id']) ? intval($_SESSION['current_question_id']) : 0;
|
||||
}
|
||||
|
||||
function setCurrentId($id){
|
||||
$_SESSION['current_question_id'] = $id;
|
||||
}
|
||||
|
||||
function fetchNewQuestion(){
|
||||
$apiData = fetchFromBaiduApi();
|
||||
|
||||
if($apiData === null){
|
||||
$cache = loadCache();
|
||||
if(!empty($cache)){
|
||||
$randomId = array_rand($cache);
|
||||
$result = formatQuestionItem($randomId, $cache[$randomId], count($cache));
|
||||
$result['from_cache'] = true;
|
||||
$result['api_status'] = 'failed';
|
||||
return $result;
|
||||
}
|
||||
return ['error' => 'API请求失败且无本地缓存'];
|
||||
}
|
||||
|
||||
$cache = loadCache();
|
||||
$newCount = mergeToCache($apiData, $cache);
|
||||
|
||||
$randomItem = $apiData[array_rand($apiData)];
|
||||
$result = formatQuestionItem(null, $randomItem, count($cache));
|
||||
$result['from_cache'] = false;
|
||||
$result['new_questions'] = $newCount;
|
||||
$result['api_status'] = 'success';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function formatQuestionItem($id, $item, $total, $fromCache = false, $newCount = 0){
|
||||
return [
|
||||
'id' => $id,
|
||||
'total' => $total,
|
||||
'question' => $item['question_content'],
|
||||
'author' => $item['type']['person'] ?? '',
|
||||
'type' => $item['type']['type'] ?? '',
|
||||
'grade' => $item['type']['grade'] ?? '',
|
||||
'dynasty' => $item['type']['dynasty'] ?? '',
|
||||
'options' => formatOptions($item['option_answers'] ?? []),
|
||||
'from_cache' => $fromCache,
|
||||
'new_questions' => $newCount
|
||||
];
|
||||
}
|
||||
|
||||
function checkAnswer($id, $answer){
|
||||
$cache = loadCache();
|
||||
if(empty($cache)){
|
||||
return ['error' => '没有题目数据'];
|
||||
}
|
||||
if($id < 0 || $id >= count($cache)){
|
||||
return ['error' => '题目不存在'];
|
||||
}
|
||||
$item = $cache[$id];
|
||||
$correctAnswer = findAnswer($item['option_answers'] ?? []);
|
||||
$isCorrect = ($answer === $correctAnswer);
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'correct' => $isCorrect,
|
||||
'your_answer' => $answer,
|
||||
'correct_answer' => $correctAnswer,
|
||||
'next_id' => $id + 1,
|
||||
'has_next' => $id + 1 < count($cache)
|
||||
];
|
||||
}
|
||||
|
||||
function getHint($id){
|
||||
$cache = loadCache();
|
||||
if(empty($cache)){
|
||||
return ['error' => '没有题目数据'];
|
||||
}
|
||||
if($id < 0 || $id >= count($cache)){
|
||||
return ['error' => '题目不存在'];
|
||||
}
|
||||
$item = $cache[$id];
|
||||
return [
|
||||
'id' => $id,
|
||||
'hint' => '这是首描写' . ($item['type']['type'] ?? '') . '的诗,你在' . ($item['type']['grade'] ?? '') . '学过它。',
|
||||
'author' => $item['type']['person'] ?? '',
|
||||
'dynasty' => $item['type']['dynasty'] ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
function getQuestionList(){
|
||||
$cache = loadCache();
|
||||
if(empty($cache)){
|
||||
return ['total' => 0, 'list' => []];
|
||||
}
|
||||
$list = [];
|
||||
foreach($cache as $i => $item){
|
||||
$list[] = [
|
||||
'id' => $i,
|
||||
'question' => mb_substr($item['question_content'], 0, 30, 'UTF-8') . '...',
|
||||
'author' => $item['type']['person'] ?? '',
|
||||
'dynasty' => $item['type']['dynasty'] ?? ''
|
||||
];
|
||||
}
|
||||
return ['total' => count($list), 'list' => $list];
|
||||
}
|
||||
|
||||
function refreshCache(){
|
||||
$apiData = fetchFromBaiduApi();
|
||||
if($apiData === null){
|
||||
return ['refreshed' => false, 'error' => 'API请求失败'];
|
||||
}
|
||||
$cache = loadCache();
|
||||
$newCount = mergeToCache($apiData, $cache);
|
||||
return ['refreshed' => true, 'total' => count($cache), 'new_questions' => $newCount];
|
||||
}
|
||||
|
||||
function loadCache(){
|
||||
global $cacheData;
|
||||
|
||||
if($cacheData !== null){
|
||||
return $cacheData;
|
||||
}
|
||||
|
||||
if(!file_exists(CACHE_FILE)){
|
||||
$cacheData = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
$content = file_get_contents(CACHE_FILE);
|
||||
if($content === false){
|
||||
$cacheData = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if(!is_array($data) || !isset($data['questions'])){
|
||||
$cacheData = [];
|
||||
return [];
|
||||
}
|
||||
|
||||
$cacheData = $data['questions'];
|
||||
return $cacheData;
|
||||
}
|
||||
|
||||
function saveCache($questions){
|
||||
global $cacheData;
|
||||
|
||||
if(!is_dir(CACHE_DIR)){
|
||||
mkdir(CACHE_DIR, 0755, true);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'updated' => date('Y-m-d H:i:s'),
|
||||
'count' => count($questions),
|
||||
'questions' => $questions
|
||||
];
|
||||
|
||||
$tempFile = CACHE_FILE . '.tmp';
|
||||
$result = file_put_contents($tempFile, json_encode($data, JSON_UNESCAPED_UNICODE), LOCK_EX);
|
||||
|
||||
if($result !== false){
|
||||
rename($tempFile, CACHE_FILE);
|
||||
}
|
||||
|
||||
$cacheData = $questions;
|
||||
}
|
||||
|
||||
function fetchFromBaiduApi(){
|
||||
if(file_exists(API_LOCK_FILE)){
|
||||
$lockTime = intval(file_get_contents(API_LOCK_FILE));
|
||||
if(time() - $lockTime < API_INTERVAL){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
file_put_contents(API_LOCK_FILE, time());
|
||||
|
||||
$url = 'https://hanyu.baidu.com/hanyu/ajax/pingce_data';
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
'Accept-Language: zh-CN,zh;q=0.9'
|
||||
]
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if($httpCode !== 200 || empty($response)){
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($response, true);
|
||||
if(!isset($json['data']) || !is_array($json['data'])){
|
||||
return null;
|
||||
}
|
||||
|
||||
return $json['data'];
|
||||
}
|
||||
|
||||
function mergeToCache($newData, &$existingCache){
|
||||
if(empty($newData)){
|
||||
return 0;
|
||||
}
|
||||
|
||||
$existingKeys = [];
|
||||
foreach($existingCache as $item){
|
||||
$key = $item['question_content'] ?? '';
|
||||
$existingKeys[$key] = true;
|
||||
}
|
||||
|
||||
$newCount = 0;
|
||||
foreach($newData as $item){
|
||||
$key = $item['question_content'] ?? '';
|
||||
if(!isset($existingKeys[$key])){
|
||||
$existingCache[] = $item;
|
||||
$existingKeys[$key] = true;
|
||||
$newCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if($newCount > 0){
|
||||
saveCache($existingCache);
|
||||
}
|
||||
|
||||
return $newCount;
|
||||
}
|
||||
|
||||
function formatOptions($options){
|
||||
$result = [];
|
||||
foreach($options as $i => $opt){
|
||||
$result[] = [
|
||||
'index' => $i + 1,
|
||||
'content' => $opt['answer_content'] ?? ''
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
function findAnswer($options){
|
||||
foreach($options as $i => $opt){
|
||||
if(($opt['is_standard_answer'] ?? 0) === 1){
|
||||
return (string)($i + 1);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
?>
|
||||
5
ht/test/data/.htaccess
Normal file
5
ht/test/data/.htaccess
Normal file
@@ -0,0 +1,5 @@
|
||||
order allow,deny
|
||||
deny from all
|
||||
<Files ~ "\.(json|txt|lock|tmp)$">
|
||||
deny from all
|
||||
</Files>
|
||||
4029
ht/test/data/questions.json
Normal file
4029
ht/test/data/questions.json
Normal file
File diff suppressed because it is too large
Load Diff
422
ht/test/index.html
Normal file
422
ht/test/index.html
Normal file
@@ -0,0 +1,422 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>诗词答题 API 测试</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.4rem;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 14px 20px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f5f5f7;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e8e8ed;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e5e5ea;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.url-display {
|
||||
background: #f5f5f7;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
margin-bottom: 15px;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.response-area {
|
||||
background: #1a1a2e;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.response-area pre {
|
||||
margin: 0;
|
||||
color: #a8e6cf;
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-loading {
|
||||
background: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.question-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.question-card .question {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.question-card .meta {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.question-card .meta span {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.option-btn {
|
||||
background: white;
|
||||
border: 2px solid #e5e5ea;
|
||||
padding: 15px 20px;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.option-btn:hover {
|
||||
border-color: #667eea;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.option-btn.correct {
|
||||
border-color: #34c759;
|
||||
background: #d4edda;
|
||||
}
|
||||
|
||||
.option-btn.wrong {
|
||||
border-color: #ff3b30;
|
||||
background: #f8d7da;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📚 诗词答题 API 测试</h1>
|
||||
<p>测试所有接口功能</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<h2>🎯 接口测试</h2>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" onclick="testQuestion()">📖 获取题目</button>
|
||||
<button class="btn btn-primary" onclick="testNext()">➡️ 下一题</button>
|
||||
<button class="btn btn-primary" onclick="testFetch()">🔄 获取新题</button>
|
||||
<button class="btn btn-primary" onclick="testList()">📋 题目列表</button>
|
||||
<button class="btn btn-primary" onclick="testRefresh()">🔃 刷新缓存</button>
|
||||
<button class="btn btn-primary" onclick="testStats()">📊 状态统计</button>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>题目 ID</label>
|
||||
<input type="number" id="questionId" value="0" placeholder="输入题目ID">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>答案 (1-4)</label>
|
||||
<input type="number" id="answer" value="1" placeholder="输入答案">
|
||||
</div>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-secondary" onclick="testAnswer()">✅ 提交答案</button>
|
||||
<button class="btn btn-secondary" onclick="testHint()">💡 获取提示</button>
|
||||
</div>
|
||||
|
||||
<div class="url-display" id="urlDisplay">等待请求...</div>
|
||||
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📤 响应结果</h2>
|
||||
<div id="questionPreview"></div>
|
||||
<div class="response-area">
|
||||
<pre id="response">点击左侧按钮开始测试...</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentQuestion = null;
|
||||
|
||||
function showStatus(msg, type) {
|
||||
const statusDiv = document.getElementById('status');
|
||||
const icons = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
loading: '⏳'
|
||||
};
|
||||
statusDiv.innerHTML = `<div class="status status-${type}">${icons[type]} ${msg}</div>`;
|
||||
}
|
||||
|
||||
function showUrl(url) {
|
||||
document.getElementById('urlDisplay').textContent = url;
|
||||
}
|
||||
|
||||
function showResponse(data) {
|
||||
document.getElementById('response').textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function showQuestion(data) {
|
||||
const preview = document.getElementById('questionPreview');
|
||||
if (!data || data.error) {
|
||||
preview.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
preview.innerHTML = `
|
||||
<div class="question-card">
|
||||
<div class="question">${data.question}</div>
|
||||
<div class="meta">
|
||||
<span>👤 ${data.author || '未知'}</span>
|
||||
<span>📅 ${data.dynasty || '未知'}</span>
|
||||
<span>📚 ${data.type || '未知'}</span>
|
||||
<span>🎓 ${data.grade || '未知'}</span>
|
||||
<span>📊 ${data.id + 1}/${data.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="options-list">
|
||||
${(data.options || []).map(opt => `
|
||||
<button class="option-btn" onclick="selectOption(${opt.index})">
|
||||
${opt.index}. ${opt.content}
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
currentQuestion = data;
|
||||
}
|
||||
|
||||
function selectOption(index) {
|
||||
if (!currentQuestion) return;
|
||||
document.getElementById('answer').value = index;
|
||||
testAnswer();
|
||||
}
|
||||
|
||||
async function apiRequest(action, params = {}) {
|
||||
const query = new URLSearchParams({ action, ...params }).toString();
|
||||
const url = `api.php?${query}`;
|
||||
showUrl(url);
|
||||
showStatus('请求中...', 'loading');
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
showStatus('请求成功', 'success');
|
||||
showResponse(data);
|
||||
|
||||
if (data.data && data.data.question) {
|
||||
showQuestion(data.data);
|
||||
} else {
|
||||
document.getElementById('questionPreview').innerHTML = '';
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
showStatus('请求失败: ' + err.message, 'error');
|
||||
showResponse({ error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function testQuestion() {
|
||||
const id = document.getElementById('questionId').value;
|
||||
apiRequest('question', { id });
|
||||
}
|
||||
|
||||
function testNext() {
|
||||
apiRequest('next');
|
||||
}
|
||||
|
||||
function testFetch() {
|
||||
apiRequest('fetch');
|
||||
}
|
||||
|
||||
function testAnswer() {
|
||||
const id = document.getElementById('questionId').value;
|
||||
const answer = document.getElementById('answer').value;
|
||||
apiRequest('answer', { id, answer });
|
||||
}
|
||||
|
||||
function testHint() {
|
||||
const id = document.getElementById('questionId').value;
|
||||
apiRequest('hint', { id });
|
||||
}
|
||||
|
||||
function testList() {
|
||||
apiRequest('list');
|
||||
}
|
||||
|
||||
function testRefresh() {
|
||||
apiRequest('refresh');
|
||||
}
|
||||
|
||||
function testStats() {
|
||||
apiRequest('stats');
|
||||
}
|
||||
|
||||
window.onload = () => {
|
||||
testQuestion();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user