适合人群：具备 Vue 基础，希望接入 AI 文本/图片与流式输出的开发者或技术爱好者。建议先看：实践三（RESTful API 设计原则）、实践五（接口监控与限流）。你将收获：SSE 两种实现、Axios 标准化封装、vite 代理配置与常见坑。创建项目npm create vue@latest实现文本聊天页面目标：实现基础的文本输入、发送与消息列表展示。要点：输入框与发送按钮、消息时间顺序、滚动定位到最新消息。组件建议：ChatInput（输入区）、ChatList（消息区）。实现图片目标：支持根据文本生成图片并展示缩略图。要点：提交参数（prompt、size、数量）、生成态 loading、失败重试、下载按钮。组件建议：ImageGenerateForm、ImageGrid。实现文本聊天SSE的方式sse 前端实现方式第一种 EventSourceconst url = "/api/ai/chat/stream?platform=openai&model=gpt-4o&message=" + encodeURIComponent(message); const sse = new EventSource(url); const handleDelta = (event: MessageEvent) => { const data = (event as MessageEvent).data; }; const handleDone = () => { sse.close(); }; // Some servers don't set a named event; treat default messages as delta sse.onmessage = handleDelta; sse.addEventListener("delta", handleDelta as EventListener); sse.addEventListener("done", handleDone as EventListener); sse.onerror = (err) => { console.error("SSE error", err); sse.close(); };第二种 使用自带的fetch const res = await fetch("/api/ai/chat/stream", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ platform: "openai", model: "gpt-4o", message }), }); if (!res.ok || !res.body) throw new Error(`请求失败: ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Try to parse as SSE events: data: {json}\n\n let sepIndex: number; while ((sepIndex = buffer.indexOf("\n\n")) !== -1) { const rawEvent = buffer.slice(0, sepIndex); buffer = buffer.slice(sepIndex + 2); const dataLines = rawEvent .split("\n") .filter((l) => l.startsWith("data:")) .map((l) => l.replace(/^data:\s?/, "")) .join(""); console.log("dataLines", dataLines); if (!dataLines) continue; consumeChunk(dataLines, assistantId); } // Fallback: handle NDJSON lines if server doesn't use blank-line separators // let lineIndex: number; // while ((lineIndex = buffer.indexOf("\n")) !== -1) { // const rawLine = buffer.slice(0, lineIndex).trim(); // buffer = buffer.slice(lineIndex + 1); // if (!rawLine) continue; // if (rawLine.startsWith("data:")) { // consumeChunk(rawLine.replace(/^data:\s?/, ""), assistantId); // } else { // consumeChunk(rawLine, assistantId); // } // }增加对话框目标：多会话管理，支持创建、重命名、删除与切换。要点：侧边栏会话列表、高亮当前会话、未读/进行中状态提示。数据：本地 localStorage 或后端存储会话元信息与消息。API 调用信息目标：在界面中透明展示平台、模型、剩余次数/有效期等关键信息。要点：统一从 /api/user/quota 拉取；错误时给出可读提示（如未登录/已过期）。展示：头部条或设置抽屉中展示，避免打断主要流程。API升级目标：后端接口版本/平台能力变化时，前端具备平滑升级能力。要点：通过配置层映射平台与模型；在切换平台时热更新可用模型列表。回退：接口报错时回退到上一个可用版本并提示用户。登录与注册目标：支持注册/登录/登出与 Token 管理。要点：表单校验、登录态持久化（短期 Token 或 HttpOnly Cookie）、过期刷新。交互：失败清晰提示（401 引导重新登录）、成功跳转到最近会话。axios/** * Axios HTTP 封装：内置拦截器、Token 注入与类型友好的请求助手。 * baseURL 来自 VITE_API_BASE_URL，未配置则回退为 '/api'。 */ import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios' /** * 统一的接口返回结构（兼容常见服务约定）： * - success：显式标记是否成功 * - code：业务状态码（0/200 视为成功） * - data：实际数据载荷 * - message/msg：人类可读的提示 */ export interface ApiResponse<T = any> { code?: number | string data: T message?: string msg?: string success?: boolean } /** 所有请求的默认基础路径。 */ const baseURL = '/api' /** 预配置的 Axios 实例。 */ const http: AxiosInstance = axios.create({ baseURL, timeout: 15000, withCredentials: false, }) /** * 请求拦截器： * - 从 localStorage.token 注入 Authorization Bearer 令牌（若请求头未显式设置）。 */ http.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token && config.headers) { const hasAuth = typeof config.headers.Authorization === 'string' && config.headers.Authorization.length > 0 if (!hasAuth) { config.headers.Authorization = `Bearer ${token}` } } return config }, (error) => Promise.reject(error), ) /** 从响应载荷中提取可读消息（若存在）。 */ function extractMessage(payload: unknown): string | undefined { if (!payload || typeof payload !== 'object') return undefined const obj = payload as Record<string, unknown> const candidates = ['message', 'msg', 'error', 'detail'] for (const key of candidates) { const value = obj[key] if (typeof value === 'string' && value.trim().length > 0) { return value } } return undefined } /** 将 HTTP 状态码转换为本地化错误信息。 */ function httpStatusMessage(status: number): string { switch (status) { case 400: return '请求参数错误' case 401: return '未授权或登录已过期' case 403: return '无权限访问' case 404: return '资源未找到' case 408: return '请求超时' case 500: return '服务器错误' case 502: return '网关错误' case 503: return '服务不可用' case 504: return '网关超时' default: return `请求错误（${status}）` } } /** * 响应拦截器： * - 当 success === true 或 code ∈ {0, 200} 时直接返回 data * - 若存在 code 且表示失败，则使用提取的 message 拒绝 * - 统一网络/服务端错误为可读信息 */ http.interceptors.response.use( (response: AxiosResponse<ApiResponse>) => { const { data, status } = response if (status >= 200 && status < 300) { if (data && typeof data === 'object') { const code = data.code as number | string | undefined const success = data.success if (success === true) return (data.data as any) ?? (data as any) if (code === 0 || code === 200 || code === '0' || code === '200') { return (data.data as any) ?? (data as any) } if (typeof code === 'undefined') { return data as any } const message = extractMessage(data) || '请求失败' return Promise.reject(new Error(message)) } return response.data as any } return Promise.reject(new Error('网络错误')) }, (error) => { if (axios.isCancel(error)) return Promise.reject(error) if (error?.response) { const status = error.response.status as number const msg = (extractMessage(error.response.data) || httpStatusMessage(status)) ?? '请求出错' return Promise.reject(new Error(msg)) } const message = error?.message || '网络异常，请检查您的网络连接' return Promise.reject(new Error(message)) }, ) /** 与 AxiosRequestConfig 等价，用于保留下方助手的范型。 */ type RequestConfig<T = any> = AxiosRequestConfig<T> /** 低级请求助手，保留范型返回类型。 */ export function request<T = any>(config: RequestConfig): Promise<T> { return http.request<any, T>(config) } /** GET 快捷方法。 */ export const get = <T = any, R = T>(url: string, config?: RequestConfig) => http.get<R>(url, config) /** POST 快捷方法。 */ export const post = <T = any, R = T>(url: string, data?: T, config?: RequestConfig) => http.post<R>(url, data, config) /** PUT 快捷方法。 */ export const put = <T = any, R = T>( url: string, data?: T, config?: RequestConfig, ) => http.put<R>(url, data, config) /** DELETE 快捷方法。 */ export const del = <T = any, R = T>(url: string, config?: RequestConfig) => http.delete<R>(url, config) export default http 代理vite.config.ts server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, // 将 /api 前缀转发到后端时保留或重写 // 若后端实际无 /api 前缀，请取消注释下一行： rewrite: (path) => path.replace(/^\/api/, ''), }, }, },基本功能目标1、登录，注册2、api购买记录，有效期3、文本聊天，文本聊天sse模式4、图片生成5、多平台切换6、api调用次数统计 分1，7，30天，不同平台，不同api类型7、多窗口中级功能目标1、聊天内容保存2、支持模型选择3、AI回复内容，支持 文本复制，图片下载4、免费用户，每天只能调用三次5、api购买实现，plus一个月调用100次，pro一个月调用1000次高级功能1、图片支持size设置，生成图片个数支持，1，2，3，42、图片进行本地存储，异步执行最佳实践与常见问题SSE 断线重连：EventSource 自带重连，手写 fetch 流需在网络断开时恢复并合并内容。错误提示：服务端返回 429/5xx 时统一友好文案，区分鉴权类与系统类；必要时弹窗引导降载。Token 存储：避免将长效 Token 存在 localStorage，优先短时 Token 或 HttpOnly Cookie。代理重写：vite 代理 rewrite 与服务端路由前缀保持一致，避免 404/跨域问题。请求退避：对热点接口失败建议指数退避，避免前端风暴放大后端压力。图片下载：使用 a[download] 或 Blob URL；注意 CORS 与缓存策略。
软件工程实践七：AI 大模型 API 合并与适配层设计
适合人群：希望统一多厂商（OpenAI、阿里、科大讯飞等）调用体验的后端开发者或技术爱好者。建议先看：实践三（RESTful API 设计原则）、实践六（阿里大模型对接）。你将收获：一套可扩展的适配层模型与返回体规范，最小变更对接新厂商。设计目标与边界统一接口：隐藏厂商差异，暴露稳定域模型（模型、聊天、流式聊天、图片）。易于扩展：新增厂商仅需实现适配器，不影响上层调用与返回结构。一致的错误语义：统一 success/code/message，流式以 [process]/[done] 信号闭环。安全与合规：不在代码中硬编码密钥，敏感信息通过环境变量或配置中心注入。对输入参数进行统一几乎不改返回值统一模型@Data public class BigModel { private String id; public BigModel(String id) { this.id = id; } public BigModel() {} } 文字聊天@Data public class ChatMessage { private String id; private String content; private String status; // success fail private String error; }文字聊天流@Data public class ChatMessageStream { private String id; private String content; private String status; // done process }图片@Data public class ChatImage { private String id; private String imageUrl; private String status; // success fail private String error; }流程合并使用统一接口OpenAI@Service public class OpenAiAdopter { @Autowired private OpenAiService openAiService; public List<BigModel> getModels() { Map model = openAiService.getModel(); List<Map> models = (List<Map>) model.get("data"); return models.stream().map(m -> new BigModel(m.get("id").toString())).collect(Collectors.toList()); } public ChatMessage chat(String model, String message) { Map chat = openAiService.chat(model, message); String id = chat.get("id").toString(); List<Map> output = (List<Map>) chat.get("output"); ChatMessage chatMessage = new ChatMessage(); chatMessage.setId(id); String contentStr = ""; chatMessage.setStatus("completed"); for (Map map : output) { if (!map.get("type").toString().equals("message")) { continue; } if (!map.get("status").toString().equals("completed")) { continue; } List<Map> contents = (List<Map>) map.get("content"); for (Map content : contents) { contentStr += content.get("text").toString(); } } chatMessage.setContent(contentStr); return chatMessage; } public void chatStream(String model, String message, Consumer<ChatMessageStream> onDelta){ openAiService.chatStream(model, message, (delta)->{ ChatMessageStream chatMessageStream = new ChatMessageStream(); chatMessageStream.setId(UUID.randomUUID().toString()); chatMessageStream.setStatus("process"); chatMessageStream.setContent(delta); onDelta.accept(chatMessageStream); }); } public ChatImage generateImage(String model, String prompt, String size) { try { Map image = openAiService.generateImageUrl(model, prompt, size); ChatImage chatImage = new ChatImage(); chatImage.setId(UUID.randomUUID().toString()); List<Map> images = (List<Map>)image.get("data"); chatImage.setImageUrl(images.get(0).get("url").toString()); chatImage.setStatus("success"); return chatImage; } catch (Exception e) { throw new RuntimeException(e); } } }阿里@RestController @RequestMapping("/ai") public class AiController { @Autowired private AliAiAdopter aliAiAdopter; @Autowired private OpenAiAdopter openAiAdopter; @GetMapping("/get/models") public List<BigModel> getModels(@RequestParam("platform") String platform) { if ("ali".equals(platform)) { return aliAiAdopter.getModels(); } return openAiAdopter.getModels(); } @PostMapping("/chat") public ChatMessage chat(@RequestBody Map<String, String> param) throws JsonProcessingException { String platform = ""; if (param.get("platform") != null) { platform = param.get("platform"); } String model = ""; if (param.get("model") != null) { model = param.get("model"); } String message = ""; if (param.get("message") != null) { message = param.get("message"); } if ("ali".equals(platform)) { return aliAiAdopter.chat(model, message); } return openAiAdopter.chat(model, message); } @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestBody Map<String, String> param, HttpServletResponse response) { String platform = ""; if (param.get("platform") != null) { platform = param.get("platform"); } String model; if (param.get("model") != null) { model = param.get("model"); } else { model = ""; } String message; if (param.get("message") != null) { message = param.get("message"); } else { message = ""; } SseEmitter emitter = new SseEmitter(0L); // 0 表示不超时；可按需设置毫秒 if ("ali".equals(platform)) { CompletableFuture.runAsync(() -> { try { aliAiAdopter.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 emitter.send(SseEmitter.event().name("done").data("[DONE]")); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); } else { CompletableFuture.runAsync(() -> { try { openAiAdopter.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 ChatMessageStream chatMessageStream = new ChatMessageStream(); chatMessageStream.setId(UUID.randomUUID().toString()); chatMessageStream.setStatus("[done]"); chatMessageStream.setContent(""); emitter.send(SseEmitter.event().name("done").data(chatMessageStream)); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); } return emitter; } @PostMapping("/generate/image") public ChatImage generateImage(@RequestBody Map<String, String> param) { String model = param.getOrDefault("model", "dall-e-3"); String platform = param.getOrDefault("platform", "ali"); String prompt = param.getOrDefault("prompt", ""); String size = param.getOrDefault("size", "1024x1024"); if ("ali".equals(platform)) { return aliAiAdopter.generateImage(model, prompt, size); } return openAiAdopter.generateImage(model, prompt, size); } }接口统一@RestController @RequestMapping("/ai") public class AiController { @Autowired private AliAiAdopter aliAiAdopter; @Autowired private OpenAiAdopter openAiAdopter; @GetMapping("/get/models") public List<BigModel> getModels(@RequestParam("platform") String platform) { if ("ali".equals(platform)) { return aliAiAdopter.getModels(); } return openAiAdopter.getModels(); } @PostMapping("/chat") public ChatMessage chat(@RequestBody Map<String, String> param) throws JsonProcessingException { String platform = ""; if (param.get("platform") != null) { platform = param.get("platform"); } String model = ""; if (param.get("model") != null) { model = param.get("model"); } String message = ""; if (param.get("message") != null) { message = param.get("message"); } if ("ali".equals(platform)) { return aliAiAdopter.chat(model, message); } return openAiAdopter.chat(model, message); } @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chatStream(@RequestBody Map<String, String> param, HttpServletResponse response) { String platform = ""; if (param.get("platform") != null) { platform = param.get("platform"); } String model; if (param.get("model") != null) { model = param.get("model"); } else { model = ""; } String message; if (param.get("message") != null) { message = param.get("message"); } else { message = ""; } SseEmitter emitter = new SseEmitter(0L); // 0 表示不超时；可按需设置毫秒 if ("ali".equals(platform)) { CompletableFuture.runAsync(() -> { try { aliAiAdopter.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 emitter.send(SseEmitter.event().name("done").data("[DONE]")); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); } else { CompletableFuture.runAsync(() -> { try { openAiAdopter.chatStream(model, message, delta -> { try { emitter.send(SseEmitter.event().name("delta").data(delta)); } catch (IOException e) { emitter.completeWithError(e); } }); // 上游完成 ChatMessageStream chatMessageStream = new ChatMessageStream(); chatMessageStream.setId(UUID.randomUUID().toString()); chatMessageStream.setStatus("[done]"); chatMessageStream.setContent(""); emitter.send(SseEmitter.event().name("done").data(chatMessageStream)); emitter.complete(); } catch (Exception ex) { emitter.completeWithError(ex); } }); } return emitter; } @PostMapping("/generate/image") public ChatImage generateImage(@RequestBody Map<String, String> param) { String model = param.getOrDefault("model", "dall-e-3"); String platform = param.getOrDefault("platform", "ali"); String prompt = param.getOrDefault("prompt", ""); String size = param.getOrDefault("size", "1024x1024"); if ("ali".equals(platform)) { return aliAiAdopter.generateImage(model, prompt, size); } return openAiAdopter.generateImage(model, prompt, size); } }
适合人群：具备 Spring Web 基础、希望在接口层做统一治理的开发者或技术爱好者。建议先看：软件工程实践二（Spring Boot 知识回顾）、实践三（RESTful API 设计原则）。1. 方案选型速览Filter（Servlet 层）：最靠前，处理包括静态资源；适合全局跨域、XSS、统一编码等。HandlerInterceptor（Spring MVC 层）：在进入 Controller 前后执行；适合接口打点、鉴权、限流、审计。AOP（方法层）：对注解/切点进行横切；适合注解式限流、审计、幂等等。API Gateway（边界层）：最靠外；适合全局流控、黑白名单、熔断、灰度，一处管多服务。一般应用内优先用 Interceptor + AOP；如果有网关，也可在网关做粗粒度限流，在应用内做精细化限流与监控。国内比较熟悉的：Sentinel2. 基于 HandlerInterceptor 的接口监控目标：记录每次请求的关键信息与耗时，并输出到日志或指标系统。示例：// RequestLoggingInterceptor.java // 功能：为每个进入的 HTTP 请求生成请求ID、记录入站/出站日志与耗时 package com.example.demo.web; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import java.util.UUID; public class RequestLoggingInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(RequestLoggingInterceptor.class); private static final String REQ_ID = "reqId"; // MDC 中的请求 ID 键 private static final String START = "startAt"; // 请求开始时间属性键 @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String requestId = UUID.randomUUID().toString(); // 生成请求唯一 ID，便于链路追踪 MDC.put(REQ_ID, requestId); // 放入日志 MDC request.setAttribute(START, System.currentTimeMillis()); // 记录起始时间 String method = request.getMethod(); String uri = request.getRequestURI(); String ip = getClientIp(request); // 识别真实客户端 IP（支持代理场景） log.info("[IN] {} {} ip={} ua={}", method, uri, ip, request.getHeader("User-Agent")); return true; // 返回 true 放行请求 } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { // 可在此处添加对响应体或模型的额外处理 } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { Long start = (Long) request.getAttribute(START); long costMs = start == null ? -1 : (System.currentTimeMillis() - start); // 计算耗时 int status = response.getStatus(); if (ex != null) { log.warn("[OUT] status={} costMs={} ex={}", status, costMs, ex.toString()); // 异常时告警 } else { log.info("[OUT] status={} costMs={}", status, costMs); // 正常返回 } MDC.remove(REQ_ID); // 清理 MDC，避免线程复用造成污染 } private String getClientIp(HttpServletRequest request) { String xff = request.getHeader("X-Forwarded-For"); // 代理链：第一个为原始客户端 IP if (xff != null && !xff.isEmpty()) { return xff.split(",")[0].trim(); } String realIp = request.getHeader("X-Real-IP"); // 一些代理使用该头 if (realIp != null && !realIp.isEmpty()) { return realIp; } return request.getRemoteAddr(); // 直接连接时的远端地址 } }注册拦截器：// WebMvcConfig.java // 功能：注册全局拦截器，并对健康检查/文档/静态资源等路径放行 package com.example.demo.config; import com.example.demo.web.RequestLoggingInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new RequestLoggingInterceptor()) // 注册请求日志拦截器 .addPathPatterns("/**") // 默认拦截所有路径 .excludePathPatterns( // 放行以下常见非业务路径 "/actuator/**", "/swagger-resources/**", "/v3/api-docs/**", "/doc.html", "/swagger-ui/**", "/error", "/static/**" ); } }3. log配置文件 logback-spring.xml<?xml version="1.0" encoding="UTF-8"?> <configuration> <appender name="console" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg %X%n</pattern> </encoder> </appender> <appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>7</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg %X%n</pattern> </encoder> </appender> <root level="info"> <appender-ref ref="console"/> <appender-ref ref="file"/> </root> </configuration> 4. 流量控制的常见策略与算法维度：全局、按接口、按 IP、按用户、按来源（AppId）、按租户等算法：固定窗口（simple, 可能边界抖动）滑动窗口（更平滑）令牌桶（Token Bucket，允许突发）漏桶（Leaky Bucket，恒定速率）选型建议：单机快速落地：Guava RateLimiter 或 Bucket4j分布式一致性：Redis网关层粗粒度 + 应用内细粒度5. 本地单机限流：Guava RateLimiter（简单上手） <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>33.0.0-jre</version> </dependency>// SimpleRateLimitInterceptor.java // 功能：针对 URI 维度基于 Guava RateLimiter 的单机限流 package com.example.demo.limit; import com.google.common.util.concurrent.RateLimiter; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; import org.springframework.web.servlet.HandlerInterceptor; import java.nio.charset.StandardCharsets; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class SimpleRateLimitInterceptor implements HandlerInterceptor { private final ConcurrentMap<String, RateLimiter> uriToLimiter = new ConcurrentHashMap<>(); // 每个 URI 一个限流器 private final double permitsPerSecond; // 每秒允许的请求数（QPS） public SimpleRateLimitInterceptor(double permitsPerSecond) { this.permitsPerSecond = permitsPerSecond; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String key = request.getRequestURI(); // 维度选择：可替换为 URI+IP/用户ID 等 RateLimiter limiter = uriToLimiter.computeIfAbsent(key, k -> RateLimiter.create(permitsPerSecond)); boolean allowed = limiter.tryAcquire(); // 非阻塞获取令牌，失败立即返回 429 if (!allowed) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); byte[] body = "Too Many Requests".getBytes(StandardCharsets.UTF_8); response.getOutputStream().write(body); return false; } return true; } }注册：// 将限流器应用到 /api/** 的请求路径 registry.addInterceptor(new SimpleRateLimitInterceptor(50.0)) // 每秒 50 次/接口 .addPathPatterns("/api/**");优点：简单易用；缺点：单机生效，不适合分布式一致性。6. 本地单机限流：Bucket4j（功能更强） <dependency> <groupId>com.bucket4j</groupId> <artifactId>bucket4j-core</artifactId> <version>8.10.1</version> </dependency>// Bucket4jRateLimitInterceptor.java // 功能：基于 Bucket4j 的单机令牌桶限流 package com.example.demo.limit; import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket; import io.github.bucket4j.Bucket4j; import io.github.bucket4j.Refill; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.http.HttpStatus; import org.springframework.web.servlet.HandlerInterceptor; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class Bucket4jRateLimitInterceptor implements HandlerInterceptor { private final ConcurrentMap<String, Bucket> bucketMap = new ConcurrentHashMap<>(); // 每个 URI 一个桶 private Bucket newBucket() { // 令牌桶配置：容量 100，按每秒补充 50 个令牌；允许一定突发 Bandwidth limit = Bandwidth.classic(100, Refill.intervally(50, Duration.ofSeconds(1))); return Bucket.builder().addLimit(limit).build(); } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String key = request.getRequestURI(); Bucket bucket = bucketMap.computeIfAbsent(key, k -> newBucket()); // 延迟创建桶 if (bucket.tryConsume(1)) { // 消耗 1 个令牌 return true; } response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.getOutputStream().write("Too Many Requests".getBytes(StandardCharsets.UTF_8)); return false; } }优势：策略多、可配额外指标；劣势：默认仍是单机。可选用 bucket4j-redis 或 Hazelcast 实现分布式令牌桶。7. 分布式限流思路：使用固定窗口计数法（Fixed Window）。对某个限流 key（如 接口+IP），在一个窗口期内使用 INCR 计数，首次创建时设置 EXPIRE 为窗口大小，到期自动清除。无需 Lua，保证实现简单、易维护。特性：简单高效；适合大多数分布式限流需求。边界在窗口切换时可能出现短暂突发（固定窗口特性）。若需更平滑的滑动窗口，可使用 Redis 事务（WATCH/MULTI/EXEC）或 Lua（本文不使用 Lua）。// RedisFixedWindowRateLimitInterceptor.java // 功能：基于 Redis 的固定窗口限流（纯 Redis 命令：INCR + EXPIRE） package com.example.demo.limit; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.web.servlet.HandlerInterceptor; import java.nio.charset.StandardCharsets; import java.time.Duration; public class RedisFixedWindowRateLimitInterceptor implements HandlerInterceptor { private final StringRedisTemplate redis; private final int maxReq; // 窗口内最大请求数 private final Duration window; // 窗口大小 public RedisFixedWindowRateLimitInterceptor(StringRedisTemplate redis, int maxReq, Duration window) { this.redis = redis; this.maxReq = maxReq; this.window = window; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String baseKey = buildBaseKey(request); // 例如: rl:{uri}:{ip} // 将窗口时间片纳入 key，避免首次请求起始时间的不确定性 long windowMs = window.toMillis(); long bucket = System.currentTimeMillis() / windowMs; String key = baseKey + ":" + bucket; // 形如 rl:/api/xx:1.2.3.4:19736622 Long count = redis.opsForValue().increment(key); if (count != null && count == 1L) { // 首次创建时设置过期时间，避免键长期滞留 redis.expire(key, window); } if (count != null && count <= maxReq) { return true; } response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.getOutputStream().write("Too Many Requests".getBytes(StandardCharsets.UTF_8)); return false; } private String buildBaseKey(HttpServletRequest request) { String uri = request.getRequestURI(); String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty()) ip = request.getRemoteAddr(); return "rl:" + uri + ":" + ip; // key 结构：rl:/api/xxx:1.2.3.4 } }注册：// 在 /api/** 启用分布式固定窗口限流：1 秒内最多 100 次 registry.addInterceptor(new RedisFixedWindowRateLimitInterceptor(stringRedisTemplate, 100, Duration.ofSeconds(1))) .addPathPatterns("/api/**");说明：key 维度可按需自定义（接口/用户/租户/来源等）。如果需要更平滑的限流，可在应用层做“本地桶 + Redis 合并”或升级为滑动窗口（仍可用纯 Redis 事务实现）。返回 429（Too Many Requests）。8. 注解式限流（AOP）定义注解：// RateLimit.java // 功能：声明式限流参数，支持在类或方法上使用 package com.example.demo.limit; import java.lang.annotation.*; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RateLimit { int max() default 100; // 窗口内最大请求数 long windowMs() default 1000; // 窗口大小（毫秒） String key() default "#request.requestURI"; // 基于 SpEL 的 key 表达式 }切面实现（示例改为每个 key 一个 Guava RateLimiter；按注解参数换算速率）：@Aspect @Component public class RateLimitAspect { private final HttpServletRequest request; // private final ConcurrentMap<String, RateLimiter> uriToLimiter = new ConcurrentHashMap<>();// private final ConcurrentMap<String, Bucket> bucketMap = new ConcurrentHashMap<>(); // 每个 URI 一个桶 public RateLimitAspect(HttpServletRequest request) { this.request = request; } @Around("@annotation(rateLimit)") public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { String key = buildKey(rateLimit); // 可结合 SpEL 动态生成 double permitsPerSecond = calcPermitsPerSecond(rateLimit.max(), rateLimit.windowMs()); // RateLimiter limiter = uriToLimiter.computeIfAbsent(key, k -> RateLimiter.create(permitsPerSecond)); Bucket bucket = bucketMap.computeIfAbsent(key, (k)->{ Bandwidth limit = Bandwidth.classic(Long.parseLong(permitsPerSecond + ""), Refill.intervally(Long.parseLong(permitsPerSecond + ""), Duration.ofSeconds(1))); return Bucket.builder().addLimit(limit).build(); }); boolean allowed = bucket.tryConsume(1); if (allowed) { return pjp.proceed(); } throw new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Too Many Requests"); } private String buildKey(RateLimit rateLimit) { String uri = request.getRequestURI(); String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty()) ip = request.getRemoteAddr(); return "rl:" + uri + ":" + ip; } private double calcPermitsPerSecond(int max, long windowMs) { if (windowMs <= 0) return Double.POSITIVE_INFINITY; double sec = windowMs / 1000.0; double qps = max / sec; return Math.max(qps, 0.000001); // 避免 0 } }使用：// 在 1 秒窗口内限制最多 20 次 @RateLimit(max = 20, windowMs = 1000) @GetMapping("/api/resource") public String resource() { return "ok"; }9. 统一异常与统一返回// GlobalExceptionHandler.java // 功能：统一将异常转换为标准响应体，便于前端与调用方处理 package com.example.demo.web; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.server.ResponseStatusException; import java.util.HashMap; import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(ResponseStatusException.class) public ResponseEntity<Map<String, Object>> handle(ResponseStatusException ex) { Map<String, Object> body = new HashMap<>(); body.put("code", ex.getStatusCode().value()); // 与 HTTP 状态一致 body.put("message", ex.getReason()); // 由抛出方提供的原因 return new ResponseEntity<>(body, ex.getStatusCode()); } @ExceptionHandler(Exception.class) public ResponseEntity<Map<String, Object>> handle(Exception ex) { Map<String, Object> body = new HashMap<>(); body.put("code", 500); body.put("message", ex.getMessage()); // 生产可隐藏或映射为通用提示 return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); } }10. 网关与真实 IP使用 Nginx/Ingress 时，转发需设置并在应用端读取 X-Forwarded-For 或 X-Real-IP。若部署在网关后：网关做全局粗粒度限流（防 DDoS），应用内做细粒度限流（按用户/接口）。11. 压测与验证curl 验证：# 并发 100，持续 10s（需要已安装 hey） hey -z 10s -c 100 http://localhost:8080/api/resource观察日志中 [IN]/[OUT] 与 429；查看 /actuator/prometheus 指标，并在 Grafana 绘制 QPS、P95、限流次数等。12. 最佳实践清单日志与指标：所有入口统一打点（请求、状态、耗时、异常）。限流维度：按场景选择（全局/接口/用户/IP/租户）。算法选择：突发流量多用令牌桶；需要平滑用滑动窗口。分布式一致性：使用 Redis/Lua 或 Bucket4j 分布式支持。白名单/开关：在限流前置判断白名单；提供动态阈值与紧急开关。安全：注意绕过手段（多 IP、代理）；对敏感接口更严格。性能：Redis 压力大时做本地缓存合并、批量/管道、热点降级。观测：Actuator + Micrometer + Prometheus + Grafana，统一观察服务健康。13. 参考依赖清单（择需）<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>33.0.0-jre</version> </dependency> <dependency> <groupId>com.github.vladimir-bukhtoyarov</groupId> <artifactId>bucket4j-core</artifactId> <version>8.10.1</version> </dependency> </dependencies>
目标与范围明确一致的接口风格，降低客户端心智负担，提升跨团队协作效率。规范涵盖：资源建模、URI 设计、HTTP 方法与状态码、请求/响应、错误、分页过滤排序、版本化、安全、缓存、可观测性与文档。1. 核心理念资源导向：一切皆资源（名词复数命名），动作用 HTTP 方法表达。统一接口：方法、状态码、媒体类型、错误结构一致。无状态：每个请求自包含认证与上下文，不依赖服务器会话。可缓存：合理使用条件请求与缓存头，降低延迟与负载。可演进：通过版本化与向后兼容策略平滑升级。2. 资源建模与 URI 规范资源命名：使用复数、短小、层级表达从属关系。/api/v1/users/api/v1/users/{userId}/api/v1/users/{userId}/orders关联过滤：也可使用查询参数表达关联（优先简单方案）。/api/v1/orders?userId=123避免在路径中使用动词；确需动作，用子资源表达。POST /api/v1/invoices/{id}/pay标识符：建议使用不可泄漏信息的 ID（UUID/雪花），避免自增 ID 暴露业务规模。路径示例与反例：推荐：GET /api/v1/users/{id}，PATCH /api/v1/users/{id}，DELETE /api/v1/users/{id}不推荐：POST /api/update/users/{id}、POST /api/create/users/{id}（应分别使用 PUT/PATCH 与 POST /api/v1/users）3. HTTP 方法语义与幂等性GET（安全、幂等）：获取资源或集合。POST（非幂等）：创建资源、触发计算/异步任务。PUT（幂等）：整体替换资源（客户端提供完整表述）。PATCH（建议近幂等）：局部更新，使用 JSON Merge Patch 或 JSON Patch。DELETE（幂等）：删除资源（软删/硬删在语义上对客户端保持透明）。4. 标准状态码2xx：200 OK：成功，返回资源或结果；201 Created：创建成功，Location 指向新资源；202 Accepted：已受理异步任务；204 No Content：成功但无响应体（删除、幂等更新）。4xx：400 Bad Request：参数错误/校验失败；401 Unauthorized：未认证或凭证无效；403 Forbidden：已认证但无权限；404 Not Found：资源不存在；409 Conflict：资源状态冲突（如唯一键冲突）；412 Precondition Failed：条件请求失败（ETag 并发控制）；415 Unsupported Media Type：媒体类型不支持；422 Unprocessable Entity：语义错误（校验未通过）；429 Too Many Requests：限流触发。5xx：服务器错误，尽量避免；记录告警并快速恢复。5. 请求与响应规范媒体类型：请求/响应 Content-Type/Accept 统一使用 application/json; charset=utf-8。字段命名与格式：推荐 camelCase；时间使用 ISO 8601（UTC），如 2025-08-20T10:30:00Z。大整数（如 ID）防止前端精度丢失可按 string 传输。统一返回结构（可选但推荐，便于一致性与观测）：{ "code": "OK", // 可选 "message": "success", // 可选 "data": { /* 资源对象或结果 */ }, "requestId": "f7a2b..."， }错误返回结构：{ "code": "VALIDATION_ERROR", // 可以是数字，字符串 "message": "email is invalid",// 提示 "requestId": "9e6d7...", // 可选 "details": [ // 可选 { "field": "email", "issue": "must be a valid email" } ] }6. 示例：Todo 列表接口（内存 List）用途：演示使用内存 List 存储并返回 todo 集合（示例代码，非生产）。路径：GET /api/v1/todos返回：200 OK，application/json; charset=utf-86.1 控制器示例（Java / Spring）package com.example.demo.todo; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicLong; @RestController @RequestMapping("/api/v1/todos") public class TodoController { private final List<Todo> store = new CopyOnWriteArrayList<>(); private final AtomicLong idGen = new AtomicLong(0); public record Todo(Long id, String title, boolean completed) {} public TodoController() { store.add(new Todo(idGen.incrementAndGet(), "Learn REST", false)); store.add(new Todo(idGen.incrementAndGet(), "Write docs", true)); } @GetMapping public List<Todo> list() { return store; // 直接返回内存 List } }6.2 响应示例[ { "id": 1, "title": "Learn REST", "completed": false }, { "id": 2, "title": "Write docs", "completed": true } ]6.3 创建 Todo（POST）路径：POST /api/v1/todos请求体：{ "title": string, "completed": boolean? }返回：201 Created（或 200 OK），Location 指向新资源，响应体为创建后的 todoPOST /api/v1/todos Content-Type: application/json { "title": "Read book", "completed": false }HTTP/1.1 201 Created Location: /api/v1/todos/3 Content-Type: application/json; charset=utf-8 { "id": 3, "title": "Read book", "completed": false }6.4 修改 Todo（PATCH）路径：PATCH /api/v1/todos/{id}请求体：可选字段，部分更新返回：200 OK，返回更新后的 todo；不存在返回 404 Not FoundPATCH /api/v1/todos/1 Content-Type: application/json { "completed": true }HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "id": 1, "title": "Learn REST", "completed": true }6.5 删除 Todo（DELETE）路径：DELETE /api/v1/todos/{id}返回：存在则 204 No Content，不存在返回 404 Not FoundDELETE /api/v1/todos/2HTTP/1.1 204 No Content6.6 完整控制器（含新增/修改/删除）package com.example.demo.todo; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicLong; @RestController @RequestMapping("/api/v1/todos") public class TodoController { private final List<Todo> store = new CopyOnWriteArrayList<>(); private final AtomicLong idGen = new AtomicLong(0); public record Todo(Long id, String title, boolean completed) {} public record CreateTodoRequest(String title, Boolean completed) {} public record PatchTodoRequest(String title, Boolean completed) {} public TodoController() { store.add(new Todo(idGen.incrementAndGet(), "Learn REST", false)); store.add(new Todo(idGen.incrementAndGet(), "Write docs", true)); } @GetMapping public List<Todo> list() { return store; } @PostMapping public ResponseEntity<Todo> create(@RequestBody CreateTodoRequest req) { if (req == null || req.title() == null || req.title().isBlank()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } boolean completed = req.completed() != null ? req.completed() : false; long id = idGen.incrementAndGet(); Todo todo = new Todo(id, req.title(), completed); store.add(todo); return ResponseEntity .status(HttpStatus.CREATED) .header(HttpHeaders.LOCATION, "/api/v1/todos/" + id) .body(todo); } @PatchMapping("/{id}") public ResponseEntity<Todo> patch(@PathVariable Long id, @RequestBody PatchTodoRequest req) { int idx = indexOfId(id); if (idx < 0) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } Todo old = store.get(idx); String newTitle = (req != null && req.title() != null) ? req.title() : old.title(); boolean newCompleted = (req != null && req.completed() != null) ? req.completed() : old.completed(); Todo updated = new Todo(id, newTitle, newCompleted); store.set(idx, updated); return ResponseEntity.ok(updated); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { boolean removed = store.removeIf(t -> t.id().equals(id)); return removed ? ResponseEntity.noContent().build() : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } private int indexOfId(Long id) { for (int i = 0; i < store.size(); i++) { if (store.get(i).id().equals(id)) { return i; } } return -1; } }7. 示例：Product 产品接口（内存 List）用途：演示“产品”资源的增删改查实现（示例代码，非生产）。路径：GET /api/v1/products 列表GET /api/v1/products/{id} 详情POST /api/v1/products 新建PATCH /api/v1/products/{id} 部分更新DELETE /api/v1/products/{id} 删除模型字段：id: number/string（响应中为数字，此处示例用 Long）description: string（产品描述）price: number（价格，建议十进制定点，Java 用 BigDecimal）stock: number（库存，非负整数）7.1 请求/响应示例列表：GET /api/v1/products[ { "id": 1, "description": "Demo A", "price": 99.90, "stock": 10 }, { "id": 2, "description": "Demo B", "price": 199.00, "stock": 5 } ]详情：GET /api/v1/products/1{ "id": 1, "description": "Demo A", "price": 99.90, "stock": 10 }新建：POST /api/v1/products Content-Type: application/json { "description": "New Product", "price": 9.99, "stock": 100 }HTTP/1.1 201 Created Location: /api/v1/products/3 Content-Type: application/json; charset=utf-8 { "id": 3, "description": "New Product", "price": 9.99, "stock": 100 }修改（部分字段）：PATCH /api/v1/products/1 Content-Type: application/json { "stock": 8 }HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "id": 1, "description": "Demo A", "price": 99.90, "stock": 8 }删除：DELETE /api/v1/products/2HTTP/1.1 204 No Content7.2 控制器示例（Java / Spring）package com.example.demo.product; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicLong; @RestController @RequestMapping("/api/v1/products") public class ProductController { private final List<Product> store = new CopyOnWriteArrayList<>(); private final AtomicLong idGen = new AtomicLong(0); public record Product(Long id, String description, BigDecimal price, int stock) {} public record CreateProductRequest(String description, BigDecimal price, Integer stock) {} public record PatchProductRequest(String description, BigDecimal price, Integer stock) {} public ProductController() { store.add(new Product(idGen.incrementAndGet(), "Demo A", new BigDecimal("99.90"), 10)); store.add(new Product(idGen.incrementAndGet(), "Demo B", new BigDecimal("199.00"), 5)); } @GetMapping public List<Product> list() { return store; } @GetMapping("/{id}") public ResponseEntity<Product> getById(@PathVariable Long id) { int idx = indexOfId(id); if (idx < 0) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } return ResponseEntity.ok(store.get(idx)); } @PostMapping public ResponseEntity<Product> create(@RequestBody CreateProductRequest req) { if (!isValidCreate(req)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } long id = idGen.incrementAndGet(); Product product = new Product(id, req.description(), req.price(), req.stock()); store.add(product); return ResponseEntity .status(HttpStatus.CREATED) .header(HttpHeaders.LOCATION, "/api/v1/products/" + id) .body(product); } @PatchMapping("/{id}") public ResponseEntity<Product> patch(@PathVariable Long id, @RequestBody PatchProductRequest req) { int idx = indexOfId(id); if (idx < 0) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } Product old = store.get(idx); String newDescription = req != null && req.description() != null ? req.description() : old.description(); BigDecimal newPrice = req != null && req.price() != null ? req.price() : old.price(); Integer newStockBoxed = req != null && req.stock() != null ? req.stock() : old.stock(); if (!isValidFields(newDescription, newPrice, newStockBoxed)) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } Product updated = new Product(id, newDescription, newPrice, newStockBoxed); store.set(idx, updated); return ResponseEntity.ok(updated); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { boolean removed = store.removeIf(p -> p.id().equals(id)); return removed ? ResponseEntity.noContent().build() : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } private int indexOfId(Long id) { for (int i = 0; i < store.size(); i++) { if (store.get(i).id().equals(id)) { return i; } } return -1; } private boolean isValidCreate(CreateProductRequest req) { if (req == null || req.description() == null || req.description().isBlank()) return false; if (req.price() == null || req.price().compareTo(BigDecimal.ZERO) < 0) return false; if (req.stock() == null || req.stock() < 0) return false; return true; } private boolean isValidFields(String description, BigDecimal price, Integer stock) { if (description == null || description.isBlank()) return false; if (price == null || price.compareTo(BigDecimal.ZERO) < 0) return false; if (stock == null || stock < 0) return false; return true; } }
概述FriendsRSS 是一款专为 Typecho 博客系统设计的智能友链 RSS/Atom 聚合插件。它能够自动检测、解析和聚合您友链博客的最新文章，通过多种展示方式（短代码、API接口、RSS输出、前台页面）让您的博客成为友圈动态的聚合中心。插件功能核心功能友链来源集成：从 Typecho links 表的指定分类读取友链，自动参与聚合。兼容性增强：当指定分类不存在时，自动获取所有友链，完美兼容只有一种友链的主题博客。排除功能：支持配置排除检测的博客网址，每行一个，支持部分匹配。RSS 自动检测：采用"三轮检测"策略（常用路径/feed和/feed/ → HTML 解析 → 其他常见路径），并对检测结果进行 10天缓存（RSS地址相对稳定）。定时检测功能：支持配置定时检测RSS地址间隔（默认240小时/10天），RSS地址缓存时间自动与定时检测间隔保持一致。定时解析功能：支持配置定时解析间隔（默认6小时），提供独立的定时任务脚本，支持命令行和HTTP调用。手动配置 RSS：后台提供"选择友链 + 输入 RSS 地址"的表单，保存到 rss_config.json 作为覆盖与纠偏。短代码功能：在任意文章或页面中使用 rss 短代码来显示友链RSS聚合内容，支持 limit 参数控制显示文章数量。多格式支持：兼容 RSS 2.0 / Atom 1.0 / RSS 1.0，解析标题、链接、作者、摘要与发布时间等字段。文章聚合与排序：为每个博客限制抓取数量，并按发布时间 倒序 排列，支持聚合总数上限。缓存与并发保护：聚合结果持久化缓存，内置 锁文件 防止并发重复任务；可读取已有缓存以快速展示。日志记录：记录检测、解析与网络状态，便于排查异常与性能问题。输出功能RSS订阅地址：/action/friends-rss?do=rss - 生成标准RSS 2.0格式的聚合订阅源API接口地址：/action/friends-rss?do=page - 返回JSON格式的聚合文章数据前台展示页面：/action/friends-rss?do=pageview - 美观的前台展示页面，支持响应式设计[collapse status="false" title="示例图片查看"][/collapse]管理界面统计卡片：显示友链数量、文章数量、最后更新时间、已配置RSS数量等统计信息操作卡片：定时检测RSS地址：显示当前检测间隔和下次执行时间，提供"立即执行"按钮定时解析RSS内容：显示当前解析间隔和下次执行时间，提供"立即执行"按钮添加RSS地址：手动配置友链RSS地址的表单界面访问地址区块：显示所有可用的访问地址和友链分类信息友链与文章列表：显示友链列表和最新聚合文章，支持RSS配置状态标识日志显示区域：实时显示操作日志，支持自动滚动到最新内容短代码功能基本用法：在任意文章或页面中使用 rss 短代码来显示友链RSS聚合内容参数支持：支持 limit 参数控制显示文章数量块状设计：使用简洁美观的块状设计，包含博客信息、文章标题、摘要等响应式布局：自动适配移动端设备，优化显示效果网络与兼容性优化请求优化：优先使用 cURL，请求超时与重定向受控内容清理：自动清理与修复常见 XML/文本问题、摘要长度截断错误处理：完善的异常处理和日志记录机制并发保护：使用锁文件防止重复任务执行缓存策略：智能缓存管理，支持缓存统计和清理定时任务功能独立脚本：提供 cron.php 独立定时任务脚本HTTP调用：支持通过HTTP请求触发定时任务状态管理：记录定时任务执行状态和下次执行时间安全验证：支持密钥验证防止未授权访问Ajax功能实时检测：支持SSE（Server-Sent Events）实时显示RSS检测进度批量操作：支持批量检测RSS地址和清除缓存进度反馈：实时显示操作进度和结果错误处理：完善的错误处理和用户反馈机制安装与配置将插件文件夹上传到 usr/plugins/ 目录。在 Typecho 后台启用插件 FriendsRSS。在插件设置页配置友链来源、每个博客抓取文章数、聚合总数上限与缓存时间等参数。Github地址：[hide]https://github.com/BXCQ/FriendsRSS[/hide]配置参数说明友链分类：指定需要聚合的友链分组。兼容性说明：Handsome主题：填写"ten"获取全站链接，填写"one"获取内页链接，填写"good"获取推荐链接其他主题：通常留空即可，插件会自动获取所有友链当指定分类不存在时，插件会自动获取所有友链，确保兼容性排除检测的博客网址：每行一个网址，支持部分匹配。例如：example.com - 排除所有包含example.com的友链https://blog.example.com - 排除特定博客留空表示不排除任何友链每个博客文章数：每个博客抓取与展示的文章数量限制。最大文章数：聚合后的文章总数上限。定时解析间隔：定时解析友链RSS的间隔时间（小时），默认6小时，设置为0表示禁用。文章缓存时间会自动与定时解析间隔保持一致。定时检测间隔：定时检测友链RSS地址的间隔时间（小时），默认240小时（10天），设置为0表示禁用。RSS地址缓存时间会自动与定时检测间隔保持一致。启用前台展示：是否开放前台聚合页与 RSS 输出。使用方法检测 RSS：在后台点击"定时检测RSS地址"卡片中的"立即执行"按钮，自动为友链站点识别 RSS/Atom 地址并保存。定时检测：在后台点击"定时检测RSS地址"，按配置的间隔时间自动检测友链RSS地址。可在宝塔面板计划任务重添加shell脚本# 使用 curl 访问触发更新，-s 表示静默模式，> /dev/null 丢弃输出 curl -s "https://www.example.com/action/friends-rss?do=cron" > /dev/null 2>&1 # 或者打印一下时间，方便看日志（可选） echo "FriendsRSS Cron Executed at $(date)"手动添加：在"添加 RSS 地址"区域选择目标友链并填写正确的订阅地址后保存。聚合文章：点击"定时解析RSS内容"，按时间排序聚合最新文章并写入缓存。访问输出：RSS订阅地址：/action/friends-rss?do=rssAPI接口地址：/action/friends-rss?do=page（需在设置中启用）前台展示页面：/action/friends-rss?do=pageview（需在设置中启用）性能与稳定性超时控制：为单次请求与连接设置合理超时并限制重定向次数。并发保护：使用锁文件/状态标记避免重复聚合任务并减少服务器压力。分轮检测：优先常用路径，其次 HTML 解析，最后其他常见路径以提升成功率与速度。缓存策略缓存类型缓存时间说明RSS地址缓存与定时检测间隔一致RSS地址相对稳定，与定时检测间隔同步，减少重复检测文章内容缓存与定时解析间隔一致确保定时解析有意义，避免缓存过期后立即重新解析失败结果缓存1小时避免重复请求失败的RSS源，减少服务器压力定时策略配置项默认值说明定时解析间隔6小时定时解析友链RSS的间隔时间，设置为0表示禁用文章缓存时间自动同步与定时解析间隔保持一致，确保定时解析有意义定时检测间隔240小时（10天）定时检测友链RSS地址的间隔时间，设置为0表示禁用RSS地址缓存时间自动同步与定时检测间隔保持一致，减少重复检测实际效果缓存过期时重新执行定时任务执行时缓存刚好过期，获取最新内容设计思路：以"逻辑一致性"为核心，确保定时任务与缓存时间同步，提升用户体验和系统效率。RSS短代码使用基本用法在任何文章或页面中使用 rss 短代码来显示友链RSS聚合内容，使用简洁的块状设计：rss默认显示10篇最新文章，使用块状设计。或者rss limit="5"只显示5篇最新文章。显示内容每个文章块包含：博客信息头部：博客名称、作者信息、发布时间文章标题：可点击跳转到原文文章摘要：自动截取的文章概要（200字符以内）参数说明limit类型: 数字默认值: 10说明: 显示文章的数量注意不要放置多个，会重复显示。设计特点块状设计简洁美观：每篇文章独立成块，清晰区分信息完整：包含博客、作者、时间、标题、摘要视觉层次：合理的间距和颜色搭配悬停效果：鼠标悬停时显示阴影效果响应式设计移动端优化：自动适配小屏幕设备布局调整：移动端下头部信息垂直排列字体缩放：移动端下适当调整字体大小注意事项缓存机制: 短代码使用缓存机制，文章更新可能有延迟性能考虑: 建议在高流量站点限制显示数量（limit参数）样式兼容: 短代码样式已针对常见主题优化，如有冲突可自定义CSS安全性: 所有外部内容都经过HTML转义处理错误处理与兼容性超时处理：如果你的友链过多且开启了CDN，可以在CDN中设置超时时间，从默认的30s到60s或者更高。网络异常与 HTTP 错误：会被记录并跳过，继续处理其他源。XML 内容清理：在解析前进行清理，尽量兼容 RSS 2.0、Atom 1.0 与 RSS 1.0 常见格式。扩展要求：建议启用 cURL、SimpleXML 等常用扩展并确保插件对缓存目录拥有写入权限。更新日志日期版本更新类型变更内容2025/8/31v2.2.1缓存机制修复• 缓存机制修复：修复缓存机制导致RSS配置无法及时更新的问题：增加配置文件时间戳检查，当配置文件比缓存文件更新时自动强制刷新缓存，确保能读取到最新的手动配置RSS地址• 调试信息增强：增强RSS配置读取调试信息：添加详细的配置文件读取日志，特别针对特定友链配置状态的检查和记录，便于问题排查• 思路：以"缓存一致性"为核心，确保配置文件更新后能及时生效，提升手动配置RSS的可靠性2025/8/31v2.2.0配置文件修复• 配置文件修复：修复RSS配置文件和缓存目录缺失导致的配置读取失败问题：在构造函数中自动创建rss_config.json文件和缓存目录，确保系统正常运行• 错误处理完善：完善错误处理机制：增加文件读取、JSON解析和缓存操作的详细错误日志记录，改进后台RSS配置保存逻辑• 思路：以"健壮性优先"为核心，确保插件在各种环境下都能正常工作，提升系统稳定性2025/8/25v2.1.6定时检测RSS功能• 定时检测RSS功能：新增定时检测RSS功能：支持配置定时检测RSS地址间隔（默认240小时/10天），RSS地址缓存时间自动与定时检测间隔保持一致• 定时检测界面：管理界面优化：新增"定时检测RSS地址"操作卡片，显示当前检测间隔和下次执行时间• 按钮简化：移除重复的"手动检测"按钮，只保留"立即执行"按钮，避免功能重复• 缓存策略：缓存策略优化：RSS地址缓存时间从固定7天改为与定时检测间隔同步，提升系统一致性• 短代码文档：新增RSS短代码使用说明，包含基本用法、参数说明、使用示例和设计特点• 思路：以"逻辑一致性"为核心，确保定时检测与RSS地址缓存同步，提升用户体验和系统效率2025/8/25v2.1.0RSS地址检测• RSS地址检测：新增RSS地址检测功能：自动检测友链博客的RSS地址，支持多种常见RSS路径，提供手动添加功能• 管理界面重构：管理界面重构：新增RSS地址管理、检测日志显示、定时任务设置等功能模块• 缓存机制优化：缓存机制优化：RSS地址缓存24小时，RSS内容缓存可配置，提升系统性能• 思路：以"自动化检测"为核心，减少手动配置工作量，提升用户体验2025/8/25v2.1.5友链分类兼容• 友链分类兼容：友链分类兼容性增强：当指定分类不存在时，自动获取所有友链，完美兼容只有一种友链的主题博客• 排除功能：新增排除功能：支持配置排除检测的博客网址，每行一个，支持部分匹配，提供更灵活的友链管理• 分类统计界面：管理界面优化：新增"可用分类"统计卡片，在访问地址区域显示所有可用的友链分类及当前使用状态• 配置界面优化：配置界面优化：友链分类设置项默认值改为空，添加详细的兼容性说明，帮助用户正确配置不同主题的友链• 思路：以"最大兼容性"为核心，确保插件能在各种Typecho主题环境下正常工作，提升用户体验2025/8/25v2.1.4日志显示修复• 日志显示修复：修复日志显示错误：将"用时20s"等错误显示修正为实际请求用时，使用microtime()精确计算请求耗时• 日志自动滚动：管理界面体验优化：日志显示区域自动滚动到最新内容，无需手动滚动查看最新日志• 策略文档：新增缓存策略和定时解析策略表格说明，更直观地展示系统设计思路2025/8/25v2.1.3缓存策略优化• 缓存策略优化：缓存策略优化：RSS地址缓存时间从24小时延长至7天（更符合RSS地址相对稳定的特点），文章内容缓存时间自动与定时解析间隔保持一致，移除独立的缓存时间设置项• 定时解析功能：新增定时解析功能：支持配置定时解析间隔（默认6小时），提供独立的定时任务脚本，支持命令行和HTTP调用• 配置界面简化：简化配置界面：移除"缓存时间"设置项，避免用户困惑，定时解析间隔设置中说明缓存时间会自动同步• 思路：以"逻辑一致性"为核心，确保定时解析与缓存时间同步，提升用户体验和系统效率2025/8/25v2.1.2界面优化• 缓存时间优化：RSS缓存设置优化：将缓存时间单位从秒改为天，默认值从360000秒（100小时）调整为10天，更符合用户使用习惯• 移除超时设置：移除请求超时设置项：删除未使用的timeout配置项，简化插件设置界面• 界面名称优化：管理界面名称优化：将"检测友链RSS"改为"检测友链RSS地址"，"定时解析控制"改为"定时解析RSS内容"，"前台展示页面"改为"前台接口地址"，"预览页面"改为"前台展示页面"• 思路：以"用户体验优化"为核心，简化配置项，统一命名规范，提升界面友好度2025/8/25v2.1.1管理界面优化• 预览页面地址：新增预览页面地址：在访问地址区域添加 /action/friends-rss?do=pageview 预览链接• 管理界面优化：管理界面优化：将定时解析控制块替换解析RSS功能，移除定时任务设置说明，简化操作流程• 移除预览页面块：移除预览页面块：简化管理界面，减少冗余展示区域• 错误日志显示：新增错误日志显示：在管理页面中显示 error.log 日志内容，便于问题排查• 思路：以"简化操作 + 增强监控"为核心，提升管理效率和问题诊断能力2025/8/24v2.0.4UI布局优化• UI布局优化：后台"添加 RSS 地址"区域 UI 调整：输入框与选择框长度比例优化（输入 65% / 选择 30%），取消选择框固定高度，移动端响应式更佳• select对齐修复：解决 Typecho 默认 select 固定高度 28px 导致与输入框不对齐的问题• 思路：表单布局采用 flex，优先保障主要输入区可用性与可读性，减少视觉噪点2025/8/24v2.0.3管理面板重构• 管理面板重构：管理面板重构：新增统计卡片、操作卡片与访问地址区块；友链与文章列表支持状态徽标，信息呈现更直观• 最后更新修复：修复"最后更新"在无缓存时显示 1970 的问题，改为显示"暂无"• 思路：采用 模块化卡片布局，将"检测/解析/添加"动作解耦，提升可维护性与扩展空间2025/8/23v2.0.2锁文件机制• 锁文件机制：新增聚合任务 锁文件 机制；提供缓存清理与缓存统计；聚合结果持久化读取，页面可直接消费缓存• 并发解析修复：修复并发解析导致缓存文件被覆盖/损坏的偶发问题；修复缓存为空时解析流程可能抛错的情况• 思路：从"实时拉取"转为 缓存优先 的数据策略，后台按需触发解析，降低对外部源的耦合与压力2025/8/23v2.0.1三轮RSS检测• 三轮RSS检测：引入 三轮 RSS 自动检测；为检测结果设置 24 小时缓存；细化请求超时与重定向限制，并记录响应头• 链接解析修复：修复部分站点 HTML 中相对链接解析错误；修正 Content-Type 判断大小写不一致导致的识别失败• 思路：采用"快速路径优先 → HTML 解析 → 兜底常见路径"的分轮策略，先提升成功率与速度，再做细化校验2025/8/23v2.0.0解析器升级• 解析器升级：解析器升级：支持 RSS 2.0 / Atom 1.0 / RSS 1.0；清理与截断描述文本；新增 rss_config.json 用于手动配置；按发布时间倒序聚合并限制总量• XML解析修复：修复特殊字符、BOM 与控制字符导致 XML 解析失败的问题；增强 HTML 实体修复• 思路：以"可靠解析"为核心：先清理再解析，最大化容错；将必要的清洗逻辑前置，保证数据质量2025/8/23v1.5.0RSS输出功能• RSS输出功能：提供聚合 RSS 输出 与可选 前台展示页面；后台展示最近文章与来源信息• 时间排序修复：修复文章缺失时间时排序不稳定，统一回退到当前时间• 思路：以 /action/friends-rss 为统一入口，降低主题/路由差异带来的接入成本2025/8/23v1.4.0多源聚合功能• 多源聚合功能：支持多源聚合与发布时间排序；可配置"每博客文章数"与"聚合总数"• 内存优化：优化大列表处理的内存开销，改为先按需切片再渲染• 思路：将"数量限制"前移到聚合阶段，减少后续处理成本2025/8/23v1.3.0摘要截断功能• 摘要截断功能：自动 截断摘要，保证列表简洁可读• HTML清理修复：修复未清理 HTML 标签导致的样式外溢问题• 思路：以摘要优先，保证信息密度与浏览效率2025/8/23v1.2.0手动添加RSS功能• 手动添加RSS功能：后台新增"手动添加 RSS"能力，为自动检测失败站点提供纠偏• JSON读写修复：修复首次保存配置时空文件读取异常，增强 json 读写容错• 思路：自动检测与 人工纠偏 并行，提高覆盖率与准确度2025/8/23v1.1.0性能参数配置• 性能参数配置：增加缓存时间、重定向上限与超时等基础性能参数配置• 超时设置修复：修复部分环境下超时设置不生效的问题• 思路：坚持"可控超时"原则，保证失败快速返回与系统整体可用性2025/8/23v1.0.0初始版本• 初始版本：初始版本：多源 RSS/Atom 聚合、后台基础管理与展示• Bug：无• 思路：提供最小可用集合，优先打通"来源 → 解析 → 聚合 → 展示"的闭环
前言在个人博客的侧边栏展示自己的QQ信息，包括头像、昵称、QQ号、等级和Q龄等，不仅可以增加博客的个性化，还能让访客更好地了解博主。本教程将详细介绍如何在Typecho博客的Handsome主题侧边栏中添加QQ信息卡片功能。效果展示实现后的效果是在侧边栏显示一个包含以下信息的QQ卡片：QQ头像QQ昵称QQ号码Q龄（注册年份）QQ等级（使用emoji表情：👑☀️🌙⭐表示不同等级）实现步骤1. 准备工作首先，我们需要找到合适的API来获取QQ信息。本教程使用的是jkapi.com提供的API服务，它可以根据QQ号获取相关信息，这个用了半个月就不行了，换成了api.dwo.cc的。2. 修改sidebar.php文件在Handsome主题中，侧边栏的内容是由sidebar.php文件控制的。我们需要编辑这个文件来添加QQ信息卡片。2.1 找到合适的位置通常，我们会将QQ信息卡片添加到侧边栏的个人信息模块附近。在sidebar.php文件中找到合适的位置，例如在博主信息卡片之后。2.2 添加QQ信息卡片代码将以下代码添加到选定的位置：[hide] <?php _me("QQ信息") ?> <?php function getQQInfoFromDwoApi($qq) { $url = "https://api.dwo.cc/api/aiys_qq?qq=" . $qq; $ch = curl_init(); // 设置cURL选项 curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_TIMEOUT, 5); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'ckey: *************', // 在header中传递秘钥 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ]); $response = curl_exec($ch); curl_close($ch); if ($response) { $data = json_decode($response, true); if (isset($data['code']) && $data['code'] == 200) { return $data; } } return null; } function generateQQLevelIconsHtml($level) { $html = ''; $level = intval($level); // 等级计算规则 $crowns = floor($level / 64); $suns = floor(($level % 64) / 16); $moons = floor(($level % 16) / 4); $stars = $level % 4; // 使用emoji表情替代图片，更加稳定可靠 $crown_icon = '👑'; // 皇冠 $sun_icon = '☀️'; // 太阳 $moon_icon = '🌙'; // 月亮 $star_icon = '⭐'; // 星星 $icon_style = 'style="font-size:16px; margin:0 1px;"'; for ($i = 0; $i < $crowns; $i++) { $html .= '' . $crown_icon . ''; } for ($i = 0; $i < $suns; $i++) { $html .= '' . $sun_icon . ''; } for ($i = 0; $i < $moons; $i++) { $html .= '' . $moon_icon . ''; } for ($i = 0; $i < $stars; $i++) { $html .= '' . $star_icon . ''; } return $html; } // 缓存文件路径 - 使用Typecho的缓存目录 $cache_dir = defined('__TYPECHO_TMP_DIR__') ? __TYPECHO_TMP_DIR__ : __DIR__ . '/cache'; if (!is_dir($cache_dir)) { @mkdir($cache_dir, 0755, true); } $qq = '123456789'; // 您的QQ号 $cache_file = $cache_dir . '/qq_info_' . $qq . '.json'; $cache_time = 86400; // 缓存1天（单位：秒） // 检查缓存是否存在且有效 $qqInfo = null; if (file_exists($cache_file) && (time() - filemtime($cache_file) < $cache_time)) { // 缓存有效，直接读取缓存 $cache_content = @file_get_contents($cache_file); if ($cache_content !== false) { $qqInfo = json_decode($cache_content, true); } } // 如果缓存无效或读取失败，则调用API if ($qqInfo === null) { // 缓存无效或不存在，调用API获取数据 $qqInfo = getQQInfoFromDwoApi($qq); // 如果API调用成功，保存到缓存 if ($qqInfo) { @file_put_contents($cache_file, json_encode($qqInfo)); } } // 只使用API返回的数据或缓存数据 if ($qqInfo && isset($qqInfo['data'])) { // 新API结构 - aiys_qq接口 $level = $qqInfo['data']['iQQLevel'] ?? ''; $nickname = $qqInfo['data']['sNickName'] ?? ''; $avatar = $qqInfo['data']['sFaceUrl'] ?? ''; // 获取PC端在线时长(单位：分钟) $pcOnlineTime = isset($qqInfo['data']['iPCQQOnlineTime']) ? intval($qqInfo['data']['iPCQQOnlineTime']) : 0; // 获取升级剩余天数 $nextLevelDay = $qqInfo['data']['iNextLevelDay'] ?? ''; // 获取总活跃天数 $totalActiveDay = $qqInfo['data']['iTotalActiveDay'] ?? ''; // 获取VIP等级 $vipLevel = $qqInfo['data']['iVipLevel'] ?? 0; // 是否是SVIP $isSVip = isset($qqInfo['data']['iSVip']) && $qqInfo['data']['iSVip'] == '1'; $levelIconsHtml = generateQQLevelIconsHtml($level); ?> <?php if ($avatar): ?> <img src="<?php echo $avatar; ?>" class="img-circle" loading="lazy" style="width:60px; height:60px; border:2px solid #fff; box-shadow:0 2px 5px rgba(0,0,0,0.1);"> <?php else: ?> <?php endif; ?> <?php echo htmlspecialchars($nickname ?: 'QQ用户'); ?> <?php echo $qq; ?> <?php if ($vipLevel > 0): ?> <i data-feather="award" style="width:16px; height:16px; margin-right:6px; flex-shrink: 0; <?php echo $isSVip ? 'color:#FF0000;' : 'color:#FF9800;'; ?>"> <span style="<?php echo $isSVip ? 'color:#FF0000;' : 'color:#FF9800;'; ?>"> <?php echo $isSVip ? 'SVIP' : 'VIP'; ?><?php echo $vipLevel; ?> <?php endif; ?> <?php if ($level): ?> LV<?php echo $level; ?> <?php echo $levelIconsHtml; ?> <?php endif; ?> <?php if ($pcOnlineTime > 0): ?> PC在线时长: <?php echo $pcOnlineTime; ?> 分钟 <?php endif; ?> <?php if ($nextLevelDay): ?> 升级剩余: <?php echo $nextLevelDay; ?>天 <?php endif; ?> <?php if ($totalActiveDay): ?> 总活跃天数: <?php echo $totalActiveDay; ?>天 <?php endif; ?> <?php } else { // API请求失败且没有缓存数据时显示 echo ' 暂时无法获取QQ信息 '; } ?> [/hide]3. 添加主题设置选项为了让用户可以通过主题设置面板控制是否显示QQ信息卡片，我们需要修改主题的设置文件。在Handsome主题的functions.php文件中找到themeConfig函数，在适当位置添加以下代码：//侧边栏显示QQ信息 $form->checkbox('sidebarBlock', array('ShowQQInfo'), _t('侧边栏显示QQ信息'), _t('开启后侧边栏将显示QQ信息卡片'));4. 修改两个数值1、$qq = '676567473'; // 改成你的QQ号2、'ckey: *', // 改成你的秘钥在小渡api（获取api的网站）中注册登录，并查看秘钥注意事项替换QQ号码：在代码中将'你的QQ号码'替换为你自己的QQ号码。API可用性：本教程使用的是jkapi.com的API，如果该API不可用，可能需要寻找替代的API服务。缓存机制：代码中已实现了缓存机制，数据会缓存10天，避免频繁请求API。等级图标：使用emoji表情（👑☀️🌙⭐）代替传统的QQ等级图标，避免因CDN链接不稳定导致图标无法显示。错误处理：代码中包含了基本的错误处理机制，当API请求失败时会显示错误信息。常见问题Q: 为什么我的QQ信息不显示？A: 可能是API服务不稳定或者网络问题，请检查浏览器控制台是否有错误信息。Q: 如何修改缓存时间？A: 在代码中找到10 * 24 * 60 * 60 * 1000这一部分，修改前面的数字可以改变缓存天数。Q: 如何添加更多QQ信息？A: 可以根据API返回的数据，在displayQQInfo函数中添加更多信息的显示。结语通过以上步骤，你已经成功在Typecho博客的Handsome主题侧边栏添加了QQ等级和信息显示功能。这个功能不仅美观实用，还具有缓存机制，避免频繁请求API导致的性能问题。如果你有任何问题或改进建议，欢迎在评论区留言讨论。
分享60个有趣实用的API及其Typecho博客集成指南
引言突发奇想，搜集了一些公开API，为博客添加一些动态和个性化的元素让网站更加生动有趣。本文介绍60个有趣的API，它们都可以集成到Typecho博客中，让您的博客脱颖而出。目录个人社交账号API热搜榜单API工具类API内容增强类API博客功能增强类API游戏与娱乐类API个人社交账号API1. QQ等级显示通过QQ空间API获取您的QQ等级、成长值等信息，并在博客中展示。<?php // 在sidebar.php或自定义页面中添加 function getQQLevel($qq) { $url = "https://r.qzone.qq.com/fcg-bin/cgi_get_score.fcg?mask=7&uins=".$qq; $data = file_get_contents($url); $data = mb_convert_encoding($data, "UTF-8", "GBK"); preg_match('/lvtext:"(\d+级)"/', $data, $level); return isset($level[1]) ? $level[1] : '获取失败'; } $qq = '你的QQ号'; echo '我的QQ等级: '.getQQLevel($qq); ?>2. QQ音乐状态API展示您正在听的QQ音乐歌曲信息。<?php function getQQMusicStatus($qq) { // QQ音乐状态获取 $url = "https://c.y.qq.com/rsc/fcgi-bin/fcg_get_profile_homepage.fcg?format=json&inCharset=utf-8&outCharset=utf-8&notice=0&platform=yqq&uin=".$qq; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); $data = json_decode(file_get_contents($url, false, $context), true); // 判断是否有正在收听的歌曲 if (isset($data['data']['listen']) && !empty($data['data']['listen']['now'])) { return [ 'status' => true, 'song_name' => $data['data']['listen']['now']['song_name'], 'singer' => $data['data']['listen']['now']['singer'][0]['name'], 'cover' => $data['data']['listen']['now']['cover'] ]; } else { return [ 'status' => false ]; } } $qq = '你的QQ号'; $music = getQQMusicStatus($qq); if ($music['status']) { echo ' '; echo ' 我正在听 '; echo ' '; echo ' '; echo ' '.$music['song_name'].' '; echo ' - '.$music['singer'].' '; echo ' '; echo ' '; echo ' '; } else { echo '暂无音乐播放记录'; } ?>3. 哔哩哔哩UP主数据展示您的B站粉丝数、视频数、播放量等数据。<?php function getBiliStats($uid) { $url = "https://api.bilibili.com/x/relation/stat?vmid=".$uid."&jsonp=jsonp"; $data = json_decode(file_get_contents($url), true); return $data['data']; } $uid = '你的B站UID'; $stats = getBiliStats($uid); echo "粉丝数: ".$stats['follower']; ?>4. 网易云音乐状态展示您最近听的歌曲或者创建一个迷你播放器。<?php // 网易云音乐用户歌单获取 function getNeteaseMusicPlaylist($uid) { $url = "http://music.163.com/api/user/playlist/?offset=0&limit=1&uid=".$uid; $data = json_decode(file_get_contents($url), true); return $data['playlist'][0]['id']; } $uid = '你的网易云音乐ID'; $playlistId = getNeteaseMusicPlaylist($uid); ?> <iframe frameborder="no" border="0" marginwidth="0" marginheight="0" width=330 height=86 src="//music.163.com/outchain/player?type=2&id=<?php echo $playlistId; ?>&auto=0&height=66"> 5. GitHub贡献统计在博客上展示您的GitHub贡献图表。 6. 豆瓣读书/电影展示您最近阅读的书籍或观看的电影。<?php function getDoubanRecentBooks($uid) { $url = "https://api.douban.com/v2/book/user/".$uid."/collections?status=reading&count=5"; $data = json_decode(file_get_contents($url), true); return $data; } $uid = '你的豆瓣ID'; $books = getDoubanRecentBooks($uid); foreach ($books['collections'] as $book) { echo $book['book']['title'].' '; } ?>7. Instagram基本数据通过Instagram API展示您的基本数据。<?php function getInstagramBasicData($access_token) { $url = "https://graph.instagram.com/me?fields=id,username,account_type&access_token=".$access_token; $data = json_decode(file_get_contents($url), true); return $data; } $access_token = "您的Instagram访问令牌"; $data = getInstagramBasicData($access_token); echo "Instagram用户名: ".$data['username']; ?>8. Discord服务器状态展示您的Discord服务器状态和成员数。<?php function getDiscordServerInfo($server_id, $bot_token) { $url = "https://discord.com/api/guilds/".$server_id; $context = stream_context_create([ 'http' => [ 'header' => 'Authorization: Bot '.$bot_token ] ]); $data = json_decode(file_get_contents($url, false, $context), true); return $data; } $server_id = "您的Discord服务器ID"; $bot_token = "您的Discord机器人令牌"; $server = getDiscordServerInfo($server_id, $bot_token); echo "Discord服务器: ".$server['name']." "; echo "成员数: ".$server['approximate_member_count']; ?>9. Telegram频道订阅数展示您的Telegram频道订阅人数。<?php function getTelegramChannelInfo($channel_name) { // 需要通过Telegram Bot API获取频道信息 $bot_token = "您的Telegram机器人令牌"; $url = "https://api.telegram.org/bot".$bot_token."/getChatMembersCount?chat_id=@".$channel_name; $data = json_decode(file_get_contents($url), true); return $data['result']; } $channel = "您的频道名称（不含@）"; $subscribers = getTelegramChannelInfo($channel); echo "@".$channel." 订阅人数: ".$subscribers; ?>10. 小红书笔记展示在博客中展示您最近的小红书笔记。<?php function getXiaoHongShuPosts($userId) { // 获取小红书用户公开信息 $url = "https://www.xiaohongshu.com/user/profile/" . $userId; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); $html = file_get_contents($url, false, $context); // 使用正则表达式提取笔记信息 // 注意：这是一个简化版，实际需要考虑网站结构变化 preg_match_all('/ (.*?)<\/div>/s', $html, $matches); $posts = []; foreach ($matches[1] as $match) { if (preg_match('/<img src="(.*?)"/s', $match, $img) && preg_match('/ (.*?)<\/div>/s', $match, $title)) { $posts[] = [ 'title' => trim($title[1]), 'image' => $img[1] ]; } } return $posts; } $userId = '你的小红书ID'; $posts = getXiaoHongShuPosts($userId); echo ' 我的小红书笔记 '; echo ' '; foreach (array_slice($posts, 0, 4) as $post) { echo ' '; echo ' '; echo ' '.$post['title'].' '; echo ' '; } echo ' '; ?>11. 王者荣耀战绩API展示您的王者荣耀游戏数据。<?php function getKingGloryStats($openId) { // 需要通过官方申请接口权限，这里仅提供示例 $apiKey = '你的API密钥'; $url = "https://api.wgapi.com/v2/player?openid={$openId}&token={$apiKey}"; $data = json_decode(file_get_contents($url), true); if ($data['code'] == 200) { return [ 'nickname' => $data['data']['nickname'], 'tier' => $data['data']['tier_name'], 'win_rate' => $data['data']['win_rate'], 'total_matches' => $data['data']['total_matches'] ]; } else { return null; } } $openId = '您的王者荣耀OpenID'; $stats = getKingGloryStats($openId); if ($stats) { echo ' '; echo ' 我的王者荣耀战绩 '; echo ' 昵称: '.$stats['nickname'].' '; echo ' 段位: '.$stats['tier'].' '; echo ' 胜率: '.$stats['win_rate'].'% '; echo ' 总场次: '.$stats['total_matches'].' '; echo ' '; } else { echo '数据获取失败'; } ?>12. Steam游戏状态展示您的Steam游戏状态和最近玩的游戏。<?php function getSteamGameStatus($steamid, $apikey) { $url = "https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/?key=".$apikey."&steamid=".$steamid."&format=json"; $data = json_decode(file_get_contents($url), true); return $data['response']['games']; } $steamid = '你的SteamID'; $apikey = '你的Steam API密钥'; $games = getSteamGameStatus($steamid, $apikey); echo " 我最近玩的游戏 "; foreach(array_slice($games, 0, 3) as $game) { echo $game['name']." - 最近游戏时间: ".round($game['playtime_2weeks']/60, 1)." 小时 "; } ?>热搜榜单API13. 微博热搜API展示实时的微博热搜榜单。<?php function getWeiboHotSearch() { $url = "https://weibo.com/ajax/side/hotSearch"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); $data = json_decode(file_get_contents($url, false, $context), true); return $data['data']['realtime']; } echo " 微博热搜 "; echo " "; foreach(array_slice(getWeiboHotSearch(), 0, 10) as $item) { echo " ".$item['note']." (".number_format($item['num']).") "; } echo " "; ?>14. 知乎热榜API展示知乎热门话题。<?php function getZhihuHotTopics() { $url = "https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=10"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); $data = json_decode(file_get_contents($url, false, $context), true); return $data['data']; } echo " 知乎热榜 "; echo " "; foreach(getZhihuHotTopics() as $item) { echo " ".$item['target']['title']." "; } echo " "; ?>15. 抖音热门话题展示当前抖音热门话题和标签。<?php function getDouyinHotTopics() { // 使用公开的抖音热榜API $url = "https://www.iesdouyin.com/web/api/v2/hotsearch/billboard/word/"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); $data = json_decode(file_get_contents($url, false, $context), true); if (isset($data['word_list'])) { return $data['word_list']; } else { return []; } } $topics = getDouyinHotTopics(); echo ' 抖音热门话题 '; echo ' '; foreach (array_slice($topics, 0, 10) as $index => $topic) { echo ' '; echo ''.($index + 1).' '; echo $topic['word']; if (isset($topic['hot_value'])) { echo ' ('.number_format($topic['hot_value']).')'; } echo ' '; } echo ' '; ?>16. 百度热搜榜展示百度搜索热榜内容。<?php function getBaiduHotSearch() { $url = "https://top.baidu.com/api/board?platform=wise&tab=realtime"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); $data = json_decode(file_get_contents($url, false, $context), true); if (isset($data['data']['cards'][0]['content'])) { return $data['data']['cards'][0]['content']; } else { return []; } } $hotSearches = getBaiduHotSearch(); echo ' 百度热搜榜 '; echo ' '; foreach (array_slice($hotSearches, 0, 10) as $index => $item) { echo ' '; echo ''.($index + 1).' '; echo $item['word']; if (isset($item['hotScore'])) { echo ' ('.number_format($item['hotScore']).')'; } echo ' '; } echo ' '; ?>17. 腾讯新闻热点展示腾讯新闻热点话题。<?php function getTencentHotNews() { $url = "https://i.news.qq.com/trpc.qqnews_web.kv_srv.kv_srv_http_proxy/list?sub_srv_id=24hours&srv_id=pc"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); $data = json_decode(file_get_contents($url, false, $context), true); if (isset($data['data']['list'])) { return $data['data']['list']; } else { return []; } } $hotNews = getTencentHotNews(); echo ' 腾讯新闻热点 '; echo ' '; foreach (array_slice($hotNews, 0, 10) as $index => $news) { echo ' '; echo ''.($index + 1).' '; echo ''.$news['title'].''; echo ' '; } echo ' '; ?>18. 哔哩哔哩热搜展示B站当前热搜词。<?php function getBilibiliHotSearch() { $url = "https://api.bilibili.com/x/web-interface/search/square?limit=10"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); $data = json_decode(file_get_contents($url, false, $context), true); if (isset($data['data']['trending']['list'])) { return $data['data']['trending']['list']; } else { return []; } } $hotSearch = getBilibiliHotSearch(); echo ' B站热搜 '; echo ' '; foreach ($hotSearch as $index => $item) { echo ' '; echo ''.($index + 1).' '; echo $item['keyword']; if (isset($item['show_name'])) { echo ' '.$item['show_name'].''; } echo ' '; } echo ' '; ?>19. 即刻社区热门展示即刻社区的热门话题。<?php function getJikeCommunityHot() { // 即刻热门话题API $url = "https://api.ruguoapp.com/1.0/topics/recommendation/list"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); try { $data = json_decode(file_get_contents($url, false, $context), true); if (isset($data['data'])) { return $data['data']; } } catch (Exception $e) { // 接口可能不稳定，返回一些示例数据 return [ ['content' => '今天又是元气满满的一天', 'likeCount' => 2453], ['content' => '来分享你的摄影作品', 'likeCount' => 1892], ['content' => '程序员日常', 'likeCount' => 1654], ['content' => '美食分享计划', 'likeCount' => 1245], ['content' => '今日份猫猫', 'likeCount' => 985] ]; } return []; } $topics = getJikeCommunityHot(); echo ' 即刻热门话题 '; echo ' '; foreach (array_slice($topics, 0, 5) as $topic) { echo ' '; echo $topic['content']; if (isset($topic['likeCount'])) { echo ' ('.$topic['likeCount'].'人关注)'; } echo ' '; } echo ' '; ?>20. 搜狗热搜榜展示搜狗搜索引擎热搜榜。<?php function getSogouHotSearch() { $url = "https://top.sogou.com/hot/json"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); try { $data = json_decode(file_get_contents($url, false, $context), true); if (isset($data['data'])) { return $data['data']; } } catch (Exception $e) { // 返回示例数据 return [ ['keyword' => '热门电影', 'heat' => 9876], ['keyword' => '国内新闻头条', 'heat' => 8765], ['keyword' => '今日天气', 'heat' => 7654], ['keyword' => '最新游戏', 'heat' => 6543], ['keyword' => '热门综艺', 'heat' => 5432] ]; } return []; } $hotSearch = getSogouHotSearch(); echo ' 搜狗热搜榜 '; echo ' '; foreach (array_slice($hotSearch, 0, 10) as $index => $item) { echo ' '; echo ''.($index + 1).' '; echo $item['keyword']; if (isset($item['heat'])) { echo ' ('.number_format($item['heat']).')'; } echo ' '; } echo ' '; ?>21. 360热搜榜展示360搜索热榜内容。<?php function get360HotSearch() { $url = "https://trends.so.com/hot/search"; $context = stream_context_create([ 'http' => [ 'header' => 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' ] ]); try { $html = file_get_contents($url, false, $context); // 使用正则表达式提取数据 preg_match_all('/(.*?)<\/a>/s', $html, $matches); $results = []; for ($i = 0; $i < count($matches[2]); $i++) { $results[] = [ 'keyword' => trim(strip_tags($matches[2][$i])), 'url' => $matches[1][$i] ]; } return $results; } catch (Exception $e) { // 返回示例数据 return [ ['keyword' => '今日头条', 'url' => '#'], ['keyword' => '最新电影', 'url' => '#'], ['keyword' => '热门游戏', 'url' => '#'], ['keyword' => '流行音乐', 'url' => '#'], ['keyword' => '科技新闻', 'url' => '#'] ]; } } $hotSearch = get360HotSearch(); echo ' 360热搜榜 '; echo ' '; foreach (array_slice($hotSearch, 0, 10) as $index => $item) { echo ' '; echo ''.($index + 1).' '; echo ''.$item['keyword'].''; echo ' '; } echo ' '; ?>22. 热搜聚合展示将多个热搜榜单整合到一个页面展示。<?php // 获取各平台热搜函数 // ... (使用前面定义的函数) ... // 展示聚合热搜 echo ' '; // 微博热搜 echo ' '; echo ' 微博热搜 '; echo ' '; foreach(array_slice(getWeiboHotSearch(), 0, 5) as $index => $item) { echo ' '.($index + 1).' '.$item['note'].' '; } echo ' '; echo ' '; // 知乎热榜 echo ' '; echo ' 知乎热榜 '; echo ' '; foreach(array_slice(getZhihuHotTopics(), 0, 5) as $index => $item) { echo ' '.($index + 1).' '.$item['target']['title'].' '; } echo ' '; echo ' '; // 百度热搜 echo ' '; echo ' 百度热搜 '; echo ' '; $baiduHot = getBaiduHotSearch(); foreach(array_slice($baiduHot, 0, 5) as $index => $item) { echo ' '.($index + 1).' '.$item['word'].' '; } echo ' '; echo ' '; echo ' '; ?>工具类API23. 天气API根据IP或设定位置显示天气信息。<?php function getWeather($city) { $url = "http://wthrcdn.etouch.cn/weather_mini?city=".urlencode($city); $data = json_decode(file_get_contents($url), true); return $data['data']['forecast'][0]; } $city = '北京'; $weather = getWeather($city); echo $city.' 今天: '.$weather['type'].', '.$weather['low'].' ~ '.$weather['high']; ?>24. 一言API随机展示一句名言。<?php $hitokoto = json_decode(file_get_contents('https://v1.hitokoto.cn/'), true); echo $hitokoto['hitokoto'].' —— '.$hitokoto['from']; ?>25. 每日诗词展示随机古诗词。<?php $poetry = json_decode(file_get_contents('https://v2.jinrishici.com/one.json'), true); echo $poetry['data']['content'].' —— '.$poetry['data']['origin']['dynasty'].' · '.$poetry['data']['origin']['author'].'《'.$poetry['data']['origin']['title'].'》'; ?>26. 随机猫咪图片每次刷新页面展示不同的猫咪图片。 27. 随机狗狗图片展示随机狗狗图片。<?php $dog = json_decode(file_get_contents('https://dog.ceo/api/breeds/image/random'), true); echo ' '; ?>28. 实时汇率转换展示不同货币之间的实时汇率。<?php function getExchangeRate($from, $to) { $url = "https://api.exchangerate-api.com/v4/latest/".$from; $data = json_decode(file_get_contents($url), true); return $data['rates'][$to]; } echo "1 USD = ".getExchangeRate('USD', 'CNY')." CNY"; ?>29. 历史上的今天展示历史上的今天发生的事件。<?php $date = date('m/d'); $url = "https://baike.baidu.com/cms/home/eventsOnHistory/".$date.".json"; $data = json_decode(file_get_contents($url), true); $events = $data[$date]; echo " 历史上的今天（".$date."） "; foreach(array_slice($events, 0, 5) as $event) { echo $event['year']."年: ".$event['title']." "; } ?>30. 每日NASA图片展示NASA每日天文图片。<?php $nasa = json_decode(file_get_contents('https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY'), true); echo ' '.$nasa['title'].' '; echo ' '; echo ' '.$nasa['explanation'].' '; ?>31. 随机笑话展示随机笑话。<?php $joke = json_decode(file_get_contents('https://v2.jokeapi.dev/joke/Any?lang=zh'), true); if($joke['type'] == 'single') { echo $joke['joke']; } else { echo $joke['setup'].' '.$joke['delivery']; } ?>32. 实时疫情数据展示实时的新冠疫情数据。<?php $covid = json_decode(file_get_contents('https://lab.isaaclin.cn/nCoV/api/overall'), true); $data = $covid['results'][0]; echo "确诊: ".$data['confirmedCount']." "; echo "治愈: ".$data['curedCount']." "; echo "死亡: ".$data['deadCount']." "; echo "更新时间: ".date('Y-m-d H:i:s', $data['updateTime']/1000); ?>博客功能增强类API33. 代码高亮使用Prism或Highlight.js自动为代码块添加语法高亮。 34. 文章阅读时间估算根据文章字数自动计算阅读时间。<?php function readingTime($content) { $wordCount = mb_strlen(strip_tags($content), 'UTF-8'); $minutes = ceil($wordCount / 400); // 假设阅读速度为400字/分钟 return $minutes; } // 在文章页面调用 $readTime = readingTime($this->content); echo "预计阅读时间: ".$readTime." 分钟"; ?>35. IP地理位置显示显示访客的地理位置。<?php function getIpLocation($ip) { $url = "http://ip-api.com/json/".$ip."?lang=zh-CN"; $data = json_decode(file_get_contents($url), true); return $data['country'].' '.$data['city']; } $ip = $_SERVER['REMOTE_ADDR']; echo "您来自: ".getIpLocation($ip); ?>36. 实时在线人数统计显示当前在线访问人数。<?php // 需要在数据库中创建相关表来记录访问信息 function getOnlineCount() { // 过期时间设置为15分钟 $expire = 15 * 60; $now = time(); // 清理过期会话 $db = Typecho_Db::get(); $db->query($db->delete('table.online') ->where('time < ?', $now - $expire)); // 更新当前会话 $sid = session_id(); if($sid) { $exist = $db->fetchRow($db->select('sid')->from('table.online') ->where('sid = ?', $sid)); if($exist) { $db->query($db->update('table.online') ->rows(array('time' => $now)) ->where('sid = ?', $sid)); } else { $db->query($db->insert('table.online') ->rows(array('sid' => $sid, 'time' => $now))); } } // 获取在线人数 $online = $db->fetchObject($db->select('COUNT(*) AS count')->from('table.online'))->count; return $online; } echo "当前在线: ".getOnlineCount()." 人"; ?>37. 文章热度排行根据阅读量、评论数等显示热门文章。<?php function getHotPosts($limit = 5) { $db = Typecho_Db::get(); $posts = $db->fetchAll($db->select('cid', 'title', 'views') ->from('table.contents') ->where('type = ?', 'post') ->where('status = ?', 'publish') ->order('views', Typecho_Db::SORT_DESC) ->limit($limit)); return $posts; } echo " 热门文章 "; echo " "; foreach(getHotPosts() as $post) { echo ' $post['cid'])).'">'.$post['title'].'('.$post['views'].'阅读) '; } echo " "; ?>游戏与娱乐类API38. 随机二次元图片展示随机的二次元图片。<?php echo ' '; ?>39. 每日一句英语显示每日英语句子及翻译。<?php function getDailyEnglish() { $url = "https://api.vvhan.com/api/en"; $data = json_decode(file_get_contents($url), true); return $data; } $english = getDailyEnglish(); echo " 每日一句 "; echo " ".$english['data']['en']." "; echo " ".$english['data']['zh']." "; ?>40. 随机密码生成器生成随机安全密码。<?php function generateRandomPassword($length = 12) { $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+'; $password = ''; for($i = 0; $i < $length; $i++) { $password .= $chars[rand(0, strlen($chars) - 1)]; } return $password; } echo " 随机安全密码 "; echo "".generateRandomPassword().""; ?>41. 随机色彩搭配提供随机的色彩搭配方案。<?php function getRandomColorPalette() { $palette = array(); for($i = 0; i < 5; $i++) { $color = sprintf('#%06X', mt_rand(0, 0xFFFFFF)); $palette[] = $color; } return $palette; } $colors = getRandomColorPalette(); echo " 今日色彩搭配 "; echo " "; foreach($colors as $color) { echo " "; } echo " "; echo " "; foreach($colors as $color) { echo " ".$color." "; } echo " "; ?>42. 文章相关度推荐基于内容相似度推荐相关文章。<?php function getSimilarPosts($content, $exclude_cid, $limit = 5) { // 提取当前文章关键词 $keywords = preg_split('/\s+/', strip_tags($content)); $keywords = array_filter($keywords, function($word) { return mb_strlen($word, 'UTF-8') > 2; // 过滤短词 }); // 获取所有其他文章 $db = Typecho_Db::get(); $posts = $db->fetchAll($db->select('cid', 'title', 'text') ->from('table.contents') ->where('type = ?', 'post') ->where('status = ?', 'publish') ->where('cid <> ?', $exclude_cid)); // 计算相似度 $scores = array(); foreach($posts as $post) { $post_text = strip_tags($post['text']); $score = 0; foreach($keywords as $word) { $count = substr_count(strtolower($post_text), strtolower($word)); $score += $count; } $scores[$post['cid']] = $score; } // 按相似度排序 arsort($scores); // 返回前N篇相似度最高的文章 $result = array(); $count = 0; foreach($scores as $cid => $score) { if($score > 0) { foreach($posts as $post) { if($post['cid'] == $cid) { $result[] = array( 'cid' => $cid, 'title' => $post['title'], 'score' => $score ); $count++; break; } } if($count >= $limit) break; } } return $result; } // 在文章页面使用 $similar_posts = getSimilarPosts($this->content, $this->cid); echo " 相关推荐 "; echo " "; foreach($similar_posts as $post) { echo ' $post['cid'])).'">'.$post['title'].' '; } echo " "; ?>博客功能增强类API43. 代码运行结果展示展示代码片段的实际运行结果。<?php function executeCodeSnippet($code, $language) { $result = ''; switch($language) { case 'php': ob_start(); eval($code); $result = ob_get_clean(); break; case 'js': // 需要服务器支持Node.js或使用在线执行服务 // 此处使用前端JavaScript执行 $result = ' '; break; case 'python': // 需要服务器支持Python $tempFile = tempnam(sys_get_temp_dir(), 'py_'); file_put_contents($tempFile, $code); $result = shell_exec('python '.$tempFile); unlink($tempFile); break; } return $result; } $code = 'echo "Hello, World!";'; $result = executeCodeSnippet($code, 'php'); echo " 代码运行结果 "; echo " ".$result." "; ?>44. 文本情感分析分析文章内容的情感倾向。<?php function analyzeSentiment($text) { // 简单情感分析，实际可使用NLP API $positive_words = array('好', '喜欢', '优秀', '精彩', '美丽', '高兴', '开心'); $negative_words = array('差', '讨厌', '糟糕', '失望', '难过', '伤心', '消极'); $positive_count = 0; $negative_count = 0; foreach($positive_words as $word) { $positive_count += substr_count($text, $word); } foreach($negative_words as $word) { $negative_count += substr_count($text, $word); } if($positive_count > $negative_count) { return array('sentiment' => 'positive', 'score' => $positive_count - $negative_count); } else if($negative_count > $positive_count) { return array('sentiment' => 'negative', 'score' => $negative_count - $positive_count); } else { return array('sentiment' => 'neutral', 'score' => 0); } } // 在文章页面使用 $sentiment = analyzeSentiment($this->content); echo "文章情感倾向: "; if($sentiment['sentiment'] == 'positive') { echo "积极 (+".$sentiment['score'].")"; } else if($sentiment['sentiment'] == 'negative') { echo "消极 (-".$sentiment['score'].")"; } else { echo "中性"; } ?>45. 二维码生成API为文章或链接生成二维码。<?php function generateQRCode($text, $size = 150) { $url = "https://api.qrserver.com/v1/create-qr-code/?size=".$size."x".$size."&data=".urlencode($text); return $url; } // 生成当前文章的二维码 $current_url = Typecho_Common::url($this->permalink); $qrcode_url = generateQRCode($current_url); echo " 扫描分享文章 "; echo ' '; ?>46. 文章字数统计统计博客所有文章的总字数。<?php function getTotalWordCount() { $db = Typecho_Db::get(); $posts = $db->fetchAll($db->select('text') ->from('table.contents') ->where('type = ?', 'post') ->where('status = ?', 'publish')); $total = 0; foreach($posts as $post) { $total += mb_strlen(strip_tags($post['text']), 'UTF-8'); } return $total; } $total_words = getTotalWordCount(); echo "博客总字数: ".number_format($total_words)." 字"; ?>47. 访客地图展示访客来源地理位置的世界地图。<?php // 首先需要收集访客IP并存储地理位置信息 function logVisitorLocation() { $ip = $_SERVER['REMOTE_ADDR']; $data = json_decode(file_get_contents("http://ip-api.com/json/".$ip."?fields=country,lat,lon"), true); if($data && isset($data['country'])) { $db = Typecho_Db::get(); $db->query($db->insert('table.visitors') ->rows(array( 'ip' => $ip, 'country' => $data['country'], 'latitude' => $data['lat'], 'longitude' => $data['lon'], 'time' => time() ))); } } // 显示访客地图 function displayVisitorMap() { $db = Typecho_Db::get(); $visitors = $db->fetchAll($db->select('country', 'latitude', 'longitude', 'COUNT(*) as count') ->from('table.visitors') ->group('country')); $markers = array(); foreach($visitors as $visitor) { $markers[] = array( 'lat' => $visitor['latitude'], 'lng' => $visitor['longitude'], 'title' => $visitor['country'].' ('.$visitor['count'].')' ); } $markers_json = json_encode($markers); return ' '; } // 记录当前访客 logVisitorLocation(); // 显示地图 echo " 访客地图 "; echo displayVisitorMap(); ?>48. 随机迷你游戏在博客中嵌入简单的JS游戏。<?php // 随机选择一个迷你游戏 $games = array( // 猜数字游戏 ' 猜数字游戏 我已经想好了一个1-100之间的数字，你猜是多少？ 猜！ ', // 石头剪刀布 ' 石头剪刀布 石头 布 剪刀 战绩：0胜 0负 0平 ' ); // 随机选择一个游戏 $random_game = $games[array_rand($games)]; echo $random_game; ?>49. 每日单词学习每天展示一个英文单词及其解释。<?php function getDailyWord() { // 使用日期作为种子确保同一天显示相同单词 $date = date('Ymd'); srand(intval($date)); $words = array( array("word" => "ubiquitous", "part" => "adj.", "meaning" => "存在于无所不在的，普遍存在的"), array("word" => "ephemeral", "part" => "adj.", "meaning" => "短暂的，瞬息的"), array("word" => "serendipity", "part" => "n.", "meaning" => "意外发现珍奇事物的本领"), array("word" => "paradigm", "part" => "n.", "meaning" => "范例，模式"), array("word" => "eloquent", "part" => "adj.", "meaning" => "雄辩的，有口才的"), array("word" => "pragmatic", "part" => "adj.", "meaning" => "实用的，实际的"), array("word" => "resilience", "part" => "n.", "meaning" => "适应力，恢复力"), array("word" => "quintessential", "part" => "adj.", "meaning" => "精髓的，典型的"), array("word" => "juxtaposition", "part" => "n.", "meaning" => "并置，并列"), array("word" => "cacophony", "part" => "n.", "meaning" => "刺耳的声音，不和谐的声音") ); $index = rand(0, count($words) - 1); return $words[$index]; } $word = getDailyWord(); echo " 每日单词 "; echo " ".$word['word']." ".$word['part']." "; echo " ".$word['meaning']." "; ?>50. 随机表情包随机展示一个表情包图片。<?php function getRandomMeme() { // 表情包图片URL数组 $memes = array( "https://i.imgur.com/1NPi2kN.jpg", "https://i.imgur.com/GXPO2Yh.jpg", "https://i.imgur.com/C5YRB0x.jpg", "https://i.imgur.com/tZNOabj.jpg", "https://i.imgur.com/wJLRN9A.jpg" ); return $memes[array_rand($memes)]; } echo " 随机表情包 "; echo ' '; ?>51. 历史上的今天事件轮播使用轮播方式展示历史上的今天发生的多个事件。<?php function getTodayInHistory() { $date = date('m/d'); $url = "https://baike.baidu.com/cms/home/eventsOnHistory/".$date.".json"; $data = json_decode(file_get_contents($url), true); return $data[$date]; } $events = getTodayInHistory(); $events = array_slice($events, 0, 10); echo ' 历史上的今天 ('.date('m月d日').') '; foreach($events as $index => $event) { echo ' '.$event['year'].'年 '.$event['title'].' '; } echo ' < > '; ?>52. 热门影视推荐展示当前热门电影或电视剧。<?php function getHotMovies() { // 这里可以使用豆瓣API或其他影视API // 简单起见，这里使用静态数据 $movies = array( array( "title" => "流浪地球2", "rating" => "8.3", "image" => "https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2885941329.jpg", "url" => "https://movie.douban.com/subject/35267208/" ), array( "title" => "满江红", "rating" => "7.1", "image" => "https://img2.doubanio.com/view/photo/s_ratio_poster/public/p2885955777.jpg", "url" => "https://movie.douban.com/subject/35766491/" ), array( "title" => "铃芽之旅", "rating" => "7.4", "image" => "https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2884182275.jpg", "url" => "https://movie.douban.com/subject/35371261/" ) ); return $movies; } $movies = getHotMovies(); echo " 热门影视推荐 "; echo " "; foreach($movies as $movie) { echo " "; echo " "; echo " ".$movie['title']." (".$movie['rating']."⭐) "; echo " "; } echo " "; ?>53. 随机美食食谱展示随机美食食谱。<?php function getRandomRecipe() { $url = "https://www.themealdb.com/api/json/v1/1/random.php"; $data = json_decode(file_get_contents($url), true); return $data['meals'][0]; } $recipe = getRandomRecipe(); echo " 今日美食推荐: ".$recipe['strMeal']." "; echo ' '; echo " 材料: "; for($i=1; $i<=20; $i++) { if(!empty($recipe['strIngredient'.$i])) { echo " ".$recipe['strIngredient'.$i]." - ".$recipe['strMeasure'.$i]." "; } } echo " "; ?>54. 每日星座运势根据星座显示每日运势。<?php function getHoroscope($sign) { $url = "https://api.vvhan.com/api/horoscope?type=".urlencode($sign); $data = json_decode(file_get_contents($url), true); return $data['data']; } $sign = "双鱼座"; // 可以改为任何星座 $horoscope = getHoroscope($sign); echo " ".$sign."今日运势 "; echo "综合指数: ".$horoscope['index']['all']." "; echo "幸运颜色: ".$horoscope['fortuneColor']." "; echo "幸运数字: ".$horoscope['luckynumber']." "; echo "今日运势: ".$horoscope['fortunetext']." "; ?>55. 每日推荐歌曲每天随机推荐一首歌曲。<?php function getDailyRecommendedSong() { // 使用种子确保同一天返回同一首歌 $date = date('Ymd'); srand(intval($date)); // 歌曲库（可以扩展） $songs = array( array("name" => "Shape of You", "artist" => "Ed Sheeran", "id" => "7qiZfU4dY1lWllzX7mPBI3"), array("name" => "Dance Monkey", "artist" => "Tones and I", "id" => "1rgnBhdG2JDFTbYkYRZAku"), array("name" => "Blinding Lights", "artist" => "The Weeknd", "id" => "0VjIjW4GlUZAMYd2vXMi3b"), array("name" => "Someone You Loved", "artist" => "Lewis Capaldi", "id" => "7qEHsqek33rTcFNT9PFqLf"), array("name" => "Bad Guy", "artist" => "Billie Eilish", "id" => "2Fxmhks0bxGSBdJ92vM42m") ); $index = rand(0, count($songs) - 1); return $songs[$index]; } $song = getDailyRecommendedSong(); echo " 今日推荐歌曲 "; echo $song['name']." - ".$song['artist']." "; echo ' '; ?>56. 倒计时日期提醒展示距离特定日期的倒计时。<?php function getDaysUntil($date, $event_name) { $now = time(); $event_time = strtotime($date); $days_left = ceil(($event_time - $now) / 86400); return array( 'name' => $event_name, 'date' => $date, 'days' => $days_left ); } $events = array( getDaysUntil('2023-12-25', '圣诞节'), getDaysUntil('2024-01-01', '元旦'), getDaysUntil('2024-02-10', '春节'), // 根据实际日期调整 ); echo " 节日倒计时 "; echo " "; foreach($events as $event) { if($event['days'] > 0) { echo " 距离 ".$event['name']." 还有 ".$event['days']." 天 "; } else if($event['days'] == 0) { echo " 今天是 ".$event['name']."！ "; } } echo " "; ?>57. 随机壁纸API每次访问显示不同的精美壁纸。<?php echo ' '; ?>58. Minecraft服务器状态显示您的Minecraft服务器状态。<?php function getMinecraftServerStatus($server) { $url = "https://api.mcsrvstat.us/2/".$server; $data = json_decode(file_get_contents($url), true); return $data; } $server = 'mc.hypixel.net'; // 服务器地址 $status = getMinecraftServerStatus($server); echo " Minecraft服务器状态 "; echo "服务器: ".$server." "; echo "状态: ".($status['online'] ? '在线' : '离线')." "; if($status['online']) { echo "当前玩家: ".$status['players']['online']."/".$status['players']['max']." "; echo "版本: ".$status['version']." "; } ?>59. 随机好看的渐变背景为您的博客添加随机生成的渐变背景。 60. 随机英文名言展示随机英文名言。<?php $quote = json_decode(file_get_contents('https://api.quotable.io/random'), true); echo '"'.$quote['content'].'" — '.$quote['author']; ?>集成方法要将这些API集成到Typecho博客中，您可以：创建插件：将API功能封装成Typecho插件，这样可以方便地在后台启用/禁用。修改主题文件：直接在主题的sidebar.php、footer.php或其他模板文件中添加相关代码。创建独立页面：创建一个专门用于展示API数据的独立页面。使用Widget：创建自定义Widget并在需要的位置调用。注意事项大多数API调用需要使用PHP的file_get_contents函数，请确保您的服务器已启用allow_url_fopen。某些API可能需要注册并获取API密钥，请按照相应API的官方文档进行操作。考虑使用缓存机制减少API请求次数，提高博客加载速度。注意API的使用限制，避免过度调用导致被封禁。API 汇总列表个人社交账号APIQQ等级显示API - 展示QQ等级和成长值信息QQ音乐状态API - 显示正在听的音乐哔哩哔哩UP主信息API - 展示粉丝数、视频数、播放量等数据网易云音乐状态API - 展示最近听的歌曲或音乐播放器GitHub贡献统计API - 展示代码贡献图表豆瓣读书/电影API - 展示最近阅读的书籍或观看的电影Instagram基本数据API - 展示Instagram账号基本信息Discord服务器状态API - 展示服务器状态和成员数Telegram频道订阅数API - 展示Telegram频道的订阅人数小红书笔记展示API - 展示最近的小红书笔记王者荣耀战绩API - 展示游戏数据和战绩Steam游戏状态API - 展示最近玩的游戏和时长热搜榜单API微博热搜API - 展示微博实时热搜榜单知乎热榜API - 展示知乎热门话题抖音热门话题API - 展示抖音热门话题和标签百度热搜榜API - 展示百度搜索热榜内容腾讯新闻热点API - 展示腾讯新闻热点话题哔哩哔哩热搜API - 展示B站当前热搜词即刻社区热门API - 展示即刻社区热门话题搜狗热搜榜API - 展示搜狗搜索热榜内容360热搜榜API - 展示360搜索热榜内容热搜聚合展示API - 整合多平台热搜于一页工具类API天气API - 根据位置显示天气信息一言API - 随机展示一句名言每日诗词API - 展示随机古诗词随机猫咪图片API - 每次刷新显示不同猫咪图片随机狗狗图片API - 展示随机狗狗图片实时汇率转换API - 展示不同货币之间的实时汇率历史上的今天API - 展示历史上的今天发生的事件每日NASA图片API - 展示NASA每日天文图片随机笑话API - 展示随机笑话实时疫情数据API - 展示实时的新冠疫情数据博客功能增强API代码高亮API - 为代码块添加语法高亮文章阅读时间估算API - 根据字数计算阅读时间IP地理位置显示API - 显示访客的地理位置实时在线人数统计API - 显示当前在线访问人数文章热度排行API - 显示热门文章随机二次元图片API - 展示随机的二次元图片每日一句英语API - 显示每日英语句子及翻译随机密码生成器API - 生成随机安全密码随机色彩搭配API - 提供随机的色彩搭配方案文章相关度推荐API - 推荐相关文章代码运行结果展示API - 展示代码片段的实际运行结果文本情感分析API - 分析文章内容的情感倾向二维码生成API - 为文章或链接生成二维码文章字数统计API - 统计博客所有文章的总字数访客地图API - 展示访客来源地理位置的世界地图游戏与娱乐类API随机迷你游戏API - 在博客中嵌入简单的JS游戏每日单词学习API - 每天展示一个英文单词及其解释随机表情包API - 随机展示一个表情包图片历史上的今天事件轮播API - 轮播展示历史上的今天事件热门影视推荐API - 展示当前热门电影或电视剧随机美食食谱API - 展示随机美食食谱每日星座运势API - 根据星座显示每日运势每日推荐歌曲API - 每天随机推荐一首歌曲倒计时日期提醒API - 展示距离特定日期的倒计时随机壁纸API - 每次访问显示不同的精美壁纸Minecraft服务器状态API - 显示Minecraft服务器状态随机好看的渐变背景API - 为博客添加随机生成的渐变背景随机英文名言API - 展示随机英文名言结语通过集成这些丰富多样的API，你可以为Typecho博客增添更多动态和个性化元素，提升用户体验和互动性。选择适合的博客主题和风格的API进行集成，让你的博客更加与众不同！记住，合理使用API缓存机制可以有效减少服务器压力和提高页面加载速度。希望本文介绍的这60个API能为您的博客增添更多精彩内容！有什么问题可在评论区留言，觉得有趣也可以留言！