1、新增对外短信接口;

2、新增硕软相关接口;
This commit is contained in:
chenlin 2025-09-04 17:55:02 +08:00
parent 120379a0f9
commit ef1a39a0a3
8 changed files with 541 additions and 1 deletions

View File

@ -1,6 +1,7 @@
package bus_apis
import (
"bytes"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
@ -13,6 +14,7 @@ import (
"go-admin/app/admin/service/bus_service"
"go-admin/tools/app"
"go-admin/tools/crypto"
"go-admin/tools/sms"
"go-admin/tools/utils"
"gorm.io/gorm"
"io"
@ -2164,3 +2166,173 @@ func (e SmsApi) ExportSmsTemplate(c *gin.Context) {
app.OK(c, bus_models.ExportTemplateResp{ExportUrl: fileUrl}, "导出成功")
}
// SendMessage 发送短信
// @Summary 发送短信
// @Description 根据手机号发送短信
// @Tags 短信管理-V1.0.0
// @Accept json
// @Produce json
// @Param request body bus_models.SendMessageMassReq true "短信发送请求参数"
// @Success 200 {object} bus_models.SendMessageMassResp
// @Router /api/v1/sms/send_message [post]
func (e SmsApi) SendMessage(c *gin.Context) {
var req bus_models.SendMessageMassReq
smsService := bus_service.SmsService{}
err := e.MakeContext(c).
MakeOrm().
Bind(&req, binding.JSON).
MakeService(&smsService.Service).
Errors
if err != nil {
e.Logger.Error(err)
//e.Error(400, err, "参数绑定失败")
app.OK(c, bus_models.SendMessageMassResp{
Code: -1,
Msg: "参数绑定失败",
}, "OK")
return
}
// 时间戳校验3分钟内有效
nowMillis := time.Now().UnixMilli()
if nowMillis-req.Timestamp > 3*60*1000 {
//e.Error(400, nil, "时间戳已失效,请重新发起请求")
app.OK(c, bus_models.SendMessageMassResp{
Code: -8,
Msg: "时间戳已失效,请重新发起请求",
}, "OK")
return
}
// 查询账户汇总信息
var summary bus_models.SmsSummary
err = e.Orm.Where("name = ?", req.UserName).First(&summary).Error
if err != nil {
//e.Error(400, err, "未找到该账号的短信汇总信息")
app.OK(c, bus_models.SendMessageMassResp{
Code: -2,
Msg: "未找到该账号的短信汇总信息",
}, "OK")
return
}
if summary.Name == "" {
//e.Error(400, err, "未找到该账号")
app.OK(c, bus_models.SendMessageMassResp{
Code: -3,
Msg: "未找到该账号",
}, "OK")
return
}
// 检查余额
if summary.Balance <= 0 {
//e.Error(400, nil, "短信余额不足,请充值后再发送")
app.OK(c, bus_models.SendMessageMassResp{
Code: -4,
Msg: "短信余额不足,请充值后再发送",
}, "OK")
return
}
// 签名校验
expectedSign := sms.GenerateSign(req.UserName, summary.PassWord, req.Content, req.Phone, req.Timestamp)
if strings.ToLower(req.Sign) != expectedSign {
//e.Error(400, nil, "签名验证失败")
app.OK(c, bus_models.SendMessageMassResp{
Code: -5,
Msg: "签名验证失败",
}, "OK")
return
}
if req.Phone == "" || req.Content == "" {
//e.Error(400, nil, "手机号和短信内容不能为空")
app.OK(c, bus_models.SendMessageMassResp{
Code: -6,
Msg: "手机号和短信内容不能为空",
}, "OK")
return
}
var phoneList []string
phoneList = append(phoneList, req.Phone)
// 执行发送
err = sms.GtSendMessage(phoneList, req.Content)
if err != nil {
//e.Error(500, err, "短信发送失败")
app.OK(c, bus_models.SendMessageMassResp{
Code: -7,
Msg: "短信发送失败",
}, "OK")
return
}
// 更新短信使用记录
err = e.Orm.Model(&bus_models.SmsSummary{}).
Where("id = ?", summary.ID).
Updates(map[string]interface{}{
"used": gorm.Expr("used + ?", 1),
"balance": gorm.Expr("balance - ?", 1),
}).Error
if err != nil {
e.Logger.Errorf("短信发送成功,但更新短信汇总记录失败: %v", err)
}
app.OK(c, bus_models.SendMessageMassResp{
Code: 0,
Msg: "短信已发送",
}, "OK")
}
// Callback 短信状态报告回调
// @Summary 短信状态回调
// @Description 短信服务商调用此接口,推送短信发送状态
// @Tags 短信管理-V1.0.0
// @Accept json
// @Produce json
// @Param request body bus_models.SmsCallbackReq true "短信状态回调请求参数"
// @Success 200 {object} bus_models.SmsCallbackResp
// @Router /api/v1/sohan/notice [post]
func (e SmsApi) Callback(c *gin.Context) {
fmt.Printf("enter Callback")
// 读取原始请求体
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
e.Logger.Error("读取回调请求体失败:", err)
fmt.Printf("读取回调请求体失败: %+v", err)
app.OK(c, bus_models.SmsCallbackResp{StatusReturn: false}, "读取请求失败")
return
}
// 重新设置 Body以便后续 Bind 使用
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
// 打印原始请求体
fmt.Printf("收到短信回调原始请求体: %s", string(bodyBytes))
var req bus_models.SmsCallbackReq
err = e.MakeContext(c).
Bind(&req, binding.JSON).
Errors
if err != nil {
e.Logger.Error("回调参数绑定失败:", err)
app.OK(c, bus_models.SmsCallbackResp{
StatusReturn: false,
}, "参数绑定失败")
return
}
fmt.Printf("response: %+v", req)
// 打印回调内容
e.Logger.Infof("收到短信回调: phone=%s, outOrderId=%s, status=%d, receiveTime=%s, message=%v",
req.PhoneNumber, req.OutOrderID, req.SendStatus, req.ReceiveTime, req.Message)
// 返回成功
app.OK(c, bus_models.SmsCallbackResp{
StatusReturn: true,
}, "回调处理成功")
}

View File

@ -630,3 +630,47 @@ type SmsTemplateCreateRequest struct {
ExpireAt *time.Time `json:"expire_at"` // 到期时间
Remark string `json:"remark"` // 备注
}
// SmsSummary 短信使用汇总表
type SmsSummary struct {
models.Model
Name string `gorm:"type:varchar(255);index;not null" json:"name"` // 帐户名称
PassWord string `gorm:"type:varchar(255);index;not null" json:"pass_word"` // 帐户密码
Used int `gorm:"default:0" json:"used"` // 已发送
Balance int `gorm:"default:0" json:"balance"` // 帐户余额
}
// SendMessageMassReq 发送短信入参
type SendMessageMassReq struct {
UserName string `json:"userName"` //账号用户名
Content string `json:"content"` //短信内容
Phone string `json:"phone"` //手机号
Timestamp int64 `json:"timestamp"` //时间戳(毫秒)
Sign string `json:"sign"` //签名
}
// SendMessageMassResp 发送短信出参
type SendMessageMassResp struct {
Code int `json:"code"` // 0成功其他失败
Msg string `json:"message"`
}
// SmsCallbackReq 硕软回调接口入参
type SmsCallbackReq struct {
Message *string `json:"message,omitempty"`
ModelID *int64 `json:"modelId,omitempty"`
OutOrderID string `json:"outOrderId"`
PhoneNumber string `json:"phoneNumber"`
ReceiveTime string `json:"receiveTime"`
SendStatus int64 `json:"sendStatus"`
Sign string `json:"sign"`
SignatureID *int64 `json:"signatureId,omitempty"`
Timestamp string `json:"timestamp"`
UserAccount string `json:"userAccount"`
}
// SmsCallbackResp 硕软回调接口出参
type SmsCallbackResp struct {
StatusReturn bool `json:"statusReturn"`
}

View File

@ -13,7 +13,6 @@ func registerSmsManageRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMidd
sms := v1.Group("/sms").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
sms.POST("/self_import_phone", api.SelfImportPhone) // 导入号码(个性短信)
sms.POST("/file_import_phone", api.FileImportPhone) // 导入号码(文件短信)
sms.POST("/excel_import_phone", api.ExcelImportPhone) // 导入号码EXCEL短信
@ -79,3 +78,18 @@ func registerSmsManageRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMidd
sms.POST("/template/export", api.ExportSmsTemplate) // 导出短信模版 OK
}
}
func registerSmsManageUnAuthRouter(v1 *gin.RouterGroup) {
api := bus_apis.SmsApi{}
sms := v1.Group("/sms")
{
// 临时对外提供的短信接口
sms.POST("/send_message", api.SendMessage) // 批量发送短信
}
sohan := v1.Group("/sohan")
{
sohan.POST("/notice", api.Callback) // 硕软回调接口
}
}

View File

@ -29,6 +29,9 @@ func businessNoCheckRoleRouter(r *gin.Engine) {
for _, f := range routerNoCheckRole {
f(v1)
}
// 短信管理
registerSmsManageUnAuthRouter(v1)
}
// 需要认证的路由示例

View File

@ -9,6 +9,7 @@ import (
"fmt"
"go-admin/app/admin/models/bus_models"
"go-admin/tools/crypto"
"go-admin/tools/sms"
"io"
"os"
@ -150,3 +151,8 @@ func EncryptWithRSA(publicKeyPath string, aesKey []byte) (string, error) {
// 返回加密后的密钥base64 编码)
return base64.StdEncoding.EncodeToString(encryptedKey), nil
}
func TestGenerateSign(t *testing.T) {
sign := sms.GenerateSign("test", "123456", "测试", "13800001111", 1749524000)
fmt.Println("**********sign**********:", sign)
}

View File

@ -11,6 +11,7 @@ import (
"io/ioutil"
"net/http"
"net/url"
"strconv"
"time"
)
@ -198,3 +199,10 @@ func (m *ExchangeClient) post(amApi string, params, resp interface{}) error {
}
return nil
}
// GenerateSign 生成签名MD5(userName + content + phone + timestamp + MD5(password))
func GenerateSign(userName, passWord, content, phone string, timestamp int64) string {
md5Pwd := MD5Encode32(passWord)
raw := userName + content + phone + strconv.FormatInt(timestamp, 10) + md5Pwd
return MD5Encode32(raw)
}

209
tools/sms/sohan.go Normal file
View File

@ -0,0 +1,209 @@
package sms
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"time"
)
const (
DefaultUserAccount = "8871f88e273edb8c20834c61ce97b287"
DefaultUserSecret = "76d9ae4fe2ac3523d7c051e158c1477d"
DefaultBaseURL = "https://apiext.szshanyun.com:8443"
)
// ---------------- 工具函数 ----------------
// GenTimestamp 生成 13 位时间戳(毫秒)
func GenTimestamp() string {
return fmt.Sprintf("%d", time.Now().UnixNano()/1e6)
}
// MD5 md5加密
func MD5(s string) string {
h := md5.Sum([]byte(s))
return hex.EncodeToString(h[:])
}
// SignWithBusinessBody 生成发送/状态查询接口签名md5(businessBody + userSecret + timestamp)
func SignWithBusinessBody(body interface{}, userSecret, timestamp string) (string, error) {
b, err := json.Marshal(body)
if err != nil {
return "", err
}
return MD5(string(b) + userSecret + timestamp), nil
}
// SignSimple 生成余额接口签名md5(userAccount + userSecret + timestamp)
func SignSimple(userAccount, userSecret, timestamp string) string {
return MD5(userAccount + userSecret + timestamp)
}
// ---------------- 数据结构 ----------------
// SendRequest ===== 短信发送 =====
type SendRequest struct {
BusinessBody BusinessBody `json:"businessBody"`
Sign string `json:"sign"`
Timestamp string `json:"timestamp"`
UserAccount string `json:"userAccount"`
}
type BusinessBody struct {
ChildUserNumber *string `json:"childUserNumber,omitempty"`
Content *string `json:"content,omitempty"`
ModelID *string `json:"modelId,omitempty"`
Number *string `json:"number,omitempty"`
ReturnURL *string `json:"returnUrl,omitempty"`
SendList []SendList `json:"sendList"`
SignatureID *string `json:"signatureId,omitempty"`
SignatureStr *string `json:"signatureStr,omitempty"`
}
type SendList struct {
Content *string `json:"content,omitempty"`
ModelReplace *ModelReplace `json:"modelReplace,omitempty"`
OutOrderID string `json:"outOrderId"`
PhoneNumber string `json:"phoneNumber"`
}
type ModelReplace struct {
FlowSize *string `json:"FlowSize,omitempty"`
ISPNumber *string `json:"ispNumber,omitempty"`
Time *string `json:"Time,omitempty"`
TimeLimit *string `json:"TimeLimit,omitempty"`
}
type SendResponse struct {
Message string `json:"message"`
StatusCode int64 `json:"statusCode"`
}
// BalanceRequest ===== 余额查询 =====
type BalanceRequest struct {
Sign string `json:"sign"`
Timestamp string `json:"timestamp"`
UserAccount string `json:"userAccount"`
}
type BalanceResponse struct {
Data BalanceData `json:"data"`
Message string `json:"message"`
StatusCode int64 `json:"statusCode"`
}
type BalanceData struct {
Balance string `json:"balance"`
UserName string `json:"userName"`
}
// StateRequest ===== 状态查询 =====
type StateRequest struct {
BusinessBody StateBusinessBody `json:"businessBody"`
Sign string `json:"sign"`
Timestamp string `json:"timestamp"`
UserAccount string `json:"userAccount"`
}
type StateBusinessBody struct {
OutOrderID string `json:"outOrderId"`
PhoneNumber string `json:"phoneNumber"`
}
type StateResponse struct {
Data StateData `json:"data"`
Message string `json:"message"`
StatusCode int64 `json:"statusCode"`
}
type StateData struct {
SendStatus string `json:"sendStatus"`
}
// ---------------- Client 封装 ----------------
type Client struct {
UserAccount string
UserSecret string
BaseURL string
}
func NewClient() *Client {
return &Client{
UserAccount: DefaultUserAccount,
UserSecret: DefaultUserSecret,
BaseURL: DefaultBaseURL,
}
}
// SendSMS 发送短信
func (c *Client) SendSMS(body BusinessBody) (*SendResponse, error) {
timestamp := GenTimestamp()
sign, err := SignWithBusinessBody(body, c.UserSecret, timestamp)
if err != nil {
return nil, err
}
req := SendRequest{
BusinessBody: body,
Sign: sign,
Timestamp: timestamp,
UserAccount: c.UserAccount,
}
return post[SendResponse](c.BaseURL+"/receive", req)
}
// GetBalance 查询余额
func (c *Client) GetBalance() (*BalanceResponse, error) {
timestamp := GenTimestamp()
sign := SignSimple(c.UserAccount, c.UserSecret, timestamp)
req := BalanceRequest{
Sign: sign,
Timestamp: timestamp,
UserAccount: c.UserAccount,
}
return post[BalanceResponse](c.BaseURL+"/balance", req)
}
// QueryState 查询状态
func (c *Client) QueryState(outOrderID, phone string) (*StateResponse, error) {
body := StateBusinessBody{
OutOrderID: outOrderID,
PhoneNumber: phone,
}
timestamp := GenTimestamp()
sign, err := SignWithBusinessBody(body, c.UserSecret, timestamp)
if err != nil {
return nil, err
}
req := StateRequest{
BusinessBody: body,
Sign: sign,
Timestamp: timestamp,
UserAccount: c.UserAccount,
}
return post[StateResponse](c.BaseURL+"/state", req)
}
// ---------------- POST工具 ----------------
func post[T any](url string, payload interface{}) (*T, error) {
b, err := json.Marshal(payload)
if err != nil {
return nil, err
}
resp, err := http.Post(url, "application/json", bytes.NewBuffer(b))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result T
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}

84
tools/sms/sohan_test.go Normal file
View File

@ -0,0 +1,84 @@
package sms
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// ---------------- 模拟服务 ----------------
// mockServer 返回一个 httptest.Server用于模拟API响应
func mockServer(t *testing.T, path string, response interface{}) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != path {
t.Errorf("unexpected path: got %s, want %s", r.URL.Path, path)
}
_ = json.NewEncoder(w).Encode(response)
}))
}
// ---------------- 测试发送短信 ----------------
func TestSendSMS(t *testing.T) {
client := NewClient()
content := "【明慧科技】提醒您的go2ns租卡会员时长仅剩余一个月现在续费最高立减200元赶快进入小程序领取优惠吧"
returnUrl := "https://telecom.2016js.com/api/v1/sohan/notice"
body := BusinessBody{
Content: &content,
SendList: []SendList{
{
OutOrderID: "ORDER777",
PhoneNumber: "15019230751"},
},
ReturnURL: &returnUrl,
}
resp, err := client.SendSMS(body)
if err != nil {
t.Fatalf("SendSMS error: %v", err)
}
fmt.Printf("response: %+v", resp)
if resp.StatusCode != 1 {
t.Errorf("unexpected response: %+v", resp)
}
}
// ---------------- 测试查询余额 ----------------
func TestGetBalance(t *testing.T) {
client := NewClient()
resp, err := client.GetBalance()
if err != nil {
t.Fatalf("GetBalance error: %v", err)
}
fmt.Printf("response: %+v", resp)
if resp.StatusCode != 1 {
t.Errorf("unexpected response: %+v", resp)
}
}
// ---------------- 测试查询状态 ----------------
func TestQueryState(t *testing.T) {
client := NewClient()
resp, err := client.QueryState("ORDER123", "15019230751")
if err != nil {
t.Fatalf("QueryState error: %v", err)
}
fmt.Printf("response: %+v", resp)
if resp.StatusCode != 1 {
t.Errorf("unexpected sendStatus: %s", resp.Data.SendStatus)
}
}