Jelajahi Sumber

feat(phone-list): 实现新晒号接口

- 新增 screenPhoneNumberV2 方法,用于调用新的晒号接口
- 添加文件上传和 MD5 计算功能- 实现 RSA 加密和分块加密逻辑
- 集成 yauzl 模块用于处理 ZIP 文件
- 优化网络请求和错误处理
wui 1 tahun lalu
induk
melakukan
06b97dce75
5 mengubah file dengan 273 tambahan dan 5 penghapusan
  1. 2 0
      package.json
  2. 2 1
      src/phone-list/phone-list.module.ts
  3. 160 2
      src/phone-list/phone-list.service.ts
  4. 88 1
      src/utils/crypto.ts
  5. 21 1
      yarn.lock

+ 2 - 0
package.json

@@ -95,6 +95,7 @@
     "uuid": "^9.0.0",
     "ws": "^8.14.2",
     "xlsx": "^0.18.5",
+    "yauzl": "^3.2.0",
     "yup": "^1.0.0"
   },
   "devDependencies": {
@@ -111,6 +112,7 @@
     "@types/nodemailer-express-handlebars": "^4.0.2",
     "@types/supertest": "^2.0.12",
     "@types/uuid": "^9.0.1",
+    "@types/yauzl": "^2.10.3",
     "@typescript-eslint/eslint-plugin": "^5.53.0",
     "@typescript-eslint/parser": "^5.53.0",
     "eslint": "^8.34.0",

+ 2 - 1
src/phone-list/phone-list.module.ts

@@ -7,9 +7,10 @@ import { Phone } from './entities/phone.entity'
 import { Users } from '../users/entities/users.entity'
 import { SysConfigModule } from '../sys-config/sys-config.module'
 import { BalanceModule } from '../balance/balance.module'
+import { FileModule } from '../file/file.module'
 
 @Module({
-    imports: [TypeOrmModule.forFeature([PhoneList, Phone, Users]), SysConfigModule, BalanceModule],
+    imports: [TypeOrmModule.forFeature([PhoneList, Phone, Users]), SysConfigModule, BalanceModule, FileModule],
     controllers: [PhoneListController],
     providers: [PhoneListService],
     exports: [PhoneListService]

+ 160 - 2
src/phone-list/phone-list.service.ts

@@ -1,4 +1,4 @@
-import { Injectable, Req } from '@nestjs/common'
+import { Injectable, Req, ServiceUnavailableException } from '@nestjs/common'
 import { InjectRepository } from '@nestjs/typeorm'
 import { PhoneList } from './entities/phone-list.entity'
 import { DataSource, In, Repository } from 'typeorm'
@@ -8,6 +8,9 @@ import { Pagination, paginate } from 'nestjs-typeorm-paginate'
 import { Response } from 'express'
 import { Users } from '../users/entities/users.entity'
 import axios from 'axios'
+import { FileService } from '../file/file.service'
+import { encryptData, getFileMd5FromUrl } from '../utils/crypto'
+import * as yauzl from 'yauzl'
 
 const token =
     'vLp1Vl/yauWWx2bhaf+e9/VgXzUt5QRIZS4Rj+UuOv4eUpQWkJQC4zVnM3gXaqf5jc6j7lEY2Lagw/QCIf/4/ZTB4MKMfcvUmHRc9ISg4vXgIoC6SB2dyoeJxkOqJ5wQTftzPG2QSLFBSyhV3BHZGOguKSoXSlexmhl8pTqL/Fs='
@@ -20,6 +23,23 @@ const axiosInstance = axios.create({
     }
 })
 
+const appId = 'GftmoIruy8mkX4Z5'
+const secret = 'mayzg1xAsHoNaPuF5bXEkpqkAF6vPaRN'
+const publicKey = `-----BEGIN PUBLIC KEY-----
+MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAIzEZG2iwnFrvOLVCtI7F
+SsMKmVx/
+BtY2iVgDNGt1PfgSwFMfHhwJ28fvO+FuuA7SlmmwLEw3KqjRiuUXrnh
+ph0CAwEAAQ==
+-----END PUBLIC KEY-----`
+
+const axiosInstanceV2 = axios.create({
+    baseURL: 'http://8.218.211.187/screenApi/',
+    headers: {
+        uhost: '7sbn61ty.bomcnt.com',
+        uprotocol: 'http'
+    }
+})
+
 @Injectable()
 export class PhoneListService {
     constructor(
@@ -29,7 +49,8 @@ export class PhoneListService {
         private phoneRepository: Repository<Phone>,
         @InjectRepository(Users)
         private userRepository: Repository<Users>,
-        private readonly dataSource: DataSource
+        private readonly dataSource: DataSource,
+        private readonly fileService: FileService
     ) {}
 
     async findAllPhoneList(req: PageRequest<PhoneList>): Promise<Pagination<PhoneList>> {
@@ -185,4 +206,141 @@ export class PhoneListService {
         }
         return phones
     }
+
+    async screenPhoneNumberV2(file: Express.Multer.File, country: string): Promise<string[]> {
+        const fileRes = await this.fileService.upload(file)
+        if (!fileRes.url) {
+            throw new ServiceUnavailableException(`File upload error!`)
+        }
+        const filePath = fileRes.url
+        const fileMd5 = await getFileMd5FromUrl(filePath)
+
+        // 获取token
+        const tokenRes = await axiosInstanceV2.post('getToken', {
+            appId,
+            secret
+        })
+        const tokenData = tokenRes.data
+        if (tokenData.code !== 0) {
+            throw new ServiceUnavailableException(`Get token error!`)
+        }
+        const token = tokenData.token
+        const config = {
+            headers: {
+                'Content-Type': 'application/json',
+                appId,
+                token
+            }
+        }
+        console.log('config:', config)
+
+        const sendParam = {
+            filePath,
+            fileMd5,
+            operate: 'filter',
+            country: 'US'
+        }
+        const sendParamString = JSON.stringify(sendParam)
+        const sendData = encryptData(sendParamString, publicKey)
+        console.log('sendData:', sendData)
+
+        // 添加任务
+        const addRes = await axiosInstanceV2.post('api/addTask', sendData, config)
+        console.log('addRes:', addRes.data)
+        if (addRes.data.code !== 0) {
+            throw new ServiceUnavailableException(`ScreenPhoneNumberV2 addTask error!`)
+        }
+        const taskId = addRes.data.data.taskId
+
+        // 开启任务
+        const startParam = {
+            taskId,
+            operate: 'filter'
+        }
+        const startParamString = JSON.stringify(startParam)
+        const startData = encryptData(startParamString, publicKey)
+        const startRes = await axiosInstanceV2.post('api/startTask', startData, config)
+        console.log('startRes:', startRes.data)
+
+        // 获取进度
+        let check = true
+        let attempts = 1
+        while (check && attempts <= 50) {
+            await new Promise((resolve) => setTimeout(resolve, 5000))
+            console.log(`第${attempts}次查询晒号任务状态`)
+            const progressRes = await axiosInstanceV2.post('api/getProcess', startData, config)
+            console.log('progressRes:', progressRes.data)
+            if (progressRes.data.code === 0) {
+                if (progressRes.data.data.state === 'finish') {
+                    console.log('progressRes:', progressRes.data)
+                    check = false
+                    break
+                }
+            } else {
+                throw new ServiceUnavailableException(`ScreenPhoneNumberV2 getProcess error!`)
+            }
+            attempts++
+        }
+
+        if (check) {
+            throw new ServiceUnavailableException(`ScreenPhoneNumberV2 getProcess timed out after 50 attempts!`)
+        }
+        // 获取报告
+        const reportParam = {
+            taskId,
+            operate: 'filter',
+            downloadType: 1
+        }
+        const reportParamString = JSON.stringify(reportParam)
+        const reportData = encryptData(reportParamString, publicKey)
+
+        const reportResponse = await axiosInstanceV2.post('api/getReport', reportData, {
+            headers: {
+                'Content-Type': 'application/json',
+                appId,
+                token
+            },
+            responseType: 'arraybuffer'
+        })
+
+        let numbers = []
+        await new Promise((resolve, reject) => {
+            yauzl.fromBuffer(Buffer.from(reportResponse.data), { lazyEntries: true }, (err, zipFile) => {
+                if (err) return reject(err)
+
+                zipFile.readEntry()
+                zipFile.on('entry', (entry) => {
+                    if (entry.fileName.endsWith('.txt')) {
+                        zipFile.openReadStream(entry, (err, readStream) => {
+                            if (err) return reject(err)
+
+                            const chunks = []
+                            readStream.on('data', (chunk) => chunks.push(chunk))
+                            readStream.on('end', () => {
+                                const content = Buffer.concat(chunks).toString('utf-8')
+                                numbers = content
+                                    .split('\n')
+                                    .map((line) => line.trim())
+                                    .filter((line) => line)
+                                zipFile.close()
+                                resolve(numbers)
+                            })
+                        })
+                    } else {
+                        zipFile.readEntry()
+                    }
+                })
+
+                zipFile.on('end', () => resolve(numbers))
+                zipFile.on('error', (err) => reject(err))
+            })
+        })
+            .then((numbers) => console.log('ScreenPhoneNumberV2 getReport success!'))
+            .catch((err) => {
+                console.error('Error:', err)
+                throw new ServiceUnavailableException(`ScreenPhoneNumberV2 getReport error!`)
+            })
+
+        return numbers
+    }
 }

+ 88 - 1
src/utils/crypto.ts

@@ -1,4 +1,7 @@
 import * as crypto from 'crypto'
+import * as https from 'https'
+import * as http from 'http'
+import { URL } from 'url'
 
 /**
  * 鉴权加密
@@ -37,4 +40,88 @@ export function getEncodeHeader(params = {}, appKey, appSecret) {
  */
 export function getMd5Content(str) {
     return crypto.createHash('md5').update(str).digest('hex')
-}
+}
+
+/**
+ * MD5 加密
+ * @param str 待加密的字符串
+ * @param length 输出长度(16 或 32,默认 32 位)
+ * @param isUpperCase 是否返回大写字母(默认小写)
+ * @returns 加密后的 MD5 字符串
+ */
+export function getMd5DetailContent(str: string, length: 16 | 32 = 32, isUpperCase: boolean = false): string {
+    const hash = crypto.createHash('md5').update(str).digest('hex')
+    const result = length === 16 ? hash.substring(8, 24) : hash
+    return isUpperCase ? result.toUpperCase() : result
+}
+
+/**
+ * 根据 URL 获取文件并计算 MD5 值
+ * @param fileUrl 文件的网络地址
+ * @returns 文件的 MD5 值(32 位字符串)
+ */
+export function getFileMd5FromUrl(fileUrl: string): Promise<string> {
+    return new Promise((resolve, reject) => {
+        try {
+            const url = new URL(fileUrl);
+
+            // 根据协议选择模块
+            const client = url.protocol === 'https:' ? https : http;
+
+            const request = client.get(url, (response) => {
+                if (response.statusCode !== 200) {
+                    reject(new Error(`请求失败,状态码: ${response.statusCode}`));
+                    response.resume(); // 消耗响应数据以释放内存
+                    return;
+                }
+
+                const hash = crypto.createHash('md5'); // 创建 MD5 哈希对象
+                response.on('data', (chunk) => hash.update(chunk)); // 更新哈希
+                response.on('end', () => resolve(hash.digest('hex'))); // 返回 32 位 MD5 字符串
+            });
+
+            request.on('error', (error) => {
+                reject(new Error(`网络请求出错: ${error.message}`));
+            });
+        } catch (error) {
+            reject(new Error(`解析 URL 出错: ${error.message}`));
+        }
+    });
+}
+
+/**
+ * RSA 加密,支持大数据分块加密
+ * @param data 待加密的数据
+ * @param publicKey 公钥字符串
+ * @param maxEncryptBlock RSA 最大加密块大小(默认 53 字节,适用于 512 位 RSA 密钥)
+ * @returns Base64 编码的加密结果
+ */
+export function encryptData(data: string, publicKey: string, maxEncryptBlock: number = 53): string {
+    try {
+        const buffer = Buffer.from(data, 'utf8')
+        const dataLength = buffer.length
+
+        let offset = 0
+        const encryptedBuffers: Buffer[] = []
+
+        while (offset < dataLength) {
+            const chunkSize = Math.min(maxEncryptBlock, dataLength - offset)
+            const chunk = buffer.subarray(offset, offset + chunkSize)
+            const encryptedChunk = crypto.publicEncrypt(
+                {
+                    key: publicKey,
+                    padding: crypto.constants.RSA_PKCS1_PADDING
+                },
+                chunk
+            )
+            encryptedBuffers.push(encryptedChunk)
+            offset += chunkSize
+        }
+
+        const encryptedBuffer = Buffer.concat(encryptedBuffers)
+        return encryptedBuffer.toString('base64')
+    } catch (error) {
+        console.error('RSA 加密失败:', error)
+        throw error
+    }
+}

+ 21 - 1
yarn.lock

@@ -1602,6 +1602,13 @@
   dependencies:
     "@types/yargs-parser" "*"
 
+"@types/yauzl@^2.10.3":
+  version "2.10.3"
+  resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999"
+  integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==
+  dependencies:
+    "@types/node" "*"
+
 "@typescript-eslint/eslint-plugin@^5.53.0":
   version "5.62.0"
   resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz#aeef0328d172b9e37d9bab6dbc13b87ed88977db"
@@ -2666,7 +2673,7 @@ bser@2.1.1:
   dependencies:
     node-int64 "^0.4.0"
 
-buffer-crc32@^0.2.1, buffer-crc32@^0.2.13:
+buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
   version "0.2.13"
   resolved "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
   integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
@@ -6723,6 +6730,11 @@ pem@^1.14.7:
     os-tmpdir "^1.0.2"
     which "^2.0.2"
 
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
+
 picocolors@^1.0.0, picocolors@^1.1.0:
   version "1.1.1"
   resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
@@ -8530,6 +8542,14 @@ yargs@^17.3.1, yargs@^17.6.2:
     y18n "^5.0.5"
     yargs-parser "^21.1.1"
 
+yauzl@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-3.2.0.tgz#7b6cb548f09a48a6177ea0be8ece48deb7da45c0"
+  integrity sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    pend "~1.2.0"
+
 year@^0.2.1:
   version "0.2.1"
   resolved "https://registry.npmmirror.com/year/-/year-0.2.1.tgz#4083ae520a318b23ec86037f3000cb892bdf9bb0"