feat: 增强订单风控配置,主要针对游客下单
This commit is contained in:
1 parent
2bc4e7512f
commit
513619eb94
33 files changed
+1641
-714
No files matched your search
@@ -95,17 +95,6 @@ changelog:
|
||||
- 'Merge branch'
|
||||
|
||||
release:
|
||||
# 过渡版本必须把警告直接放在 GitHub Release body 首部:旧版后台的一键升级
|
||||
# 只会展示 Release notes,不会主动打开新版在线文档。等 v1.3.1 及更早版本
|
||||
# 已完成迁移后,可以在后续版本删除这个一次性 header。
|
||||
header: |
|
||||
> [!WARNING]
|
||||
> **二进制部署从 v1.3.1 或更早版本升级:**如果 systemd unit 仍是 `Restart=on-failure`,请先改成 `Restart=always` 并执行 `sudo systemctl daemon-reload`,再使用后台一键升级;否则本次升级后需要手动执行 `sudo systemctl start <你的 unit 名>`。
|
||||
>
|
||||
> **二進制部署從 v1.3.1 或更早版本升級:**如果 systemd unit 仍是 `Restart=on-failure`,請先改成 `Restart=always` 並執行 `sudo systemctl daemon-reload`,再使用後臺一鍵升級;否則本次升級後需要手動執行 `sudo systemctl start <你的 unit 名>`。
|
||||
>
|
||||
> **Binary deployments upgrading from v1.3.1 or earlier:** if the systemd unit still uses `Restart=on-failure`, change it to `Restart=always` and run `sudo systemctl daemon-reload` before using one-click upgrade. Otherwise, you must run `sudo systemctl start <your-unit>` manually after this upgrade.
|
||||
|
||||
footer: >-
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ server:
|
||||
host: 0.0.0.0
|
||||
port: 8080
|
||||
mode: debug # debug 或 release
|
||||
# 仅信任实际反向代理的地址或网段。留空表示完全不信任转发头、直接使用 TCP 来源地址。
|
||||
# 同机 Nginx 使用下面默认值;Docker/CDN 部署必须改成真实代理网段,禁止配置 0.0.0.0/0 或 ::/0。
|
||||
trusted_proxies:
|
||||
- 127.0.0.1/32
|
||||
- ::1/128
|
||||
|
||||
log:
|
||||
dir: "" # 为空时使用当前运行目录/logs
|
||||
|
||||
@@ -2683,37 +2683,53 @@ const messages = {
|
||||
},
|
||||
orderRiskControl: {
|
||||
title: '订单风控配置',
|
||||
subtitle: '配置下单风控策略,防止恶意占用库存。',
|
||||
enabled: '启用订单风控',
|
||||
enabledHint: '开启后,系统将对下单行为进行风控检查。',
|
||||
pendingLimits: {
|
||||
title: '并发待支付订单限制',
|
||||
subtitle: '限制同一用户/IP/游客邮箱的最大待支付订单数量,防止恶意占库存。设为 0 表示不限制。',
|
||||
perUser: '每用户最大待支付订单数',
|
||||
perIP: '每 IP 最大待支付订单数',
|
||||
perGuestEmail: '每游客邮箱最大待支付订单数',
|
||||
subtitle: '按游客与会员身份分别配置订单和库存占用保护。',
|
||||
master: {
|
||||
title: '订单风险防线',
|
||||
subtitle: '总开关关闭时所有身份策略均不执行;商品自身的销售数量规则不受影响。',
|
||||
},
|
||||
statusEnabled: '防护已开启',
|
||||
statusDisabled: '防护未开启',
|
||||
noLimit: '不限制',
|
||||
guest: {
|
||||
title: '游客下单风控',
|
||||
priority: '重点保护',
|
||||
subtitle: '以可信客户端 IP 为核心,限制游客待支付订单和实际库存占用。IPv6 地址按 /64 前缀归并。',
|
||||
applyRecommended: '应用推荐值',
|
||||
maxPendingPerIP: '每 IP 最大待支付游客订单数',
|
||||
maxPendingPerIPHint: '只统计游客父订单;设为 0 表示不限制。',
|
||||
maxQuantityPerProduct: '每单、每商品最大数量',
|
||||
maxQuantityPerProductHint: '同一商品的不同 SKU 会合并计算;设为 0 表示不限制。',
|
||||
maxPendingQuantityPerIPProduct: '每 IP、每商品最大待支付占用',
|
||||
maxPendingQuantityPerIPProductHint: '跨多个待支付订单累计,直接限制同一商品被占用的库存。',
|
||||
paymentExpireMinutes: '游客支付超时(分钟)',
|
||||
paymentExpireMinutesHint: '设为 0 时使用系统通用订单支付超时时间。',
|
||||
summary: '当前策略:每个 IP 最多 {orders} 个待支付游客订单;每单同一商品最多 {quantity} 件;同一 IP 对同一商品最多待支付占用 {pending} 件。',
|
||||
rateLimitTitle: '游客下单频率',
|
||||
rateLimitHint: '只消耗正式创建订单次数,订单预览不会计数。',
|
||||
},
|
||||
member: {
|
||||
title: '会员下单风控',
|
||||
subtitle: '以用户 ID 为主要身份,默认比游客宽松;共享网络较多时建议保持会员 IP 限制为 0。',
|
||||
maxPendingPerUser: '每会员最大待支付订单数',
|
||||
maxPendingPerUserHint: '按登录用户 ID 统计,设为 0 表示不限制。',
|
||||
maxPendingPerIP: '每 IP 最大待支付会员订单数',
|
||||
maxPendingPerIPHint: '可能影响公司、校园或运营商共享网络,默认不限制。',
|
||||
maxQuantityPerProduct: '每单、每商品最大数量',
|
||||
maxQuantityPerProductHint: '作为会员额外风控上限;商品自身销售上限仍然有效。',
|
||||
rateLimitTitle: '会员下单频率',
|
||||
rateLimitHint: '按用户 ID 计数,默认关闭。',
|
||||
},
|
||||
rateLimit: {
|
||||
title: '下单频率限制',
|
||||
subtitle: '限制单位时间内的下单次数,防止高频刷单。',
|
||||
enabled: '启用频率限制',
|
||||
windowSeconds: '时间窗口(秒)',
|
||||
windowSecondsHint: '在此时间范围内统计下单次数(10~3600)',
|
||||
maxRequests: '最大下单次数',
|
||||
maxRequestsHint: '时间窗口内允许的最大下单次数(1~100)',
|
||||
blockSeconds: '超限封禁时长(秒)',
|
||||
blockSecondsHint: '超出频率后临时封禁的时长(0~86400),设为 0 则到窗口期结束自动解除',
|
||||
},
|
||||
ipBlacklist: {
|
||||
title: 'IP 黑名单',
|
||||
subtitle: '被封禁的 IP 将无法下单。每行填写一个 IP 地址,支持 CIDR 格式(如 1.2.3.0/24)。',
|
||||
subtitle: '同时影响游客和会员。每行一个 IP 或 CIDR;游客邮箱不参与任何风控判断。',
|
||||
placeholder: '每行一个 IP 地址,例如:\n1.2.3.4\n5.6.7.0/24',
|
||||
},
|
||||
emailBlacklist: {
|
||||
title: '邮箱黑名单',
|
||||
subtitle: '被封禁的邮箱将无法以游客身份下单。每行填写一个邮箱地址。',
|
||||
placeholder: '每行一个邮箱地址,例如:\nspam@example.com',
|
||||
},
|
||||
},
|
||||
upstreamSync: {
|
||||
title: '上游同步配置',
|
||||
@@ -7023,37 +7039,53 @@ const messages = {
|
||||
},
|
||||
orderRiskControl: {
|
||||
title: '訂單風控配置',
|
||||
subtitle: '配置下單風控策略,防止惡意佔用庫存。',
|
||||
enabled: '啟用訂單風控',
|
||||
enabledHint: '開啟後,系統將對下單行為進行風控檢查。',
|
||||
pendingLimits: {
|
||||
title: '並發待支付訂單限制',
|
||||
subtitle: '限制同一用戶/IP/遊客郵箱的最大待支付訂單數量,防止惡意佔庫存。設為 0 表示不限制。',
|
||||
perUser: '每用戶最大待支付訂單數',
|
||||
perIP: '每 IP 最大待支付訂單數',
|
||||
perGuestEmail: '每遊客郵箱最大待支付訂單數',
|
||||
subtitle: '按遊客與會員身份分別配置訂單和庫存佔用保護。',
|
||||
master: {
|
||||
title: '訂單風險防線',
|
||||
subtitle: '總開關關閉時所有身份策略均不執行;商品自身的銷售數量規則不受影響。',
|
||||
},
|
||||
statusEnabled: '防護已開啟',
|
||||
statusDisabled: '防護未開啟',
|
||||
noLimit: '不限制',
|
||||
guest: {
|
||||
title: '遊客下單風控',
|
||||
priority: '重點保護',
|
||||
subtitle: '以可信客戶端 IP 為核心,限制遊客待支付訂單和實際庫存佔用。IPv6 地址按 /64 前綴歸併。',
|
||||
applyRecommended: '套用推薦值',
|
||||
maxPendingPerIP: '每 IP 最大待支付遊客訂單數',
|
||||
maxPendingPerIPHint: '只統計遊客父訂單;設為 0 表示不限制。',
|
||||
maxQuantityPerProduct: '每單、每商品最大數量',
|
||||
maxQuantityPerProductHint: '同一商品的不同 SKU 會合併計算;設為 0 表示不限制。',
|
||||
maxPendingQuantityPerIPProduct: '每 IP、每商品最大待支付佔用',
|
||||
maxPendingQuantityPerIPProductHint: '跨多個待支付訂單累計,直接限制同一商品被佔用的庫存。',
|
||||
paymentExpireMinutes: '遊客支付逾時(分鐘)',
|
||||
paymentExpireMinutesHint: '設為 0 時使用系統通用訂單支付逾時時間。',
|
||||
summary: '目前策略:每個 IP 最多 {orders} 個待支付遊客訂單;每單同一商品最多 {quantity} 件;同一 IP 對同一商品最多待支付佔用 {pending} 件。',
|
||||
rateLimitTitle: '遊客下單頻率',
|
||||
rateLimitHint: '只消耗正式建立訂單次數,訂單預覽不會計數。',
|
||||
},
|
||||
member: {
|
||||
title: '會員下單風控',
|
||||
subtitle: '以使用者 ID 為主要身份,預設比遊客寬鬆;共享網絡較多時建議保持會員 IP 限制為 0。',
|
||||
maxPendingPerUser: '每會員最大待支付訂單數',
|
||||
maxPendingPerUserHint: '按登入使用者 ID 統計,設為 0 表示不限制。',
|
||||
maxPendingPerIP: '每 IP 最大待支付會員訂單數',
|
||||
maxPendingPerIPHint: '可能影響公司、校園或運營商共享網絡,預設不限制。',
|
||||
maxQuantityPerProduct: '每單、每商品最大數量',
|
||||
maxQuantityPerProductHint: '作為會員額外風控上限;商品自身銷售上限仍然有效。',
|
||||
rateLimitTitle: '會員下單頻率',
|
||||
rateLimitHint: '按使用者 ID 計數,預設關閉。',
|
||||
},
|
||||
rateLimit: {
|
||||
title: '下單頻率限制',
|
||||
subtitle: '限制單位時間內的下單次數,防止高頻刷單。',
|
||||
enabled: '啟用頻率限制',
|
||||
windowSeconds: '時間窗口(秒)',
|
||||
windowSecondsHint: '在此時間範圍內統計下單次數(10~3600)',
|
||||
maxRequests: '最大下單次數',
|
||||
maxRequestsHint: '時間窗口內允許的最大下單次數(1~100)',
|
||||
blockSeconds: '超限封禁時長(秒)',
|
||||
blockSecondsHint: '超出頻率後臨時封禁的時長(0~86400),設為 0 則到窗口期結束自動解除',
|
||||
},
|
||||
ipBlacklist: {
|
||||
title: 'IP 黑名單',
|
||||
subtitle: '被封禁的 IP 將無法下單。每行填寫一個 IP 地址,支援 CIDR 格式(如 1.2.3.0/24)。',
|
||||
subtitle: '同時影響遊客和會員。每行一個 IP 或 CIDR;遊客郵箱不參與任何風控判斷。',
|
||||
placeholder: '每行一個 IP 地址,例如:\n1.2.3.4\n5.6.7.0/24',
|
||||
},
|
||||
emailBlacklist: {
|
||||
title: '郵箱黑名單',
|
||||
subtitle: '被封禁的郵箱將無法以遊客身份下單。每行填寫一個郵箱地址。',
|
||||
placeholder: '每行一個郵箱地址,例如:\nspam@example.com',
|
||||
},
|
||||
},
|
||||
upstreamSync: {
|
||||
title: '上游同步配置',
|
||||
@@ -11363,37 +11395,53 @@ const messages = {
|
||||
},
|
||||
orderRiskControl: {
|
||||
title: 'Order Risk Control',
|
||||
subtitle: 'Configure order risk control policies to prevent malicious inventory hoarding.',
|
||||
enabled: 'Enable Order Risk Control',
|
||||
enabledHint: 'When enabled, the system will perform risk checks on order placement.',
|
||||
pendingLimits: {
|
||||
title: 'Concurrent Pending Order Limits',
|
||||
subtitle: 'Limit the maximum number of pending orders per user/IP/guest email to prevent inventory hoarding. Set to 0 for no limit.',
|
||||
perUser: 'Max pending orders per user',
|
||||
perIP: 'Max pending orders per IP',
|
||||
perGuestEmail: 'Max pending orders per guest email',
|
||||
subtitle: 'Configure order and inventory protection separately for guests and members.',
|
||||
master: {
|
||||
title: 'Order Risk Guard',
|
||||
subtitle: 'When the master switch is off, identity policies are skipped. Product sales quantity rules still apply.',
|
||||
},
|
||||
statusEnabled: 'Protection enabled',
|
||||
statusDisabled: 'Protection disabled',
|
||||
noLimit: 'No limit',
|
||||
guest: {
|
||||
title: 'Guest Order Protection',
|
||||
priority: 'Priority',
|
||||
subtitle: 'Uses the trusted client IP to limit guest pending orders and actual inventory exposure. IPv6 addresses are grouped by /64 prefix.',
|
||||
applyRecommended: 'Apply recommended',
|
||||
maxPendingPerIP: 'Max pending guest orders per IP',
|
||||
maxPendingPerIPHint: 'Counts guest parent orders only. Set to 0 for no limit.',
|
||||
maxQuantityPerProduct: 'Max quantity per product per order',
|
||||
maxQuantityPerProductHint: 'Different SKUs of the same product are combined. Set to 0 for no limit.',
|
||||
maxPendingQuantityPerIPProduct: 'Max pending quantity per IP and product',
|
||||
maxPendingQuantityPerIPProductHint: 'Accumulates across pending orders and directly caps inventory exposure for one product.',
|
||||
paymentExpireMinutes: 'Guest payment timeout (minutes)',
|
||||
paymentExpireMinutesHint: 'Set to 0 to use the system-wide order payment timeout.',
|
||||
summary: 'Current policy: each IP may have {orders} pending guest orders; each order may contain {quantity} units of one product; one IP may hold {pending} pending units of one product.',
|
||||
rateLimitTitle: 'Guest order rate',
|
||||
rateLimitHint: 'Only confirmed order creation consumes the limit. Order previews are not counted.',
|
||||
},
|
||||
member: {
|
||||
title: 'Member Order Protection',
|
||||
subtitle: 'Uses user ID as the primary identity and is more permissive by default. Keep the member IP limit at 0 for sites with shared networks.',
|
||||
maxPendingPerUser: 'Max pending orders per member',
|
||||
maxPendingPerUserHint: 'Counted by authenticated user ID. Set to 0 for no limit.',
|
||||
maxPendingPerIP: 'Max pending member orders per IP',
|
||||
maxPendingPerIPHint: 'May affect offices, schools, and carrier NAT networks. Disabled by default.',
|
||||
maxQuantityPerProduct: 'Max quantity per product per order',
|
||||
maxQuantityPerProductHint: 'An additional member risk cap. Product sales limits still apply.',
|
||||
rateLimitTitle: 'Member order rate',
|
||||
rateLimitHint: 'Counted by user ID and disabled by default.',
|
||||
},
|
||||
rateLimit: {
|
||||
title: 'Order Rate Limit',
|
||||
subtitle: 'Limit the number of orders within a time window to prevent rapid-fire ordering.',
|
||||
enabled: 'Enable rate limit',
|
||||
windowSeconds: 'Time window (seconds)',
|
||||
windowSecondsHint: 'Count orders within this time range (10~3600)',
|
||||
maxRequests: 'Max orders',
|
||||
maxRequestsHint: 'Maximum orders allowed within the time window (1~100)',
|
||||
blockSeconds: 'Block duration (seconds)',
|
||||
blockSecondsHint: 'Temporary block duration after exceeding the limit (0~86400). Set to 0 to unblock when window expires',
|
||||
},
|
||||
ipBlacklist: {
|
||||
title: 'IP Blacklist',
|
||||
subtitle: 'Blocked IPs will not be able to place orders. Enter one IP per line. CIDR notation supported (e.g. 1.2.3.0/24).',
|
||||
subtitle: 'Applies to guests and members. Enter one IP or CIDR per line. Guest email is never used for risk decisions.',
|
||||
placeholder: 'One IP per line, e.g.:\n1.2.3.4\n5.6.7.0/24',
|
||||
},
|
||||
emailBlacklist: {
|
||||
title: 'Email Blacklist',
|
||||
subtitle: 'Blocked emails will not be able to place guest orders. Enter one email per line.',
|
||||
placeholder: 'One email per line, e.g.:\nspam@example.com',
|
||||
},
|
||||
},
|
||||
upstreamSync: {
|
||||
title: 'Upstream Sync Config',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Gauge, Network, ShieldCheck, UserRound } from 'lucide-vue-next'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -9,196 +11,318 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { notifyError, notifySuccess } from '@/utils/notify'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const defaultRateLimit = (guest: boolean) => ({
|
||||
enabled: guest,
|
||||
window_seconds: 60,
|
||||
max_requests: guest ? 3 : 10,
|
||||
block_seconds: 120,
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
max_pending_orders_per_user: 3,
|
||||
max_pending_orders_per_ip: 5,
|
||||
max_pending_orders_per_guest_email: 2,
|
||||
order_rate_limit: {
|
||||
enabled: false,
|
||||
window_seconds: 60,
|
||||
max_requests: 5,
|
||||
block_seconds: 120,
|
||||
common: {
|
||||
ip_blacklist_text: '',
|
||||
},
|
||||
guest: {
|
||||
enabled: true,
|
||||
max_pending_orders_per_ip: 2,
|
||||
max_quantity_per_product_per_order: 1,
|
||||
max_pending_quantity_per_ip_product: 2,
|
||||
payment_expire_minutes: 10,
|
||||
rate_limit: defaultRateLimit(true),
|
||||
},
|
||||
member: {
|
||||
enabled: true,
|
||||
max_pending_orders_per_user: 5,
|
||||
max_pending_orders_per_ip: 0,
|
||||
max_quantity_per_product_per_order: 0,
|
||||
rate_limit: defaultRateLimit(false),
|
||||
},
|
||||
ip_blacklist_text: '',
|
||||
email_blacklist_text: '',
|
||||
})
|
||||
|
||||
const numberValue = (value: unknown, fallback: number) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
const applyRateLimit = (target: typeof form.guest.rate_limit, raw: unknown, guest: boolean) => {
|
||||
const data = raw as Record<string, unknown> | undefined
|
||||
const defaults = defaultRateLimit(guest)
|
||||
target.enabled = data?.enabled != null ? !!data.enabled : defaults.enabled
|
||||
target.window_seconds = numberValue(data?.window_seconds, defaults.window_seconds)
|
||||
target.max_requests = numberValue(data?.max_requests, defaults.max_requests)
|
||||
target.block_seconds = numberValue(data?.block_seconds, defaults.block_seconds)
|
||||
}
|
||||
|
||||
const applyLegacyRateLimit = (target: typeof form.guest.rate_limit, raw: unknown) => {
|
||||
const data = raw as Record<string, unknown> | undefined
|
||||
target.enabled = data?.enabled != null ? !!data.enabled : false
|
||||
target.window_seconds = numberValue(data?.window_seconds, 60)
|
||||
target.max_requests = numberValue(data?.max_requests, 5)
|
||||
target.block_seconds = numberValue(data?.block_seconds, 120)
|
||||
}
|
||||
|
||||
const applyRecommendedGuestPolicy = () => {
|
||||
form.guest.enabled = true
|
||||
form.guest.max_pending_orders_per_ip = 2
|
||||
form.guest.max_quantity_per_product_per_order = 1
|
||||
form.guest.max_pending_quantity_per_ip_product = 2
|
||||
form.guest.payment_expire_minutes = 10
|
||||
Object.assign(form.guest.rate_limit, defaultRateLimit(true))
|
||||
}
|
||||
|
||||
const loadNestedConfig = (data: Record<string, unknown>) => {
|
||||
const common = data.common as Record<string, unknown> | undefined
|
||||
const guest = data.guest as Record<string, unknown> | undefined
|
||||
const member = data.member as Record<string, unknown> | undefined
|
||||
form.enabled = !!data.enabled
|
||||
form.common.ip_blacklist_text = ((common?.ip_blacklist as string[] | undefined) || []).join('\n')
|
||||
|
||||
form.guest.enabled = guest?.enabled != null ? !!guest.enabled : true
|
||||
form.guest.max_pending_orders_per_ip = numberValue(guest?.max_pending_orders_per_ip, 2)
|
||||
form.guest.max_quantity_per_product_per_order = numberValue(guest?.max_quantity_per_product_per_order, 1)
|
||||
form.guest.max_pending_quantity_per_ip_product = numberValue(guest?.max_pending_quantity_per_ip_product, 2)
|
||||
form.guest.payment_expire_minutes = numberValue(guest?.payment_expire_minutes, 10)
|
||||
applyRateLimit(form.guest.rate_limit, guest?.rate_limit, true)
|
||||
|
||||
form.member.enabled = member?.enabled != null ? !!member.enabled : true
|
||||
form.member.max_pending_orders_per_user = numberValue(member?.max_pending_orders_per_user, 5)
|
||||
form.member.max_pending_orders_per_ip = numberValue(member?.max_pending_orders_per_ip, 0)
|
||||
form.member.max_quantity_per_product_per_order = numberValue(member?.max_quantity_per_product_per_order, 0)
|
||||
applyRateLimit(form.member.rate_limit, member?.rate_limit, false)
|
||||
}
|
||||
|
||||
const loadFlatConfig = (data: Record<string, unknown>) => {
|
||||
form.enabled = !!data.enabled
|
||||
form.common.ip_blacklist_text = ((data.ip_blacklist as string[] | undefined) || []).join('\n')
|
||||
form.guest.enabled = true
|
||||
form.guest.max_pending_orders_per_ip = numberValue(data.max_pending_orders_per_ip, 5)
|
||||
form.guest.max_quantity_per_product_per_order = 0
|
||||
form.guest.max_pending_quantity_per_ip_product = 0
|
||||
form.guest.payment_expire_minutes = 0
|
||||
applyLegacyRateLimit(form.guest.rate_limit, data.order_rate_limit)
|
||||
form.member.enabled = true
|
||||
form.member.max_pending_orders_per_user = numberValue(data.max_pending_orders_per_user, 3)
|
||||
form.member.max_pending_orders_per_ip = numberValue(data.max_pending_orders_per_ip, 5)
|
||||
form.member.max_quantity_per_product_per_order = 0
|
||||
applyLegacyRateLimit(form.member.rate_limit, data.order_rate_limit)
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await adminAPI.getSettings({ key: 'order_risk_control_config' })
|
||||
const data = res.data?.data as Record<string, unknown> | undefined
|
||||
if (data) {
|
||||
form.enabled = !!data.enabled
|
||||
form.max_pending_orders_per_user = data.max_pending_orders_per_user != null ? Number(data.max_pending_orders_per_user) : 3
|
||||
form.max_pending_orders_per_ip = data.max_pending_orders_per_ip != null ? Number(data.max_pending_orders_per_ip) : 5
|
||||
form.max_pending_orders_per_guest_email = data.max_pending_orders_per_guest_email != null ? Number(data.max_pending_orders_per_guest_email) : 2
|
||||
const rl = data.order_rate_limit as Record<string, unknown> | undefined
|
||||
if (rl) {
|
||||
form.order_rate_limit.enabled = !!rl.enabled
|
||||
form.order_rate_limit.window_seconds = rl.window_seconds != null ? Number(rl.window_seconds) : 60
|
||||
form.order_rate_limit.max_requests = rl.max_requests != null ? Number(rl.max_requests) : 5
|
||||
form.order_rate_limit.block_seconds = rl.block_seconds != null ? Number(rl.block_seconds) : 120
|
||||
}
|
||||
const ipList = data.ip_blacklist as string[] | undefined
|
||||
form.ip_blacklist_text = ipList?.join('\n') || ''
|
||||
const emailList = data.email_blacklist as string[] | undefined
|
||||
form.email_blacklist_text = emailList?.join('\n') || ''
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
if (data.guest && data.member) loadNestedConfig(data)
|
||||
else loadFlatConfig(data)
|
||||
}
|
||||
} catch {
|
||||
// ignore load error, use defaults
|
||||
// 读取失败时保留安全的推荐表单值,保存仍由用户主动触发。
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeLines = (value: string) => Array.from(new Set(value
|
||||
.split('\n')
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line.length > 0)))
|
||||
|
||||
const save = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const ipBlacklist = form.ip_blacklist_text
|
||||
.split('\n')
|
||||
.map((s: string) => s.trim())
|
||||
.filter((s: string) => s.length > 0)
|
||||
const emailBlacklist = form.email_blacklist_text
|
||||
.split('\n')
|
||||
.map((s: string) => s.trim().toLowerCase())
|
||||
.filter((s: string) => s.length > 0)
|
||||
|
||||
await adminAPI.updateSettings({
|
||||
key: 'order_risk_control_config',
|
||||
value: {
|
||||
version: 2,
|
||||
enabled: form.enabled,
|
||||
max_pending_orders_per_user: Number(form.max_pending_orders_per_user),
|
||||
max_pending_orders_per_ip: Number(form.max_pending_orders_per_ip),
|
||||
max_pending_orders_per_guest_email: Number(form.max_pending_orders_per_guest_email),
|
||||
order_rate_limit: {
|
||||
enabled: form.order_rate_limit.enabled,
|
||||
window_seconds: Number(form.order_rate_limit.window_seconds),
|
||||
max_requests: Number(form.order_rate_limit.max_requests),
|
||||
block_seconds: Number(form.order_rate_limit.block_seconds),
|
||||
common: {
|
||||
ip_blacklist: normalizeLines(form.common.ip_blacklist_text),
|
||||
},
|
||||
guest: {
|
||||
enabled: form.guest.enabled,
|
||||
max_pending_orders_per_ip: Number(form.guest.max_pending_orders_per_ip),
|
||||
max_quantity_per_product_per_order: Number(form.guest.max_quantity_per_product_per_order),
|
||||
max_pending_quantity_per_ip_product: Number(form.guest.max_pending_quantity_per_ip_product),
|
||||
payment_expire_minutes: Number(form.guest.payment_expire_minutes),
|
||||
rate_limit: {
|
||||
enabled: form.guest.rate_limit.enabled,
|
||||
window_seconds: Number(form.guest.rate_limit.window_seconds),
|
||||
max_requests: Number(form.guest.rate_limit.max_requests),
|
||||
block_seconds: Number(form.guest.rate_limit.block_seconds),
|
||||
},
|
||||
},
|
||||
member: {
|
||||
enabled: form.member.enabled,
|
||||
max_pending_orders_per_user: Number(form.member.max_pending_orders_per_user),
|
||||
max_pending_orders_per_ip: Number(form.member.max_pending_orders_per_ip),
|
||||
max_quantity_per_product_per_order: Number(form.member.max_quantity_per_product_per_order),
|
||||
rate_limit: {
|
||||
enabled: form.member.rate_limit.enabled,
|
||||
window_seconds: Number(form.member.rate_limit.window_seconds),
|
||||
max_requests: Number(form.member.rate_limit.max_requests),
|
||||
block_seconds: Number(form.member.rate_limit.block_seconds),
|
||||
},
|
||||
},
|
||||
ip_blacklist: ipBlacklist,
|
||||
email_blacklist: emailBlacklist,
|
||||
},
|
||||
})
|
||||
notifySuccess(t('admin.settings.alerts.saveSuccess'))
|
||||
} catch (err) {
|
||||
const known = err as Error & { __notified?: boolean }
|
||||
if (!known?.__notified) {
|
||||
notifyError(known?.message || t('admin.settings.alerts.saveFailed'))
|
||||
}
|
||||
if (!known?.__notified) notifyError(known?.message || t('admin.settings.alerts.saveFailed'))
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ save })
|
||||
const formatLimit = (value: number) => value > 0 ? String(value) : t('admin.settings.orderRiskControl.noLimit')
|
||||
const guestSummary = computed(() => t('admin.settings.orderRiskControl.guest.summary', {
|
||||
orders: formatLimit(Number(form.guest.max_pending_orders_per_ip)),
|
||||
quantity: formatLimit(Number(form.guest.max_quantity_per_product_per_order)),
|
||||
pending: formatLimit(Number(form.guest.max_pending_quantity_per_ip_product)),
|
||||
}))
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
defineExpose({ save })
|
||||
onMounted(loadConfig)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-lg border p-6">
|
||||
<h2 class="text-lg font-semibold">{{ t('admin.settings.orderRiskControl.title') }}</h2>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="space-y-6" :class="{ 'pointer-events-none opacity-60': loading }">
|
||||
<section class="overflow-hidden rounded-xl border bg-card">
|
||||
<div class="flex flex-col gap-5 border-b bg-muted/30 p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-lg border bg-background p-2 text-foreground shadow-sm">
|
||||
<ShieldCheck class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-semibold">{{ t('admin.settings.orderRiskControl.master.title') }}</h2>
|
||||
<p class="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.master.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 rounded-full border bg-background px-4 py-2">
|
||||
<span class="text-xs font-medium" :class="form.enabled ? 'text-emerald-600' : 'text-muted-foreground'">
|
||||
{{ form.enabled ? t('admin.settings.orderRiskControl.statusEnabled') : t('admin.settings.orderRiskControl.statusDisabled') }}
|
||||
</span>
|
||||
<Switch v-model="form.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 总开关 -->
|
||||
<div class="rounded-lg border p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch v-model="form.enabled" />
|
||||
<div>
|
||||
<Label class="text-sm font-medium">{{ t('admin.settings.orderRiskControl.enabled') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.enabledHint') }}</p>
|
||||
<section v-show="form.enabled" class="overflow-hidden rounded-xl border border-amber-200/70 bg-card shadow-sm dark:border-amber-900/60">
|
||||
<div class="border-b border-amber-200/70 bg-amber-50/70 p-5 dark:border-amber-900/60 dark:bg-amber-950/20">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-lg bg-amber-500 p-2 text-white shadow-sm"><Network class="h-5 w-5" /></div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base font-semibold">{{ t('admin.settings.orderRiskControl.guest.title') }}</h3>
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:bg-amber-900/50 dark:text-amber-200">
|
||||
{{ t('admin.settings.orderRiskControl.guest.priority') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.guest.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button type="button" variant="outline" size="sm" @click="applyRecommendedGuestPolicy">
|
||||
{{ t('admin.settings.orderRiskControl.guest.applyRecommended') }}
|
||||
</Button>
|
||||
<Switch v-model="form.guest.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 并发待支付订单限制 -->
|
||||
<div v-show="form.enabled" class="rounded-lg border p-6 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">{{ t('admin.settings.orderRiskControl.pendingLimits.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.pendingLimits.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.orderRiskControl.pendingLimits.perUser') }}</label>
|
||||
<Input v-model.number="form.max_pending_orders_per_user" type="number" min="0" max="100" />
|
||||
<div v-show="form.guest.enabled" class="space-y-6 p-5">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('admin.settings.orderRiskControl.guest.maxPendingPerIP') }}</Label>
|
||||
<Input v-model.number="form.guest.max_pending_orders_per_ip" type="number" min="0" max="100" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.guest.maxPendingPerIPHint') }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('admin.settings.orderRiskControl.guest.maxQuantityPerProduct') }}</Label>
|
||||
<Input v-model.number="form.guest.max_quantity_per_product_per_order" type="number" min="0" max="100000" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.guest.maxQuantityPerProductHint') }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('admin.settings.orderRiskControl.guest.maxPendingQuantityPerIPProduct') }}</Label>
|
||||
<Input v-model.number="form.guest.max_pending_quantity_per_ip_product" type="number" min="0" max="100000" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.guest.maxPendingQuantityPerIPProductHint') }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('admin.settings.orderRiskControl.guest.paymentExpireMinutes') }}</Label>
|
||||
<Input v-model.number="form.guest.payment_expire_minutes" type="number" min="0" max="10080" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.guest.paymentExpireMinutesHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.orderRiskControl.pendingLimits.perIP') }}</label>
|
||||
<Input v-model.number="form.max_pending_orders_per_ip" type="number" min="0" max="100" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.orderRiskControl.pendingLimits.perGuestEmail') }}</label>
|
||||
<Input v-model.number="form.max_pending_orders_per_guest_email" type="number" min="0" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下单频率限制 -->
|
||||
<div v-show="form.enabled" class="rounded-lg border p-6 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">{{ t('admin.settings.orderRiskControl.rateLimit.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.rateLimit.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch v-model="form.order_rate_limit.enabled" />
|
||||
<Label class="text-sm font-medium">{{ t('admin.settings.orderRiskControl.rateLimit.enabled') }}</Label>
|
||||
</div>
|
||||
<div v-show="form.order_rate_limit.enabled" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.orderRiskControl.rateLimit.windowSeconds') }}</label>
|
||||
<Input v-model.number="form.order_rate_limit.window_seconds" type="number" min="10" max="3600" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.rateLimit.windowSecondsHint') }}</p>
|
||||
<div class="rounded-lg border border-dashed bg-muted/25 px-4 py-3 text-xs leading-5 text-muted-foreground">
|
||||
{{ guestSummary }}
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.orderRiskControl.rateLimit.maxRequests') }}</label>
|
||||
<Input v-model.number="form.order_rate_limit.max_requests" type="number" min="1" max="100" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.rateLimit.maxRequestsHint') }}</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.orderRiskControl.rateLimit.blockSeconds') }}</label>
|
||||
<Input v-model.number="form.order_rate_limit.block_seconds" type="number" min="0" max="86400" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.rateLimit.blockSecondsHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP 黑名单 -->
|
||||
<div v-show="form.enabled" class="rounded-lg border p-6 space-y-4">
|
||||
<div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Gauge class="h-4 w-4 text-amber-600" />
|
||||
<div>
|
||||
<Label>{{ t('admin.settings.orderRiskControl.guest.rateLimitTitle') }}</Label>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.guest.rateLimitHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch v-model="form.guest.rate_limit.enabled" />
|
||||
</div>
|
||||
<div v-show="form.guest.rate_limit.enabled" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.rateLimit.windowSeconds') }}</Label><Input v-model.number="form.guest.rate_limit.window_seconds" type="number" min="10" max="3600" /></div>
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.rateLimit.maxRequests') }}</Label><Input v-model.number="form.guest.rate_limit.max_requests" type="number" min="1" max="100" /></div>
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.rateLimit.blockSeconds') }}</Label><Input v-model.number="form.guest.rate_limit.block_seconds" type="number" min="0" max="86400" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="form.enabled" class="overflow-hidden rounded-xl border bg-card">
|
||||
<div class="border-b bg-sky-50/50 p-5 dark:bg-sky-950/10">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-lg bg-sky-600 p-2 text-white shadow-sm"><UserRound class="h-5 w-5" /></div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">{{ t('admin.settings.orderRiskControl.member.title') }}</h3>
|
||||
<p class="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.member.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch v-model="form.member.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="form.member.enabled" class="space-y-6 p-5">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.member.maxPendingPerUser') }}</Label><Input v-model.number="form.member.max_pending_orders_per_user" type="number" min="0" max="100" /><p class="text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.member.maxPendingPerUserHint') }}</p></div>
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.member.maxPendingPerIP') }}</Label><Input v-model.number="form.member.max_pending_orders_per_ip" type="number" min="0" max="100" /><p class="text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.member.maxPendingPerIPHint') }}</p></div>
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.member.maxQuantityPerProduct') }}</Label><Input v-model.number="form.member.max_quantity_per_product_per_order" type="number" min="0" max="100000" /><p class="text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.member.maxQuantityPerProductHint') }}</p></div>
|
||||
</div>
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<div><Label>{{ t('admin.settings.orderRiskControl.member.rateLimitTitle') }}</Label><p class="mt-1 text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.member.rateLimitHint') }}</p></div>
|
||||
<Switch v-model="form.member.rate_limit.enabled" />
|
||||
</div>
|
||||
<div v-show="form.member.rate_limit.enabled" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.rateLimit.windowSeconds') }}</Label><Input v-model.number="form.member.rate_limit.window_seconds" type="number" min="10" max="3600" /></div>
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.rateLimit.maxRequests') }}</Label><Input v-model.number="form.member.rate_limit.max_requests" type="number" min="1" max="100" /></div>
|
||||
<div class="space-y-2"><Label>{{ t('admin.settings.orderRiskControl.rateLimit.blockSeconds') }}</Label><Input v-model.number="form.member.rate_limit.block_seconds" type="number" min="0" max="86400" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-show="form.enabled" class="rounded-xl border bg-card p-5">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-sm font-semibold">{{ t('admin.settings.orderRiskControl.ipBlacklist.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.ipBlacklist.subtitle') }}</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t('admin.settings.orderRiskControl.ipBlacklist.subtitle') }}</p>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model="form.ip_blacklist_text"
|
||||
:placeholder="t('admin.settings.orderRiskControl.ipBlacklist.placeholder')"
|
||||
rows="5"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱黑名单 -->
|
||||
<div v-show="form.enabled" class="rounded-lg border p-6 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">{{ t('admin.settings.orderRiskControl.emailBlacklist.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('admin.settings.orderRiskControl.emailBlacklist.subtitle') }}</p>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model="form.email_blacklist_text"
|
||||
:placeholder="t('admin.settings.orderRiskControl.emailBlacklist.placeholder')"
|
||||
rows="5"
|
||||
class="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Textarea v-model="form.common.ip_blacklist_text" :placeholder="t('admin.settings.orderRiskControl.ipBlacklist.placeholder')" rows="6" class="font-mono text-sm" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -87,7 +87,6 @@ func (c *Container) initApplicationServices() {
|
||||
c.MemberLevelService = memberlevelapp.NewService(c.MemberLevelRepo, c.MemberLevelPriceRepo, c.MemberLevelUserRepo)
|
||||
c.OrderRiskControlService = orderriskapp.NewService(orderriskapp.Options{
|
||||
Settings: c.SettingService,
|
||||
Orders: c.OrderStore,
|
||||
RateLimiter: orderrisklimiter.New(),
|
||||
})
|
||||
orderQueue := orderqueue.New(c.QueueClient)
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -60,6 +61,9 @@ func SetupRouter(cfg *config.Config, c *container.Container) *gin.Engine {
|
||||
log = logger.Init(cfg.Server.Mode, cfg.Log.ToLoggerOptions())
|
||||
}
|
||||
r := gin.New()
|
||||
if err := configureTrustedProxies(r, cfg.Server.TrustedProxies); err != nil {
|
||||
panic(fmt.Errorf("server.trusted_proxies 配置错误: %w", err))
|
||||
}
|
||||
captchaVerifier := captchahttp.NewVerifier(c.CaptchaService)
|
||||
|
||||
// 初始化 Handler(按前台/后台分组)
|
||||
@@ -252,6 +256,30 @@ func SetupRouter(cfg *config.Config, c *container.Container) *gin.Engine {
|
||||
return r
|
||||
}
|
||||
|
||||
func configureTrustedProxies(engine *gin.Engine, trustedProxies []string) error {
|
||||
if engine == nil {
|
||||
return fmt.Errorf("gin engine is nil")
|
||||
}
|
||||
for _, rawProxy := range trustedProxies {
|
||||
proxy := strings.TrimSpace(rawProxy)
|
||||
if proxy == "" {
|
||||
return fmt.Errorf("trusted proxy cannot be empty")
|
||||
}
|
||||
if net.ParseIP(proxy) != nil {
|
||||
continue
|
||||
}
|
||||
_, network, err := net.ParseCIDR(proxy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid trusted proxy %q: %w", proxy, err)
|
||||
}
|
||||
ones, bits := network.Mask.Size()
|
||||
if ones == 0 && (bits == 32 || bits == 128) {
|
||||
return fmt.Errorf("trusted proxy %q would trust every address", proxy)
|
||||
}
|
||||
}
|
||||
return engine.SetTrustedProxies(trustedProxies)
|
||||
}
|
||||
|
||||
type adminPermissionCatalogItem struct {
|
||||
Module string `json:"module"`
|
||||
Method string `json:"method"`
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestConfigureTrustedProxiesRejectsSpoofedForwardedIPFromUntrustedPeer(t *testing.T) {
|
||||
engine := gin.New()
|
||||
if err := configureTrustedProxies(engine, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine.GET("/ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ip", nil)
|
||||
req.RemoteAddr = "203.0.113.9:1234"
|
||||
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
resp := httptest.NewRecorder()
|
||||
engine.ServeHTTP(resp, req)
|
||||
if resp.Body.String() != "203.0.113.9" {
|
||||
t.Fatalf("untrusted peer spoofed client IP: %q", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureTrustedProxiesAcceptsForwardedIPFromConfiguredProxy(t *testing.T) {
|
||||
engine := gin.New()
|
||||
if err := configureTrustedProxies(engine, []string{"127.0.0.1/32"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine.GET("/ip", func(c *gin.Context) { c.String(http.StatusOK, c.ClientIP()) })
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ip", nil)
|
||||
req.RemoteAddr = "127.0.0.1:1234"
|
||||
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
resp := httptest.NewRecorder()
|
||||
engine.ServeHTTP(resp, req)
|
||||
if resp.Body.String() != "1.2.3.4" {
|
||||
t.Fatalf("trusted proxy client IP=%q, want 1.2.3.4", resp.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureTrustedProxiesRejectsInvalidCIDR(t *testing.T) {
|
||||
if err := configureTrustedProxies(gin.New(), []string{"not-a-cidr"}); err == nil {
|
||||
t.Fatal("expected invalid trusted proxy configuration to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureTrustedProxiesRejectsTrustAllNetworks(t *testing.T) {
|
||||
for _, network := range []string{"0.0.0.0/0", "::/0"} {
|
||||
t.Run(network, func(t *testing.T) {
|
||||
if err := configureTrustedProxies(gin.New(), []string{network}); err == nil {
|
||||
t.Fatalf("expected %s to be rejected", network)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@ func TestOrderAdminHTTPLivesInTransport(t *testing.T) {
|
||||
assertDirectoryGoFileBudget(t, domainRoot, 5)
|
||||
assertDirectoryGoFileBudget(t, contractRoot, 4)
|
||||
assertDirectoryGoFileBudget(t, applicationRoot, 20)
|
||||
assertDirectoryGoFileBudget(t, storeRoot, 7)
|
||||
// 风控并发闸门保留独立测试文件,避免与租户、搜索等存储语义混杂。
|
||||
assertDirectoryGoFileBudget(t, storeRoot, 8)
|
||||
assertDirectoryGoFileBudget(t, presenterRoot, 3)
|
||||
assertProductionImportsAbsent(t, applicationRoot, moduleImportPath+"/internal/service")
|
||||
assertProductionImportsAbsent(t, applicationRoot, moduleImportPath+"/internal/repository")
|
||||
|
||||
@@ -11,6 +11,7 @@ func TestOrderRiskUsesCompleteVerticalLayout(t *testing.T) {
|
||||
moduleRoot := filepath.Join(repositoryRoot, "internal", "modules", "orderrisk")
|
||||
applicationRoot := filepath.Join(moduleRoot, "application")
|
||||
contractRoot := filepath.Join(moduleRoot, "contract")
|
||||
domainRoot := filepath.Join(moduleRoot, "domain")
|
||||
limiterRoot := filepath.Join(moduleRoot, "infrastructure", "redislimiter")
|
||||
|
||||
production, total := countDirectGoFiles(t, moduleRoot)
|
||||
@@ -19,16 +20,18 @@ func TestOrderRiskUsesCompleteVerticalLayout(t *testing.T) {
|
||||
}
|
||||
assertDirectoryGoFileBudget(t, applicationRoot, 2)
|
||||
assertDirectoryGoFileBudget(t, contractRoot, 4)
|
||||
assertDirectoryGoFileBudget(t, domainRoot, 1)
|
||||
assertDirectoryGoFileBudget(t, limiterRoot, 1)
|
||||
|
||||
assertFileDeclaresTypes(t, filepath.Join(applicationRoot, "service.go"), []string{"Options", "Service"})
|
||||
assertFileDeclaresFunctions(t, filepath.Join(applicationRoot, "service.go"), []string{"NewService"})
|
||||
assertFileDeclaresTypes(t, filepath.Join(contractRoot, "types.go"), []string{"CheckInput"})
|
||||
assertFileDeclaresTypes(t, filepath.Join(contractRoot, "ports.go"), []string{
|
||||
"SettingReader", "PendingOrderCounter", "RateLimiter", "Controller",
|
||||
"SettingReader", "PendingOrderGate", "RateLimiter", "Controller",
|
||||
})
|
||||
assertFileDeclaresTypes(t, filepath.Join(contractRoot, "errors.go"), []string{"RateLimitedError"})
|
||||
assertFileDeclaresFunctions(t, filepath.Join(contractRoot, "errors.go"), []string{"GetRetryAfter"})
|
||||
assertFileDeclaresTypes(t, filepath.Join(domainRoot, "lock_key.go"), []string{"LockKey"})
|
||||
assertFileDeclaresTypes(t, filepath.Join(limiterRoot, "limiter.go"), []string{"Limiter"})
|
||||
assertFileDeclaresFunctions(t, filepath.Join(limiterRoot, "limiter.go"), []string{"New"})
|
||||
|
||||
|
||||
@@ -188,8 +188,10 @@ func mapError(err error) error {
|
||||
target error
|
||||
}{
|
||||
{orderriskcontract.ErrIPBlacklisted, channeltransport.ErrRiskIPBlacklisted},
|
||||
{orderriskcontract.ErrEmailBlacklisted, channeltransport.ErrRiskEmailBlacklisted},
|
||||
{orderriskcontract.ErrClientIPUnavailable, channeltransport.ErrRiskClientIPUnavailable},
|
||||
{orderriskcontract.ErrTooManyPendingOrders, channeltransport.ErrRiskTooManyPendingOrders},
|
||||
{orderriskcontract.ErrProductQuantityLimit, channeltransport.ErrRiskProductQuantityLimit},
|
||||
{orderriskcontract.ErrPendingProductQuantityLimit, channeltransport.ErrRiskPendingProductLimit},
|
||||
{orderriskcontract.ErrOrderRateLimited, channeltransport.ErrRiskOrderRateLimited},
|
||||
{orderapp.ErrProductSKURequired, channeltransport.ErrProductSKURequired},
|
||||
{orderapp.ErrProductSKUInvalid, channeltransport.ErrProductSKUInvalid},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/dujiao-next/internal/constants"
|
||||
affiliatedomain "github.com/dujiao-next/internal/modules/affiliate/domain"
|
||||
apicredentialdomain "github.com/dujiao-next/internal/modules/apicredential/domain"
|
||||
auditlogdomain "github.com/dujiao-next/internal/modules/auditlog/domain"
|
||||
@@ -22,6 +23,8 @@ import (
|
||||
memberleveldomain "github.com/dujiao-next/internal/modules/memberlevel/domain"
|
||||
notificationdomain "github.com/dujiao-next/internal/modules/notification/domain"
|
||||
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
|
||||
orderriskcontract "github.com/dujiao-next/internal/modules/orderrisk/contract"
|
||||
orderriskdomain "github.com/dujiao-next/internal/modules/orderrisk/domain"
|
||||
paymentdomain "github.com/dujiao-next/internal/modules/payment/domain"
|
||||
procurementdomain "github.com/dujiao-next/internal/modules/procurement/domain"
|
||||
promotiondomain "github.com/dujiao-next/internal/modules/promotion/domain"
|
||||
@@ -32,6 +35,8 @@ import (
|
||||
broadcastdomain "github.com/dujiao-next/internal/modules/telegram/broadcast/domain"
|
||||
walletdomain "github.com/dujiao-next/internal/modules/wallet/domain"
|
||||
"github.com/dujiao-next/internal/platform/database/gormdb"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AutoMigrate owns the application-wide schema registry and ordered data migrations.
|
||||
@@ -56,6 +61,7 @@ func AutoMigrate() error {
|
||||
&orderdomain.Order{},
|
||||
&orderdomain.OrderItem{},
|
||||
&orderdomain.OrderRefundRecord{},
|
||||
&orderriskdomain.LockKey{},
|
||||
&cartdomain.Item{},
|
||||
&paymentdomain.PaymentChannel{},
|
||||
&paymentdomain.Payment{},
|
||||
@@ -95,6 +101,9 @@ func AutoMigrate() error {
|
||||
if err := ensureUserOAuthIdentityUserProviderUniqueIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillPendingOrderRiskIPs(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resellerstore.Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -132,3 +141,32 @@ func AutoMigrate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfillPendingOrderRiskIPs 只迁移仍可能占用库存的在途订单,避免启动时扫描全部历史订单。
|
||||
func backfillPendingOrderRiskIPs(db *gorm.DB) error {
|
||||
type orderIPRow struct {
|
||||
ID uint
|
||||
ClientIP string
|
||||
}
|
||||
var rows []orderIPRow
|
||||
if err := db.Model(&orderdomain.Order{}).
|
||||
Select("id", "client_ip").
|
||||
Where("deleted_at IS NULL AND status = ? AND (risk_ip IS NULL OR risk_ip = '') AND client_ip <> ''", constants.OrderStatusPendingPayment).
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, row := range rows {
|
||||
riskIP := orderriskcontract.NormalizeRiskIP(row.ClientIP)
|
||||
if riskIP == "" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&orderdomain.Order{}).
|
||||
Where("id = ? AND (risk_ip IS NULL OR risk_ip = '')", row.ID).
|
||||
UpdateColumn("risk_ip", riskIP).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dujiao-next/internal/constants"
|
||||
cardsecretdomain "github.com/dujiao-next/internal/modules/cardsecret/domain"
|
||||
cartdomain "github.com/dujiao-next/internal/modules/cart/domain"
|
||||
externalidentitydomain "github.com/dujiao-next/internal/modules/identity/externalidentity/domain"
|
||||
@@ -452,6 +453,45 @@ func TestAutoMigrateOwnsResellerSchemaAndCrossModuleConstraints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillPendingOrderRiskIPsHandlesLegacyNullsAndIPv6(t *testing.T) {
|
||||
db := setupSKUMigrationTestDB(t)
|
||||
if err := db.AutoMigrate(&orderdomain.Order{}); err != nil {
|
||||
t.Fatalf("auto migrate orders: %v", err)
|
||||
}
|
||||
|
||||
pendingIPv4 := &orderdomain.Order{OrderNo: "risk-ipv4", Status: constants.OrderStatusPendingPayment, Currency: "USD", ClientIP: "1.2.3.4"}
|
||||
pendingIPv6 := &orderdomain.Order{OrderNo: "risk-ipv6", Status: constants.OrderStatusPendingPayment, Currency: "USD", ClientIP: "2001:db8:abcd:12::9"}
|
||||
paid := &orderdomain.Order{OrderNo: "risk-paid", Status: constants.OrderStatusPaid, Currency: "USD", ClientIP: "5.6.7.8"}
|
||||
for _, order := range []*orderdomain.Order{pendingIPv4, pendingIPv6, paid} {
|
||||
if err := db.Create(order).Error; err != nil {
|
||||
t.Fatalf("create order %s: %v", order.OrderNo, err)
|
||||
}
|
||||
if err := db.Exec("UPDATE orders SET risk_ip = NULL WHERE id = ?", order.ID).Error; err != nil {
|
||||
t.Fatalf("set legacy NULL risk_ip for %s: %v", order.OrderNo, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := backfillPendingOrderRiskIPs(db); err != nil {
|
||||
t.Fatalf("backfill risk IPs: %v", err)
|
||||
}
|
||||
var gotIPv4, gotIPv6, gotPaid orderdomain.Order
|
||||
if err := db.First(&gotIPv4, pendingIPv4.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&gotIPv6, pendingIPv6.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&gotPaid, paid.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotIPv4.RiskIP != "1.2.3.4" || gotIPv6.RiskIP != "2001:db8:abcd:12::/64" {
|
||||
t.Fatalf("unexpected risk IP backfill: ipv4=%q ipv6=%q", gotIPv4.RiskIP, gotIPv6.RiskIP)
|
||||
}
|
||||
if gotPaid.RiskIP != "" {
|
||||
t.Fatalf("paid history must not be backfilled, got %q", gotPaid.RiskIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCartForeignKeyConstraintsPreservesRowsAndEnforcesReferences(t *testing.T) {
|
||||
db := setupRegistryMigrationTestDB(t)
|
||||
for _, statement := range []string{
|
||||
|
||||
@@ -503,8 +503,10 @@ func mapOrderTransportError(err error) error {
|
||||
{orderapp.ErrResellerCouponNotAllowed, ordertransport.ErrResellerCouponNotAllowed},
|
||||
{orderapp.ErrQueueUnavailable, ordertransport.ErrQueueUnavailable},
|
||||
{orderriskcontract.ErrIPBlacklisted, ordertransport.ErrRiskIPBlacklisted},
|
||||
{orderriskcontract.ErrEmailBlacklisted, ordertransport.ErrRiskEmailBlacklisted},
|
||||
{orderriskcontract.ErrClientIPUnavailable, ordertransport.ErrRiskClientIPUnavailable},
|
||||
{orderriskcontract.ErrTooManyPendingOrders, ordertransport.ErrRiskTooManyPendingOrders},
|
||||
{orderriskcontract.ErrProductQuantityLimit, ordertransport.ErrRiskProductQuantityLimit},
|
||||
{orderriskcontract.ErrPendingProductQuantityLimit, ordertransport.ErrRiskPendingProductLimit},
|
||||
{orderriskcontract.ErrOrderRateLimited, ordertransport.ErrRiskOrderRateLimited},
|
||||
} {
|
||||
if errors.Is(err, mapping.source) {
|
||||
|
||||
@@ -43,9 +43,10 @@ type AppConfig struct {
|
||||
|
||||
// ServerConfig 服务器配置
|
||||
type ServerConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port string `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug / release
|
||||
Host string `mapstructure:"host"`
|
||||
Port string `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"` // debug / release
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||
}
|
||||
|
||||
// LogConfig 日志配置
|
||||
@@ -316,6 +317,7 @@ func Load() *Config {
|
||||
viper.SetDefault("server.host", "0.0.0.0")
|
||||
viper.SetDefault("server.port", "8080")
|
||||
viper.SetDefault("server.mode", "debug")
|
||||
viper.SetDefault("server.trusted_proxies", []string{"127.0.0.1/32", "::1/128"})
|
||||
viper.SetDefault("log.dir", "")
|
||||
viper.SetDefault("log.filename", "app.log")
|
||||
viper.SetDefault("log.max_size_mb", 100)
|
||||
|
||||
@@ -181,9 +181,11 @@ var messages = map[string]map[string]string{
|
||||
"error.order_amount_invalid": "订单金额不合法",
|
||||
"error.order_currency_mismatch": "订单币种不一致",
|
||||
"error.order_not_found": "订单不存在",
|
||||
"error.risk_ip_blacklisted": "您的 IP 已被限制下单,请联系客服",
|
||||
"error.risk_email_blacklisted": "该邮箱已被限制下单,请联系客服",
|
||||
"error.risk_too_many_pending_orders": "待支付订单过多,请先完成或取消已有订单后再下单",
|
||||
"error.risk_ip_blacklisted": "当前网络已被限制下单,请联系客服",
|
||||
"error.risk_client_ip_unavailable": "无法识别当前网络,请稍后重试或联系客服",
|
||||
"error.risk_too_many_pending_orders": "当前网络或账号的待支付订单过多,请先完成支付或取消已有订单",
|
||||
"error.risk_product_quantity_limit": "本次订单中的商品数量超过当前身份允许的上限",
|
||||
"error.risk_pending_product_quantity_limit": "当前网络对该商品的待支付占用已达上限,请先完成支付或等待订单取消",
|
||||
"error.risk_order_rate_limited": "下单过于频繁,请稍后再试",
|
||||
"error.order_create_failed": "创建订单失败",
|
||||
"error.order_fetch_failed": "获取订单失败",
|
||||
@@ -532,9 +534,11 @@ var messages = map[string]map[string]string{
|
||||
"error.order_amount_invalid": "訂單金額不合法",
|
||||
"error.order_currency_mismatch": "訂單幣種不一致",
|
||||
"error.order_not_found": "訂單不存在",
|
||||
"error.risk_ip_blacklisted": "您的 IP 已被限制下單,請聯繫客服",
|
||||
"error.risk_email_blacklisted": "該郵箱已被限制下單,請聯繫客服",
|
||||
"error.risk_too_many_pending_orders": "待支付訂單過多,請先完成或取消已有訂單後再下單",
|
||||
"error.risk_ip_blacklisted": "當前網絡已被限制下單,請聯繫客服",
|
||||
"error.risk_client_ip_unavailable": "無法識別當前網絡,請稍後重試或聯繫客服",
|
||||
"error.risk_too_many_pending_orders": "當前網絡或賬號的待支付訂單過多,請先完成支付或取消已有訂單",
|
||||
"error.risk_product_quantity_limit": "本次訂單中的商品數量超過當前身份允許的上限",
|
||||
"error.risk_pending_product_quantity_limit": "當前網絡對該商品的待支付佔用已達上限,請先完成支付或等待訂單取消",
|
||||
"error.risk_order_rate_limited": "下單過於頻繁,請稍後再試",
|
||||
"error.order_create_failed": "建立訂單失敗",
|
||||
"error.order_fetch_failed": "獲取訂單失敗",
|
||||
@@ -883,9 +887,11 @@ var messages = map[string]map[string]string{
|
||||
"error.order_amount_invalid": "Invalid order amount",
|
||||
"error.order_currency_mismatch": "Order currency mismatch",
|
||||
"error.order_not_found": "Order not found",
|
||||
"error.risk_ip_blacklisted": "Your IP has been restricted from placing orders, please contact support",
|
||||
"error.risk_email_blacklisted": "This email has been restricted from placing orders, please contact support",
|
||||
"error.risk_too_many_pending_orders": "Too many pending orders, please complete or cancel existing orders first",
|
||||
"error.risk_ip_blacklisted": "Orders from the current network have been restricted. Please contact support",
|
||||
"error.risk_client_ip_unavailable": "Unable to identify the current network. Please try again later or contact support",
|
||||
"error.risk_too_many_pending_orders": "The current network or account has too many pending orders. Please pay or cancel an existing order first",
|
||||
"error.risk_product_quantity_limit": "A product quantity in this order exceeds the limit for the current buyer type",
|
||||
"error.risk_pending_product_quantity_limit": "This network has reached the pending inventory limit for that product. Please pay or wait for an order to be canceled",
|
||||
"error.risk_order_rate_limited": "Ordering too frequently, please try again later",
|
||||
"error.order_create_failed": "Failed to create order",
|
||||
"error.order_fetch_failed": "Failed to fetch order",
|
||||
|
||||
@@ -26,8 +26,10 @@ import (
|
||||
|
||||
var (
|
||||
ErrRiskIPBlacklisted = errors.New("risk ip blacklisted")
|
||||
ErrRiskEmailBlacklisted = errors.New("risk email blacklisted")
|
||||
ErrRiskClientIPUnavailable = errors.New("risk client ip unavailable")
|
||||
ErrRiskTooManyPendingOrders = errors.New("too many pending orders")
|
||||
ErrRiskProductQuantityLimit = errors.New("risk product quantity limit")
|
||||
ErrRiskPendingProductLimit = errors.New("risk pending product quantity limit")
|
||||
ErrRiskOrderRateLimited = errors.New("order rate limited")
|
||||
ErrProductSKURequired = errors.New("product sku required")
|
||||
ErrProductSKUInvalid = errors.New("product sku invalid")
|
||||
|
||||
@@ -32,8 +32,10 @@ func channelErrorRule(target error, httpCode, code int, errorCode, key string) m
|
||||
|
||||
var channelOrderCreateErrorRules = []mappedChannelError{
|
||||
channelErrorRule(ErrRiskIPBlacklisted, http.StatusForbidden, response.CodeForbidden, "risk_blocked", "error.risk_ip_blacklisted"),
|
||||
channelErrorRule(ErrRiskEmailBlacklisted, http.StatusForbidden, response.CodeForbidden, "risk_blocked", "error.risk_email_blacklisted"),
|
||||
channelErrorRule(ErrRiskClientIPUnavailable, http.StatusForbidden, response.CodeForbidden, "risk_blocked", "error.risk_client_ip_unavailable"),
|
||||
channelErrorRule(ErrRiskTooManyPendingOrders, http.StatusTooManyRequests, response.CodeTooManyRequests, "risk_blocked", "error.risk_too_many_pending_orders"),
|
||||
channelErrorRule(ErrRiskProductQuantityLimit, http.StatusBadRequest, response.CodeBadRequest, "quantity_limit_exceeded", "error.risk_product_quantity_limit"),
|
||||
channelErrorRule(ErrRiskPendingProductLimit, http.StatusTooManyRequests, response.CodeTooManyRequests, "risk_blocked", "error.risk_pending_product_quantity_limit"),
|
||||
channelErrorRule(ErrRiskOrderRateLimited, http.StatusTooManyRequests, response.CodeTooManyRequests, "risk_blocked", "error.risk_order_rate_limited"),
|
||||
channelErrorRule(ErrProductSKURequired, http.StatusBadRequest, response.CodeBadRequest, "validation_error", "error.order_item_invalid"),
|
||||
channelErrorRule(ErrProductSKUInvalid, http.StatusBadRequest, response.CodeBadRequest, "sku_not_found", "error.order_item_invalid"),
|
||||
|
||||
@@ -270,21 +270,23 @@ func (s *OrderService) CreateGuestOrder(input CreateGuestOrderInput) (*orderdoma
|
||||
}
|
||||
|
||||
type orderCreateParams struct {
|
||||
UserID uint
|
||||
GuestEmail string
|
||||
GuestPassword string
|
||||
GuestLocale string
|
||||
Tenant resellercontract.TenantContext
|
||||
Items []CreateOrderItem
|
||||
CouponCode string
|
||||
AffiliateCode string
|
||||
AffiliateVisitorKey string
|
||||
ClientIP string
|
||||
IsGuest bool
|
||||
ManualFormData map[string]jsonmap.JSON
|
||||
SkipManualFormCheck bool
|
||||
SkipRiskControl bool
|
||||
SkipIPRiskControl bool
|
||||
UserID uint
|
||||
GuestEmail string
|
||||
GuestPassword string
|
||||
GuestLocale string
|
||||
Tenant resellercontract.TenantContext
|
||||
Items []CreateOrderItem
|
||||
CouponCode string
|
||||
AffiliateCode string
|
||||
AffiliateVisitorKey string
|
||||
ClientIP string
|
||||
RiskIP string
|
||||
RiskPaymentExpireMinutes int
|
||||
IsGuest bool
|
||||
ManualFormData map[string]jsonmap.JSON
|
||||
SkipManualFormCheck bool
|
||||
SkipRiskControl bool
|
||||
SkipIPRiskControl bool
|
||||
}
|
||||
|
||||
// OrderPreview 订单金额预览
|
||||
@@ -338,7 +340,7 @@ func (s *OrderService) PreviewOrder(input CreateOrderInput) (*OrderPreview, erro
|
||||
if input.UserID == 0 {
|
||||
return nil, ErrInvalidOrderItem
|
||||
}
|
||||
return s.previewOrder(orderCreateParams{
|
||||
params := orderCreateParams{
|
||||
UserID: input.UserID,
|
||||
Tenant: input.Tenant,
|
||||
Items: input.Items,
|
||||
@@ -348,12 +350,16 @@ func (s *OrderService) PreviewOrder(input CreateOrderInput) (*OrderPreview, erro
|
||||
ClientIP: input.ClientIP,
|
||||
ManualFormData: input.ManualFormData,
|
||||
SkipManualFormCheck: true,
|
||||
})
|
||||
}
|
||||
if err := s.checkOrderRisk(¶ms, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.previewOrder(params)
|
||||
}
|
||||
|
||||
// PreviewGuestOrder 游客订单金额预览
|
||||
func (s *OrderService) PreviewGuestOrder(input CreateGuestOrderInput) (*OrderPreview, error) {
|
||||
return s.previewOrder(orderCreateParams{
|
||||
params := orderCreateParams{
|
||||
GuestEmail: input.Email,
|
||||
GuestPassword: input.OrderPassword,
|
||||
GuestLocale: input.Locale,
|
||||
@@ -366,7 +372,11 @@ func (s *OrderService) PreviewGuestOrder(input CreateGuestOrderInput) (*OrderPre
|
||||
IsGuest: true,
|
||||
ManualFormData: input.ManualFormData,
|
||||
SkipManualFormCheck: true,
|
||||
})
|
||||
}
|
||||
if err := s.checkOrderRisk(¶ms, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.previewOrder(params)
|
||||
}
|
||||
|
||||
func (s *OrderService) previewOrder(input orderCreateParams) (*OrderPreview, error) {
|
||||
@@ -419,17 +429,8 @@ func (s *OrderService) createOrder(input orderCreateParams) (*orderdomain.Order,
|
||||
return nil, ErrQueueUnavailable
|
||||
}
|
||||
|
||||
// 风控检查(在锁库存之前)
|
||||
if s.riskControlSvc != nil && !input.SkipRiskControl {
|
||||
if err := s.riskControlSvc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: input.UserID,
|
||||
GuestEmail: input.GuestEmail,
|
||||
ClientIP: input.ClientIP,
|
||||
IsGuest: input.IsGuest,
|
||||
SkipIPCheck: input.SkipIPRiskControl,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.checkOrderRisk(&input, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.buildOrderResult(input)
|
||||
@@ -493,6 +494,9 @@ func (s *OrderService) createOrder(input orderCreateParams) (*orderdomain.Order,
|
||||
}
|
||||
|
||||
expireMinutes := s.resolveExpireMinutes()
|
||||
if input.RiskPaymentExpireMinutes > 0 {
|
||||
expireMinutes = input.RiskPaymentExpireMinutes
|
||||
}
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(time.Duration(expireMinutes) * time.Minute)
|
||||
order := &orderdomain.Order{
|
||||
@@ -519,6 +523,7 @@ func (s *OrderService) createOrder(input orderCreateParams) (*orderdomain.Order,
|
||||
AffiliateCode: affiliateCode,
|
||||
ExpiresAt: &expiresAt,
|
||||
ClientIP: strings.TrimSpace(input.ClientIP),
|
||||
RiskIP: input.RiskIP,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -536,6 +541,11 @@ func (s *OrderService) createOrder(input orderCreateParams) (*orderdomain.Order,
|
||||
err = s.orderStore.WithinTransaction(func(tx ordercontract.Transaction) error {
|
||||
orderStore := tx.Orders()
|
||||
productSKURepo := tx.ProductSKUs()
|
||||
if s.riskControlSvc != nil && !input.SkipRiskControl {
|
||||
if err := s.riskControlSvc.CheckPendingOrderAllowed(buildRiskCheckInput(input, false), orderStore); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := orderStore.Create(order, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -570,6 +580,7 @@ func (s *OrderService) createOrder(input orderCreateParams) (*orderdomain.Order,
|
||||
AffiliateCode: affiliateCode,
|
||||
ExpiresAt: &expiresAt,
|
||||
ClientIP: order.ClientIP,
|
||||
RiskIP: order.RiskIP,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -654,6 +665,17 @@ func (s *OrderService) createOrder(input orderCreateParams) (*orderdomain.Order,
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
for _, riskErr := range []error{
|
||||
orderriskcontract.ErrClientIPUnavailable,
|
||||
orderriskcontract.ErrTooManyPendingOrders,
|
||||
orderriskcontract.ErrProductQuantityLimit,
|
||||
orderriskcontract.ErrPendingProductQuantityLimit,
|
||||
orderriskcontract.ErrOrderRateLimited,
|
||||
} {
|
||||
if errors.Is(err, riskErr) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if errors.Is(err, ErrCardSecretInsufficient) {
|
||||
return nil, ErrCardSecretInsufficient
|
||||
}
|
||||
@@ -699,6 +721,41 @@ func (s *OrderService) createOrder(input orderCreateParams) (*orderdomain.Order,
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (s *OrderService) checkOrderRisk(input *orderCreateParams, consumeRateLimit bool) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
input.ClientIP = strings.TrimSpace(input.ClientIP)
|
||||
input.RiskIP = orderriskcontract.NormalizeRiskIP(input.ClientIP)
|
||||
input.RiskPaymentExpireMinutes = 0
|
||||
if s.riskControlSvc == nil || input.SkipRiskControl {
|
||||
return nil
|
||||
}
|
||||
result, err := s.riskControlSvc.CheckOrderAllowed(buildRiskCheckInput(*input, consumeRateLimit))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.RiskIP = result.RiskIP
|
||||
input.RiskPaymentExpireMinutes = result.PaymentExpireMinutes
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildRiskCheckInput(input orderCreateParams, consumeRateLimit bool) orderriskcontract.CheckInput {
|
||||
items := make([]orderriskcontract.OrderItem, 0, len(input.Items))
|
||||
for _, item := range input.Items {
|
||||
items = append(items, orderriskcontract.OrderItem{ProductID: item.ProductID, Quantity: item.Quantity})
|
||||
}
|
||||
return orderriskcontract.CheckInput{
|
||||
UserID: input.UserID,
|
||||
ClientIP: input.ClientIP,
|
||||
RiskIP: input.RiskIP,
|
||||
IsGuest: input.IsGuest,
|
||||
SkipIPCheck: input.SkipIPRiskControl,
|
||||
ConsumeRateLimit: consumeRateLimit,
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func generateOrderNo() string {
|
||||
return serial.Generate("DJ")
|
||||
}
|
||||
@@ -44,8 +44,10 @@ type Store interface {
|
||||
UpdateStatus(id uint, status string, updates map[string]interface{}) error
|
||||
CountOrderItemsByProduct(productID uint) (int64, error)
|
||||
CountPendingByUserID(userID uint) (int64, error)
|
||||
CountPendingByClientIP(clientIP string) (int64, error)
|
||||
CountPendingByGuestEmail(email string) (int64, error)
|
||||
LockRiskKeys(keys []string) error
|
||||
CountPendingGuestByRiskIP(riskIP string) (int64, error)
|
||||
CountPendingMemberByRiskIP(riskIP string) (int64, error)
|
||||
SumPendingGuestQuantityByRiskIP(riskIP string, productIDs []uint) (map[uint]int64, error)
|
||||
UpdateFields(id uint, updates map[string]interface{}) error
|
||||
UpdateChildrenStatus(parentID uint, targetStatus string, now time.Time) (int64, error)
|
||||
UpdateFieldsWhereWalletPaid(id uint, updates map[string]interface{}) (int64, error)
|
||||
|
||||
@@ -9,39 +9,40 @@ import (
|
||||
|
||||
// Order 订单表
|
||||
type Order struct {
|
||||
ID uint `gorm:"primarykey" json:"id"` // 主键
|
||||
OrderNo string `gorm:"uniqueIndex;not null" json:"order_no"` // 订单编号
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"` // 父订单ID
|
||||
UserID uint `gorm:"index;not null" json:"user_id,omitempty"` // 用户ID(游客订单为 0)
|
||||
GuestEmail string `gorm:"index" json:"guest_email,omitempty"` // 游客邮箱
|
||||
GuestPassword string `gorm:"type:varchar(200)" json:"-"` // 游客订单密码
|
||||
GuestLocale string `gorm:"type:varchar(20)" json:"guest_locale,omitempty"` // 游客语言
|
||||
Status string `gorm:"index;not null" json:"status"` // 订单状态
|
||||
Currency string `gorm:"not null" json:"currency"` // 币种
|
||||
OriginalAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"original_amount"` // 原始金额
|
||||
DiscountAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"discount_amount"` // 优惠金额
|
||||
MemberDiscountAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"member_discount_amount"` // 会员优惠金额
|
||||
PromotionDiscountAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"promotion_discount_amount"` // 活动价优惠金额
|
||||
WholesaleDiscountAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"wholesale_discount_amount"` // 批发价优惠金额
|
||||
TotalAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"total_amount"` // 实付金额
|
||||
WalletPaidAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"wallet_paid_amount"` // 钱包支付金额
|
||||
OnlinePaidAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"online_paid_amount"` // 在线支付金额
|
||||
RefundedAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"refunded_amount"` // 已退款金额(退回钱包)
|
||||
MemberLevelID *uint `gorm:"index" json:"member_level_id,omitempty"` // 下单时等级快照
|
||||
CouponID *uint `gorm:"index" json:"coupon_id,omitempty"` // 优惠券ID
|
||||
PromotionID *uint `gorm:"index" json:"promotion_id,omitempty"` // 活动价ID(单品订单)
|
||||
AffiliateProfileID *uint `gorm:"index" json:"affiliate_profile_id,omitempty"` // 推广返利关联用户ID快照
|
||||
AffiliateCode string `gorm:"type:varchar(32);index" json:"affiliate_code,omitempty"` // 推广返利联盟ID快照
|
||||
ResellerID *uint `gorm:"index" json:"reseller_id,omitempty"` // 分销商ID,主站订单为 NULL
|
||||
ResellerDomain string `gorm:"type:varchar(255);index" json:"reseller_domain,omitempty"` // 下单分销域名快照
|
||||
ResellerProfitAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"reseller_profit_amount"` // 分销差价快照
|
||||
ClientIP string `gorm:"type:varchar(64)" json:"client_ip,omitempty"` // 下单客户端IP
|
||||
ExpiresAt *time.Time `gorm:"index" json:"expires_at"` // 过期时间
|
||||
PaidAt *time.Time `gorm:"index" json:"paid_at"` // 支付时间
|
||||
CanceledAt *time.Time `gorm:"index" json:"canceled_at"` // 取消时间
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `gorm:"index" json:"updated_at"` // 更新时间
|
||||
DeletedAt *time.Time `gorm:"index" json:"-"` // 软删除时间
|
||||
ID uint `gorm:"primarykey" json:"id"` // 主键
|
||||
OrderNo string `gorm:"uniqueIndex;not null" json:"order_no"` // 订单编号
|
||||
ParentID *uint `gorm:"index;index:idx_orders_risk_pending,priority:4" json:"parent_id,omitempty"` // 父订单ID
|
||||
UserID uint `gorm:"index;not null;index:idx_orders_risk_pending,priority:2" json:"user_id,omitempty"` // 用户ID(游客订单为 0)
|
||||
GuestEmail string `gorm:"index" json:"guest_email,omitempty"` // 游客邮箱
|
||||
GuestPassword string `gorm:"type:varchar(200)" json:"-"` // 游客订单密码
|
||||
GuestLocale string `gorm:"type:varchar(20)" json:"guest_locale,omitempty"` // 游客语言
|
||||
Status string `gorm:"index;not null;index:idx_orders_risk_pending,priority:3" json:"status"` // 订单状态
|
||||
Currency string `gorm:"not null" json:"currency"` // 币种
|
||||
OriginalAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"original_amount"` // 原始金额
|
||||
DiscountAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"discount_amount"` // 优惠金额
|
||||
MemberDiscountAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"member_discount_amount"` // 会员优惠金额
|
||||
PromotionDiscountAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"promotion_discount_amount"` // 活动价优惠金额
|
||||
WholesaleDiscountAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"wholesale_discount_amount"` // 批发价优惠金额
|
||||
TotalAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"total_amount"` // 实付金额
|
||||
WalletPaidAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"wallet_paid_amount"` // 钱包支付金额
|
||||
OnlinePaidAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"online_paid_amount"` // 在线支付金额
|
||||
RefundedAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"refunded_amount"` // 已退款金额(退回钱包)
|
||||
MemberLevelID *uint `gorm:"index" json:"member_level_id,omitempty"` // 下单时等级快照
|
||||
CouponID *uint `gorm:"index" json:"coupon_id,omitempty"` // 优惠券ID
|
||||
PromotionID *uint `gorm:"index" json:"promotion_id,omitempty"` // 活动价ID(单品订单)
|
||||
AffiliateProfileID *uint `gorm:"index" json:"affiliate_profile_id,omitempty"` // 推广返利关联用户ID快照
|
||||
AffiliateCode string `gorm:"type:varchar(32);index" json:"affiliate_code,omitempty"` // 推广返利联盟ID快照
|
||||
ResellerID *uint `gorm:"index" json:"reseller_id,omitempty"` // 分销商ID,主站订单为 NULL
|
||||
ResellerDomain string `gorm:"type:varchar(255);index" json:"reseller_domain,omitempty"` // 下单分销域名快照
|
||||
ResellerProfitAmount money.Amount `gorm:"type:decimal(20,2);not null;default:0" json:"reseller_profit_amount"` // 分销差价快照
|
||||
ClientIP string `gorm:"type:varchar(64)" json:"client_ip,omitempty"` // 下单客户端IP
|
||||
RiskIP string `gorm:"type:varchar(80);index;index:idx_orders_risk_pending,priority:1" json:"-"` // 规范化风控IP(IPv6按/64)
|
||||
ExpiresAt *time.Time `gorm:"index" json:"expires_at"` // 过期时间
|
||||
PaidAt *time.Time `gorm:"index" json:"paid_at"` // 支付时间
|
||||
CanceledAt *time.Time `gorm:"index" json:"canceled_at"` // 取消时间
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"` // 创建时间
|
||||
UpdatedAt time.Time `gorm:"index" json:"updated_at"` // 更新时间
|
||||
DeletedAt *time.Time `gorm:"index;index:idx_orders_risk_pending,priority:5" json:"-"` // 软删除时间
|
||||
|
||||
Items []OrderItem `gorm:"foreignKey:OrderID" json:"items,omitempty"` // 订单项
|
||||
// 关联
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
userdomain "github.com/dujiao-next/internal/modules/identity/user/domain"
|
||||
ordercontract "github.com/dujiao-next/internal/modules/order/contract"
|
||||
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
|
||||
orderriskdomain "github.com/dujiao-next/internal/modules/orderrisk/domain"
|
||||
"github.com/dujiao-next/internal/persistence/gormutil"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -687,34 +689,98 @@ func (r *Store) CountPendingByUserID(userID uint) (int64, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CountPendingByClientIP 统计某 IP 待支付的父订单数量
|
||||
func (r *Store) CountPendingByClientIP(clientIP string) (int64, error) {
|
||||
if clientIP == "" {
|
||||
// LockRiskKeys 在当前事务中按固定顺序锁定风控维度;数据库只保存 SHA-256 摘要。
|
||||
func (r *Store) LockRiskKeys(keys []string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
hashes := make([]string, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
digest := sha256.Sum256([]byte(key))
|
||||
hash := hex.EncodeToString(digest[:])
|
||||
if _, exists := seen[hash]; exists {
|
||||
continue
|
||||
}
|
||||
seen[hash] = struct{}{}
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
sort.Strings(hashes)
|
||||
for _, hash := range hashes {
|
||||
row := orderriskdomain.LockKey{KeyHash: hash}
|
||||
if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.db.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("key_hash = ?", hash).
|
||||
First(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountPendingGuestByRiskIP 统计某风控 IP 的游客待支付父订单数量。
|
||||
func (r *Store) CountPendingGuestByRiskIP(riskIP string) (int64, error) {
|
||||
if riskIP == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
if err := r.db.Model(&orderdomain.Order{}).
|
||||
Where("orders.deleted_at IS NULL AND client_ip = ? AND status = ? AND parent_id IS NULL", clientIP, constants.OrderStatusPendingPayment).
|
||||
Where("orders.deleted_at IS NULL AND risk_ip = ? AND user_id = 0 AND status = ? AND parent_id IS NULL", riskIP, constants.OrderStatusPendingPayment).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CountPendingByGuestEmail 统计游客邮箱待支付的父订单数量
|
||||
func (r *Store) CountPendingByGuestEmail(email string) (int64, error) {
|
||||
if email == "" {
|
||||
// CountPendingMemberByRiskIP 统计某风控 IP 的会员待支付父订单数量。
|
||||
func (r *Store) CountPendingMemberByRiskIP(riskIP string) (int64, error) {
|
||||
if riskIP == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var count int64
|
||||
if err := r.db.Model(&orderdomain.Order{}).
|
||||
Where("orders.deleted_at IS NULL AND guest_email = ? AND status = ? AND parent_id IS NULL", email, constants.OrderStatusPendingPayment).
|
||||
Where("orders.deleted_at IS NULL AND risk_ip = ? AND user_id > 0 AND status = ? AND parent_id IS NULL", riskIP, constants.OrderStatusPendingPayment).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// SumPendingGuestQuantityByRiskIP 按商品汇总某风控 IP 已锁定的游客待支付数量。
|
||||
func (r *Store) SumPendingGuestQuantityByRiskIP(riskIP string, productIDs []uint) (map[uint]int64, error) {
|
||||
result := make(map[uint]int64, len(productIDs))
|
||||
if riskIP == "" || len(productIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
type quantityRow struct {
|
||||
ProductID uint
|
||||
Quantity int64
|
||||
}
|
||||
var rows []quantityRow
|
||||
err := r.db.Table("order_items AS oi").
|
||||
Select("oi.product_id, COALESCE(SUM(oi.quantity), 0) AS quantity").
|
||||
Joins("JOIN orders AS child ON child.id = oi.order_id AND child.deleted_at IS NULL").
|
||||
Joins("JOIN orders AS parent ON parent.id = child.parent_id AND parent.deleted_at IS NULL").
|
||||
Where("oi.deleted_at IS NULL AND parent.risk_ip = ? AND parent.user_id = 0", riskIP).
|
||||
Where("parent.status = ? AND child.status = ?", constants.OrderStatusPendingPayment, constants.OrderStatusPendingPayment).
|
||||
Where("oi.product_id IN ?", productIDs).
|
||||
Group("oi.product_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.ProductID] = row.Quantity
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CountOrderItemsByProduct 统计商品关联的订单项数量
|
||||
func (r *Store) CountOrderItemsByProduct(productID uint) (int64, error) {
|
||||
if productID == 0 {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package gormstore
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dujiao-next/internal/constants"
|
||||
orderdomain "github.com/dujiao-next/internal/modules/order/domain"
|
||||
orderriskdomain "github.com/dujiao-next/internal/modules/orderrisk/domain"
|
||||
"github.com/dujiao-next/internal/shared/jsonmap"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupRiskGateDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := filepath.Join(t.TempDir(), "risk-gate.db") + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&orderdomain.Order{}, &orderdomain.OrderItem{}, &orderriskdomain.LockKey{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRiskGateCountsOnlyMatchingPendingIdentityAndProducts(t *testing.T) {
|
||||
db := setupRiskGateDB(t)
|
||||
store := New(db, "risk-gate-test-secret")
|
||||
riskIP := "1.2.3.4"
|
||||
|
||||
guestParent := orderdomain.Order{OrderNo: "guest-parent", UserID: 0, Status: constants.OrderStatusPendingPayment, Currency: "USD", RiskIP: riskIP}
|
||||
if err := db.Create(&guestParent).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
guestChild := orderdomain.Order{OrderNo: "guest-child", ParentID: &guestParent.ID, UserID: 0, Status: constants.OrderStatusPendingPayment, Currency: "USD", RiskIP: riskIP}
|
||||
if err := db.Create(&guestChild).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&orderdomain.OrderItem{OrderID: guestChild.ID, ProductID: 7, TitleJSON: jsonmap.JSON{}, Quantity: 2}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, order := range []orderdomain.Order{
|
||||
{OrderNo: "member-parent", UserID: 9, Status: constants.OrderStatusPendingPayment, Currency: "USD", RiskIP: riskIP},
|
||||
{OrderNo: "canceled-guest", UserID: 0, Status: constants.OrderStatusCanceled, Currency: "USD", RiskIP: riskIP},
|
||||
{OrderNo: "other-ip", UserID: 0, Status: constants.OrderStatusPendingPayment, Currency: "USD", RiskIP: "5.6.7.8"},
|
||||
} {
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
guestCount, err := store.CountPendingGuestByRiskIP(riskIP)
|
||||
if err != nil || guestCount != 1 {
|
||||
t.Fatalf("guest count=%d err=%v", guestCount, err)
|
||||
}
|
||||
memberCount, err := store.CountPendingMemberByRiskIP(riskIP)
|
||||
if err != nil || memberCount != 1 {
|
||||
t.Fatalf("member count=%d err=%v", memberCount, err)
|
||||
}
|
||||
quantities, err := store.SumPendingGuestQuantityByRiskIP(riskIP, []uint{7, 8})
|
||||
if err != nil || quantities[7] != 2 || quantities[8] != 0 {
|
||||
t.Fatalf("quantities=%v err=%v", quantities, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskGateSerializesConcurrentGuestQuotaChecks(t *testing.T) {
|
||||
db := setupRiskGateDB(t)
|
||||
store := New(db, "risk-gate-test-secret")
|
||||
const limit = int64(2)
|
||||
var allowed atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
errorsCh := make(chan error, 12)
|
||||
start := make(chan struct{})
|
||||
|
||||
for index := 0; index < 12; index++ {
|
||||
index := index
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
err := store.db.Transaction(func(dbtx *gorm.DB) error {
|
||||
txStore := store.bind(dbtx)
|
||||
if err := txStore.LockRiskKeys([]string{"guest:ip:1.2.3.4"}); err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := txStore.CountPendingGuestByRiskIP("1.2.3.4")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= limit {
|
||||
return errQuotaReached
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
order := &orderdomain.Order{
|
||||
OrderNo: fmt.Sprintf("concurrent-%d", index),
|
||||
UserID: 0,
|
||||
Status: constants.OrderStatusPendingPayment,
|
||||
Currency: "USD",
|
||||
RiskIP: "1.2.3.4",
|
||||
}
|
||||
if err := txStore.Create(order, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
allowed.Add(1)
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, errQuotaReached) {
|
||||
errorsCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errorsCh)
|
||||
for err := range errorsCh {
|
||||
t.Fatalf("unexpected concurrent transaction error: %v", err)
|
||||
}
|
||||
if allowed.Load() != limit {
|
||||
t.Fatalf("allowed=%d, want %d", allowed.Load(), limit)
|
||||
}
|
||||
count, err := store.CountPendingGuestByRiskIP("1.2.3.4")
|
||||
if err != nil || count != limit {
|
||||
t.Fatalf("persisted count=%d err=%v", count, err)
|
||||
}
|
||||
var locks []orderriskdomain.LockKey
|
||||
if err := db.Find(&locks).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(locks) != 1 || locks[0].KeyHash == "guest:ip:1.2.3.4" {
|
||||
t.Fatalf("expected one hashed lock key, got %#v", locks)
|
||||
}
|
||||
}
|
||||
|
||||
var errQuotaReached = errors.New("quota reached")
|
||||
@@ -38,8 +38,10 @@ var (
|
||||
ErrResellerCouponNotAllowed = errors.New("reseller coupon not allowed")
|
||||
ErrQueueUnavailable = errors.New("queue unavailable")
|
||||
ErrRiskIPBlacklisted = errors.New("risk: ip blacklisted")
|
||||
ErrRiskEmailBlacklisted = errors.New("risk: email blacklisted")
|
||||
ErrRiskClientIPUnavailable = errors.New("risk: client ip unavailable")
|
||||
ErrRiskTooManyPendingOrders = errors.New("risk: too many pending orders")
|
||||
ErrRiskProductQuantityLimit = errors.New("risk: product quantity limit")
|
||||
ErrRiskPendingProductLimit = errors.New("risk: pending product quantity limit")
|
||||
ErrRiskOrderRateLimited = errors.New("risk: order rate limited")
|
||||
)
|
||||
|
||||
@@ -291,11 +293,17 @@ func retryAfterSeconds(err error) int64 {
|
||||
}
|
||||
|
||||
func respondUserOrderPreviewError(c *gin.Context, err error) {
|
||||
respondWithMappedError(c, err, append(userOrderCommonErrorRules, userOrderPreviewExtraErrorRules...), response.CodeInternal, "error.order_create_failed")
|
||||
rules := append([]mappedError{}, orderRiskControlErrorRules...)
|
||||
rules = append(rules, userOrderCommonErrorRules...)
|
||||
rules = append(rules, userOrderPreviewExtraErrorRules...)
|
||||
respondWithMappedError(c, err, rules, response.CodeInternal, "error.order_create_failed")
|
||||
}
|
||||
|
||||
func respondGuestOrderPreviewError(c *gin.Context, err error) {
|
||||
respondWithMappedError(c, err, append(guestOrderCommonErrorRules, guestOrderPreviewExtraErrorRules...), response.CodeInternal, "error.order_create_failed")
|
||||
rules := append([]mappedError{}, orderRiskControlErrorRules...)
|
||||
rules = append(rules, guestOrderCommonErrorRules...)
|
||||
rules = append(rules, guestOrderPreviewExtraErrorRules...)
|
||||
respondWithMappedError(c, err, rules, response.CodeInternal, "error.order_create_failed")
|
||||
}
|
||||
|
||||
func respondUserOrderCreateError(c *gin.Context, err error) {
|
||||
@@ -311,8 +319,10 @@ func respondGuestOrderCreateError(c *gin.Context, err error) {
|
||||
|
||||
var orderRiskControlErrorRules = []mappedError{
|
||||
{target: ErrRiskIPBlacklisted, code: response.CodeForbidden, key: "error.risk_ip_blacklisted"},
|
||||
{target: ErrRiskEmailBlacklisted, code: response.CodeForbidden, key: "error.risk_email_blacklisted"},
|
||||
{target: ErrRiskClientIPUnavailable, code: response.CodeForbidden, key: "error.risk_client_ip_unavailable"},
|
||||
{target: ErrRiskTooManyPendingOrders, code: response.CodeTooManyRequests, key: "error.risk_too_many_pending_orders"},
|
||||
{target: ErrRiskProductQuantityLimit, code: response.CodeBadRequest, key: "error.risk_product_quantity_limit"},
|
||||
{target: ErrRiskPendingProductLimit, code: response.CodeTooManyRequests, key: "error.risk_pending_product_quantity_limit"},
|
||||
{target: ErrRiskOrderRateLimited, code: response.CodeTooManyRequests, key: "error.risk_order_rate_limited"},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -21,14 +22,12 @@ type parsedIPBlacklist struct {
|
||||
// Options 声明订单风控应用服务的全部端口。
|
||||
type Options struct {
|
||||
Settings orderriskcontract.SettingReader
|
||||
Orders orderriskcontract.PendingOrderCounter
|
||||
RateLimiter orderriskcontract.RateLimiter
|
||||
}
|
||||
|
||||
// Service 编排订单黑名单、待支付数量和下单频率检查。
|
||||
// Service 编排身份分流、黑名单、商品数量、待支付库存配额和下单频率检查。
|
||||
type Service struct {
|
||||
settings orderriskcontract.SettingReader
|
||||
orders orderriskcontract.PendingOrderCounter
|
||||
rateLimiter orderriskcontract.RateLimiter
|
||||
|
||||
mu sync.RWMutex
|
||||
@@ -38,71 +37,198 @@ type Service struct {
|
||||
var _ orderriskcontract.Controller = (*Service)(nil)
|
||||
|
||||
func NewService(options Options) *Service {
|
||||
return &Service{
|
||||
settings: options.Settings,
|
||||
orders: options.Orders,
|
||||
rateLimiter: options.RateLimiter,
|
||||
}
|
||||
return &Service{settings: options.Settings, rateLimiter: options.RateLimiter}
|
||||
}
|
||||
|
||||
func (s *Service) CheckOrderAllowed(input orderriskcontract.CheckInput) error {
|
||||
// CheckOrderAllowed 执行不依赖订单事务的风控,并返回后续事务应复用的规范化 IP。
|
||||
func (s *Service) CheckOrderAllowed(input orderriskcontract.CheckInput) (orderriskcontract.CheckResult, error) {
|
||||
result := orderriskcontract.CheckResult{RiskIP: orderriskcontract.NormalizeRiskIP(input.ClientIP)}
|
||||
if s == nil || s.settings == nil {
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
cfg, err := s.settings.GetOrderRiskControlConfig()
|
||||
if err != nil {
|
||||
logger.Warnw("risk_control_get_config_error", "error", err)
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if !input.SkipIPCheck && input.ClientIP != "" && len(cfg.IPBlacklist) > 0 && s.isIPInBlacklist(input.ClientIP, cfg.IPBlacklist) {
|
||||
return orderriskcontract.ErrIPBlacklisted
|
||||
if !input.SkipIPCheck && input.ClientIP != "" && len(cfg.Common.IPBlacklist) > 0 && s.isIPInBlacklist(input.ClientIP, cfg.Common.IPBlacklist) {
|
||||
return result, orderriskcontract.ErrIPBlacklisted
|
||||
}
|
||||
if input.IsGuest && input.GuestEmail != "" && len(cfg.EmailBlacklist) > 0 {
|
||||
normalizedEmail := strings.ToLower(strings.TrimSpace(input.GuestEmail))
|
||||
for _, blocked := range cfg.EmailBlacklist {
|
||||
if normalizedEmail == blocked {
|
||||
return orderriskcontract.ErrEmailBlacklisted
|
||||
}
|
||||
if input.IsGuest {
|
||||
return s.checkGuestOrder(input, result, cfg)
|
||||
}
|
||||
return s.checkMemberOrder(input, result, cfg)
|
||||
}
|
||||
|
||||
func (s *Service) checkGuestOrder(input orderriskcontract.CheckInput, result orderriskcontract.CheckResult, cfg settingssecurity.OrderRiskControlConfig) (orderriskcontract.CheckResult, error) {
|
||||
policy := cfg.Guest
|
||||
if !policy.Enabled {
|
||||
return result, nil
|
||||
}
|
||||
result.PaymentExpireMinutes = policy.PaymentExpireMinutes
|
||||
if !input.SkipIPCheck && guestPolicyRequiresIP(policy) && result.RiskIP == "" {
|
||||
return result, orderriskcontract.ErrClientIPUnavailable
|
||||
}
|
||||
if err := checkProductQuantityLimit(input.Items, policy.MaxQuantityPerProductPerOrder); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if input.ConsumeRateLimit && policy.RateLimit.Enabled && s.rateLimiter != nil {
|
||||
input.RiskIP = result.RiskIP
|
||||
if err := s.rateLimiter.Check(input, policy.RateLimit); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if err := s.checkPendingOrderLimits(input, cfg); err != nil {
|
||||
return err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) checkMemberOrder(input orderriskcontract.CheckInput, result orderriskcontract.CheckResult, cfg settingssecurity.OrderRiskControlConfig) (orderriskcontract.CheckResult, error) {
|
||||
policy := cfg.Member
|
||||
if !policy.Enabled {
|
||||
return result, nil
|
||||
}
|
||||
if cfg.OrderRateLimit.Enabled && s.rateLimiter != nil {
|
||||
return s.rateLimiter.Check(input, cfg.OrderRateLimit)
|
||||
if !input.SkipIPCheck && policy.MaxPendingOrdersPerIP > 0 && result.RiskIP == "" {
|
||||
return result, orderriskcontract.ErrClientIPUnavailable
|
||||
}
|
||||
if err := checkProductQuantityLimit(input.Items, policy.MaxQuantityPerProductPerOrder); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if input.ConsumeRateLimit && policy.RateLimit.Enabled && s.rateLimiter != nil {
|
||||
input.RiskIP = result.RiskIP
|
||||
if err := s.rateLimiter.Check(input, policy.RateLimit); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func guestPolicyRequiresIP(policy settingssecurity.OrderRiskGuestPolicy) bool {
|
||||
return policy.MaxPendingOrdersPerIP > 0 ||
|
||||
policy.MaxPendingQuantityPerIPProduct > 0 ||
|
||||
policy.RateLimit.Enabled
|
||||
}
|
||||
|
||||
func checkProductQuantityLimit(items []orderriskcontract.OrderItem, maximum int) error {
|
||||
if maximum <= 0 {
|
||||
return nil
|
||||
}
|
||||
for _, quantity := range aggregateProductQuantities(items) {
|
||||
if quantity > int64(maximum) {
|
||||
return orderriskcontract.ErrProductQuantityLimit
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) checkPendingOrderLimits(input orderriskcontract.CheckInput, cfg settingssecurity.OrderRiskControlConfig) error {
|
||||
if s.orders == nil {
|
||||
func aggregateProductQuantities(items []orderriskcontract.OrderItem) map[uint]int64 {
|
||||
result := make(map[uint]int64, len(items))
|
||||
for _, item := range items {
|
||||
if item.ProductID == 0 || item.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
result[item.ProductID] += int64(item.Quantity)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CheckPendingOrderAllowed 必须在订单事务内、创建父订单和锁库存之前调用。
|
||||
func (s *Service) CheckPendingOrderAllowed(input orderriskcontract.CheckInput, gate orderriskcontract.PendingOrderGate) error {
|
||||
if s == nil || s.settings == nil || gate == nil {
|
||||
return nil
|
||||
}
|
||||
if input.UserID > 0 && cfg.MaxPendingOrdersPerUser > 0 {
|
||||
count, err := s.orders.CountPendingByUserID(input.UserID)
|
||||
cfg, err := s.settings.GetOrderRiskControlConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
return nil
|
||||
}
|
||||
if input.RiskIP == "" {
|
||||
input.RiskIP = orderriskcontract.NormalizeRiskIP(input.ClientIP)
|
||||
}
|
||||
if input.IsGuest {
|
||||
return checkGuestPendingOrder(input, cfg.Guest, gate)
|
||||
}
|
||||
return checkMemberPendingOrder(input, cfg.Member, gate)
|
||||
}
|
||||
|
||||
func checkGuestPendingOrder(input orderriskcontract.CheckInput, policy settingssecurity.OrderRiskGuestPolicy, gate orderriskcontract.PendingOrderGate) error {
|
||||
if !policy.Enabled || input.SkipIPCheck {
|
||||
return nil
|
||||
}
|
||||
if !guestPolicyRequiresIP(policy) {
|
||||
return nil
|
||||
}
|
||||
if input.RiskIP == "" {
|
||||
return orderriskcontract.ErrClientIPUnavailable
|
||||
}
|
||||
if err := gate.LockRiskKeys([]string{"guest:ip:" + input.RiskIP}); err != nil {
|
||||
return err
|
||||
}
|
||||
if policy.MaxPendingOrdersPerIP > 0 {
|
||||
count, err := gate.CountPendingGuestByRiskIP(input.RiskIP)
|
||||
if err != nil {
|
||||
logger.Warnw("risk_control_count_pending_by_user_error", "user_id", input.UserID, "error", err)
|
||||
} else if count >= int64(cfg.MaxPendingOrdersPerUser) {
|
||||
return err
|
||||
}
|
||||
if count >= int64(policy.MaxPendingOrdersPerIP) {
|
||||
return orderriskcontract.ErrTooManyPendingOrders
|
||||
}
|
||||
}
|
||||
if !input.SkipIPCheck && input.ClientIP != "" && cfg.MaxPendingOrdersPerIP > 0 {
|
||||
count, err := s.orders.CountPendingByClientIP(input.ClientIP)
|
||||
if policy.MaxPendingQuantityPerIPProduct <= 0 {
|
||||
return nil
|
||||
}
|
||||
requested := aggregateProductQuantities(input.Items)
|
||||
productIDs := make([]uint, 0, len(requested))
|
||||
for productID := range requested {
|
||||
productIDs = append(productIDs, productID)
|
||||
}
|
||||
sort.Slice(productIDs, func(i, j int) bool { return productIDs[i] < productIDs[j] })
|
||||
pending, err := gate.SumPendingGuestQuantityByRiskIP(input.RiskIP, productIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for productID, quantity := range requested {
|
||||
if pending[productID]+quantity > int64(policy.MaxPendingQuantityPerIPProduct) {
|
||||
return orderriskcontract.ErrPendingProductQuantityLimit
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkMemberPendingOrder(input orderriskcontract.CheckInput, policy settingssecurity.OrderRiskMemberPolicy, gate orderriskcontract.PendingOrderGate) error {
|
||||
if !policy.Enabled {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, 2)
|
||||
if input.UserID > 0 && policy.MaxPendingOrdersPerUser > 0 {
|
||||
keys = append(keys, fmt.Sprintf("member:user:%d", input.UserID))
|
||||
}
|
||||
if !input.SkipIPCheck && input.RiskIP != "" && policy.MaxPendingOrdersPerIP > 0 {
|
||||
keys = append(keys, "member:ip:"+input.RiskIP)
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
if err := gate.LockRiskKeys(keys); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if input.UserID > 0 && policy.MaxPendingOrdersPerUser > 0 {
|
||||
count, err := gate.CountPendingByUserID(input.UserID)
|
||||
if err != nil {
|
||||
logger.Warnw("risk_control_count_pending_by_ip_error", "ip", input.ClientIP, "error", err)
|
||||
} else if count >= int64(cfg.MaxPendingOrdersPerIP) {
|
||||
return err
|
||||
}
|
||||
if count >= int64(policy.MaxPendingOrdersPerUser) {
|
||||
return orderriskcontract.ErrTooManyPendingOrders
|
||||
}
|
||||
}
|
||||
if input.IsGuest && input.GuestEmail != "" && cfg.MaxPendingOrdersPerGuestEmail > 0 {
|
||||
count, err := s.orders.CountPendingByGuestEmail(input.GuestEmail)
|
||||
if !input.SkipIPCheck && input.RiskIP != "" && policy.MaxPendingOrdersPerIP > 0 {
|
||||
count, err := gate.CountPendingMemberByRiskIP(input.RiskIP)
|
||||
if err != nil {
|
||||
logger.Warnw("risk_control_count_pending_by_email_error", "email", input.GuestEmail, "error", err)
|
||||
} else if count >= int64(cfg.MaxPendingOrdersPerGuestEmail) {
|
||||
return err
|
||||
}
|
||||
if count >= int64(policy.MaxPendingOrdersPerIP) {
|
||||
return orderriskcontract.ErrTooManyPendingOrders
|
||||
}
|
||||
}
|
||||
@@ -126,8 +252,10 @@ func (s *Service) getOrBuildBlacklist(blacklist []string) *parsedIPBlacklist {
|
||||
if err == nil {
|
||||
parsed.cidrs = append(parsed.cidrs, cidr)
|
||||
}
|
||||
} else {
|
||||
parsed.exactIPs[entry] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(entry); ip != nil {
|
||||
parsed.exactIPs[ip.String()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,19 +270,19 @@ func (s *Service) getOrBuildBlacklist(blacklist []string) *parsedIPBlacklist {
|
||||
func hashBlacklist(list []string) string {
|
||||
hash := sha256.New()
|
||||
for _, value := range list {
|
||||
hash.Write([]byte(value))
|
||||
hash.Write([]byte{0})
|
||||
_, _ = hash.Write([]byte(value))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil))
|
||||
}
|
||||
|
||||
func (s *Service) isIPInBlacklist(clientIP string, blacklist []string) bool {
|
||||
ip := net.ParseIP(clientIP)
|
||||
ip := net.ParseIP(strings.TrimSpace(clientIP))
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
parsed := s.getOrBuildBlacklist(blacklist)
|
||||
if _, exists := parsed.exactIPs[clientIP]; exists {
|
||||
if _, exists := parsed.exactIPs[ip.String()]; exists {
|
||||
return true
|
||||
}
|
||||
for _, cidr := range parsed.cidrs {
|
||||
|
||||
@@ -1,86 +1,21 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
orderriskcontract "github.com/dujiao-next/internal/modules/orderrisk/contract"
|
||||
settingssecurity "github.com/dujiao-next/internal/modules/settings/schema/security"
|
||||
"github.com/dujiao-next/internal/shared/jsonmap"
|
||||
)
|
||||
|
||||
// --- isIPInBlacklist 测试 ---
|
||||
|
||||
func TestIsIPInBlacklist(t *testing.T) {
|
||||
svc := &Service{}
|
||||
|
||||
blacklist := []string{"1.2.3.4", "10.0.0.0/8", "192.168.1.0/24"}
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
blocked bool
|
||||
}{
|
||||
{"1.2.3.4", true}, // exact match
|
||||
{"1.2.3.5", false}, // not in list
|
||||
{"10.0.0.1", true}, // CIDR /8 match
|
||||
{"10.255.255.255", true}, // CIDR /8 match
|
||||
{"11.0.0.1", false}, // outside CIDR
|
||||
{"192.168.1.100", true}, // CIDR /24 match
|
||||
{"192.168.2.1", false}, // outside CIDR /24
|
||||
{"", false}, // empty IP
|
||||
{"invalid", false}, // invalid IP
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
if got := svc.isIPInBlacklist(tc.ip, blacklist); got != tc.blocked {
|
||||
t.Errorf("isIPInBlacklist(%q) = %v, want %v", tc.ip, got, tc.blocked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIPInBlacklist_CacheReuse(t *testing.T) {
|
||||
svc := &Service{}
|
||||
blacklist := []string{"1.2.3.4", "10.0.0.0/8"}
|
||||
|
||||
// First call builds cache
|
||||
svc.isIPInBlacklist("1.2.3.4", blacklist)
|
||||
if svc.cachedBlacklist == nil {
|
||||
t.Fatal("expected cache to be built")
|
||||
}
|
||||
hash1 := svc.cachedBlacklist.hash
|
||||
|
||||
// Same list should reuse cache
|
||||
svc.isIPInBlacklist("10.0.0.1", blacklist)
|
||||
if svc.cachedBlacklist.hash != hash1 {
|
||||
t.Fatal("expected cache to be reused")
|
||||
}
|
||||
|
||||
// Different list should rebuild cache
|
||||
svc.isIPInBlacklist("1.2.3.4", []string{"5.6.7.8"})
|
||||
if svc.cachedBlacklist.hash == hash1 {
|
||||
t.Fatal("expected cache to be rebuilt for different list")
|
||||
}
|
||||
}
|
||||
|
||||
// --- CheckOrderAllowed 集成测试(使用 mock) ---
|
||||
|
||||
type mockOrderRepoForRisk struct {
|
||||
pendingByUser int64
|
||||
pendingByIP int64
|
||||
pendingByEmail int64
|
||||
}
|
||||
|
||||
func (m *mockOrderRepoForRisk) CountPendingByUserID(_ uint) (int64, error) {
|
||||
return m.pendingByUser, nil
|
||||
}
|
||||
func (m *mockOrderRepoForRisk) CountPendingByClientIP(_ string) (int64, error) {
|
||||
return m.pendingByIP, nil
|
||||
}
|
||||
func (m *mockOrderRepoForRisk) CountPendingByGuestEmail(_ string) (int64, error) {
|
||||
return m.pendingByEmail, nil
|
||||
}
|
||||
|
||||
type settingReaderStub struct {
|
||||
config settingssecurity.OrderRiskControlConfig
|
||||
err error
|
||||
}
|
||||
|
||||
func (s settingReaderStub) GetOrderRiskControlConfig() (settingssecurity.OrderRiskControlConfig, error) {
|
||||
return s.config, s.err
|
||||
}
|
||||
|
||||
type rateLimiterStub struct {
|
||||
@@ -97,187 +32,190 @@ func (s *rateLimiterStub) Check(input orderriskcontract.CheckInput, config setti
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s settingReaderStub) GetOrderRiskControlConfig() (settingssecurity.OrderRiskControlConfig, error) {
|
||||
return s.config, nil
|
||||
type pendingGateStub struct {
|
||||
lockedKeys []string
|
||||
pendingByUser int64
|
||||
pendingGuestIP int64
|
||||
pendingMemberIP int64
|
||||
pendingByProduct map[uint]int64
|
||||
err error
|
||||
}
|
||||
|
||||
func newTestRiskControlService(pendingByUser, pendingByIP, pendingByEmail int64, cfgJSON jsonmap.JSON) *Service {
|
||||
config := settingssecurity.DecodeOrderRiskControlConfig(cfgJSON, settingssecurity.DefaultOrderRiskControlConfig())
|
||||
return NewService(Options{
|
||||
Settings: settingReaderStub{config: config},
|
||||
Orders: &mockOrderRepoForRisk{
|
||||
pendingByUser: pendingByUser,
|
||||
pendingByIP: pendingByIP,
|
||||
pendingByEmail: pendingByEmail,
|
||||
},
|
||||
})
|
||||
func (s *pendingGateStub) LockRiskKeys(keys []string) error {
|
||||
s.lockedKeys = append([]string(nil), keys...)
|
||||
return s.err
|
||||
}
|
||||
func (s *pendingGateStub) CountPendingByUserID(uint) (int64, error) {
|
||||
return s.pendingByUser, s.err
|
||||
}
|
||||
func (s *pendingGateStub) CountPendingGuestByRiskIP(string) (int64, error) {
|
||||
return s.pendingGuestIP, s.err
|
||||
}
|
||||
func (s *pendingGateStub) CountPendingMemberByRiskIP(string) (int64, error) {
|
||||
return s.pendingMemberIP, s.err
|
||||
}
|
||||
func (s *pendingGateStub) SumPendingGuestQuantityByRiskIP(string, []uint) (map[uint]int64, error) {
|
||||
return s.pendingByProduct, s.err
|
||||
}
|
||||
|
||||
func testConfig() settingssecurity.OrderRiskControlConfig {
|
||||
cfg := settingssecurity.DefaultOrderRiskControlConfig()
|
||||
cfg.Enabled = true
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_DisabledByDefault(t *testing.T) {
|
||||
svc := newTestRiskControlService(100, 100, 100, nil)
|
||||
err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: 1,
|
||||
ClientIP: "1.2.3.4",
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: settingssecurity.DefaultOrderRiskControlConfig()}})
|
||||
result, err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
ClientIP: "2001:db8:abcd:12::1234",
|
||||
Items: []orderriskcontract.OrderItem{{ProductID: 1, Quantity: 999}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil when disabled, got %v", err)
|
||||
t.Fatalf("expected nil when globally disabled, got %v", err)
|
||||
}
|
||||
if result.RiskIP != "2001:db8:abcd:12::/64" {
|
||||
t.Fatalf("expected normalized risk IP even while disabled, got %q", result.RiskIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_IPBlacklist(t *testing.T) {
|
||||
svc := newTestRiskControlService(0, 0, 0, jsonmap.JSON{
|
||||
"enabled": true,
|
||||
"ip_blacklist": []interface{}{"1.2.3.4", "10.0.0.0/8"},
|
||||
func TestCheckOrderAllowed_IPBlacklistCanonicalizesAddress(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Common.IPBlacklist = []string{"1.2.3.4", "10.0.0.0/8"}
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}})
|
||||
|
||||
for _, ip := range []string{"1.2.3.4", "::ffff:1.2.3.4", "10.2.3.4"} {
|
||||
if _, err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{IsGuest: true, ClientIP: ip}); !errors.Is(err, orderriskcontract.ErrIPBlacklisted) {
|
||||
t.Fatalf("expected %q to be blacklisted, got %v", ip, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_GuestQuantityAggregatesProductAcrossSKUs(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Guest.MaxQuantityPerProductPerOrder = 2
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}})
|
||||
|
||||
_, err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
ClientIP: "1.2.3.4",
|
||||
Items: []orderriskcontract.OrderItem{
|
||||
{ProductID: 7, Quantity: 1},
|
||||
{ProductID: 7, Quantity: 2},
|
||||
},
|
||||
})
|
||||
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{ClientIP: "1.2.3.4"}); err != orderriskcontract.ErrIPBlacklisted {
|
||||
t.Fatalf("expected ErrRiskIPBlacklisted, got %v", err)
|
||||
}
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{ClientIP: "10.0.0.1"}); err != orderriskcontract.ErrIPBlacklisted {
|
||||
t.Fatalf("expected ErrRiskIPBlacklisted for CIDR, got %v", err)
|
||||
}
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{ClientIP: "2.3.4.5"}); err != nil {
|
||||
t.Fatalf("expected nil for non-blocked IP, got %v", err)
|
||||
if !errors.Is(err, orderriskcontract.ErrProductQuantityLimit) {
|
||||
t.Fatalf("expected aggregate guest quantity to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_EmailBlacklist(t *testing.T) {
|
||||
svc := newTestRiskControlService(0, 0, 0, jsonmap.JSON{
|
||||
"enabled": true,
|
||||
"email_blacklist": []interface{}{"spam@example.com"},
|
||||
})
|
||||
func TestCheckOrderAllowed_MemberUsesIndependentQuantityPolicy(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Guest.MaxQuantityPerProductPerOrder = 1
|
||||
cfg.Member.MaxQuantityPerProductPerOrder = 5
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}})
|
||||
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
GuestEmail: "SPAM@example.com",
|
||||
ClientIP: "2.3.4.5",
|
||||
}); err != orderriskcontract.ErrEmailBlacklisted {
|
||||
t.Fatalf("expected ErrRiskEmailBlacklisted, got %v", err)
|
||||
}
|
||||
|
||||
// Non-guest should not be blocked by email blacklist
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: 1,
|
||||
ClientIP: "2.3.4.5",
|
||||
if _, err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: 3,
|
||||
Items: []orderriskcontract.OrderItem{{ProductID: 1, Quantity: 5}},
|
||||
}); err != nil {
|
||||
t.Fatalf("expected nil for non-guest, got %v", err)
|
||||
t.Fatalf("member quantity must not use guest limit: %v", err)
|
||||
}
|
||||
if _, err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: 3,
|
||||
Items: []orderriskcontract.OrderItem{{ProductID: 1, Quantity: 6}},
|
||||
}); !errors.Is(err, orderriskcontract.ErrProductQuantityLimit) {
|
||||
t.Fatalf("expected member quantity limit, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_PendingOrderLimits(t *testing.T) {
|
||||
cfg := jsonmap.JSON{
|
||||
"enabled": true,
|
||||
"max_pending_orders_per_user": float64(2),
|
||||
"max_pending_orders_per_ip": float64(3),
|
||||
"max_pending_orders_per_guest_email": float64(1),
|
||||
}
|
||||
|
||||
// User at limit
|
||||
svc := newTestRiskControlService(2, 0, 0, cfg)
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{UserID: 1, ClientIP: "5.6.7.8"}); err != orderriskcontract.ErrTooManyPendingOrders {
|
||||
t.Fatalf("expected ErrRiskTooManyPendingOrders for user, got %v", err)
|
||||
}
|
||||
|
||||
// User under limit
|
||||
svc = newTestRiskControlService(1, 0, 0, cfg)
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{UserID: 1, ClientIP: "5.6.7.8"}); err != nil {
|
||||
t.Fatalf("expected nil for user under limit, got %v", err)
|
||||
}
|
||||
|
||||
// IP at limit
|
||||
svc = newTestRiskControlService(0, 3, 0, cfg)
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{ClientIP: "5.6.7.8"}); err != orderriskcontract.ErrTooManyPendingOrders {
|
||||
t.Fatalf("expected ErrRiskTooManyPendingOrders for IP, got %v", err)
|
||||
}
|
||||
|
||||
// Guest email at limit
|
||||
svc = newTestRiskControlService(0, 0, 1, cfg)
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
GuestEmail: "test@example.com",
|
||||
ClientIP: "5.6.7.8",
|
||||
}); err != orderriskcontract.ErrTooManyPendingOrders {
|
||||
t.Fatalf("expected ErrRiskTooManyPendingOrders for guest email, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_SkipIPCheck(t *testing.T) {
|
||||
cfg := jsonmap.JSON{
|
||||
"enabled": true,
|
||||
"max_pending_orders_per_user": float64(5),
|
||||
"max_pending_orders_per_ip": float64(1),
|
||||
"ip_blacklist": []interface{}{"1.2.3.4"},
|
||||
}
|
||||
|
||||
// IP 在黑名单中,但 SkipIPCheck=true 应放行
|
||||
svc := newTestRiskControlService(0, 0, 0, cfg)
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: 1,
|
||||
ClientIP: "1.2.3.4",
|
||||
SkipIPCheck: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("expected nil with SkipIPCheck, got %v", err)
|
||||
}
|
||||
|
||||
// IP 并发超限,但 SkipIPCheck=true 应放行
|
||||
svc = newTestRiskControlService(0, 999, 0, cfg)
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: 1,
|
||||
ClientIP: "5.6.7.8",
|
||||
SkipIPCheck: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("expected nil with SkipIPCheck for IP pending, got %v", err)
|
||||
}
|
||||
|
||||
// 用户维度超限,SkipIPCheck=true 不影响,仍应拦截
|
||||
svc = newTestRiskControlService(5, 0, 0, cfg)
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: 1,
|
||||
ClientIP: "5.6.7.8",
|
||||
SkipIPCheck: true,
|
||||
}); err != orderriskcontract.ErrTooManyPendingOrders {
|
||||
t.Fatalf("expected ErrRiskTooManyPendingOrders for user limit with SkipIPCheck, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_ZeroLimitMeansNoLimit(t *testing.T) {
|
||||
svc := newTestRiskControlService(999, 999, 999, jsonmap.JSON{
|
||||
"enabled": true,
|
||||
"max_pending_orders_per_user": float64(0),
|
||||
"max_pending_orders_per_ip": float64(0),
|
||||
"max_pending_orders_per_guest_email": float64(0),
|
||||
})
|
||||
if err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{
|
||||
UserID: 1,
|
||||
ClientIP: "1.2.3.4",
|
||||
IsGuest: true,
|
||||
GuestEmail: "test@example.com",
|
||||
}); err != nil {
|
||||
t.Fatalf("expected nil when limits are 0 (disabled), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_DelegatesEnabledRateLimit(t *testing.T) {
|
||||
func TestCheckOrderAllowed_GuestRateLimitUsesRiskIPOnlyWhenConsuming(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
limited := &orderriskcontract.RateLimitedError{RetryAfter: 42}
|
||||
limiter := &rateLimiterStub{err: limited}
|
||||
config := settingssecurity.DefaultOrderRiskControlConfig()
|
||||
config.Enabled = true
|
||||
config.OrderRateLimit.Enabled = true
|
||||
svc := NewService(Options{
|
||||
Settings: settingReaderStub{config: config},
|
||||
Orders: &mockOrderRepoForRisk{},
|
||||
RateLimiter: limiter,
|
||||
})
|
||||
input := orderriskcontract.CheckInput{UserID: 7, ClientIP: "1.2.3.4"}
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}, RateLimiter: limiter})
|
||||
input := orderriskcontract.CheckInput{IsGuest: true, ClientIP: "2001:db8:1:2::abcd"}
|
||||
|
||||
err := svc.CheckOrderAllowed(input)
|
||||
if err != limited {
|
||||
if _, err := svc.CheckOrderAllowed(input); err != nil {
|
||||
t.Fatalf("preview-style check must not consume rate limit: %v", err)
|
||||
}
|
||||
if limiter.calls != 0 {
|
||||
t.Fatalf("expected no rate limiter call, got %d", limiter.calls)
|
||||
}
|
||||
input.ConsumeRateLimit = true
|
||||
if _, err := svc.CheckOrderAllowed(input); err != limited {
|
||||
t.Fatalf("expected limiter error, got %v", err)
|
||||
}
|
||||
if limiter.calls != 1 || limiter.input != input {
|
||||
t.Fatalf("expected one limiter call with input %+v, got calls=%d input=%+v", input, limiter.calls, limiter.input)
|
||||
}
|
||||
if limiter.config != config.OrderRateLimit {
|
||||
t.Fatalf("expected rate limit config %+v, got %+v", config.OrderRateLimit, limiter.config)
|
||||
if limiter.calls != 1 || limiter.input.RiskIP != "2001:db8:1:2::/64" || limiter.input.UserID != 0 {
|
||||
t.Fatalf("unexpected guest limiter input: calls=%d input=%+v", limiter.calls, limiter.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPendingOrderAllowed_GuestLocksIPAndCountsOrders(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Guest.MaxPendingOrdersPerIP = 2
|
||||
gate := &pendingGateStub{pendingGuestIP: 2}
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}})
|
||||
|
||||
err := svc.CheckPendingOrderAllowed(orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
RiskIP: "1.2.3.4",
|
||||
Items: []orderriskcontract.OrderItem{{ProductID: 1, Quantity: 1}},
|
||||
}, gate)
|
||||
if !errors.Is(err, orderriskcontract.ErrTooManyPendingOrders) {
|
||||
t.Fatalf("expected guest pending order limit, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gate.lockedKeys, []string{"guest:ip:1.2.3.4"}) {
|
||||
t.Fatalf("unexpected lock keys: %#v", gate.lockedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPendingOrderAllowed_GuestCountsPendingProductQuantity(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Guest.MaxPendingOrdersPerIP = 10
|
||||
cfg.Guest.MaxPendingQuantityPerIPProduct = 2
|
||||
gate := &pendingGateStub{pendingByProduct: map[uint]int64{7: 1}}
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}})
|
||||
|
||||
err := svc.CheckPendingOrderAllowed(orderriskcontract.CheckInput{
|
||||
IsGuest: true,
|
||||
RiskIP: "1.2.3.4",
|
||||
Items: []orderriskcontract.OrderItem{
|
||||
{ProductID: 7, Quantity: 1},
|
||||
{ProductID: 7, Quantity: 1},
|
||||
},
|
||||
}, gate)
|
||||
if !errors.Is(err, orderriskcontract.ErrPendingProductQuantityLimit) {
|
||||
t.Fatalf("expected pending product quantity limit, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPendingOrderAllowed_MemberUsesUserAndOptionalIP(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Member.MaxPendingOrdersPerUser = 5
|
||||
cfg.Member.MaxPendingOrdersPerIP = 3
|
||||
gate := &pendingGateStub{pendingByUser: 4, pendingMemberIP: 3}
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}})
|
||||
|
||||
err := svc.CheckPendingOrderAllowed(orderriskcontract.CheckInput{UserID: 9, RiskIP: "1.2.3.4"}, gate)
|
||||
if !errors.Is(err, orderriskcontract.ErrTooManyPendingOrders) {
|
||||
t.Fatalf("expected member IP pending limit, got %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gate.lockedKeys, []string{"member:user:9", "member:ip:1.2.3.4"}) {
|
||||
t.Fatalf("unexpected member lock keys: %#v", gate.lockedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckOrderAllowed_GuestReturnsExpiryOverride(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
cfg.Guest.PaymentExpireMinutes = 8
|
||||
svc := NewService(Options{Settings: settingReaderStub{config: cfg}})
|
||||
|
||||
result, err := svc.CheckOrderAllowed(orderriskcontract.CheckInput{IsGuest: true, ClientIP: "1.2.3.4"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.PaymentExpireMinutes != 8 {
|
||||
t.Fatalf("expected guest expiry 8, got %d", result.PaymentExpireMinutes)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package contract
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrIPBlacklisted = errors.New("risk: ip blacklisted")
|
||||
ErrEmailBlacklisted = errors.New("risk: email blacklisted")
|
||||
ErrTooManyPendingOrders = errors.New("risk: too many pending orders")
|
||||
ErrOrderRateLimited = errors.New("risk: order rate limited")
|
||||
ErrIPBlacklisted = errors.New("risk: ip blacklisted")
|
||||
ErrClientIPUnavailable = errors.New("risk: client ip unavailable")
|
||||
ErrTooManyPendingOrders = errors.New("risk: too many pending orders")
|
||||
ErrProductQuantityLimit = errors.New("risk: product quantity limit")
|
||||
ErrPendingProductQuantityLimit = errors.New("risk: pending product quantity limit")
|
||||
ErrOrderRateLimited = errors.New("risk: order rate limited")
|
||||
)
|
||||
|
||||
// RateLimitedError 携带 Retry-After 秒数。
|
||||
|
||||
@@ -17,3 +17,19 @@ func TestRateLimitedErrorContract(t *testing.T) {
|
||||
t.Fatalf("expected zero retry-after for unrelated error, got %d", retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRiskIP(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"1.2.3.4": "1.2.3.4",
|
||||
"::ffff:1.2.3.4": "1.2.3.4",
|
||||
"2001:db8:1234:5678::1": "2001:db8:1234:5678::/64",
|
||||
"2001:db8:1234:5678::abcd": "2001:db8:1234:5678::/64",
|
||||
"not-an-ip": "",
|
||||
"": "",
|
||||
}
|
||||
for input, expected := range tests {
|
||||
if actual := NormalizeRiskIP(input); actual != expected {
|
||||
t.Fatalf("NormalizeRiskIP(%q)=%q, want %q", input, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,13 @@ type SettingReader interface {
|
||||
GetOrderRiskControlConfig() (settingssecurity.OrderRiskControlConfig, error)
|
||||
}
|
||||
|
||||
type PendingOrderCounter interface {
|
||||
// PendingOrderGate 在订单事务中序列化相同风控身份并读取真实待支付库存占用。
|
||||
type PendingOrderGate interface {
|
||||
LockRiskKeys(keys []string) error
|
||||
CountPendingByUserID(userID uint) (int64, error)
|
||||
CountPendingByClientIP(clientIP string) (int64, error)
|
||||
CountPendingByGuestEmail(email string) (int64, error)
|
||||
CountPendingGuestByRiskIP(riskIP string) (int64, error)
|
||||
CountPendingMemberByRiskIP(riskIP string) (int64, error)
|
||||
SumPendingGuestQuantityByRiskIP(riskIP string, productIDs []uint) (map[uint]int64, error)
|
||||
}
|
||||
|
||||
// RateLimiter 执行具有外部状态的下单频率限制。
|
||||
@@ -19,5 +22,6 @@ type RateLimiter interface {
|
||||
|
||||
// Controller 是订单上下文调用风控所需的用例端口。
|
||||
type Controller interface {
|
||||
CheckOrderAllowed(input CheckInput) error
|
||||
CheckOrderAllowed(input CheckInput) (CheckResult, error)
|
||||
CheckPendingOrderAllowed(input CheckInput, gate PendingOrderGate) error
|
||||
}
|
||||
@@ -1,10 +1,45 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OrderItem 是风控所需的最小订单项快照。
|
||||
type OrderItem struct {
|
||||
ProductID uint
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// CheckInput 是订单风控检查所需的调用上下文。
|
||||
type CheckInput struct {
|
||||
UserID uint
|
||||
GuestEmail string
|
||||
ClientIP string
|
||||
IsGuest bool
|
||||
SkipIPCheck bool
|
||||
UserID uint
|
||||
ClientIP string
|
||||
RiskIP string
|
||||
IsGuest bool
|
||||
SkipIPCheck bool
|
||||
ConsumeRateLimit bool
|
||||
Items []OrderItem
|
||||
}
|
||||
|
||||
// CheckResult 返回创建订单后续步骤需要复用的规范化风控上下文。
|
||||
type CheckResult struct {
|
||||
RiskIP string
|
||||
PaymentExpireMinutes int
|
||||
}
|
||||
|
||||
// NormalizeRiskIP 生成游客风控键:IPv4 使用完整地址,IPv6 按 /64 前缀聚合。
|
||||
func NormalizeRiskIP(raw string) string {
|
||||
ip := net.ParseIP(strings.TrimSpace(raw))
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
return ipv4.String()
|
||||
}
|
||||
network := ip.Mask(net.CIDRMask(64, 128))
|
||||
if network == nil {
|
||||
return ""
|
||||
}
|
||||
return network.String() + "/64"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// LockKey 仅保存风控维度摘要,用于跨进程序列化相同身份的库存配额检查。
|
||||
type LockKey struct {
|
||||
KeyHash string `gorm:"type:varchar(64);primaryKey" json:"-"`
|
||||
CreatedAt time.Time `gorm:"not null" json:"-"`
|
||||
}
|
||||
|
||||
func (LockKey) TableName() string { return "order_risk_lock_keys" }
|
||||
@@ -39,12 +39,11 @@ func (l *Limiter) Check(input orderriskcontract.CheckInput, config settingssecur
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if !input.SkipIPCheck && input.ClientIP != "" {
|
||||
if err := checkSingle(ctx, client, fmt.Sprintf("dj:risk:order_rate:ip:%s", input.ClientIP), config); err != nil {
|
||||
if input.IsGuest && !input.SkipIPCheck && input.RiskIP != "" {
|
||||
if err := checkSingle(ctx, client, fmt.Sprintf("dj:risk:order_rate:guest_ip:%s", input.RiskIP), config); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if input.UserID > 0 {
|
||||
} else if !input.IsGuest && input.UserID > 0 {
|
||||
if err := checkSingle(ctx, client, fmt.Sprintf("dj:risk:order_rate:user:%d", input.UserID), config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"github.com/dujiao-next/internal/shared/jsonmap"
|
||||
)
|
||||
|
||||
// OrderRateLimitConfig 下单频率限制配置
|
||||
const orderRiskControlVersion = 2
|
||||
|
||||
// OrderRateLimitConfig 下单频率限制配置。
|
||||
type OrderRateLimitConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
@@ -16,107 +18,211 @@ type OrderRateLimitConfig struct {
|
||||
BlockSeconds int `json:"block_seconds"`
|
||||
}
|
||||
|
||||
// OrderRiskControlConfig 订单风控配置
|
||||
type OrderRiskControlConfig struct {
|
||||
// OrderRiskCommonPolicy 保存与购买身份无关的订单风控策略。
|
||||
type OrderRiskCommonPolicy struct {
|
||||
IPBlacklist []string `json:"ip_blacklist"`
|
||||
}
|
||||
|
||||
// OrderRiskGuestPolicy 保存以可信客户端 IP 为主体的游客库存保护策略。
|
||||
type OrderRiskGuestPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxPendingOrdersPerIP int `json:"max_pending_orders_per_ip"`
|
||||
MaxQuantityPerProductPerOrder int `json:"max_quantity_per_product_per_order"`
|
||||
MaxPendingQuantityPerIPProduct int `json:"max_pending_quantity_per_ip_product"`
|
||||
PaymentExpireMinutes int `json:"payment_expire_minutes"`
|
||||
RateLimit OrderRateLimitConfig `json:"rate_limit"`
|
||||
}
|
||||
|
||||
// OrderRiskMemberPolicy 保存以用户 ID 为主体、默认更宽松的会员策略。
|
||||
type OrderRiskMemberPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MaxPendingOrdersPerUser int `json:"max_pending_orders_per_user"`
|
||||
MaxPendingOrdersPerIP int `json:"max_pending_orders_per_ip"`
|
||||
MaxPendingOrdersPerGuestEmail int `json:"max_pending_orders_per_guest_email"`
|
||||
OrderRateLimit OrderRateLimitConfig `json:"order_rate_limit"`
|
||||
IPBlacklist []string `json:"ip_blacklist"`
|
||||
EmailBlacklist []string `json:"email_blacklist"`
|
||||
MaxQuantityPerProductPerOrder int `json:"max_quantity_per_product_per_order"`
|
||||
RateLimit OrderRateLimitConfig `json:"rate_limit"`
|
||||
}
|
||||
|
||||
// DefaultOrderRiskControlConfig 默认风控配置
|
||||
// OrderRiskControlConfig 订单风控配置。游客邮箱仅用于订单业务,不作为风控身份。
|
||||
type OrderRiskControlConfig struct {
|
||||
Version int `json:"version"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Common OrderRiskCommonPolicy `json:"common"`
|
||||
Guest OrderRiskGuestPolicy `json:"guest"`
|
||||
Member OrderRiskMemberPolicy `json:"member"`
|
||||
}
|
||||
|
||||
// DefaultOrderRiskControlConfig 返回新安装推荐值;总开关默认关闭,避免静默改变订单行为。
|
||||
func DefaultOrderRiskControlConfig() OrderRiskControlConfig {
|
||||
return OrderRiskControlConfig{
|
||||
Enabled: false,
|
||||
MaxPendingOrdersPerUser: 3,
|
||||
MaxPendingOrdersPerIP: 5,
|
||||
MaxPendingOrdersPerGuestEmail: 2,
|
||||
OrderRateLimit: OrderRateLimitConfig{
|
||||
Enabled: false,
|
||||
WindowSeconds: 60,
|
||||
MaxRequests: 5,
|
||||
BlockSeconds: 120,
|
||||
Version: orderRiskControlVersion,
|
||||
Enabled: false,
|
||||
Common: OrderRiskCommonPolicy{IPBlacklist: []string{}},
|
||||
Guest: OrderRiskGuestPolicy{
|
||||
Enabled: true,
|
||||
MaxPendingOrdersPerIP: 2,
|
||||
MaxQuantityPerProductPerOrder: 1,
|
||||
MaxPendingQuantityPerIPProduct: 2,
|
||||
PaymentExpireMinutes: 10,
|
||||
RateLimit: OrderRateLimitConfig{
|
||||
Enabled: true,
|
||||
WindowSeconds: 60,
|
||||
MaxRequests: 3,
|
||||
BlockSeconds: 120,
|
||||
},
|
||||
},
|
||||
Member: OrderRiskMemberPolicy{
|
||||
Enabled: true,
|
||||
MaxPendingOrdersPerUser: 5,
|
||||
MaxPendingOrdersPerIP: 0,
|
||||
MaxQuantityPerProductPerOrder: 0,
|
||||
RateLimit: OrderRateLimitConfig{
|
||||
Enabled: false,
|
||||
WindowSeconds: 60,
|
||||
MaxRequests: 10,
|
||||
BlockSeconds: 120,
|
||||
},
|
||||
},
|
||||
IPBlacklist: []string{},
|
||||
EmailBlacklist: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeOrderRiskControlConfig 归一化风控配置
|
||||
// NormalizeOrderRiskControlConfig 归一化风控配置;0 始终表示对应限制关闭。
|
||||
func NormalizeOrderRiskControlConfig(cfg OrderRiskControlConfig) OrderRiskControlConfig {
|
||||
if cfg.MaxPendingOrdersPerUser < 0 || cfg.MaxPendingOrdersPerUser > 100 {
|
||||
cfg.MaxPendingOrdersPerUser = 3
|
||||
}
|
||||
if cfg.MaxPendingOrdersPerIP < 0 || cfg.MaxPendingOrdersPerIP > 100 {
|
||||
cfg.MaxPendingOrdersPerIP = 5
|
||||
}
|
||||
if cfg.MaxPendingOrdersPerGuestEmail < 0 || cfg.MaxPendingOrdersPerGuestEmail > 100 {
|
||||
cfg.MaxPendingOrdersPerGuestEmail = 2
|
||||
}
|
||||
defaults := DefaultOrderRiskControlConfig()
|
||||
cfg.Version = orderRiskControlVersion
|
||||
cfg.Guest.MaxPendingOrdersPerIP = normalizeRiskLimit(cfg.Guest.MaxPendingOrdersPerIP, 100, defaults.Guest.MaxPendingOrdersPerIP)
|
||||
cfg.Guest.MaxQuantityPerProductPerOrder = normalizeRiskLimit(cfg.Guest.MaxQuantityPerProductPerOrder, 100000, defaults.Guest.MaxQuantityPerProductPerOrder)
|
||||
cfg.Guest.MaxPendingQuantityPerIPProduct = normalizeRiskLimit(cfg.Guest.MaxPendingQuantityPerIPProduct, 100000, defaults.Guest.MaxPendingQuantityPerIPProduct)
|
||||
cfg.Guest.PaymentExpireMinutes = normalizeRiskLimit(cfg.Guest.PaymentExpireMinutes, 10080, defaults.Guest.PaymentExpireMinutes)
|
||||
cfg.Guest.RateLimit = normalizeRateLimit(cfg.Guest.RateLimit, defaults.Guest.RateLimit)
|
||||
|
||||
if cfg.OrderRateLimit.WindowSeconds < 10 || cfg.OrderRateLimit.WindowSeconds > 3600 {
|
||||
cfg.OrderRateLimit.WindowSeconds = 60
|
||||
}
|
||||
if cfg.OrderRateLimit.MaxRequests < 1 || cfg.OrderRateLimit.MaxRequests > 100 {
|
||||
cfg.OrderRateLimit.MaxRequests = 5
|
||||
}
|
||||
if cfg.OrderRateLimit.BlockSeconds < 0 || cfg.OrderRateLimit.BlockSeconds > 86400 {
|
||||
cfg.OrderRateLimit.BlockSeconds = 120
|
||||
}
|
||||
cfg.Member.MaxPendingOrdersPerUser = normalizeRiskLimit(cfg.Member.MaxPendingOrdersPerUser, 100, defaults.Member.MaxPendingOrdersPerUser)
|
||||
cfg.Member.MaxPendingOrdersPerIP = normalizeRiskLimit(cfg.Member.MaxPendingOrdersPerIP, 100, defaults.Member.MaxPendingOrdersPerIP)
|
||||
cfg.Member.MaxQuantityPerProductPerOrder = normalizeRiskLimit(cfg.Member.MaxQuantityPerProductPerOrder, 100000, defaults.Member.MaxQuantityPerProductPerOrder)
|
||||
cfg.Member.RateLimit = normalizeRateLimit(cfg.Member.RateLimit, defaults.Member.RateLimit)
|
||||
|
||||
// 归一化 IP 黑名单:去空行、去首尾空格、校验格式
|
||||
cleanIPs := make([]string, 0, len(cfg.IPBlacklist))
|
||||
for _, entry := range cfg.IPBlacklist {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
cleanIPs := make([]string, 0, len(cfg.Common.IPBlacklist))
|
||||
seen := make(map[string]struct{}, len(cfg.Common.IPBlacklist))
|
||||
for _, raw := range cfg.Common.IPBlacklist {
|
||||
entry := strings.TrimSpace(raw)
|
||||
if entry == "" || !isValidIPOrCIDR(entry) {
|
||||
continue
|
||||
}
|
||||
if isValidIPOrCIDR(entry) {
|
||||
cleanIPs = append(cleanIPs, entry)
|
||||
if _, exists := seen[entry]; exists {
|
||||
continue
|
||||
}
|
||||
seen[entry] = struct{}{}
|
||||
cleanIPs = append(cleanIPs, entry)
|
||||
}
|
||||
cfg.IPBlacklist = cleanIPs
|
||||
|
||||
// 归一化邮箱黑名单:去空行、小写化
|
||||
cleanEmails := make([]string, 0, len(cfg.EmailBlacklist))
|
||||
for _, email := range cfg.EmailBlacklist {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email != "" {
|
||||
cleanEmails = append(cleanEmails, email)
|
||||
}
|
||||
}
|
||||
cfg.EmailBlacklist = cleanEmails
|
||||
|
||||
cfg.Common.IPBlacklist = cleanIPs
|
||||
return cfg
|
||||
}
|
||||
|
||||
// isValidIPOrCIDR 校验字符串是否为有效的 IP 地址或 CIDR 表示
|
||||
func isValidIPOrCIDR(s string) bool {
|
||||
if net.ParseIP(s) != nil {
|
||||
func normalizeRiskLimit(value, maximum, fallback int) int {
|
||||
if value < 0 || value > maximum {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeRateLimit(cfg, fallback OrderRateLimitConfig) OrderRateLimitConfig {
|
||||
if cfg.WindowSeconds < 10 || cfg.WindowSeconds > 3600 {
|
||||
cfg.WindowSeconds = fallback.WindowSeconds
|
||||
}
|
||||
if cfg.MaxRequests < 1 || cfg.MaxRequests > 100 {
|
||||
cfg.MaxRequests = fallback.MaxRequests
|
||||
}
|
||||
if cfg.BlockSeconds < 0 || cfg.BlockSeconds > 86400 {
|
||||
cfg.BlockSeconds = fallback.BlockSeconds
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func isValidIPOrCIDR(value string) bool {
|
||||
if net.ParseIP(value) != nil {
|
||||
return true
|
||||
}
|
||||
_, _, err := net.ParseCIDR(s)
|
||||
_, _, err := net.ParseCIDR(value)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// DecodeOrderRiskControlConfig 从 JSON map 解析风控配置
|
||||
// DecodeOrderRiskControlConfig 解析新版嵌套配置,并把旧版扁平配置一次性映射到游客/会员策略。
|
||||
func DecodeOrderRiskControlConfig(raw jsonmap.JSON, fallback OrderRiskControlConfig) OrderRiskControlConfig {
|
||||
result := fallback
|
||||
if raw == nil {
|
||||
return result
|
||||
return NormalizeOrderRiskControlConfig(fallback)
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return result
|
||||
return NormalizeOrderRiskControlConfig(fallback)
|
||||
}
|
||||
if _, hasGuest := raw["guest"]; hasGuest {
|
||||
result := fallback
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return NormalizeOrderRiskControlConfig(fallback)
|
||||
}
|
||||
return NormalizeOrderRiskControlConfig(result)
|
||||
}
|
||||
|
||||
type flatRateLimitConfig struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
WindowSeconds *int `json:"window_seconds"`
|
||||
MaxRequests *int `json:"max_requests"`
|
||||
BlockSeconds *int `json:"block_seconds"`
|
||||
}
|
||||
type flatOrderRiskConfig struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
MaxPendingOrdersPerUser *int `json:"max_pending_orders_per_user"`
|
||||
MaxPendingOrdersPerIP *int `json:"max_pending_orders_per_ip"`
|
||||
OrderRateLimit *flatRateLimitConfig `json:"order_rate_limit"`
|
||||
IPBlacklist []string `json:"ip_blacklist"`
|
||||
}
|
||||
var old flatOrderRiskConfig
|
||||
if err := json.Unmarshal(data, &old); err != nil {
|
||||
return NormalizeOrderRiskControlConfig(fallback)
|
||||
}
|
||||
result := fallback
|
||||
// 先恢复旧版缺省值,再叠加持久化字段。这样旧的稀疏 JSON 也不会误用 v2 推荐值。
|
||||
result.Guest.MaxPendingOrdersPerIP = 5
|
||||
result.Member.MaxPendingOrdersPerUser = 3
|
||||
result.Member.MaxPendingOrdersPerIP = 5
|
||||
result.Guest.MaxQuantityPerProductPerOrder = 0
|
||||
result.Guest.MaxPendingQuantityPerIPProduct = 0
|
||||
result.Guest.PaymentExpireMinutes = 0
|
||||
result.Member.MaxQuantityPerProductPerOrder = 0
|
||||
legacyRateLimit := OrderRateLimitConfig{Enabled: false, WindowSeconds: 60, MaxRequests: 5, BlockSeconds: 120}
|
||||
result.Guest.RateLimit = legacyRateLimit
|
||||
result.Member.RateLimit = legacyRateLimit
|
||||
if old.Enabled != nil {
|
||||
result.Enabled = *old.Enabled
|
||||
}
|
||||
if old.MaxPendingOrdersPerUser != nil {
|
||||
result.Member.MaxPendingOrdersPerUser = *old.MaxPendingOrdersPerUser
|
||||
}
|
||||
if old.MaxPendingOrdersPerIP != nil {
|
||||
result.Guest.MaxPendingOrdersPerIP = *old.MaxPendingOrdersPerIP
|
||||
result.Member.MaxPendingOrdersPerIP = *old.MaxPendingOrdersPerIP
|
||||
}
|
||||
if old.OrderRateLimit != nil {
|
||||
if old.OrderRateLimit.Enabled != nil {
|
||||
legacyRateLimit.Enabled = *old.OrderRateLimit.Enabled
|
||||
}
|
||||
if old.OrderRateLimit.WindowSeconds != nil {
|
||||
legacyRateLimit.WindowSeconds = *old.OrderRateLimit.WindowSeconds
|
||||
}
|
||||
if old.OrderRateLimit.MaxRequests != nil {
|
||||
legacyRateLimit.MaxRequests = *old.OrderRateLimit.MaxRequests
|
||||
}
|
||||
if old.OrderRateLimit.BlockSeconds != nil {
|
||||
legacyRateLimit.BlockSeconds = *old.OrderRateLimit.BlockSeconds
|
||||
}
|
||||
result.Guest.RateLimit = legacyRateLimit
|
||||
result.Member.RateLimit = legacyRateLimit
|
||||
}
|
||||
if old.IPBlacklist != nil {
|
||||
result.Common.IPBlacklist = old.IPBlacklist
|
||||
}
|
||||
_ = json.Unmarshal(data, &result)
|
||||
return NormalizeOrderRiskControlConfig(result)
|
||||
}
|
||||
|
||||
// EncodeOrderRiskControlConfig 将风控配置转为 map 用于存储
|
||||
func EncodeOrderRiskControlConfig(cfg OrderRiskControlConfig) jsonmap.JSON {
|
||||
normalized := NormalizeOrderRiskControlConfig(cfg)
|
||||
data, err := json.Marshal(normalized)
|
||||
|
||||
@@ -1,81 +1,136 @@
|
||||
package settingssecurity
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/dujiao-next/internal/shared/jsonmap"
|
||||
)
|
||||
|
||||
func TestNormalizeOrderRiskControlConfig_Defaults(t *testing.T) {
|
||||
cfg := NormalizeOrderRiskControlConfig(OrderRiskControlConfig{})
|
||||
if cfg.MaxPendingOrdersPerUser != 0 {
|
||||
t.Fatalf("zero should be preserved (means no limit), got %d", cfg.MaxPendingOrdersPerUser)
|
||||
cfg := NormalizeOrderRiskControlConfig(OrderRiskControlConfig{
|
||||
Guest: OrderRiskGuestPolicy{
|
||||
MaxPendingOrdersPerIP: 0,
|
||||
MaxQuantityPerProductPerOrder: 0,
|
||||
},
|
||||
Member: OrderRiskMemberPolicy{MaxPendingOrdersPerUser: 0},
|
||||
})
|
||||
if cfg.Guest.MaxPendingOrdersPerIP != 0 || cfg.Guest.MaxQuantityPerProductPerOrder != 0 {
|
||||
t.Fatalf("zero guest limits should be preserved, got %+v", cfg.Guest)
|
||||
}
|
||||
if cfg.Member.MaxPendingOrdersPerUser != 0 {
|
||||
t.Fatalf("zero member limit should be preserved, got %+v", cfg.Member)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOrderRiskControlConfig_ClampValues(t *testing.T) {
|
||||
cfg := NormalizeOrderRiskControlConfig(OrderRiskControlConfig{
|
||||
MaxPendingOrdersPerUser: -1,
|
||||
MaxPendingOrdersPerIP: 200,
|
||||
MaxPendingOrdersPerGuestEmail: 50,
|
||||
OrderRateLimit: OrderRateLimitConfig{
|
||||
WindowSeconds: 5, // below min 10
|
||||
MaxRequests: 0, // below min 1
|
||||
BlockSeconds: -100, // below min 0
|
||||
Guest: OrderRiskGuestPolicy{
|
||||
MaxPendingOrdersPerIP: 200,
|
||||
MaxQuantityPerProductPerOrder: -1,
|
||||
MaxPendingQuantityPerIPProduct: 100001,
|
||||
PaymentExpireMinutes: 20000,
|
||||
RateLimit: OrderRateLimitConfig{
|
||||
WindowSeconds: 5,
|
||||
MaxRequests: 0,
|
||||
BlockSeconds: -100,
|
||||
},
|
||||
},
|
||||
Member: OrderRiskMemberPolicy{MaxPendingOrdersPerUser: -1},
|
||||
})
|
||||
if cfg.MaxPendingOrdersPerUser != 3 {
|
||||
t.Fatalf("expected clamped to default 3, got %d", cfg.MaxPendingOrdersPerUser)
|
||||
if cfg.Guest.MaxPendingOrdersPerIP != 2 {
|
||||
t.Fatalf("expected invalid guest IP limit to use default 2, got %d", cfg.Guest.MaxPendingOrdersPerIP)
|
||||
}
|
||||
if cfg.MaxPendingOrdersPerIP != 5 {
|
||||
t.Fatalf("expected clamped to default 5, got %d", cfg.MaxPendingOrdersPerIP)
|
||||
if cfg.Guest.MaxQuantityPerProductPerOrder != 1 {
|
||||
t.Fatalf("expected invalid guest quantity limit to use default 1, got %d", cfg.Guest.MaxQuantityPerProductPerOrder)
|
||||
}
|
||||
if cfg.MaxPendingOrdersPerGuestEmail != 50 {
|
||||
t.Fatalf("expected 50 (valid), got %d", cfg.MaxPendingOrdersPerGuestEmail)
|
||||
if cfg.Guest.MaxPendingQuantityPerIPProduct != 2 {
|
||||
t.Fatalf("expected invalid pending product quantity to use default 2, got %d", cfg.Guest.MaxPendingQuantityPerIPProduct)
|
||||
}
|
||||
if cfg.OrderRateLimit.WindowSeconds != 60 {
|
||||
t.Fatalf("expected clamped window to 60, got %d", cfg.OrderRateLimit.WindowSeconds)
|
||||
if cfg.Guest.PaymentExpireMinutes != 10 {
|
||||
t.Fatalf("expected invalid guest expiry to use default 10, got %d", cfg.Guest.PaymentExpireMinutes)
|
||||
}
|
||||
if cfg.OrderRateLimit.MaxRequests != 5 {
|
||||
t.Fatalf("expected clamped max_requests to 5, got %d", cfg.OrderRateLimit.MaxRequests)
|
||||
if cfg.Guest.RateLimit.WindowSeconds != 60 || cfg.Guest.RateLimit.MaxRequests != 3 || cfg.Guest.RateLimit.BlockSeconds != 120 {
|
||||
t.Fatalf("unexpected normalized guest rate limit: %+v", cfg.Guest.RateLimit)
|
||||
}
|
||||
if cfg.OrderRateLimit.BlockSeconds != 120 {
|
||||
t.Fatalf("expected clamped block to 120, got %d", cfg.OrderRateLimit.BlockSeconds)
|
||||
if cfg.Member.MaxPendingOrdersPerUser != 5 {
|
||||
t.Fatalf("expected invalid member pending limit to use default 5, got %d", cfg.Member.MaxPendingOrdersPerUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOrderRiskControlConfig_IPValidation(t *testing.T) {
|
||||
cfg := NormalizeOrderRiskControlConfig(OrderRiskControlConfig{
|
||||
IPBlacklist: []string{
|
||||
"1.2.3.4", // valid IP
|
||||
"10.0.0.0/8", // valid CIDR
|
||||
"invalid_ip", // invalid - should be removed
|
||||
"", // empty - should be removed
|
||||
" 192.168.1.1 ", // valid with whitespace
|
||||
"999.999.999.999", // invalid IP
|
||||
"abc/24", // invalid CIDR
|
||||
Common: OrderRiskCommonPolicy{
|
||||
IPBlacklist: []string{
|
||||
"1.2.3.4", // valid IP
|
||||
"10.0.0.0/8", // valid CIDR
|
||||
"invalid_ip", // invalid - should be removed
|
||||
"", // empty - should be removed
|
||||
" 192.168.1.1 ", // valid with whitespace
|
||||
"999.999.999.999", // invalid IP
|
||||
"abc/24", // invalid CIDR
|
||||
},
|
||||
},
|
||||
})
|
||||
expected := []string{"1.2.3.4", "10.0.0.0/8", "192.168.1.1"}
|
||||
if len(cfg.IPBlacklist) != len(expected) {
|
||||
t.Fatalf("expected %d IPs, got %d: %v", len(expected), len(cfg.IPBlacklist), cfg.IPBlacklist)
|
||||
if len(cfg.Common.IPBlacklist) != len(expected) {
|
||||
t.Fatalf("expected %d IPs, got %d: %v", len(expected), len(cfg.Common.IPBlacklist), cfg.Common.IPBlacklist)
|
||||
}
|
||||
for i, ip := range expected {
|
||||
if cfg.IPBlacklist[i] != ip {
|
||||
t.Fatalf("expected IP[%d]=%q, got %q", i, ip, cfg.IPBlacklist[i])
|
||||
if cfg.Common.IPBlacklist[i] != ip {
|
||||
t.Fatalf("expected IP[%d]=%q, got %q", i, ip, cfg.Common.IPBlacklist[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOrderRiskControlConfig_EmailNormalization(t *testing.T) {
|
||||
cfg := NormalizeOrderRiskControlConfig(OrderRiskControlConfig{
|
||||
EmailBlacklist: []string{
|
||||
" Spam@Example.COM ",
|
||||
"",
|
||||
"test@test.com",
|
||||
func TestDecodeOrderRiskControlConfig_MigratesFlatPolicyWithoutEmailRisk(t *testing.T) {
|
||||
cfg := DecodeOrderRiskControlConfig(jsonmap.JSON{
|
||||
"enabled": true,
|
||||
"max_pending_orders_per_user": float64(4),
|
||||
"max_pending_orders_per_ip": float64(6),
|
||||
"max_pending_orders_per_guest_email": float64(9),
|
||||
"email_blacklist": []interface{}{"ignored@example.com"},
|
||||
"order_rate_limit": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"window_seconds": float64(90),
|
||||
"max_requests": float64(7),
|
||||
"block_seconds": float64(180),
|
||||
},
|
||||
})
|
||||
if len(cfg.EmailBlacklist) != 2 {
|
||||
t.Fatalf("expected 2 emails, got %d: %v", len(cfg.EmailBlacklist), cfg.EmailBlacklist)
|
||||
}, DefaultOrderRiskControlConfig())
|
||||
|
||||
if !cfg.Enabled || cfg.Guest.MaxPendingOrdersPerIP != 6 || cfg.Member.MaxPendingOrdersPerUser != 4 {
|
||||
t.Fatalf("unexpected migrated policy: %+v", cfg)
|
||||
}
|
||||
if cfg.EmailBlacklist[0] != "spam@example.com" {
|
||||
t.Fatalf("expected lowercased email, got %q", cfg.EmailBlacklist[0])
|
||||
if cfg.Guest.RateLimit.MaxRequests != 7 || cfg.Member.RateLimit.MaxRequests != 7 {
|
||||
t.Fatalf("expected old rate limit to migrate to both policies: guest=%+v member=%+v", cfg.Guest.RateLimit, cfg.Member.RateLimit)
|
||||
}
|
||||
if cfg.Guest.MaxQuantityPerProductPerOrder != 0 || cfg.Guest.MaxPendingQuantityPerIPProduct != 0 || cfg.Guest.PaymentExpireMinutes != 0 {
|
||||
t.Fatalf("new guest protections must remain disabled for an existing flat config: %+v", cfg.Guest)
|
||||
}
|
||||
encoded := EncodeOrderRiskControlConfig(cfg)
|
||||
if _, exists := encoded["email_blacklist"]; exists {
|
||||
t.Fatalf("email blacklist must not survive v2 encoding: %#v", encoded)
|
||||
}
|
||||
if _, exists := encoded["max_pending_orders_per_guest_email"]; exists {
|
||||
t.Fatalf("guest email pending limit must not survive v2 encoding: %#v", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeOrderRiskControlConfig_PreservesLegacyDefaultsForSparseFlatPolicy(t *testing.T) {
|
||||
cfg := DecodeOrderRiskControlConfig(jsonmap.JSON{
|
||||
"enabled": true,
|
||||
"order_rate_limit": map[string]interface{}{
|
||||
"window_seconds": float64(90),
|
||||
},
|
||||
}, DefaultOrderRiskControlConfig())
|
||||
|
||||
if cfg.Guest.MaxPendingOrdersPerIP != 5 || cfg.Member.MaxPendingOrdersPerUser != 3 || cfg.Member.MaxPendingOrdersPerIP != 5 {
|
||||
t.Fatalf("sparse flat policy must retain v1 pending defaults: %+v", cfg)
|
||||
}
|
||||
if cfg.Guest.RateLimit.Enabled || cfg.Member.RateLimit.Enabled || cfg.Guest.RateLimit.MaxRequests != 5 || cfg.Member.RateLimit.MaxRequests != 5 {
|
||||
t.Fatalf("sparse flat policy must retain the disabled v1 rate default: guest=%+v member=%+v", cfg.Guest.RateLimit, cfg.Member.RateLimit)
|
||||
}
|
||||
if cfg.Guest.RateLimit.WindowSeconds != 90 || cfg.Member.RateLimit.WindowSeconds != 90 {
|
||||
t.Fatalf("explicit legacy rate fields must still migrate: guest=%+v member=%+v", cfg.Guest.RateLimit, cfg.Member.RateLimit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user