Files
kefu/im/backend/internal/app/verification.go
T
2026-09-03 08:38:17 +08:00

136 lines
5.6 KiB
Go

package app
import (
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/json"
"net/http"
"strings"
)
func (a *App) verificationDocumentHash(value string) []byte {
key := a.config.ConfigEncryptionKey
if key == "" {
key = a.config.JWTSecret + ":development-verification"
}
mac := hmac.New(sha256.New, []byte(key+":verification-document"))
_, _ = mac.Write([]byte(strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), " ", ""))))
return mac.Sum(nil)
}
func maskDocumentNumber(value string) string {
runes := []rune(strings.TrimSpace(value))
if len(runes) <= 4 {
return strings.Repeat("*", len(runes))
}
if len(runes) <= 8 {
return string(runes[:1]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1:])
}
return string(runes[:3]) + strings.Repeat("*", len(runes)-7) + string(runes[len(runes)-4:])
}
func (a *App) myVerification(w http.ResponseWriter, r *http.Request) {
requestedType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type")))
if requestedType == "" {
requestedType = "real_name"
}
if requestedType != "real_name" && requestedType != "occupation" {
fail(w, 400, 20001, "认证类型无效")
return
}
var verificationType, status, realName, documentMask, remark, evidenceJSON string
var submittedAt, reviewedAt sql.NullTime
err := a.db.QueryRowContext(r.Context(), `SELECT verification_type,status,real_name,document_mask,remark,evidence_json,submitted_at,reviewed_at FROM user_verifications WHERE user_id=? AND verification_type=?`, current(r).ID, requestedType).Scan(&verificationType, &status, &realName, &documentMask, &remark, &evidenceJSON, &submittedAt, &reviewedAt)
if err == sql.ErrNoRows {
reply(w, map[string]any{"status": "UNVERIFIED", "type": "real_name", "evidence": []string{}})
return
}
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询认证状态失败")
return
}
evidence := []string{}
_ = json.Unmarshal([]byte(evidenceJSON), &evidence)
reply(w, map[string]any{
"type": verificationType, "status": status, "realName": realName,
"documentMask": documentMask, "remark": remark, "evidence": evidence,
"submittedAt": nullableTime(submittedAt), "reviewedAt": nullableTime(reviewedAt),
})
}
func (a *App) submitVerification(w http.ResponseWriter, r *http.Request) {
var req struct {
Type string `json:"type"`
RealName string `json:"realName"`
DocumentNumber string `json:"documentNumber"`
Evidence []string `json:"evidence"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "认证资料格式错误")
return
}
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
if req.Type != "real_name" && req.Type != "occupation" {
fail(w, http.StatusBadRequest, 20001, "认证类型无效")
return
}
req.RealName = strings.TrimSpace(req.RealName)
documentNumber := strings.ReplaceAll(strings.TrimSpace(req.DocumentNumber), " ", "")
if len([]rune(req.RealName)) < 2 || len([]rune(req.RealName)) > 50 || len([]rune(documentNumber)) < 6 || len([]rune(documentNumber)) > 80 {
fail(w, http.StatusBadRequest, 20001, "姓名或证件号码格式无效")
return
}
if len(req.Evidence) == 0 || len(req.Evidence) > 3 {
fail(w, http.StatusBadRequest, 20001, "请上传 1 至 3 张认证材料")
return
}
tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "提交认证失败")
return
}
defer func() { _ = tx.Rollback() }()
var existingStatus string
err = tx.QueryRowContext(r.Context(), `SELECT status FROM user_verifications WHERE user_id=? AND verification_type=? FOR UPDATE`, current(r).ID, req.Type).Scan(&existingStatus)
if err != nil && err != sql.ErrNoRows {
fail(w, http.StatusInternalServerError, 50001, "读取认证状态失败")
return
}
if existingStatus == "PENDING" {
fail(w, http.StatusConflict, 20001, "认证资料正在审核,请勿重复提交")
return
}
if existingStatus == "VERIFIED" {
fail(w, http.StatusConflict, 20001, "账号已完成认证")
return
}
evidence := make([]string, 0, len(req.Evidence))
seen := map[string]bool{}
for _, rawURL := range req.Evidence {
mediaURL := strings.TrimSpace(rawURL)
if mediaURL == "" || seen[mediaURL] {
fail(w, http.StatusBadRequest, 20001, "认证材料地址无效或重复")
return
}
seen[mediaURL] = true
var exists int
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type='image' AND status=1)`, current(r).ID, mediaURL).Scan(&exists); err != nil || exists != 1 {
fail(w, http.StatusBadRequest, 20001, "认证材料必须由当前账号上传")
return
}
evidence = append(evidence, mediaURL)
}
evidenceJSON, _ := json.Marshal(evidence)
_, err = tx.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,document_hash,evidence_json,remark,reviewer_admin_id,submitted_at,reviewed_at)
VALUES(?,?,'PENDING',?,?,?,?, '',NULL,NOW(3),NULL)
ON DUPLICATE KEY UPDATE verification_type=VALUES(verification_type),status='PENDING',real_name=VALUES(real_name),document_mask=VALUES(document_mask),document_hash=VALUES(document_hash),evidence_json=VALUES(evidence_json),remark='',reviewer_admin_id=NULL,submitted_at=NOW(3),reviewed_at=NULL`, current(r).ID, req.Type, req.RealName, maskDocumentNumber(documentNumber), a.verificationDocumentHash(documentNumber), string(evidenceJSON))
if err != nil || tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "提交认证失败")
return
}
reply(w, map[string]any{"status": "PENDING", "submitted": true})
}