diff --git a/app/admin/apis/bus_apis/a_sms_manage.go b/app/admin/apis/bus_apis/a_sms_manage.go index 7a4130e..4cb9634 100644 --- a/app/admin/apis/bus_apis/a_sms_manage.go +++ b/app/admin/apis/bus_apis/a_sms_manage.go @@ -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, + }, "回调处理成功") +} diff --git a/app/admin/models/bus_models/m_sms_manage.go b/app/admin/models/bus_models/m_sms_manage.go index 4d96641..02866a3 100644 --- a/app/admin/models/bus_models/m_sms_manage.go +++ b/app/admin/models/bus_models/m_sms_manage.go @@ -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"` +} diff --git a/app/admin/router/bus_sms_manage.go b/app/admin/router/bus_sms_manage.go index 56cab3c..cd19c0d 100644 --- a/app/admin/router/bus_sms_manage.go +++ b/app/admin/router/bus_sms_manage.go @@ -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) // 硕软回调接口 + } + +} diff --git a/app/admin/router/router.go b/app/admin/router/router.go index 55c1966..7ada58b 100644 --- a/app/admin/router/router.go +++ b/app/admin/router/router.go @@ -29,6 +29,9 @@ func businessNoCheckRoleRouter(r *gin.Engine) { for _, f := range routerNoCheckRole { f(v1) } + + // 短信管理 + registerSmsManageUnAuthRouter(v1) } // 需要认证的路由示例 diff --git a/test/gen_test.go b/test/gen_test.go index 8a37095..d0ac414 100644 --- a/test/gen_test.go +++ b/test/gen_test.go @@ -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) +} diff --git a/tools/sms/green_town.go b/tools/sms/green_town.go index 228f9b1..63240d2 100644 --- a/tools/sms/green_town.go +++ b/tools/sms/green_town.go @@ -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) +} diff --git a/tools/sms/sohan.go b/tools/sms/sohan.go new file mode 100644 index 0000000..d9cc6cb --- /dev/null +++ b/tools/sms/sohan.go @@ -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 +} diff --git a/tools/sms/sohan_test.go b/tools/sms/sohan_test.go new file mode 100644 index 0000000..0393e84 --- /dev/null +++ b/tools/sms/sohan_test.go @@ -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) + } +}