소스 검색

短信发送功能

wuyi 5 일 전
부모
커밋
8f7a20edbf
5개의 변경된 파일267개의 추가작업 그리고 0개의 파일을 삭제
  1. 3 0
      package.json
  2. 5 0
      src/services/sms/get-sms-service.ts
  3. 14 0
      src/services/sms/sms.types.ts
  4. 223 0
      src/services/sms/xins.service.ts
  5. 22 0
      yarn.lock

+ 3 - 0
package.json

@@ -19,11 +19,13 @@
     "@fastify/swagger-ui": "^5.2.2",
     "@types/ali-oss": "^6.16.11",
     "ali-oss": "^6.23.0",
+    "async-lock": "^1.4.1",
     "bcryptjs": "^3.0.2",
     "class-transformer": "^0.5.1",
     "class-validator": "^0.14.1",
     "decimal.js": "^10.6.0",
     "dotenv": "^16.4.7",
+    "fast-xml-parser": "^4.2.10",
     "fastify": "^5.2.2",
     "mysql2": "^3.14.0",
     "reflect-metadata": "^0.2.2",
@@ -33,6 +35,7 @@
     "xlsx": "^0.18.5"
   },
   "devDependencies": {
+    "@types/async-lock": "^1.4.2",
     "@types/bcryptjs": "^3.0.0",
     "@types/node": "^22.13.14",
     "pino-pretty": "^13.0.0",

+ 5 - 0
src/services/sms/get-sms-service.ts

@@ -0,0 +1,5 @@
+export abstract class GetSmsService {
+    abstract sendSms(numbers: string[], message: string): Promise<any>
+
+    abstract getReport(params: any): Promise<any>
+}

+ 14 - 0
src/services/sms/sms.types.ts

@@ -0,0 +1,14 @@
+export interface SendSmsResult {
+  msgid: string
+  success: boolean
+}
+
+export interface PhoneStatus {
+  number: string
+  status: 'success' | 'waiting' | 'fail'
+}
+
+export interface GetReportResult {
+  phoneStatusList: PhoneStatus[]
+}
+

+ 223 - 0
src/services/sms/xins.service.ts

@@ -0,0 +1,223 @@
+import axios from 'axios'
+import { FastifyInstance } from 'fastify'
+import { GetSmsService } from './get-sms-service'
+import { XMLParser } from 'fast-xml-parser'
+import { SendSmsResult, PhoneStatus, GetReportResult } from './sms.types'
+
+const axiosInstance = axios.create({
+  baseURL: 'http://114.199.71.130:8138/'
+})
+
+const MKT_USER_NAME = 'JIAXINMKT'
+const MKT_PASSWORD = 'RB7EAo3d'
+
+const OTP_USER_NAME = 'JIAXINOTP'
+const OTP_PASSWORD = 'OgXb8X4q'
+
+const DEFAULT_CALLER = 10123456789
+
+export class xinsService extends GetSmsService {
+  private xmlParser: XMLParser
+  private app: FastifyInstance
+
+  constructor(app: FastifyInstance) {
+    super()
+    this.app = app
+    this.xmlParser = new XMLParser({
+      ignoreAttributes: false,
+      attributeNamePrefix: '@_'
+    })
+  }
+
+  /**
+   * 解析 XML 字符串
+   */
+  private parseXml(xml: string): any {
+    return this.xmlParser.parse(xml)
+  }
+
+  async sendSms(numbers: string[], message: string): Promise<SendSmsResult> {
+    if (numbers.length > 100) {
+      throw new Error('Maximum number of numbers is 100')
+    }
+    const Callee = numbers.join(',')
+    const Text = encodeURIComponent(message)
+    const response = await axiosInstance.get('14.dox', {
+      params: {
+        UserName: MKT_USER_NAME,
+        PassWord: MKT_PASSWORD,
+        Caller: DEFAULT_CALLER,
+        Callee,
+        CallerAddrTon: 1,
+        CallerAddrNpi: 1,
+        CalleeAddrTon: 1,
+        CalleeAddrNpi: 1,
+        CharSet: 1,
+        DCS: 8,
+        Text
+      }
+    })
+
+    // 处理返回结果
+    const xmlData = response.data
+    const parsed = this.parseXml(xmlData)
+
+    const status = parsed?.Message?.Head?.Status
+    if (status !== '0') {
+      const errorDesc = this.getAckErrorDescription(status)
+      throw new Error(`发送短信失败 [Status:${status}]: ${errorDesc}`)
+    }
+
+    this.app.log.info(`发送短信成功 : ${xmlData}`)
+
+    const msgid = parsed?.Message?.Body?.MsgID
+    if (!msgid) {
+      throw new Error('MsgID is missing in response')
+    }
+
+    return {
+      msgid,
+      success: true
+    }
+  }
+
+  async getReport(params: any): Promise<GetReportResult> {
+    const response = await axiosInstance.get('5.dox', {
+      params: {
+        UserName: MKT_USER_NAME,
+        PassWord: MKT_PASSWORD
+      }
+    })
+
+    const xmlData = response.data
+    const parsed = this.parseXml(xmlData)
+
+    const status = parsed?.Message?.Head?.Status
+    if (status !== '0') {
+      const errorDesc = this.getAckErrorDescription(status)
+      throw new Error(`获取报告失败 [Status:${status}]: ${errorDesc}`)
+    }
+
+    this.app.log.info('获取报告成功')
+
+    // Report
+    const body = parsed?.Message?.Body
+    let reports: any[] = []
+
+    if (body?.Report) {
+      if (Array.isArray(body.Report)) {
+        reports = body.Report
+      } else {
+        reports = [body.Report]
+      }
+    }
+
+    // msgid过滤
+    if (params?.msgid) {
+      reports = reports.filter(report => report.MsgID === params.msgid)
+    }
+
+    const phoneStatusList: PhoneStatus[] = reports.map(report => {
+      // 000 表示成功
+      const state = report.State || ''
+      const error = report.Error || ''
+
+      const status = this.parseReportStatus(state, error)
+
+      return {
+        number: `+${report.Callee}`,
+        status
+      }
+    })
+
+    return {
+      phoneStatusList
+    }
+  }
+
+  private parseReportStatus(state: string, error: string): 'success' | 'waiting' | 'fail' {
+    if (error === '000' && state === 'DELIVRD') {
+      return 'success'
+    }
+
+    if (error !== '000' && error !== '') {
+      return 'fail'
+    }
+
+    if (state) {
+      if (state.includes(':')) {
+        const [statusType] = state.split(':')
+        switch (statusType) {
+          case 'REJECTD':
+          case 'UNDELIV':
+          case 'DELETED':
+          case 'EXPIRED':
+            return 'fail'
+          default:
+            return 'waiting'
+        }
+      } else {
+        switch (state) {
+          case 'DELIVRD':
+            return 'waiting'
+          case 'UNKNOWN':
+            return 'waiting'
+          default:
+            return 'waiting'
+        }
+      }
+    }
+
+    return 'waiting'
+  }
+
+  private getAckErrorDescription(status: string): string {
+    const errorMap: Record<string, string> = {
+      '0': '成功',
+      '1': '登陆错误:用户名、密码鉴权错误',
+      '2': '登陆错误:IP地址错误',
+      '3': '登陆错误:账号被禁用',
+      '4': '登陆错误:登陆接口数量过多',
+      '9': '系统内部错误:客户需要重新提交,直到提交成功为止',
+      '12': '短信内容URL解码错误',
+      '13': 'UCS2转换GBK错误',
+      '14': '字符集错误',
+      '15': '短信内容UTF8解码错误',
+      '16': 'GBK转换UCS2错误',
+      '17': '短信内容长度过长或为空',
+      '20': '被叫匹配MCC/MNC失败',
+      '21': '被叫没有对匹配到的费率',
+      '22': '相同内容重复提交',
+      '23': '目的账号没有配置',
+      '24': '签名有前后各1个',
+      '25': '高频自动拒绝',
+      '26': '被叫长度错误',
+      '27': '被叫是黑名单',
+      '28': '短信内容鉴权失败:内容没有匹配到模板',
+      '29': '短信内容鉴权失败:内容出现违禁词',
+      '30': 'DCS错误',
+      '31': '内容和DCS不一致',
+      '44': '路由错误',
+      '45': '短信内容长度超长',
+      '46': '落地网关没有设置费率',
+      '48': '客户自定义签名错误',
+      '49': '系统没有匹配出签名',
+      '50': '客户签名和主叫绑定失败',
+      '60': '长消息过长或子消息数量过多',
+      '61': '长消息的子消息DCS不一致',
+      '62': '长消息组合超时',
+      '63': '长消息中的子消息序号越界',
+      '64': '长消息:子消息已经存在',
+      '77': 'DR超时的错误码',
+      '78': '定制DR的错误码',
+      '88': '客户余额不足',
+      '98': '落地原因导致消息不可到达',
+      '99': '未知状态',
+      '240': 'DCS错误',
+      '241': '主叫号码异常:Caller字段值过长',
+      '242': '被叫号码异常:被叫号码字段不存在或字段值过长',
+      '253': '被叫号码数量过多'
+    }
+    return errorMap[status] || `未知错误码: ${status}`
+  }
+}

+ 22 - 0
yarn.lock

@@ -471,6 +471,11 @@
   resolved "https://registry.npmmirror.com/@types/ali-oss/-/ali-oss-6.16.11.tgz"
   integrity sha512-/AyemPZy93ZXGzEokMsoPFgjH37snpzH4X/fwans/n63HLaCleriCG3PyrkHCPkgHEc9vj9Uo6paqsBN3vJ3OA==
 
+"@types/async-lock@^1.4.2":
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/@types/async-lock/-/async-lock-1.4.2.tgz#c2037ba1d6018de766c2505c3abe3b7b6b244ab4"
+  integrity sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==
+
 "@types/bcryptjs@^3.0.0":
   version "3.0.0"
   resolved "https://registry.npmmirror.com/@types/bcryptjs/-/bcryptjs-3.0.0.tgz"
@@ -666,6 +671,11 @@ asn1.js@^5.4.1:
     minimalistic-assert "^1.0.0"
     safer-buffer "^2.1.0"
 
+async-lock@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f"
+  integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==
+
 asynckit@^0.4.0:
   version "0.4.0"
   resolved "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz"
@@ -1250,6 +1260,13 @@ fast-uri@^3.0.0, fast-uri@^3.0.1, fast-uri@^3.0.5:
   resolved "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.0.6.tgz"
   integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==
 
+fast-xml-parser@^4.2.10:
+  version "4.5.3"
+  resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz#c54d6b35aa0f23dc1ea60b6c884340c006dc6efb"
+  integrity sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==
+  dependencies:
+    strnum "^1.1.1"
+
 fastfall@^1.5.0:
   version "1.5.1"
   resolved "https://registry.npmmirror.com/fastfall/-/fastfall-1.5.1.tgz"
@@ -2522,6 +2539,11 @@ strip-json-comments@^3.1.1:
   resolved "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
   integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
+strnum@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4"
+  integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==
+
 supports-preserve-symlinks-flag@^1.0.0:
   version "1.0.0"
   resolved "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"