PBlack 实战 - 从零搭建黑名单检测 API

0 阅读4分钟

上一篇聊了 PBlack 的架构设计,今天来点更实在的——手把手教你从零搭建一个能跑起来的黑名单检测 API。

读完这篇,你会有:

  • • 一个完整的本地开发环境

  • • 能用的黑名单检测接口

  • • 对核心代码的理解

PBlack 控制台 PBlack 控制台

环境准备

PBlack 的技术栈是 Vue + Django + Go,所以你需要:

# Go 1.21+
go version

# Node.js 18+
node -v

# Python 3.11+
python3 --version

# PostgreSQL 15
# Redis 7

数据库用 Docker 启动最省事:

# docker-compose.yml
version: '3.8'
services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: pblack
      POSTGRES_USER: pblack
      POSTGRES_PASSWORD: your_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:
docker-compose up -d

第一步:搭建 Django 管理后台

Django 负责用户管理、API 密钥、号码库维护。先创建项目:

cd pblack
django-admin startproject padmin
cd padmin

核心模型设计:

# padmin/core/models.py
from django.db import models
from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    """扩展用户模型"""
    balance = models.DecimalField(max_digits=12, decimal_places=4, default=0)
    price_per_request = models.DecimalField(max_digits=10, decimal_places=4, default=0.01)
    
class ApiKey(models.Model):
    """API 密钥管理"""
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    app_id = models.CharField(max_length=32, unique=True)
    app_secret = models.CharField(max_length=64)
    allowed_ips = models.JSONField(default=list, blank=True)
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)

class PhoneBlacklist(models.Model):
    """黑名单号码库"""
    phone = models.CharField(max_length=20, db_index=True)
    source = models.CharField(max_length=50)
    reason = models.CharField(max_length=200, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        indexes = [
            models.Index(fields=['phone']),
        ]

Django Admin 配置:

# padmin/core/admin.py
from django.contrib import admin
from .models import User, ApiKey, PhoneBlacklist

@admin.register(ApiKey)
class ApiKeyAdmin(admin.ModelAdmin):
    list_display = ['app_id''user''is_active''created_at']
    list_filter = ['is_active''created_at']
    search_fields = ['app_id''user__username']

@admin.register(PhoneBlacklist)
class PhoneBlacklistAdmin(admin.ModelAdmin):
    list_display = ['phone''source''reason''created_at']
    search_fields = ['phone''reason']
    list_filter = ['source''created_at']

启动管理后台:

python manage.py migrate
python manage.py createsuperuser
python manage.py runserver 0.0.0.0:8000

访问 http://localhost:8000/admin,创建几个测试用的 API 密钥。

API 密钥管理 API 密钥管理

第二步:核心检测服务(Go)

这是整个系统的性能核心。新建 Go 项目:

mkdir phone-filter-api
cd phone-filter-api
go mod init github.com/yourname/phone-filter-api

项目结构:

phone-filter-api/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── api/
│   │   └── handler.go
│   ├── service/
│   │   └── blacklist.go
│   ├── database/
│   │   └── redis.go
│   └── middleware/
│       └── auth.go
├── config/
│   └── config.yaml
└── go.mod

Redis 连接和黑名单查询:

// internal/database/redis.go
package database

import (
    "context"
    "github.com/redis/go-redis/v9"
)

var rdb *redis.Client

func InitRedis(addr string) {
    rdb = redis.NewClient(&redis.Options{
        Addr: addr,
        DB:   0,
    })
}

func IsPhoneInBlacklistSet(ctx context.Context, phone string) (bool, error) {
    return rdb.SIsMember(ctx, "blacklist:phones", phone).Result()
}

func AddToBlacklistSet(ctx context.Context, phones ...string) error {
    return rdb.SAdd(ctx, "blacklist:phones", phones).Err()
}

核心检测逻辑:

// internal/service/blacklist.go
package service

import (
    "context"
    "crypto/md5"
    "fmt"
    "strings"
    "time"
    
    "phone-filter-api/internal/database"
)

type CheckRequest struct {
    AppID     string `json:"appId"`
    Timestamp int64  `json:"timestamp"`
    Sign      string `json:"sign"`
    Phone     string `json:"phone"`
    Level     int    `json:"level"`
}

type CheckResponse struct {
    Code      int    `json:"code"`
    Message   string `json:"message"`
    IsBlocked bool   `json:"isBlocked"`
}

func (s *BlacklistService) Check(ctx context.Context, req *CheckRequest) (*CheckResponse, error) {
    // 1. 验证签名
    if !s.verifySign(req) {
        return &CheckResponse{Code: 401, Message: "invalid sign"}, nil
    }
    
    // 2. 检查时间戳(5分钟有效期)
    if time.Now().Unix()-req.Timestamp > 300 {
        return &CheckResponse{Code: 401, Message: "timestamp expired"}, nil
    }
    
    // 3. 标准化手机号
    phone := normalizePhone(req.Phone)
    
    // 4. 执行检测
    isBlocked, _ := database.IsPhoneInBlacklistSet(ctx, phone)
    
    // 5. 异步记录访问日志
    go s.logAccess(req.AppID, phone, isBlocked)
    
    return &CheckResponse{
        Code:      200,
        Message:   "success",
        IsBlocked: isBlocked,
    }, nil
}

func normalizePhone(phone string) string {
    var result strings.Builder
    for _, c := range phone {
        if c >= '0' && c <= '9' {
            result.WriteRune(c)
        }
    }
    return result.String()
}

启动服务:

// cmd/server/main.go
package main

import (
    "log"
    "net/http"
    
    "phone-filter-api/internal/api"
    "phone-filter-api/internal/database"
)

func main() {
    database.InitRedis("localhost:6379")
    
    handler := api.NewHandler()
    http.HandleFunc("/api/v1/check", handler.CheckPhone)
    
    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

运行:

go mod tidy
go run cmd/server/main.go

第三步:测试接口

先用 Redis CLI 导入几条测试数据:

redis-cli
SADD blacklist:phones "13800138000" "13900139000" "15000150000"

然后用 curl 测试:

# 生成签名(假设 appId=test001, appSecret=secret123)
TIMESTAMP=$(date +%s)
SIGN=$(echo -n "test001${TIMESTAMP}secret123" | md5sum | cut -d' ' -f1)

curl -X POST http://localhost:8080/api/v1/check \
  -H "Content-Type: application/json" \
  -d "{
    \"appId\": \"test001\",
    \"timestamp\": $TIMESTAMP,
    \"sign\": \"$SIGN\",
    \"phone\": \"13800138000\",
    \"level\": 1
  }"

预期返回:

{
  "code": 200,
  "message""success",
  "isBlocked"true
}

测试一个不在黑名单的号码:

{
  "code": 200,
  "message""success",
  "isBlocked"false
}

第四步:批量导入黑名单

实际项目中,黑名单可能来自多个渠道:

// internal/service/importer.go
package service

import (
    "bufio"
    "context"
    "os"
    "strings"
    
    "phone-filter-api/internal/database"
)

func ImportFromFile(ctx context.Context, filepath string, source string) (int, error) {
    file, err := os.Open(filepath)
    if err != nil {
        return 0, err
    }
    defer file.Close()
    
    var phones []string
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        phone := strings.TrimSpace(scanner.Text())
        if phone != "" {
            phones = append(phones, normalizePhone(phone))
        }
    }
    
    // 批量写入 Redis
    if err := database.AddToBlacklistSet(ctx, phones...); err != nil {
        return 0, err
    }
    
    return len(phones), nil
}

导入命令:

go run cmd/import/main.go --file=blacklist.txt --source=manual

第五步:性能压测

写个简单的压测脚本:

#!/bin/bash
# benchmark.sh

CONCURRENT=100
REQUESTS=10000
API_URL="http://localhost:8080/api/v1/check"

TIMESTAMP=$(date +%s)
SIGN=$(echo -n "test001${TIMESTAMP}secret123" | md5sum | cut -d' ' -f1)

ab -n $REQUESTS -c $CONCURRENT \
  -T "application/json" \
  -p <(echo "{
    \"appId\": \"test001\",
    \"timestamp\": $TIMESTAMP,
    \"sign\": \"$SIGN\",
    \"phone\": \"13800138000\",
    \"level\": 1
  }") \
  $API_URL

在我的笔记本(M1 Pro)上实测数据:

| 指标 | 数值 | | --- | --- | | 单机 QPS | 8,500+ | | 平均响应 | 5ms | | P99 延迟 | 12ms | | Redis 命中率 | 100% |

这个性能对于中小业务完全够用了。如果需要更高并发,水平扩展几台机器就行。

常见问题

Q: 黑名单数据怎么同步到 Redis?

A: 几种方案:

Q: 怎么防止 API 密钥泄露?

A: 除了签名验证,建议加上:

  • • IP 白名单限制

  • • 请求频率限制(rate limiting)

  • • 异常调用监控告警

Q: 需要支持国际号码吗?

A: 目前代码只处理了数字提取。如果需要支持国际号码(如 +86-138-0013-8000),可以在 normalizePhone 函数里保留 + 号,或者增加国家码字段。

总结

这篇文章带你从零搭建了一个可用的手机号黑名单检测 API。核心要点:

代码已经能跑起来了,下一篇我会聊聊如何部署到生产环境,包括 Nginx 配置、systemd 服务托管、监控告警这些运维话题。