关于 openrouter
介绍如何通过 OpenRouter 统一 API 构建语言模型应用的技能,涵盖模型选择、流式响应、函数调用、回退及成本和速率限制处理,并采用 OpenAI 兼容接口。
主要功能
- 01通过单一 API 访问模型
- 02OpenAI 兼容接口
- 03模型路由与回退
- 04流式响应和函数调用
- 05成本估算与优化
- 06速率限制和错误处理
适用场景
- 01在应用中切换模型
- 02实现流式聊天响应
- 03按任务或预算选择模型
- 04处理 API 故障和速率限制
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ bobmatnyc/ claude-mpm-skills --skill openrouter安装后在 Claude Code 中输入/openrouter确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.jsonQUICK_REFERENCE.mdREADME.md详细介绍
OpenRouter - 统一 AI API 网关
概述
OpenRouter 提供统一 API,可访问来自 OpenAI、Anthropic、Google、Meta、Mistral 等厂商的 200 多种大语言模型。它提供智能路由、流式传输、成本优化,以及标准化的 OpenAI 兼容接口。
主要功能:
- 通过一个 API 访问 200 多种模型
- 兼容 OpenAI 的接口(可直接替换)
- 智能模型路由和备用方案
- 实时流式响应
- 成本跟踪和优化
- 模型性能分析
- 支持函数调用
- 支持视觉模型
定价模式:
- 按 Token 付费(无订阅)
- 提供批量折扣
- 提供含额度的免费套餐
- 价格因模型而异
安装:
npm install openai # Use OpenAI SDK
# or
pip install openai # Python
快速入门
1. 获取 API 密钥
# Sign up at https://openrouter.ai/keys
export OPENROUTER_API_KEY="sk-or-v1-..."
2. 基本聊天补全
import OpenAI from 'openai';
const client = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: process.env.OPENROUTER_API_KEY,
defaultHeaders: {
'HTTP-Referer': 'https://your-app.com', // Optional
'X-Title': 'Your App Name', // Optional
}
});
async function chat() {
const completion = await client.chat.completions.create({
model: 'anthropic/claude-3.5-sonnet',
messages: [
{ role: 'user', content: 'Explain quantum computing in simple terms' }
],
});
console.log(completion.choices[0].message.content);
}
3. 流式响应
async function streamChat() {
const stream = await client.chat.completions.create({
model: 'openai/gpt-4-turbo',
messages: [
{ role: 'user', content: 'Write a short story about AI' }
],
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
process.stdout.write(content);
}
}
模型选择策略
可用模型类别
旗舰模型(质量最高):
const flagshipModels = {
claude: 'anthropic/claude-3.5-sonnet', // Best reasoning
gpt4: 'openai/gpt-4-turbo', // Best general purpose
gemini: 'google/gemini-pro-1.5', // Best long context
opus: 'anthropic/claude-3-opus', // Best complex tasks
};
快速模型(延迟低):
const fastModels = {
claude: 'anthropic/claude-3-haiku', // Fastest Claude
gpt35: 'openai/gpt-3.5-turbo', // Fast GPT
gemini: 'google/gemini-flash-1.5', // Fast Gemini
llama: 'meta-llama/llama-3.1-8b-instruct', // Fast open source
};
成本优化型模型:
const budgetModels = {
haiku: 'anthropic/claude-3-haiku', // $0.25/$1.25 per 1M tokens
gemini: 'google/gemini-flash-1.5', // $0.075/$0.30 per 1M tokens
llama: 'meta-llama/llama-3.1-8b-instruct', // $0.06/$0.06 per 1M tokens
mixtral: 'mistralai/mixtral-8x7b-instruct', // $0.24/$0.24 per 1M tokens
};
专用模型:
const specializedModels = {
vision: 'openai/gpt-4-vision-preview', // Image understanding
code: 'anthropic/claude-3.5-sonnet', // Code generation
longContext: 'google/gemini-pro-1.5', // 2M token context
function: 'openai/gpt-4-turbo', // Function calling
};
模型选择逻辑
interface ModelSelector {
task: 'chat' | 'code' | 'vision' | 'function' | 'summary';
priority: 'quality' | 'speed' | 'cost';
maxCost?: number; // Max cost per 1M tokens
contextSize?: number;
}
function selectModel(criteria: ModelSelector): string {
if (criteria.task === 'vision') {
return 'openai/gpt-4-vision-preview';
}
if (criteria.task === 'code') {
return criteria.priority === 'quality'
? 'anthropic/claude-3.5-sonnet'
: 'meta-llama/llama-3.1-70b-instruct';
}
if (criteria.contextSize && criteria.contextSize > 100000) {
return 'google/gemini-pro-1.5'; // 2M context
}
// Default selection by priority
switch (criteria.priority) {
case 'quality':
return 'anthropic/claude-3.5-sonnet';
case 'speed':
return 'anthropic/claude-3-haiku';
case 'cost':
return criteria.maxCost && criteria.maxCost < 0.5
? 'google/gemini-flash-1.5'
: 'anthropic/claude-3-haiku';
default:
return 'openai/gpt-4-turbo';
}
}
// Usage
const model = selectModel({
task: 'code',
priority: 'quality',
});
流式传输实现
使用 TypeScript 实现流式传输并处理错误
async function robustStreamingChat(
prompt: string,
model: string = 'anthropic/claude-3.5-sonnet'
) {
try {
const stream = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
stream: true,
max_tokens: 4000,
});
let fullResponse = '';
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (delta?.content) {
fullResponse += delta.content;
process.stdout.write(delta.content);
}
// Handle function calls
if (delta?.function_call) {
console.log('\nFunction call:', delta.function_call);
}
// Check for finish reason
if (chunk.choices[0]?.finish_reason) {
console.log(`\n[Finished: ${chunk.choices[0].finish_reason}]`);
}
}
return fullResponse;
} catch (error) {
if (error instanceof Error) {
console.error('Streaming error:', error.message);
}
throw error;
}
}
Python 流式传输
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ.get("OPENROUTER_API_KEY"),
)
def stream_chat(prompt: str, model: str = "anthropic/claude-3.5-sonnet"):
stream = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=True,
)
full_response = ""
for chunk in stream:
if chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
full_response += content
print(content, end="", flush=True)
print() # New line
return full_response
React 流式传输组件
import { useState } from 'react';
function StreamingChat() {
const [response, setResponse] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
async function handleSubmit(prompt: string) {
setIsStreaming(true);
setResponse('');
try {
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'anthropic/claude-3.5-sonnet',
messages: [{ role: 'user', content: prompt }],
stream: true,
}),
});
const reader = res.body?.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader!.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices[0]?.delta?.content || '';
setResponse(prev => prev + content);
} catch (e) {
// Skip invalid JSON
}
}
}
}
} catch (error) {
console.error('Streaming error:', error);
} finally {
setIsStreaming(false);
}
}
return (
<div>
<textarea
value={response}
readOnly
rows={20}
cols={80}
placeholder="Response will appear here..."
/>
<button onClick={() => handleSubmit('Explain AI')}>
{isStreaming ? 'Streaming...' : 'Send'}
</button>
</div>
);
}
函数调用
基本函数调用
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get current weather for a location',
parameters: {
type: 'object',
properties: {
location: {
type: 'string',
description: 'City name, e.g. San Francisco',
},
unit: {
type: 'string',
enum: ['celsius', 'fahrenheit'],
},
},
required: ['location'],
},
},
},
];
async function chatWithFunctions() {
const completion = await client.chat.completions.create({
model: 'openai/gpt-4-turbo',
messages: [
{ role: 'user', content: 'What is the weather in Tokyo?' }
],
tools,
tool_choice: 'auto',
});
const message = completion.choices[0].message;
if (message.tool_calls) {
for (const toolCall of message.tool_calls) {
console.log('Function:', toolCall.function.name);
console.log('Arguments:', toolCall.function.arguments);
// Execute function
const args = JSON.parse(toolCall.function.arguments);
const result = await getWeather(args.location, args.unit);
// Send result back
const followUp = await client.chat.completions.create({
model: 'openai/gpt-4-turbo',
messages: [
{ role: 'user', content: 'What is the weather in Tokyo?' },
message,
{
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(result),
},
],
tools,
});
console.log(followUp.choices[0].message.content);
}
}
}
多步函数调用
async function multiStepFunctionCall(userQuery: string) {
const messages = [{ role: 'user', content: userQuery }];
let iterationCount = 0;
const maxIterations = 5;
while (iterationCount < maxIterations) {
const completion = await client.chat.completions.create({
model: 'openai/gpt-4-turbo',
messages,
tools,
tool_choice: 'auto',
});
const message = completion.choices[0].message;
messages.push(message);
if (!message.tool_calls) {
// No more function calls, return final response
return message.content;
}
// Execute all function calls
for (const toolCall of message.tool_calls) {
const functionName = toolCall.function.name;
const args = JSON.parse(toolCall.function.arguments);
// Execute function (implement your function registry)
const result = await executeFunctionCall(functionName, args);
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(result),
});
}
iterationCount++;
}
throw new Error('Max iterations reached');
}
成本优化
Token 计数和成本估算
import { encoding_for_model } from 'tiktoken';
interface CostEstimate {
promptTokens: number;
completionTokens: number;
promptCost: number;
completionCost: number;
totalCost: number;
}
const modelPricing = {
'anthropic/claude-3.5-sonnet': { input: 3.00, output: 15.00 }, // per 1M tokens
'anthropic/claude-3-haiku': { input: 0.25, output: 1.25 },
'openai/gpt-4-turbo': { input: 10.00, output: 30.00 },
'openai/gpt-3.5-turbo': { input: 0.50, output: 1.50 },
'google/gemini-flash-1.5': { input: 0.075, output: 0.30 },
};
function estimateCost(
prompt: string,
expectedCompletion: number,
model: string
): CostEstimate {
const encoder = encoding_for_model('gpt-4'); // Approximation
const promptTokens = encoder.encode(prompt).length;
const completionTokens = expectedCompletion;
const pricing = modelPricing[model] || { input: 0, output: 0 };
const promptCost = (promptTokens / 1_000_000) * pricing.input;
const completionCost = (completionTokens / 1_000_000) * pricing.output;
return {
promptTokens,
completionTokens,
promptCost,
completionCost,
totalCost: promptCost + completionCost,
};
}
// Usage
const estimate = estimateCost(
'Explain quantum computing',
500, // Expected response tokens
'anthropic/claude-3.5-sonnet'
);
console.log(`Estimated cost: $${estimate.totalCost.toFixed(4)}`);
按预算动态选择模型
async function budgetOptimizedChat(
prompt: string,
maxCostPerRequest: number = 0.01 // $0.01 max
) {
// Estimate with expensive model
const expensiveEstimate = estimateCost(
prompt,
1000,
'anthropic/claude-3.5-sonnet'
);
let selectedModel = 'anthropic/claude-3.5-sonnet';
if (expensiveEstimate.totalCost > maxCostPerRequest) {
// Try cheaper models
const cheapEstimate = estimateCost(
prompt,
1000,
'anthropic/claude-3-haiku'
);
if (cheapEstimate.totalCost > maxCostPerRequest) {
selectedModel = 'google/gemini-flash-1.5';
} else {
selectedModel = 'anthropic/claude-3-haiku';
}
}
console.log(`Selected model: ${selectedModel}`);
const completion = await client.chat.completions.create({
model: selectedModel,
messages: [{ role: 'user', content: prompt }],
});
return completion.choices[0].message.content;
}
通过批处理降低成本
async function batchProcess(prompts: string[], model: string) {
// Process multiple prompts in parallel with rate limiting
const concurrency = 5;
const results = [];
for (let i = 0; i < prompts.length; i += concurrency) {
const batch = prompts.slice(i, i + concurrency);
const batchResults = await Promise.all(
batch.map(prompt =>
client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 500, // Limit tokens to control cost
})
)
);
results.push(...batchResults);
// Rate limiting delay
if (i + concurrency < prompts.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return results;
}
模型备用方案和重试策略
自动切换备用模型
const modelFallbackChain = [
'anthropic/claude-3.5-sonnet',
'openai/gpt-4-turbo',
'anthropic/claude-3-haiku',
'google/gemini-flash-1.5',
];
async function chatWithFallback(
prompt: string,
maxRetries: number = 3
): Promise<string> {
for (const model of modelFallbackChain) {
try {
console.log(`Trying model: ${model}`);
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 2000,
});
return completion.choices[0].message.content || '';
} catch (error) {
console.warn(`Model ${model} failed:`, error);
// Continue to next model
if (model === modelFallbackChain[modelFallbackChain.length - 1]) {
throw new Error('All models failed');
}
}
}
throw new Error('No models available');
}
针对速率限制的指数退避
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 5
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
// Check if rate limit error
if (error.status === 429) {
const delay = Math.pow(2, i) * 1000; // Exponential backoff
console.log(`Rate limited. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
} else {
throw error; // Non-retryable error
}
}
}
throw lastError!;
}
// Usage
const result = await retryWithBackoff(() =>
client.chat.completions.create({
model: 'anthropic/claude-3.5-sonnet',
messages: [{ role: 'user', content: 'Hello' }],
})
);
提示词工程最佳实践
使用系统提示词保持一致性
const systemPrompts = {
concise: 'You are a helpful assistant. Be concise and direct.',
detailed: 'You are a knowledgeable expert. Provide comprehensive answers with examples.',
code: 'You are an expert programmer. Provide clean, well-commented code with explanations.',
creative: 'You are a creative writing assistant. Be imaginative and engaging.',
};
async function chatWithPersonality(
prompt: string,
personality: keyof typeof systemPrompts
) {
const completion = await client.chat.completions.create({
model: 'anthropic/claude-3.5-sonnet',
messages: [
{ role: 'system', content: systemPrompts[personality] },
{ role: 'user', content: prompt },
],
});
return completion.choices[0].message.content;
}
少样本提示
async function fewShotClassification(text: string) {
const completion = await client.chat.completions.create({
model: 'openai/gpt-4-turbo',
messages: [
{
role: 'system',
content: 'Classify text sentiment as positive, negative, or neutral.',
},
{ role: 'user', content: 'I love this product!' },
{ role: 'assistant', content: 'positive' },
{ role: 'user', content: 'This is terrible.' },
{ role: 'assistant', content: 'negative' },
{ role: 'user', content: 'It works fine.' },
{ role: 'assistant', content: 'neutral' },
{ role: 'user', content: text },
],
});
return completion.choices[0].message.content;
}
思维链提示
async function reasoningTask(problem: string) {
const completion = await client.chat.completions.create({
model: 'anthropic/claude-3.5-sonnet',
messages: [
{
role: 'user',
content: `${problem}\n\nLet's solve this step by step:\n1.`,
},
],
max_tokens: 3000,
});
return completion.choices[0].message.content;
}
速率限制和节流
速率限制处理程序
class RateLimitedClient {
private requestQueue: Array<() => Promise<any>> = [];
private processing = false;
private requestsPerMinute = 60;
private requestInterval = 60000 / this.requestsPerMinute;
async enqueue<T>(request: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.requestQueue.push(async () => {
try {
const result = await request();
resolve(result);
} catch (error) {
reject(error);
}
});
this.processQueue();
});
}
private async processQueue() {
if (this.processing || this.requestQueue.length === 0) return;
this.processing = true;
while (this.requestQueue.length > 0) {
const request = this.requestQueue.shift()!;
await request();
await new Promise(resolve => setTimeout(resolve, this.requestInterval));
}
this.processing = false;
}
}
// Usage
const rateLimitedClient = new RateLimitedClient();
const result = await rateLimitedClient.enqueue(() =>
client.chat.completions.create({
model: 'anthropic/claude-3.5-sonnet',
messages: [{ role: 'user', content: 'Hello' }],
})
);
视觉模型
图像理解
async function analyzeImage(imageUrl: string, question: string) {
const completion = await client.chat.completions.create({
model: 'openai/gpt-4-vision-preview',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: question },
{ type: 'image_url', image_url: { url: imageUrl } },
],
},
],
max_tokens: 1000,
});
return completion.choices[0].message.content;
}
// Usage
const result = await analyzeImage(
'https://example.com/image.jpg',
'What objects are in this image?'
);
多图像分析
async function compareImages(imageUrls: string[]) {
const completion = await client.chat.completions.create({
model: 'openai/gpt-4-vision-preview',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Compare these images and describe the differences:' },
...imageUrls.map(url => ({
type: 'image_url' as const,
image_url: { url },
})),
],
},
],
});
return completion.choices[0].message.content;
}
错误处理和监控
全面的错误处理程序
interface ErrorResponse {
error: {
message: string;
type: string;
code: string;
};
}
async function robustCompletion(prompt: string) {
try {
const completion = await client.chat.completions.create({
model: 'anthropic/claude-3.5-sonnet',
messages: [{ role: 'user', content: prompt }],
});
return completion.choices[0].message.content;
} catch (error: any) {
// Rate limit errors
if (error.status === 429) {
console.error('Rate limit exceeded. Please wait.');
throw new Error('RATE_LIMIT_EXCEEDED');
}
// Invalid API key
if (error.status === 401) {
console.error('Invalid API key');
throw new Error('INVALID_API_KEY');
}
// Model not found
if (error.status === 404) {
console.error('Model not found');
throw new Error('MODEL_NOT_FOUND');
}
// Server errors
if (error.status >= 500) {
console.error('OpenRouter server error');
throw new Error('SERVER_ERROR');
}
// Unknown error
console.error('Unknown error:', error);
throw error;
}
}
请求/响应日志记录
class LoggingClient {
async chat(prompt: string, model: string) {
const startTime = Date.now();
console.log('[Request]', {
timestamp: new Date().toISOString(),
model,
promptLength: prompt.length,
});
try {
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
});
const duration = Date.now() - startTime;
console.log('[Response]', {
timestamp: new Date().toISOString(),
duration,
usage: completion.usage,
finishReason: completion.choices[0].finish_reason,
});
return completion;
} catch (error) {
console.error('[Error]', {
timestamp: new Date().toISOString(),
duration: Date.now() - startTime,
error,
});
throw error;
}
}
}
最佳实践
-
模型选择:
- 简单任务使用快速模型(Haiku、Flash)
- 复杂推理使用旗舰模型(Sonnet、GPT-4)
- 考虑上下文长度需求
- 针对你的用例测试多个模型
-
成本优化:
- 在发送请求前估算成本
- 尽可能使用更便宜的模型
- 设置 Token 限制
- 缓存常见响应
- 批量处理相似请求
-
流式传输:
- 面向用户的应用始终使用流式传输
- 处理连接中断
- 显示进度指示器
- 缓冲部分响应
-
错误处理:
- 使用指数退避实现重试逻辑
- 使用备用模型提高可靠性
- 记录所有错误以便调试
- 妥善处理速率限制
-
提示词工程:
- 使用系统提示词保持一致性
- 针对特定任务使用少样本学习
- 复杂推理使用思维链
- 保持提示词简洁以降低成本
-
速率限制:
- 遵守 API 速率限制
- 实现请求排队
- 使用指数退避
- 监控用量指标
-
安全性:
- 切勿在客户端代码中暴露 API 密钥
- 使用环境变量
- 实现服务端代理
- 验证用户输入
-
监控:
- 跟踪 Token 用量
- 监控响应时间
- 记录错误和故障
- 分析模型性能
常见问题
❌ 在前端暴露 API 密钥:
// WRONG - API key exposed
const client = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: 'sk-or-v1-...', // Exposed!
});
✅ 正确做法 - 服务端代理:
// Backend proxy
app.post('/api/chat', async (req, res) => {
const { prompt } = req.body;
const completion = await client.chat.completions.create({
model: 'anthropic/claude-3.5-sonnet',
messages: [{ role: 'user', content: prompt }],
});
res.json(completion);
});
❌ 未处理流式传输错误:
// WRONG - no error handling
for await (const chunk of stream) {
console.log(chunk.choices[0].delta.content);
}
✅ 正确做法 - 处理错误:
try {
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || '';
process.stdout.write(content);
}
} catch (error) {
console.error('Stream error:', error);
// Implement retry or fallback
}
❌ 忽略速率限制:
// WRONG - no rate limiting
const promises = prompts.map(prompt => chat(prompt));
await Promise.all(promises); // May hit rate limits
✅ 正确做法 - 实施速率限制:
const results = [];
for (let i = 0; i < prompts.length; i += 5) {
const batch = prompts.slice(i, i + 5);
const batchResults = await Promise.all(batch.map(chat));
results.push(...batchResults);
await new Promise(r => setTimeout(r, 1000)); // Delay between batches
}
性能优化
缓存响应
const responseCache = new Map<string, string>();
async function cachedChat(prompt: string, model: string) {
const cacheKey = `${model}:${prompt}`;
if (responseCache.has(cacheKey)) {
console.log('Cache hit');
return responseCache.get(cacheKey)!;
}
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
});
const response = completion.choices[0].message.content || '';
responseCache.set(cacheKey, response);
return response;
}
并行处理
async function parallelChat(prompts: string[], model: string) {
const results = await Promise.all(
prompts.map(prompt =>
client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
})
)
);
return results.map(r => r.choices[0].message.content);
}
资源
- 文档:https://openrouter.ai/docs
- API 参考:https://openrouter.ai/docs/api-reference
- 模型列表:https://openrouter.ai/models
- 定价:https://openrouter.ai/docs/pricing
- 状态页面:https://status.openrouter.ai
相关技能
- MCP 服务:与 Model Context Protocol 集成(构建完成后)
- TypeScript API 集成:类型安全的 OpenRouter 客户端
- Python API 集成:Python SDK 使用模式
总结
- OpenRouter 提供对 200 多个大模型的统一访问
- 兼容 OpenAI 的 API,便于迁移
- 通过选择模型和管理 Token 优化成本
- 流式传输带来响应迅速的用户体验
- 函数调用用于工具集成
- 视觉模型用于图像理解
- 回退策略提升可靠性
- 速率限制和错误处理至关重要
- 非常适用于多模型应用、注重成本的部署以及避免供应商锁定
评论
支持 Markdown