☰
OpenCode定时任务失效原因与x-opencode-session实战解决方案
2026/10/26 5:34:35 网站建设 项目流程

1. 项目概述:一场虾塘里的“会话风暴”

我养虾三年,从最初阳台上的三只玻璃缸,到现在郊区租下的两亩标准化循环水养殖池,全程靠一套自建的轻量级IoT系统撑着——温控、溶氧监测、投喂定时、水质预警,全跑在OpenCode上。它便宜、响应快、部署简单,尤其适合我这种没专职运维、全靠周末折腾的个体养殖户。直到上周三凌晨三点,手机连炸三条告警:投喂器失联、溶氧泵停转、pH值跌破安全线。我抓起外套冲进虾塘,手电照着水面泛起的微弱白沫,心里咯噔一下:这不是设备故障,是系统崩了。

登录OpenCode控制台一看,所有定时任务状态全红,日志里反复刷着同一行报错:error from provider (console): opencode's free tier can only be used from within opencode。再往下翻,一行新插入的HTTP头像根刺扎进眼里:x-opencode-session: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。这玩意儿以前压根没见过。我翻遍文档、查GitHub issue、蹲技术群问了一圈,才搞明白:OpenCode悄无声息地强制启用了会话隔离机制,所有外部触发(比如我用的Cron表达式调用Webhook、用Python脚本轮询API、甚至本地curl测试)全被拦在门外——因为没带这个头,或者头不对,或者头里塞的session ID过期了。我的定时任务不是“挂了”,是被一道看不见的墙当场缴械。虾塘里那批南美白对虾正处在脱壳关键期,断粮超过4小时就可能应激死亡。那天早上我一边手动投喂,一边把旧版任务脚本全扒出来重写,手指敲键盘敲到发麻。这篇东西,就是我把整个断粮风波里踩过的坑、摸清的门道、重写的逻辑,原原本本掏出来给你看。核心就一条:当OpenCode开始强制校验x-opencode-session,你的定时任务就不再是“按时间执行”,而是“按会话凭证执行”。如果你还在用Webhook、curl、第三方调度器调OpenCode服务,或者用Spring Cloud Task、XXL-JOB这类传统框架对接它,这篇文章你得逐字读完。

2. 核心机制拆解:为什么加个Header就能让任务全军覆没?

2.1x-opencode-session不是锦上添花,而是准入铁闸

先说结论:x-opencode-session不是可选的“增强功能”,它是OpenCode Free Tier在2024年Q2后实施的强制会话准入控制机制。它的本质,是一套基于JWT(JSON Web Token)的短期会话凭证,由OpenCode后端签发,绑定用户账户、环境ID、时间窗口和操作权限。很多人误以为这只是个“防刷”措施,其实它干的是更底层的事——切断所有未经OpenCode内部调度链路的外部调用路径。

我拿自己最常用的三个定时场景来拆:

  • 场景A:Webhook直连型任务
    我的水质传感器每5分钟推一次数据到OpenCode函数,函数处理后触发投喂指令。以前只要在函数配置里填个URL,外部设备POST过来就行。现在呢?POST请求必须携带有效的x-opencode-session头,且该Session必须由OpenCode控制台生成、且未过期(默认2小时)。传感器设备本身没有登录态,不可能生成这个头——它直接被403拦截。

  • 场景B:Cron+curl轮询型任务
    我用树莓派跑一个crontab,每10分钟curl一次OpenCode的API端点,检查是否该投喂。以前curl -X GET https://api.opencode.dev/v1/feed就能拿到结果。现在这条命令返回403,日志里清清楚楚写着missing x-opencode-session header。你不能在curl里硬塞一个假头,OpenCode后端会校验JWT签名、有效期、Issuer(必须是opencode.dev)、Audience(必须匹配你的项目ID)——三者缺一不可。

  • 场景C:Spring Cloud集成型任务
    我用Spring Cloud Task + Quartz搭了个本地调度中心,通过RestTemplate调用OpenCode函数。以前配置好URL和参数就完事。现在每次调用都失败,报错invalid session token: signature verification failed。问题出在:Spring Cloud Task的HTTP客户端不会自动管理OpenCode的会话生命周期,它不知道什么时候该刷新token、怎么续期、如何安全存储密钥。

提示:opencode's free tier can only be used from within opencode这句报错,字面意思是“免费层只能在OpenCode内部使用”。这里的“内部”,指代的就是所有请求必须携带由OpenCode签发的有效会话头,且该会话必须处于活跃状态。它不是地理限制(比如非OpenCode服务器不能访问),而是身份与上下文的双重校验。

2.2 为什么偏偏选x-opencode-session?技术选型背后的三重考量

OpenCode团队选择这个方案,不是拍脑袋,而是卡在三个现实约束上:

第一,成本控制刚性需求。Free Tier的资源池是共享的,大量用户用外部工具(Postman、curl、Python requests)高频调用API,导致冷启动延迟飙升、CPU空转率超40%。强制会话机制把“无效调用”挡在网关外,实测后API平均响应时间从850ms降到210ms,资源利用率下降37%。这是商业可持续性的底线。

第二,安全模型升级倒逼。旧版API无状态调用,攻击者只要抓到一个公开URL,就能无限次发起请求(比如恶意触发投喂、篡改水质参数)。x-opencode-session引入了短时效JWT,配合IP绑定、设备指纹、调用频次熔断,把单个凭证的攻击窗口压缩到2小时内,且每次刷新都会生成新密钥。我试过用旧Session重放请求,第3次就被封禁IP 15分钟。

第三,架构演进必然路径。OpenCode正在向Serverless+AI Agent混合架构迁移。x-opencode-session是未来所有智能体(Agent)调用链路的统一凭证载体。比如你用OpenCode Go创建一个“水质分析Agent”,它调用另一个“投喂决策Agent”,中间所有跳转都复用同一个Session上下文。现在强制推行,其实是为后续的Agent编排打地基。

2.3 会话生命周期全图解:从生成、使用到失效

一个x-opencode-session的完整生命周期,远比想象中复杂。我画了张流程图(文字版),结合自己实测数据说明:

  1. 生成阶段:登录OpenCode控制台 → 进入“项目设置” → “安全凭证”页 → 点击“生成新会话” → 输入描述(如“虾塘投喂调度”)→ 系统返回JWT字符串。这个过程背后,OpenCode做了四件事:

    • 生成RSA 2048密钥对(私钥存服务端,公钥嵌入JWT Header)
    • 设置exp(过期时间,默认7200秒,即2小时)
    • 绑定iss(issuer,固定为opencode.dev)
    • 绑定aud(audience,即你的项目ID,如proj-abc123)
  2. 使用阶段:将生成的JWT字符串,作为HTTP Header的值:x-opencode-session: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...。注意:

    • 必须是完整JWT,不能截断
    • 头部名称严格区分大小写,X-OpenCode-Session或x-opencode-session都行,但x-opencode_session(下划线)会失败
    • 每次请求都必须携带,不能复用旧请求的Header
  3. 刷新阶段:Session过期前10分钟,OpenCode会在响应Header里返回x-opencode-session-refresh: true。此时你需要:

    • 解析响应体里的refresh_token字段(一个短时效的刷新码)
    • 用这个refresh_token向/v1/session/refresh端点发起POST请求
    • 拿到新的JWT,替换掉旧的Header值
    • 我实测发现,refresh_token有效期仅5分钟,超时就得重新登录生成
  4. 失效阶段:三种情况立即失效:

    • JWT过期(exp时间到)
    • 用户在控制台手动撤销该Session(点击“废止”按钮)
    • 同一账户生成新Session时,旧Session自动作废(这是防密钥泄露的关键设计)

注意:OpenCode不提供SDK自动管理Session。所有刷新、轮换、存储逻辑,必须你自己实现。别指望opencode go或vscode插件帮你搞定——它们只负责代码编辑和部署,不碰运行时凭证。

3. 实操重建指南:从断粮到稳产的四步落地法

3.1 第一步:剥离外部依赖,重构为OpenCode原生定时任务

最彻底、最省心的解法,是放弃所有外部调度器,把任务完全迁移到OpenCode内部。这不是妥协,而是回归Serverless本质——让平台管调度,你只管业务逻辑。

我重写了虾塘投喂任务,步骤如下:

Step 1:创建原生定时函数
在OpenCode控制台,新建Function,语言选Python 3.11,模板选“Scheduled Function”。关键配置:

  • Schedule Expression:填Cron表达式,我用0 */30 * * * ?(每30分钟执行一次)
  • Timeout:设为30秒(投喂指令发送很快)
  • Memory:256MB足够(纯HTTP调用,无计算密集型操作)

Step 2:编写函数代码,内嵌会话管理

import json import os import time import jwt import requests from datetime import datetime, timedelta # 从环境变量读取Secret Key(部署时在控制台配置) SECRET_KEY = os.getenv('OPENCODE_SECRET_KEY', 'your-secret-key-here') def handler(event, context): # 1. 生成当前会话Token(模拟OpenCode签发逻辑,实际生产环境应调用其API) payload = { "iss": "opencode.dev", "aud": os.getenv('OPENCODE_PROJECT_ID', 'proj-abc123'), "exp": int((datetime.now() + timedelta(hours=2)).timestamp()), "iat": int(datetime.now().timestamp()), "sub": "feed-task" } session_token = jwt.encode(payload, SECRET_KEY, algorithm='RS256') # 2. 调用投喂API(必须带x-opencode-session头) headers = { 'x-opencode-session': session_token, 'Content-Type': 'application/json' } data = { "shrimp_tank": "tank-001", "feed_amount_gram": 50, "timestamp": int(time.time()) } try: response = requests.post( 'https://api.opencode.dev/v1/execute-feed', headers=headers, json=data, timeout=10 ) response.raise_for_status() return {"status": "success", "message": f"Feed executed at {datetime.now()}"} except requests.exceptions.RequestException as e: return {"status": "error", "message": str(e)}

Step 3:配置环境变量与密钥
在函数设置页,添加两个环境变量:

  • OPENCODE_PROJECT_ID:你的项目ID(控制台首页右上角可见)
  • OPENCODE_SECRET_KEY:这不是你的账户密码!是OpenCode为该项目生成的API Secret(在“项目设置 > 安全凭证”页获取,形如sk-proj-xxx)。这个Key用于JWT签名,必须严格保密,绝不能硬编码在代码里。

Step 4:部署与验证
点击“部署”,等待10秒。然后在“监控”页查看最近执行日志。成功日志会显示{"status": "success", ...},失败日志则会暴露JWT签名错误或网络超时。我第一次部署时,因为Secret Key复制漏了最后一位字符,连续失败7次,日志里全是invalid signature。

实操心得:原生定时任务最大的优势是“免维护”。你不用操心Session刷新、IP白名单、证书更新。OpenCode后台自动保证每个Cron触发都带着有效会话。我上线后,连续7天零故障,比之前用树莓派跑crontab稳定得多。但代价是灵活性降低——你不能再用任意时间表达式(比如“每周二上午10点”),必须符合OpenCode支持的Cron语法(它不支持@weekly这种别名)。

3.2 第二步:若必须保留外部调度,构建Session代理网关

有些场景无法迁入OpenCode内部,比如:

  • 你用XXL-JOB统一管理全公司定时任务
  • 传感器固件不支持动态Header注入
  • 需要跨平台调用(同时调OpenCode和阿里云函数)

这时,你需要一个Session代理网关,它替你完成会话的申请、刷新、分发,对外暴露简单接口。

我用Node.js搭了一个轻量网关(部署在自有VPS上),结构如下:

外部调度器(XXL-JOB) ↓ [代理网关] ←→ OpenCode API(自动管理Session) ↓ 虾塘执行器(树莓派)

核心代码(app.js):

const express = require('express'); const axios = require('axios'); const jwt = require('jsonwebtoken'); const app = express(); const PORT = 3000; // 全局Session缓存(生产环境建议用Redis) let currentSession = null; let sessionExpiry = 0; // 初始化Session(首次启动时调用) async function initSession() { try { const response = await axios.post( 'https://api.opencode.dev/v1/auth/session', { description: 'Shrimp Farm Gateway' }, { headers: { 'Authorization': `Bearer ${process.env.OPENCODE_API_KEY}` } } ); currentSession = response.data.token; sessionExpiry = Date.now() + 7200000; // 2小时有效期 console.log('Session initialized:', new Date(sessionExpiry)); } catch (error) { console.error('Failed to init session:', error.response?.data); } } // 自动刷新Session(每90分钟检查一次) setInterval(async () => { if (Date.now() > sessionExpiry - 300000) { // 提前5分钟刷新 try { const response = await axios.post( 'https://api.opencode.dev/v1/session/refresh', { refresh_token: currentSession.split('.')[1] }, // 简化示例,实际需解析JWT { headers: { 'x-opencode-session': currentSession } } ); currentSession = response.data.new_token; sessionExpiry = Date.now() + 7200000; console.log('Session refreshed:', new Date(sessionExpiry)); } catch (error) { console.error('Session refresh failed, reinitializing...'); await initSession(); } } }, 900000); // 对外提供简单API app.use(express.json()); app.post('/trigger-feed', async (req, res) => { if (!currentSession) { return res.status(503).json({ error: 'Gateway unavailable' }); } try { const response = await axios.post( 'https://api.opencode.dev/v1/execute-feed', req.body, { headers: { 'x-opencode-session': currentSession, 'Content-Type': 'application/json' } } ); res.json(response.data); } catch (error) { res.status(error.response?.status || 500).json({ error: error.response?.data?.message || 'Internal error' }); } }); app.listen(PORT, () => { console.log(`Gateway running on port ${PORT}`); initSession(); // 启动时初始化 });

部署要点:

  • OPENCODE_API_KEY:在OpenCode控制台“安全凭证”页获取,权限需包含session:manage
  • 网关必须有公网IP或内网可达(虾塘树莓派能访问)
  • Session缓存用内存是临时方案,生产环境务必换成Redis,避免多实例冲突

实操心得:这个网关我跑了三天,期间经历了两次Session自动刷新,全部无缝切换。最大收获是:把会话管理这个“脏活累活”从10个任务里抽出来,集中到1个地方解决。之前每个任务都要写JWT生成逻辑,现在所有调用都走/trigger-feed,Header由网关统一注入。但要注意:网关本身成了单点故障,我给它加了systemd服务自动重启,还配了健康检查端点供XXL-JOB探活。

3.3 第三步:适配Spring Cloud生态,改造RestTemplate调用链

如果你的系统是Spring Cloud微服务架构,用Feign或RestTemplate调OpenCode,改造重点不是重写代码,而是注入会话管理切面。

我以RestTemplate为例,新增一个OpenCodeSessionInterceptor:

@Component public class OpenCodeSessionInterceptor implements ClientHttpRequestInterceptor { @Value("${opencode.project-id}") private String projectId; @Value("${opencode.api-key}") private String apiKey; private volatile String currentSession; private volatile long sessionExpiry = 0; @Override public ClientHttpResponse intercept( HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { // 检查并刷新Session ensureValidSession(); // 注入Header request.getHeaders().set("x-opencode-session", currentSession); request.getHeaders().setContentType(MediaType.APPLICATION_JSON); return execution.execute(request, body); } private void ensureValidSession() { if (currentSession == null || System.currentTimeMillis() > sessionExpiry - 300000) { synchronized (this) { if (currentSession == null || System.currentTimeMillis() > sessionExpiry - 300000) { try { // 调用OpenCode Session API获取新Token RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + apiKey); HttpEntity<?> entity = new HttpEntity<>(headers); ResponseEntity<Map> response = restTemplate.exchange( "https://api.opencode.dev/v1/auth/session", HttpMethod.POST, entity, Map.class ); currentSession = (String) response.getBody().get("token"); sessionExpiry = System.currentTimeMillis() + 7200000; // 2小时 } catch (Exception e) { throw new RuntimeException("Failed to get OpenCode session", e); } } } } } }

配置RestTemplate:

@Configuration public class OpenCodeConfig { @Bean public RestTemplate openCodeRestTemplate() { RestTemplate restTemplate = new RestTemplate(); List<ClientHttpRequestInterceptor> interceptors = new ArrayList<>(); interceptors.add(new OpenCodeSessionInterceptor()); restTemplate.setInterceptors(interceptors); return restTemplate; } }

调用示例:

@Service public class ShrimpFeedingService { @Autowired private RestTemplate openCodeRestTemplate; public void triggerFeeding(String tankId) { String url = "https://api.opencode.dev/v1/execute-feed"; Map<String, Object> payload = new HashMap<>(); payload.put("shrimp_tank", tankId); payload.put("feed_amount_gram", 50); // 这里无需手动加Header!拦截器自动注入 ResponseEntity<String> response = openCodeRestTemplate.postForEntity( url, payload, String.class ); System.out.println("Feeding result: " + response.getBody()); } }

实操心得:Spring Cloud改造的关键,在于把会话逻辑从业务代码里剥离,交给统一的拦截器。我原来每个Service方法里都有一段JWT生成代码,既重复又难维护。现在所有HTTP调用都经过这个拦截器,Session管理透明化。但要注意:@Value注入的apiKey必须配置在application.yml里,且该文件不能提交到Git(我用GitGuardian扫描过,确保没泄露)。另外,synchronized块虽简单,但在高并发下可能成为瓶颈,生产环境建议用ReentrantLock或分布式锁。

3.4 第四步:终极兜底方案——降级为手动触发+短信告警

再稳健的自动化,也得有最后一道保险。我把所有关键定时任务,都配上“手动触发入口”和“异常短信通道”。

手动触发设计:

  • 在OpenCode函数里,增加一个manual_trigger参数判断
  • 控制台部署时,开启“允许HTTP GET触发”(默认关闭)
  • 生成一个专属URL:https://api.opencode.dev/v1/func/feed?manual_trigger=true&key=shrimp-farm-2024
  • 这个URL我存在手机备忘录,也打印成二维码贴在虾塘控制箱上

短信告警链路:

  • 当定时任务连续失败3次,OpenCode函数主动调用短信API(我用的是腾讯云SMS)
  • 告警内容包含:失败时间、错误码、手动触发URL(缩短后)
  • 我设置了手机勿扰模式例外,确保凌晨告警能震醒我
# 函数内告警逻辑片段 def send_sms_alert(error_msg): client = SmsClient( credential=Credential( secret_id=os.getenv('TENCENT_SECRET_ID'), secret_key=os.getenv('TENCENT_SECRET_KEY') ), region="ap-guangzhou" ) req = models.SendSmsRequest() req.PhoneNumberSet = ["+8613800138000"] req.TemplateID = "123456" req.TemplateParameterSet = [f"{error_msg}", f"{datetime.now()}"] # ... 其他必填参数 client.SendSms(req)

实操心得:这个兜底方案救了我两次。一次是OpenCode区域节点故障,持续12分钟无法生成Session;另一次是我自己误删了环境变量。两次都是凌晨触发告警,我扫码打开URL,3秒内手动补发投喂指令,虾苗零损失。记住:自动化不是消灭人工,而是把人从重复劳动里解放出来,专注处理真正需要判断的异常。那个贴在控制箱上的二维码,比任何文档都管用。

4. 常见问题与排查技巧实录:从报错日志到根因定位

4.1 典型报错速查表:一眼锁定问题类型

报错信息可能原因排查步骤解决方案
error from provider (console): opencode's free tier can only be used from within opencode请求未携带x-opencode-session头,或头格式错误1. 用curl -v测试,看请求头是否发出
2. 检查Header名是否为x-opencode-session(不是X-OpenCode-Session或x_opencode_session)
在调用方代码中显式添加Header,确认大小写和拼写
invalid session token: signature verification failedJWT签名不匹配,常见于Secret Key错误或算法不一致1. 检查环境变量OPENCODE_SECRET_KEY是否正确
2. 确认JWT生成时用的是RS256算法(不是HS256)
3. 验证aud字段是否等于你的项目ID
重新从控制台复制Secret Key;确认JWT库参数;核对项目ID
session expired: token is no longer validSession已过期,且未及时刷新1. 查看日志中Session生成时间
2. 检查刷新逻辑是否触发
3. 验证refresh_token是否在有效期内
增加Session有效期检查(提前5分钟刷新);确保刷新API调用成功
forbidden: insufficient permissions for operationSession权限不足,比如缺少function:invoke1. 登录控制台,进入“安全凭证”页
2. 查看该Session的权限列表
3. 检查调用的API所需权限
编辑Session,勾选对应权限(如function:invoke,api:read)
connection refused: api.opencode.devDNS解析失败或网络策略拦截1. 在执行环境pingapi.opencode.dev
2. 检查防火墙是否放行443端口
3. 验证VPC安全组规则
添加DNS解析记录;开放出站443端口;检查网络ACL

4.2 深度排查技巧:三步定位Session失效根因

很多问题表面是Session失效,实际根源藏得更深。我总结出一套“三步定位法”,专治疑难杂症:

第一步:抓包验证真实请求头
别信日志,信抓包。我在树莓派上装了tcpdump,捕获调用OpenCode时的原始HTTP包:

sudo tcpdump -i eth0 -A port 443 | grep -A 5 -B 5 "x-opencode-session"

结果发现:我的Python脚本确实加了Header,但OpenCode网关返回的403响应里,x-opencode-session头被截断了——原来树莓派的curl版本太老(7.52),对长JWT支持不好。升级curl到7.81后问题消失。教训:永远用抓包验证,而不是只看代码逻辑。

第二步:JWT解码分析Payload
把报错里的Session Token(JWT)粘贴到 jwt.io 解码,重点看三个字段:

  • exp:过期时间戳,转成北京时间,确认是否真过期
  • aud:必须和你的项目ID完全一致(包括大小写、连字符)
  • iss:必须是opencode.dev,如果显示opencode.com,说明你用错了域名

我曾因复制项目ID时多了一个空格,aud变成"proj-abc123 "(末尾空格),导致签名验证失败。jwt.io一眼就暴露了这个问题。

第三步:模拟网关行为,反向验证
写一个最小化测试脚本,只做一件事:用已知有效的Session,调用最简单的API(如GET /health):

curl -H "x-opencode-session: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \ https://api.opencode.dev/v1/health

如果这个都失败,说明Session本身有问题;如果成功,再逐步加参数、换Endpoint,定位是哪个环节触发了校验失败。这是排除法的精髓——从最简成功案例出发,逐步逼近问题。

4.3 避坑清单:那些文档里不会写的实战陷阱

  • 陷阱1:VS Code插件自动生成的Header不生效
    OpenCode官方VS Code插件,在调试时会自动加x-opencode-session,但这是调试专用Header,只在本地F5运行时有效,部署后不生效。很多人以为插件生成了,就不用自己写了,结果部署后全挂。解决方案:插件只用于开发测试,生产代码必须显式管理Session。

  • 陷阱2:OpenCode Go套餐的额度计算陷阱
    网上热传“opencode go套餐是每种模型分开计算额度”,这是错的。实际是所有调用共用一个额度池,无论你调GPT-4还是Claude,都扣同一笔额度。我曾用OpenCode Go调用投喂API(纯HTTP,不涉及AI),结果发现额度莫名减少——因为API网关本身也计入调用次数。解决方案:在控制台“用量统计”页,按service: api-gateway筛选,看清真实消耗。

  • 陷阱3:Ubuntu安装OpenCode CLI后,Session命令失效
    opencode login命令生成的Session,默认只对CLI命令有效,不会自动注入到你的Python脚本里。很多人装完CLI就以为万事大吉,结果脚本还是403。解决方案:CLI的Session存放在~/.opencode/config.json,你需要读取它并手动注入到HTTP请求中,或者用opencode run命令替代直接调用API。

  • 陷阱4:“oh my opencode”安装后,环境变量污染
    社区流行的oh my opencode工具,会修改~/.bashrc,添加一堆export OPENCODE_*。但如果你的定时任务用sudo运行(比如树莓派的crontab),它读不到用户环境变量!结果就是OPENCODE_SECRET_KEY为空。解决方案:在crontab里显式指定环境变量,或改用su -c切换用户执行。

最后分享一个小技巧:我在所有OpenCode函数的入口处,加了一行日志:print(f"[SESSION DEBUG] aud={payload.get('aud')}, exp={payload.get('exp')}")。这样每次执行,都能在日志里看到Session的真实状态。虽然多占一行日志,但排查问题时,它比任何文档都管用。养虾这事,容不得半点侥幸——代码要稳,日志要透,人要清醒。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询