news 2026/9/30 7:38:27

Rails短信验证码集成实战:从服务商抽象到限流监控

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Rails短信验证码集成实战:从服务商抽象到限流监控

做Rails项目这么多年,短信接口几乎是每个业务系统绕不开的标配:注册验证、登录验证、密码找回、风控通知、订单状态变更,全靠那一条短信撑着。但很多人对"ruby短信接口"的理解停留在"找个服务商、发个HTTP请求、完事"的程度,真上了生产环境才发现,验证码偶发收不到、短信延迟到账、被服务商限流、被羊毛党薅短信费,每个问题都能让人排查到深夜。

这篇文章把我自己在Ruby on Rails项目里做短信集成及后续优化的经验完整梳理一遍,覆盖服务商选型、Provider抽象层设计、签名计算、编码与超时、异步化改造、限流防刷、监控排查、测试上线这几个核心环节,重点讲清楚每一步背后的取舍逻辑,以及那些服务商文档里不会写的实操细节。内容适配Rails 6/7和Ruby 3.x,不管你是刚准备接入短信,还是已经在用但想优化现有模块,应该都能找到用得上的东西。

1. 动手写代码之前:短信模块要先想清楚三件事

短信集成表面上是"调用一个API",本质上却同时牵扯业务安全、用户体验和成本控制。这三件事如果在设计阶段没有想清楚,后面每一行代码都是在给自己埋坑。

1.1 验证码的技术约束:生成、存储、过期、一次性

国内项目里短信流量的大头基本是验证码。验证码看似简单,实际上对技术方案有硬性约束。

第一,验证码的生成必须使用密码学安全的随机源。Ruby里就是用SecureRandom:

code = SecureRandom.random_number(10**6).to_s.rjust(6, '0')

不要用rand,不要用时间戳取模,不要用什么"看似随机"的自定义算法。验证码只有6位数字,总共100万种组合,如果随机源不够好,攻击者完全可以预测生成序列,那短信验证码就等于形同虚设。

第二,验证码的状态存储必须考虑分布式部署。网上有大量示例代码把验证码写进session,这在单机开发环境没问题,一旦上线多实例部署,用户请求打到另一台机器就校验不过去。正确做法是存Redis,key设计为sms:verify:<手机号>,value存验证码,TTL设5分钟。校验时用Lua脚本或者get + del保证读取后立即删除,确保验证码一次性使用。这个"一次性"很关键,不然用户反复提交同一个验证码就能绕过某些逻辑。

第三,验证码模板的签名。国内服务商要求所有短信都必须带签名和模板,签名是【某公司】这种格式,模板是"您的验证码是${code},5分钟内有效"。签名需要提前在服务商控制台报备审核,模板也要审核。这块经常被开发忽略,等到上线前一天才发现签名还没过审,项目就得干等。

1.2 通知短信和营销短信是两个物种

项目里的短信需求一般分三类:验证码、通知、营销。很多人用一个SmsService类里的if分支来区分,我强烈不建议这么做,这三个场景的服务商通道、模板策略、发送时间、合规要求完全不同。

验证码要求实时性最高,用户正等着登录,晚1分钟都是事故。通知类短信(订单状态、发货提醒)允许稍微延迟,但要求稳定,不能漏。营销短信则是另一个世界——国内服务商对营销短信有严格的时间限制(通常晚8点到早8点禁止发送)、退订管理、内容审核,甚至对发送频率和单日总量都有约束。

在代码层面,这三类短信我建议直接建模成不同的发送场景,通过Provider的配置区分。比如验证码走验证码专用通道、通知走通知通道、营销走营销通道。这样某类通道出问题需要切换服务商时,改动被限制在一个局部,不会影响全局。

1.3 先定义"发不出去"的处理策略

接入短信之前,产品层面有个问题必须先有答案:服务商返回失败时,用户界面怎么表现?

我在第一个Rails项目里就是没想清楚这个问题,服务商偶发超时,用户点击获取验证码后页面一直转圈,最后弹一个"发送失败"。用户当然不爽,更麻烦的是他马上再点一次,又把频控计数加了一次——明明第一次没发出去,第二次却被限流挡掉,用户彻底被卡死。

后来我改成这样:Controller层只负责"请求接收成功"的返回。先做频控检查,通过后生成验证码、写入Redis、投递异步任务,然后立即返回"验证码已发送"。异步任务里如果发送失败,会重试两次;仍然失败,则把失败事件写入监控表,把Redis中的验证码标记为expired,用户再次点击时重新走流程即可。用户看到的始终是"已发送",但后台完整记录了真实状态,运维能第一时间发现通道故障。

2. Provider抽象层:代码里永远不要写死某一个服务商

2.1 为什么服务商SDK不能直接散落在业务代码里

国内主流短信服务商的API风格差异巨大。阿里云要求所有请求参数按字典序排列后用HMAC-SHA1签名;腾讯云是TC3-HMAC-SHA256签名,签名过程要拼接canonical request、signed headers等一堆中间产物;还有一些中小服务商用的是简单的MD5签名甚至直接明文密钥。

这些差异意味着,如果业务代码直接调用服务商SDK,将来换服务商(因为价格、稳定性、审核通过率等原因)就得把整个项目翻一遍。问题的本质是:短信发送对业务代码来说是一个"动词",它不应该知道底层用的是哪家。

我在项目里用一个SmsProvider抽象接口把这块隔离掉。业务代码只依赖接口,具体实现通过配置注入。整个抽象层只有三个方法,不多不少:

module SmsProvider def send_sms(phone:, template_code:, params: {}) raise NotImplementedError end def send_verify_code(phone:, code:, expires_in: 300) raise NotImplementedError end def balance raise NotImplementedError end end

send_sms是通用发送,send_verify_code是验证码快捷方法(内部自动选模板、处理过期时间),balance是余额查询。第三个方法看着不起眼,但它能救命的——短信服务商是先充值后消费,余额不足时接口会静默失败或者直接停止发送,等你登录后台一看,余额已经是负数了。我习惯加一个定时任务每天调用一次balance,低于阈值就告警到企业微信或钉钉群,这个成本几乎为零的功能,能避免一次"用户全部收不到验证码"的生产事故。

2.2 服务商实现的正确姿势:手写HTTP层

一个常见的错误是直接把服务商SDK作为Gem放进Gemfile,然后全项目require。这么做有三个问题:

第一,服务商SDK的维护质量参差不齐,有的半年不更新,Ruby版本一升级依赖就崩。我有一次升级Rails 6到7,一个短信SDK传递依赖的老版本rest-client直接和新的faraday产生冲突,解决依赖花了半天。第二,SDK代码不可控,一旦服务商接口有小变动(比如加了签名版本字段),你只能等SDK更新。第三,SDK往往引入一堆你用不上的功能,无形中扩大了攻击面。

我现在的做法是:不引入服务商SDK,用标准库Net::HTTP或Faraday自己写请求层。以阿里云为例,核心代码长这样:

class AliyunSmsProvider include SmsProvider API_URL = 'https://dysmsapi.aliyuncs.com/' def initialize(access_key_id:, access_key_secret:, sign_name:) @access_key_id = access_key_id @access_key_secret = access_key_secret @sign_name = sign_name end def send_sms(phone:, template_code:, params: {}) query = { PhoneNumbers: phone, SignName: @sign_name, TemplateCode: template_code, TemplateParam: params.to_json, AccessKeyId: @access_key_id, Action: 'SendSms', Format: 'JSON', RegionId: 'cn-hangzhou', SignatureMethod: 'HMAC-SHA1', SignatureVersion: '1.0', SignatureNonce: SecureRandom.uuid, Timestamp: Time.now.utc.iso8601, Version: '2017-05-25' } query['Signature'] = AwsStyleSigner.sign(@access_key_secret, query) get(query) end end

这段代码把一个盒饭级别的需求做成了自己完全可控的实现。服务商接口变了?改一行代码重新部署就行。

2.3 签名算法:唯一值得写一堆单测的地方

国内短信接口的签名机制普遍是"用密钥对请求参数做HMAC",但每家细节不一样。阿里云的规则比较典型:所有请求参数按参数名ASCII字典序升序排列,拼接成key1=value1&key2=value2,前面加上HTTP方法和&,然后以AccessKey Secret作为密钥做HMAC-SHA1,最后Base64编码。

有两个细节特别容易翻车:

  • 中文参数(签名名称、模板内容)在参与签名前要先做URL编码,而且是严格按RFC 3986编码——空格编成%20而不是+,这个差异很容易让签名结果和服务商不一致。
  • 签名用的是AccessKey Secret,不是AccessKey ID。把ID当密钥去算,结果就是"签名不匹配",而且报错信息还不直观。

签名算法我自己封装成独立模块,并且针对服务商官网文档给的示例请求参数写单测。因为签名错误是那种"代码看着完全正确、报文也发过去了、服务商就是不接受"的玄学问题,纯粹靠肉眼排查非常痛苦,有单测一次性锁定正确性。

3. 发送链路里的细节坑:手机号、编码与超时

抽象层和签名搞定了,接下来是真正发短信时最容易出问题的几个环节。这三块放在一起讲,因为它们共同决定了一条短信能不能"顺利"地送到服务商手里。

3.1 手机号归一化:全项目统一入口

手机号校验看起来简单,实际在做项目中我见过太多五花八门的写法:有的只判断1开头且11位,有的允许带+86,有的允许空格和横线,结果就是同一个手机号,注册接口能过、下单接口短信发不出去。

我建议在全项目里做一个PhoneNormalizer模块,所有入口统一走它:

module PhoneNormalizer CHINA_MOBILE_REGEX = /\A(?:\+?86)?1[3-9]\d{9}\z/ module_function def normalize(phone) raw = phone.to_s.strip.gsub(/[\s\-]/, '') raw = raw.sub(/\A\+?86/, '') if raw.start_with?('+86', '86') raw end def valid?(phone) CHINA_MOBILE_REGEX.match?(normalize(phone)) end end

这个模块做的事情很朴素:去掉首尾空格、去掉中间的空格和横线、去掉+86或86前缀、然后按号段正则校验。但它带来一个重要的收益——短信发送、用户注册、风控策略、运营报表所有环节看到的手机号格式完全一致,不会再出现"库里的手机号带+86,服务商模板变量校验失败"这种低级事故。

需要说明的是,1[3-9]\d{9}这个正则覆盖了目前主流号段,但未来如果出新号段(比如16、19开头的已存在),需要维护这个正则。更好的做法是依赖服务商接口的校验能力,把号段判断做成一个辅助校验而不是硬拦截。

3.2 超时和重试:区分"该重试"和"不该重试"

短信发送的HTTP调用必须有明确的超时。我的经验值是连接超时3秒、读取超时5秒。这个值既保证了用户体验,又给服务商的正常响应留足余量。有些项目不设超时,服务商网络一抖动,整个请求线程挂在那里,用户界面无限转圈,这是绝对不能接受的。

更关键的是重试策略。服务商返回的错误码大致分两类:

错误类型示例是否重试
参数错误模板不存在、签名不匹配、手机号格式错误不重试,修代码
业务限制isv.BUSINESS_LIMIT_CONTROL(频控)不重试,等冷却
系统错误isv.SYSTEM_ERROR、isp.SYSTEM_ERROR可重试
网络超时连接超时、读超时可重试

重试我控制在2次以内,采用指数退避:1秒、2秒。超过3次就不要再试了——连续三次失败,问题大概率不在你这边,而是服务商通道故障或你的密钥权限出了问题,此时应该触发监控告警,而不是无限重试给服务商施加更大的压力。

3.3 模板参数的JSON与URL编码

服务商的模板变量通常要求JSON字符串格式。比如模板"您的验证码是${code}",调用时要传TemplateParam: '{"code":"123456"}'。这里有两个隐藏坑:

  • 参数值里的特殊字符必须转义。如果验证码模板里还要带用户名,而用户名里有换行或引号,直接to_json可能会产生非法JSON,服务商解析失败报模板不匹配。正确的做法是用JSON.generate处理后再序列化。
  • 拼URL时所有query参数都要URI.encode_www_form_component,特别是中文签名名称和JSON参数。Rails的to_query方法可以帮忙,但要注意它默认的编码方式。

我在实际项目中遇到过一种诡异情况:本地测试一切正常,生产环境短信内容乱码,排查后发现是网关层Nginx对请求做了二次URL解码,导致服务商收到的JSON字符串里的中文被双重编码。最后是直接用POST方法把参数放body里,绕开了网关层的URL解析逻辑。所以我的经验是:涉及中文参数的短信接口,优先用POST + form body,尽量少用GET拼query string。

4. 异步化改造:验证码发送绝不能阻塞请求线程

4.1 同步发送的代价

如果短信发送直接写在Controller的动作里,每一次用户点击"获取验证码",请求线程都要等一次完整的HTTP调用。平时服务商响应300ms,看起来还能接受;一旦服务商抖动或触发重试,接口耗时轻松飙到1秒以上。这个延迟直接叠加在用户感知上,而且高并发时Web服务器的线程池很快会被这些同步等待的请求占满。

正确方案是异步化。Controller只负责三件事:校验手机号、检查频控、生成验证码写入Redis,然后投递一个Job到后台队列,立即返回。用户端展示"验证码已发送",实际发送动作在Sidekiq的后台线程里完成。

4.2 ActiveJob + Sidekiq的任务设计

class SmsSendJob < ApplicationJob queue_as :sms retry_on SmsProvider::TemporaryError, wait: :exponentially_longer, attempts: 3 def perform(provider_name, phone, template_code, params) provider = SmsProviderRegistry.get(provider_name) result = provider.send_sms(phone: phone, template_code: template_code, params: params) raise SmsProvider::TemporaryError, result.error_message unless result.success? end end

这里queue_as :sms很关键。我的Sidekiq配置里一贯按优先级建三个队列:critical(短信、支付回调)、default(普通业务任务)、low(报表统计类的批量任务)。如果所有任务都堆在default里,一个跑了10分钟的报表任务会把短信任务堵在后面,用户验证码延迟的锅最后还得扣到你头上。

4.3 幂等:重试的前提是"不会重复发送"

Sidekiq的retry_on保证任务会重试,但重试的前提是任务必须幂等。验证码场景的幂等做法是:投递Job之前已经在Redis写入验证码,Job执行时先检查该手机号是否已有未过期的验证码,如果有且没过期,就直接跳过发送。这样即使Sidekiq因为某种原因把一个Job执行了两次,用户也只会收到一条短信。

通知类短信的幂等要按业务维度去重。我的做法是让调用方传入一个业务唯一标识(比如订单号),Job执行时用SETNX写一个sms:dedup:<业务标识>的Redis key,TTL设15分钟,只有写入成功才真正调服务商接口。这个标识能保证网络抖动引发的任务重跑不会产生重复短信。

另外一个容易踩的坑:Job里千万别用puts打日志。Sidekiq进程的输出默认可能不进Rails日志,出了事故连现场都没有。要用Rails.logger写结构化日志,带上手机号脱敏后的标识、模板编号、requestId、耗时这些关键字段。

5. 限流防刷:短信是最容易被人薅到破产的出口

5.1 三层限流设计

短信面向C端时有一个残酷的现实:短信费是实打实的,攻击者可以写一个脚本,循环请求你的验证码接口,让你的服务商账户余额几分钟内清零。我在项目里做了三层限流,每一层解决的问题不同。

第一层是单手机号维度。同一手机号60秒内只能发送1条验证码,24小时内最多10条。这层直接用Redis的SETNX加过期时间实现,代码简洁高效:

class SmsRateLimiter INTERVAL = 60 DAILY_LIMIT = 10 def initialize(redis = Redis.current) @redis = redis end def allowed?(phone) interval_key = "sms:interval:#{phone}" return false unless @redis.set(interval_key, '1', nx: true, ex: INTERVAL) daily_key = "sms:daily:#{Date.today}:#{phone}" return false if @redis.get(daily_key).to_i >= DAILY_LIMIT @redis.incr(daily_key) @redis.expire(daily_key, 24.hours.to_i) true end end

第二层是IP维度。同一个IP在1小时内验证码请求不能超过20次。移动网络下IP会频繁变化,这个阈值可以放宽,它的主要价值是拦截同一出口IP的脚本批量刷,比如攻击者在云主机上跑脚本,出口IP固定,这层能精准拦截。

第三层是行为特征。比如同一个用户短时间内轮换多个手机号请求验证码、验证码接口的请求来源在短时间内从不同设备跳变,这些特征基本可以判定为恶意。检测到直接进黑名单,封禁一段时间。这层实现成本高一些,但面向C端的项目建议至少做到"同一IP多手机号"的简单检测。

5.2 验证码校验的安全细节

验证码的安全隐患往往不在发送环节,而在校验环节。我遇到过的问题包括:

  • 校验接口被暴力遍历。6位数字只有100万种组合,如果接口不限制尝试次数,攻击者可以在短时间内遍历完。对策是同一个手机号最多连续输入5次错误,之后锁定15分钟。
  • 验证码不失效。校验成功后必须立即删除Redis中的key,确保一次性使用。
  • 验证码明文打日志。有时候为了排查方便打印验证码,这个举动等于把验证码送给了所有能看日志的人。日志里要记录的是验证码的存在性、过期时间、校验次数,而不是明文。

频控的阈值参数不要硬编码在代码里,放到Rails.application.config或环境变量中,方便线上调整。因为频控阈值调多少,取决于你的用户量级和短信预算,这个参数一定是要能动态调整的。

6. 生产环境的监控与排错:短信丢了到底怎么查

短信集成做完了,真正考验人的是上线之后的某一天,用户投诉收不到验证码。这个时候如果没有一套完整的日志和状态追踪体系,排查会变成一场灾难。

6.1 全链路状态机与回执

我给每条短信定义一个状态机:pending(已投递)→sent(服务商已接收)→delivered(用户手机已收到),或者pending→failed。

服务商接口返回OK只代表它接收了你的请求,不代表用户手机收到了短信。真正决定到达率的是运营商回执,即服务商通过回调你配置的StatusReport URL,把最终的下发状态推给你。这个回执URL要在服务商控制台配置,很多开发者会漏掉这一步,造成"服务商说发了,用户说没收到,两边对质没有后端数据支持"的局面。

我习惯在数据库里建一张sms_logs表,字段包括:id、scenario(verify_code / notification / marketing)、phone(脱敏)、template_code、provider_message_id、status、error_code、error_message、cost、created_at、updated_at。所有发送、回执更新都在这里留痕。配合一个后台查询页面,客服接投诉时能直接查到这个手机号这条短信到底走到哪一步。

6.2 一次典型的"验证码收不到"排查过程

我总结了一套自己的排查路径,分享出来供参考:

第一,查redis中是否还有sms:verify:<手机号>这个key。如果没有,说明用户根本没走到发送逻辑(频控被拦或前端没调通),问题在前端或业务侧。如果有,看TTL是否已过期,过期说明发送时间太早,用户晚了几分钟才输入。

第二,查sms_logs表中该手机号的记录。状态是failed,直接看error_code,按上一节错误分类表处理。状态是sent但没有后续回执,说明服务商没有推送回执,需要联系服务商客服查`provider_message_id对应的原始下发状态。

第三,确认短信内容是否被运营商拦截。验证码短信内容如果带了营销性质的词汇(比如"点击链接"),可能被运营商短信网关策略拦截。这属于玄学问题,对策是备一个备用服务商通道,切换对比。

这个排查链路每层都能快速定位方向,不会出现"日志一堆、不知道看哪个"的情况。

6.3 顺带澄清一个搜索迷思:"display: ruby"

写这篇文章时我顺手搜了一下"ruby短信接口"相关的热词,发现很多人被display: ruby带偏了。这里澄清一下:display: ruby是CSS的显示属性,对应HTML里的ruby、rt、rp标签,用来做文字注音排版(类似日文假名注音、中文拼音注音),跟Ruby编程语言、Ruby on Rails框架没有任何关系。

如果你在Rails项目里集成短信,搜到的应该是"ruby短信接口"“sms ruby gem”“rails短信验证码”这类关键词;如果在写CSS拼音注音,才需要display: ruby。两个世界的入口,别搞混。

7. 测试策略与上线前检查清单

7.1 Mock服务商,不产生真实短信费用

短信模块的测试核心是业务逻辑,而不是服务商的网络。我全部用Minitest::Mock或者webmock拦截外部请求,测试环境通过FakeSmsProvider把短信内容打印到日志或存入内存数组,方便断言:

class SmsServiceTest < ActiveSupport::TestCase test 'sends verify code via provider' do provider = Minitest::Mock.new provider.expect(:send_verify_code, SmsResult.new(success: true), [phone: '13800138000', code: '123456']) service = SmsService.new(provider) assert service.send_verify_code('13800138000', '123456') provider.verify end end

签名算法的单测是必须的,用服务商官方文档的示例参数作为固定用例,保证算法以后不会被改坏。频控逻辑也要测:连续请求是否被拦截、Redis过期后是否恢复、日限达到后是否拒绝。

7.2 上线前清单

最后列一个我每次上线短信功能前都会过的检查清单,每一条都出自实际教训:

  1. 生产环境的AccessKey是否指向正式服务商的正式项目?有没有和测试环境的key混用?我见过测试key发短信发到真实用户手机上的事故。
  2. 模板是否在服务商控制台审核通过?模板变量名和代码参数名是否完全一致?模板里有${code},代码里传了:code以外的key,服务商会直接报错。
  3. 手机号校验规则是否覆盖所有目标用户号段?国际手机号的+号处理是否正常?
  4. Redis key是否加了环境前缀(比如production:sms:interval:)?多个环境共用Redis实例时,key不加前缀会互相干扰,测试环境把生产环境的频控计数冲掉。
  5. 服务商控制台的回执URL是否配置,接收路由是否已部署?
  6. 有没有真实手机号发一条验证短信,验证全链路状态流转?

我的体会是,短信集成的大部分生产问题都出在"配置环境"而不是"代码逻辑"上。代码写错了能通过测试发现,配置错了往往要等线上用户投诉才能暴露。

把上面这套从抽象层、异步化、限流到监控的体系搭起来,Rails项目里的短信模块才算真正"能打"。它能保证的不只是"短信能发出去",更是"发不出去的时候你能在10分钟内定位问题,用户骂娘的时候你能马上止损"。这个投入的性价比,在任何一个面向C端的项目里都是值得的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 7:37:50

校园网实操:OSPF+RIP双协议互通与Wireshark协议分析

简介&#xff1a;本资源是一份面向高校计算机网络课程设计的完整实践文档&#xff0c;聚焦思科设备搭建真实校园网环境并深入分析主流网络协议&#xff0c;适用于网络工程、信息安全等专业本科生开展课程设计、实验复现与协议原理理解。文档内容结构严谨&#xff0c;涵盖VLAN规…

作者头像 李华
网站建设 2026/9/30 7:37:50

命名管道路径决定跨进程通信成败:从原理到排障实践

做后端开发的&#xff0c;几乎都遇到过这样的事&#xff1a;两个进程明明在同一台机器上跑着&#xff0c;A进程就是连不上B进程&#xff0c;查了半天日志&#xff0c;最后发现两边约定的通道名差了一个字符。这个通道名&#xff0c;在命名管道场景里就是路径。命名管道是Window…

作者头像 李华
网站建设 2026/9/30 7:37:43

PyTorch nn.Module 核心机制与模块化设计实践

做 PyTorch 项目这几年&#xff0c;最常被问到的不是某个损失函数怎么调&#xff0c;而是“我的模型代码怎么越写越乱”。回头一看&#xff0c;大部分问题的根子都出在同一个地方&#xff1a;没有吃透nn.Module这套神经网络 API 的设计意图。很多人只是把它当成一个“装层的类”…

作者头像 李华
网站建设 2026/9/30 7:37:42

移动端100vh适配全解:从视口原理到dvh、svh、lvh实战

移动端100vh这个坑&#xff0c;我前前后后踩了不下十次。每次都是桌面端调试得好好的&#xff0c;一放到真机上&#xff0c;要么弹层底部露出一条背景色&#xff0c;要么底部按钮被地址栏顶得忽上忽下&#xff0c;用户手指刚点上去页面又抖了一下。说句实话&#xff0c;100vh在…

作者头像 李华
网站建设 2026/9/30 7:36:54

Prompt指令设计工程化:从可复用模板到回归测试的完整指南

简介&#xff1a;《AI引擎&#xff1a;Prompt指令设计绿皮书》是一份面向ChatGPT、Claude、Bard等AI工具使用者的实用指南&#xff0c;适合新媒体运营、内容创作者及希望提升AI交互效率的职场人群。资源围绕Prompt指令设计展开&#xff0c;系统讲解指令写作的技巧公式&#xff…

作者头像 李华